@things-factory/board-import 10.0.0-beta.108

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 (137) hide show
  1. package/config/config.development.js +44 -0
  2. package/config/config.production.js +18 -0
  3. package/dist-server/index.d.ts +7 -0
  4. package/dist-server/index.js +11 -0
  5. package/dist-server/index.js.map +1 -0
  6. package/dist-server/service/adapters/dxf-adapter.d.ts +8 -0
  7. package/dist-server/service/adapters/dxf-adapter.js +220 -0
  8. package/dist-server/service/adapters/dxf-adapter.js.map +1 -0
  9. package/dist-server/service/adapters/image-adapter.d.ts +32 -0
  10. package/dist-server/service/adapters/image-adapter.js +420 -0
  11. package/dist-server/service/adapters/image-adapter.js.map +1 -0
  12. package/dist-server/service/ai/types.d.ts +185 -0
  13. package/dist-server/service/ai/types.js +378 -0
  14. package/dist-server/service/ai/types.js.map +1 -0
  15. package/dist-server/service/converters/generic-to.d.ts +31 -0
  16. package/dist-server/service/converters/generic-to.js +84 -0
  17. package/dist-server/service/converters/generic-to.js.map +1 -0
  18. package/dist-server/service/extraction/contracts.d.ts +112 -0
  19. package/dist-server/service/extraction/contracts.js +3 -0
  20. package/dist-server/service/extraction/contracts.js.map +1 -0
  21. package/dist-server/service/extraction/index.d.ts +17 -0
  22. package/dist-server/service/extraction/index.js +62 -0
  23. package/dist-server/service/extraction/index.js.map +1 -0
  24. package/dist-server/service/extraction/label-scale-calibrator.d.ts +5 -0
  25. package/dist-server/service/extraction/label-scale-calibrator.js +111 -0
  26. package/dist-server/service/extraction/label-scale-calibrator.js.map +1 -0
  27. package/dist-server/service/extraction/layout-refiner.d.ts +16 -0
  28. package/dist-server/service/extraction/layout-refiner.js +88 -0
  29. package/dist-server/service/extraction/layout-refiner.js.map +1 -0
  30. package/dist-server/service/extraction/pipeline.d.ts +11 -0
  31. package/dist-server/service/extraction/pipeline.js +124 -0
  32. package/dist-server/service/extraction/pipeline.js.map +1 -0
  33. package/dist-server/service/extraction/source-router.d.ts +17 -0
  34. package/dist-server/service/extraction/source-router.js +40 -0
  35. package/dist-server/service/extraction/source-router.js.map +1 -0
  36. package/dist-server/service/extraction/stubs.d.ts +18 -0
  37. package/dist-server/service/extraction/stubs.js +38 -0
  38. package/dist-server/service/extraction/stubs.js.map +1 -0
  39. package/dist-server/service/extraction/vector-geometry.d.ts +7 -0
  40. package/dist-server/service/extraction/vector-geometry.js +21 -0
  41. package/dist-server/service/extraction/vector-geometry.js.map +1 -0
  42. package/dist-server/service/extraction/vlm-recognizer.d.ts +18 -0
  43. package/dist-server/service/extraction/vlm-recognizer.js +78 -0
  44. package/dist-server/service/extraction/vlm-recognizer.js.map +1 -0
  45. package/dist-server/service/import-session/import-actions.d.ts +79 -0
  46. package/dist-server/service/import-session/import-actions.js +98 -0
  47. package/dist-server/service/import-session/import-actions.js.map +1 -0
  48. package/dist-server/service/import-session/import-session-resolver.d.ts +39 -0
  49. package/dist-server/service/import-session/import-session-resolver.js +263 -0
  50. package/dist-server/service/import-session/import-session-resolver.js.map +1 -0
  51. package/dist-server/service/import-session/import-session.d.ts +28 -0
  52. package/dist-server/service/import-session/import-session.js +152 -0
  53. package/dist-server/service/import-session/import-session.js.map +1 -0
  54. package/dist-server/service/import-session/import-worker.d.ts +1 -0
  55. package/dist-server/service/import-session/import-worker.js +121 -0
  56. package/dist-server/service/import-session/import-worker.js.map +1 -0
  57. package/dist-server/service/import-session/index.d.ts +8 -0
  58. package/dist-server/service/import-session/index.js +19 -0
  59. package/dist-server/service/import-session/index.js.map +1 -0
  60. package/dist-server/service/import-session/materialize-from-session.d.ts +66 -0
  61. package/dist-server/service/import-session/materialize-from-session.js +104 -0
  62. package/dist-server/service/import-session/materialize-from-session.js.map +1 -0
  63. package/dist-server/service/import-session/suggest-board-name.d.ts +80 -0
  64. package/dist-server/service/import-session/suggest-board-name.js +195 -0
  65. package/dist-server/service/import-session/suggest-board-name.js.map +1 -0
  66. package/dist-server/service/import-tools.d.ts +10 -0
  67. package/dist-server/service/import-tools.js +227 -0
  68. package/dist-server/service/import-tools.js.map +1 -0
  69. package/dist-server/service/index.d.ts +26 -0
  70. package/dist-server/service/index.js +60 -0
  71. package/dist-server/service/index.js.map +1 -0
  72. package/dist-server/service/pipeline/index.d.ts +66 -0
  73. package/dist-server/service/pipeline/index.js +127 -0
  74. package/dist-server/service/pipeline/index.js.map +1 -0
  75. package/dist-server/service/pipeline/stage2-mapping.d.ts +29 -0
  76. package/dist-server/service/pipeline/stage2-mapping.js +231 -0
  77. package/dist-server/service/pipeline/stage2-mapping.js.map +1 -0
  78. package/dist-server/service/pipeline/stage3-board.d.ts +52 -0
  79. package/dist-server/service/pipeline/stage3-board.js +108 -0
  80. package/dist-server/service/pipeline/stage3-board.js.map +1 -0
  81. package/dist-server/service/pipeline/stage4-binding.d.ts +9 -0
  82. package/dist-server/service/pipeline/stage4-binding.js +28 -0
  83. package/dist-server/service/pipeline/stage4-binding.js.map +1 -0
  84. package/dist-server/service/registry/index.d.ts +27 -0
  85. package/dist-server/service/registry/index.js +108 -0
  86. package/dist-server/service/registry/index.js.map +1 -0
  87. package/dist-server/service/types/index.d.ts +152 -0
  88. package/dist-server/service/types/index.js +3 -0
  89. package/dist-server/service/types/index.js.map +1 -0
  90. package/dist-server/service/types/registry.d.ts +175 -0
  91. package/dist-server/service/types/registry.js +3 -0
  92. package/dist-server/service/types/registry.js.map +1 -0
  93. package/dist-server/tsconfig.tsbuildinfo +1 -0
  94. package/package.json +35 -0
  95. package/probe/gcp-vision-probe.mjs +114 -0
  96. package/probe/opencv-geometry-probe.py +98 -0
  97. package/server/index.ts +7 -0
  98. package/server/service/adapters/dxf-adapter.ts +230 -0
  99. package/server/service/adapters/image-adapter.test.ts +545 -0
  100. package/server/service/adapters/image-adapter.ts +464 -0
  101. package/server/service/ai/types.ts +619 -0
  102. package/server/service/converters/generic-to.test.ts +105 -0
  103. package/server/service/converters/generic-to.ts +121 -0
  104. package/server/service/extraction/contracts.ts +142 -0
  105. package/server/service/extraction/extraction.test.ts +374 -0
  106. package/server/service/extraction/index.ts +51 -0
  107. package/server/service/extraction/label-scale-calibrator.ts +124 -0
  108. package/server/service/extraction/layout-refiner.ts +104 -0
  109. package/server/service/extraction/pipeline.ts +163 -0
  110. package/server/service/extraction/source-router.ts +45 -0
  111. package/server/service/extraction/stubs.ts +46 -0
  112. package/server/service/extraction/vector-geometry.ts +26 -0
  113. package/server/service/extraction/vlm-recognizer.ts +87 -0
  114. package/server/service/import-session/import-actions.test.ts +185 -0
  115. package/server/service/import-session/import-actions.ts +164 -0
  116. package/server/service/import-session/import-session-resolver.ts +245 -0
  117. package/server/service/import-session/import-session.ts +141 -0
  118. package/server/service/import-session/import-worker.ts +132 -0
  119. package/server/service/import-session/index.ts +24 -0
  120. package/server/service/import-session/materialize-from-session.test.ts +274 -0
  121. package/server/service/import-session/materialize-from-session.ts +158 -0
  122. package/server/service/import-session/suggest-board-name.test.ts +271 -0
  123. package/server/service/import-session/suggest-board-name.ts +279 -0
  124. package/server/service/import-tools.test.ts +137 -0
  125. package/server/service/import-tools.ts +255 -0
  126. package/server/service/index.ts +117 -0
  127. package/server/service/pipeline/index.ts +184 -0
  128. package/server/service/pipeline/stage2-mapping.test.ts +204 -0
  129. package/server/service/pipeline/stage2-mapping.ts +270 -0
  130. package/server/service/pipeline/stage3-board.test.ts +133 -0
  131. package/server/service/pipeline/stage3-board.ts +179 -0
  132. package/server/service/pipeline/stage4-binding.ts +31 -0
  133. package/server/service/registry/index.ts +127 -0
  134. package/server/service/types/index.ts +169 -0
  135. package/server/service/types/registry.ts +186 -0
  136. package/things-factory.config.js +1 -0
  137. package/tsconfig.json +9 -0
@@ -0,0 +1,619 @@
1
+ /**
2
+ * BoardImportAI — board-import 의 **도메인 AI 인터페이스**.
3
+ *
4
+ * Provider 추상화 (chat/JSON/vision) 는 `@things-factory/ai-client-base` 의 `AIClient` 가 담당.
5
+ * 이 모듈은 board-import 의 도메인 메서드 (컨벤션 추론, Few-shot 분류, 도면 이미지 파싱,
6
+ * 자연어 → ImportRule) 만 정의하고, 기본 구현은 base AIClient 위에 LLM 프롬프트로 만든다.
7
+ *
8
+ * 사용 예:
9
+ * ```ts
10
+ * import { createAIClient } from '@things-factory/ai-client-base'
11
+ * import { setAIClient, DefaultBoardImportAI } from '@things-factory/board-import'
12
+ *
13
+ * const base = createAIClient({ provider: 'anthropic', apiKey: '...' })
14
+ * setAIClient(new DefaultBoardImportAI(base))
15
+ * ```
16
+ *
17
+ * Phase 1-2 결정적 파이프라인은 AI 미사용. 위 등록은 Phase 3+ 메서드 활성화용.
18
+ */
19
+ import {
20
+ getDefaultAIClient,
21
+ parseJSONResponse,
22
+ type AIClient as BaseAIClient
23
+ } from '@things-factory/ai-client-base'
24
+ import type { ComponentCategory, ImportRule } from '../types/registry'
25
+ import type { UniversalEntity } from '../types/index'
26
+
27
+ export interface BoardImportAI {
28
+ /** 식별자 — 보통 base.id 와 동일 (provider:model) */
29
+ readonly id: string
30
+
31
+ /**
32
+ * 미매칭 entity 묶음을 보고 컨벤션 규칙을 추론.
33
+ * 예: "STK_001, STK_002 가 stocker 인 것 같습니다" → 정규식 규칙 제안
34
+ */
35
+ inferConventions?(
36
+ entities: UniversalEntity[],
37
+ options?: ConventionInferenceOptions
38
+ ): Promise<RuleSuggestion[]>
39
+
40
+ /**
41
+ * 사용자가 일부 entity 를 라벨링했을 때, 나머지 미라벨 entity 의 카테고리·type 을 추론.
42
+ * Few-shot 분류.
43
+ */
44
+ classifyEntities?(
45
+ labeled: LabeledEntity[],
46
+ unlabeled: UniversalEntity[],
47
+ options?: ClassificationOptions
48
+ ): Promise<EntityClassification[]>
49
+
50
+ /**
51
+ * 도면 이미지(PNG/JPG/PDF 페이지) 에서 객체를 검출하여 UniversalEntity 로 변환.
52
+ *
53
+ * 1-pass structured response 를 권장 — view type 자동 분류 + entity 추출을 단일 vision
54
+ * 호출로 처리해 토큰 비용을 절반으로 줄인다. 응답 형태:
55
+ * - **`ImageAnalysis`** 객체: `{ viewType, confidence, entities, reasoning? }`
56
+ * - 또는 **`UniversalEntity[]`** 직접 (구버전 호환).
57
+ * ImageAdapter 는 양쪽을 모두 정규화 처리.
58
+ *
59
+ * Phase 3 (이미지 어댑터) 에서 사용.
60
+ */
61
+ parseFromImage?(
62
+ image: Buffer | Uint8Array,
63
+ options?: ImageParseOptions
64
+ ): Promise<ImageAnalysis | UniversalEntity[]>
65
+
66
+ /**
67
+ * 자연어 매핑 정의 ("STK는 stocker, BUF는 buffer") → ImportRule[] 로 변환.
68
+ */
69
+ parseNaturalLanguageMapping?(
70
+ text: string,
71
+ availableTypes: string[]
72
+ ): Promise<ImportRule[]>
73
+ }
74
+
75
+ // ── 옵션 / 결과 타입 ────────────────────────────────────────────────
76
+
77
+ export interface ConventionInferenceOptions {
78
+ /** 알려진 도메인 type 후보 (LLM 이 이 안에서 고르도록) */
79
+ knownTypes?: string[]
80
+ /** 카테고리 후보 */
81
+ categories?: ComponentCategory[]
82
+ /** 최대 제안 개수 */
83
+ maxSuggestions?: number
84
+ }
85
+
86
+ export interface RuleSuggestion {
87
+ /** 자동 생성된 ImportRule 후보 */
88
+ rule: ImportRule
89
+ /** 0..1 신뢰도 */
90
+ confidence: number
91
+ /** 추론 근거 */
92
+ reasoning?: string
93
+ }
94
+
95
+ export interface ClassificationOptions {
96
+ knownTypes?: string[]
97
+ categories?: ComponentCategory[]
98
+ }
99
+
100
+ export interface LabeledEntity {
101
+ entity: UniversalEntity
102
+ type: string
103
+ category?: ComponentCategory
104
+ }
105
+
106
+ export interface EntityClassification {
107
+ entityUid: string
108
+ type: string
109
+ category: ComponentCategory
110
+ confidence: number
111
+ }
112
+
113
+ export interface ImageParseOptions {
114
+ /** 자연어 컨텍스트. 예: "factory floor plan with AGV paths" */
115
+ context?: string
116
+ categories?: ComponentCategory[]
117
+ /**
118
+ * 사용자 자유서술 hint — 도면 설명 / 강조 / 카테고리 가이드 등.
119
+ * 1-pass VLM 프롬프트에 합류해 view type 분류 + entity 추출 양쪽에 영향.
120
+ */
121
+ userPrompt?: string
122
+ /** 이미지 dimension — 픽셀 좌표계 명시용 (top-down 모드에서). */
123
+ imageWidth?: number
124
+ imageHeight?: number
125
+ /**
126
+ * View type 강제. 미지정 시 VLM 이 자동 분류.
127
+ * - 'top-down': 픽셀 좌표 그대로
128
+ * - 'perspective-3d' / 'photo': floor plan 좌표 추정 (0..1000 단위)
129
+ */
130
+ viewType?: 'top-down' | 'perspective-3d' | 'photo'
131
+ }
132
+
133
+ /**
134
+ * 1-pass VLM 응답의 구조화된 결과 — **다중 시안 (variants)** 포함.
135
+ *
136
+ * 같은 도면을 AI 가 3 가지 다른 관점으로 해석. 사용자가 review 에서 선택.
137
+ *
138
+ * 시안 축 (프롬프트가 제시) 예시:
139
+ * - 장비(equipment) 중심 / 운송(transport) 중심 / 영역(region) 중심
140
+ * - 보수적(conservative) / 균형(balanced) / 공격적(aggressive)
141
+ *
142
+ * 각 시안은 독립적인 entities + subjectName/subjectDescription + importStrategy.
143
+ * viewType / confidence / reasoning 은 시안 무관 공통 (도면 자체의 특성).
144
+ */
145
+ export interface ImageAnalysis {
146
+ /** 자동 분류된 view type — 시안 무관 공통. */
147
+ viewType: 'top-down' | 'perspective-3d' | 'photo'
148
+ /** 분류 신뢰도 (0..1). LLM 자기보고. */
149
+ confidence: number
150
+ /** 분류 근거 한 줄 (왜 이 viewType 인가). */
151
+ reasoning?: string
152
+ /** 시안 풀세트. 보통 3 개 — variants[0] 가 default. */
153
+ variants: ImageAnalysisVariant[]
154
+ }
155
+
156
+ /**
157
+ * 한 시안의 풀데이터 — entities + 정체성 + 의도.
158
+ * 각 시안은 같은 도면이지만 다른 해석.
159
+ */
160
+ export interface ImageAnalysisVariant {
161
+ /** URL 친화적 short id. */
162
+ id: string
163
+ /** 사용자에게 보여줄 한글 라벨. 예: "장비 중심" */
164
+ label: string
165
+ /** 짧은 한 줄 설명 — 이 시안의 강조점. */
166
+ description?: string
167
+ /** 추출된 entity 목록. 좌표 의미는 ImageAnalysis.viewType 에 따름. */
168
+ entities: UniversalEntity[]
169
+ /**
170
+ * 보드 정체성 — 도면이 무엇을 나타내는가의 짧은 제목 (≤30자). 보드 name 자동 추천 기반.
171
+ * 처리 과정이 아니라 **컨텐츠 정체성** 만.
172
+ */
173
+ subjectName?: string
174
+ /**
175
+ * 보드 정체성 — 도면이 나타내는 시설/배치/구성 1-2 문장. 보드 description 자동 추천 기반.
176
+ * 처리 과정 X (importStrategy 와 분리).
177
+ */
178
+ subjectDescription?: string
179
+ /**
180
+ * AI 의 의도 narrative — "왜 이 시안에서 이렇게 해석했나". 3-5 문장.
181
+ * **transient — wizard review 카드 전시용. 영속 description 에는 사용 X.**
182
+ */
183
+ importStrategy?: string
184
+ }
185
+
186
+ // ── 기본 구현 — base AIClient 위에 LLM 프롬프트로 ────────────────────
187
+
188
+ interface SingleVariantResult {
189
+ viewType: 'top-down' | 'perspective-3d' | 'photo'
190
+ confidence: number
191
+ reasoning?: string
192
+ variant: ImageAnalysisVariant
193
+ }
194
+
195
+ export class DefaultBoardImportAI implements BoardImportAI {
196
+ readonly id: string
197
+
198
+ private static readonly VARIANT_AXES = [
199
+ {
200
+ id: 'equipment-focus',
201
+ label: '장비 중심',
202
+ instruction: '장비/설비(machine/stocker/facility) 식별 우선. 이동체/경로는 보조.'
203
+ },
204
+ {
205
+ id: 'transport-focus',
206
+ label: '운송 중심',
207
+ instruction: '차량/경로(AGV/forklift/conveyor) 식별 우선. 정적 장비는 영역으로 묶음.'
208
+ },
209
+ {
210
+ id: 'region-focus',
211
+ label: '구역 중심',
212
+ instruction: '영역/구획(zone/wall/structure) 식별 우선. 개별 장비는 generic placeholder.'
213
+ }
214
+ ] as const
215
+
216
+ constructor(private base: BaseAIClient) {
217
+ this.id = base.id
218
+ }
219
+
220
+ async inferConventions(
221
+ entities: UniversalEntity[],
222
+ options: ConventionInferenceOptions = {}
223
+ ): Promise<RuleSuggestion[]> {
224
+ const sample = entities.slice(0, 100).map(e => ({
225
+ uid: e.uid,
226
+ name: e.attribs.name,
227
+ layer: e.attribs.layer,
228
+ kind: e.kind,
229
+ shape: e.attribs.shape
230
+ }))
231
+ const knownTypes = options.knownTypes ?? []
232
+ const categories = options.categories ?? []
233
+ const maxSuggestions = options.maxSuggestions ?? 5
234
+
235
+ const prompt = `You analyze CAD/IFC drawing entities and infer naming/layer conventions for an import pipeline.
236
+
237
+ Unmatched entities (sample):
238
+ ${JSON.stringify(sample, null, 2)}
239
+
240
+ Available component types: ${knownTypes.length ? knownTypes.join(', ') : '(any)'}
241
+ Available categories: ${categories.length ? categories.join(', ') : '(any)'}
242
+
243
+ Suggest up to ${maxSuggestions} convention rules. For each, return:
244
+ - rule.componentType: chosen type
245
+ - rule.category: one of available categories (or 'unknown')
246
+ - rule.match.cad.blockNamePattern: regex pattern source string (e.g. "^STK_")
247
+ - rule.match.cad.layer: optional regex source string
248
+ - rule.build.idStrategy: 'fromAttribute' or 'auto'
249
+ - confidence: 0..1
250
+ - reasoning: short explanation`
251
+
252
+ const raw = await this.base.generateJSON<any[]>(prompt, { systemPrompt: SYSTEM_HINT })
253
+ return Array.isArray(raw) ? raw.map(r => ({ ...r, rule: hydrateRuleRegexes(r.rule) })) : []
254
+ }
255
+
256
+ async classifyEntities(
257
+ labeled: LabeledEntity[],
258
+ unlabeled: UniversalEntity[],
259
+ options: ClassificationOptions = {}
260
+ ): Promise<EntityClassification[]> {
261
+ const examples = labeled.map(l => ({
262
+ name: l.entity.attribs.name,
263
+ layer: l.entity.attribs.layer,
264
+ kind: l.entity.kind,
265
+ type: l.type,
266
+ category: l.category
267
+ }))
268
+ const targets = unlabeled.map(e => ({
269
+ uid: e.uid,
270
+ name: e.attribs.name,
271
+ layer: e.attribs.layer,
272
+ kind: e.kind
273
+ }))
274
+
275
+ const prompt = `Few-shot entity classification.
276
+
277
+ Labeled examples:
278
+ ${JSON.stringify(examples, null, 2)}
279
+
280
+ Available types: ${(options.knownTypes ?? []).join(', ') || '(any)'}
281
+ Available categories: ${(options.categories ?? []).join(', ') || '(any)'}
282
+
283
+ Classify these unlabeled entities. Return JSON array of:
284
+ - entityUid
285
+ - type
286
+ - category
287
+ - confidence: 0..1
288
+
289
+ Targets:
290
+ ${JSON.stringify(targets, null, 2)}`
291
+
292
+ const raw = await this.base.generateJSON<EntityClassification[]>(prompt, { systemPrompt: SYSTEM_HINT })
293
+ return Array.isArray(raw) ? raw : []
294
+ }
295
+
296
+ async parseFromImage(
297
+ image: Buffer | Uint8Array,
298
+ options: ImageParseOptions = {}
299
+ ): Promise<ImageAnalysis> {
300
+ if (!this.base.vision) {
301
+ throw new Error(`AI client (${this.base.id}) does not support vision input`)
302
+ }
303
+
304
+ const results = await Promise.allSettled(
305
+ DefaultBoardImportAI.VARIANT_AXES.map(axis => this._callOneVariant(image, axis, options))
306
+ )
307
+
308
+ const succeeded = results
309
+ .filter((r): r is PromiseFulfilledResult<SingleVariantResult> => r.status === 'fulfilled')
310
+ .map(r => r.value)
311
+
312
+ if (succeeded.length === 0) {
313
+ const errors = (results as PromiseRejectedResult[]).map(r => r.reason?.message ?? String(r.reason))
314
+ throw new Error(`All variant calls failed: ${errors.join('; ')}`)
315
+ }
316
+
317
+ const forcedVT = options.viewType
318
+ const viewType = forcedVT ?? consensusViewType(succeeded.map(r => r.viewType))
319
+ const confidence = succeeded.reduce((s, r) => s + r.confidence, 0) / succeeded.length
320
+ const reasoning = succeeded.find(r => r.reasoning)?.reasoning
321
+
322
+ return { viewType, confidence, reasoning, variants: succeeded.map(r => r.variant) }
323
+ }
324
+
325
+ private async _callOneVariant(
326
+ image: Buffer | Uint8Array,
327
+ axis: { id: string; label: string; instruction: string },
328
+ options: ImageParseOptions
329
+ ): Promise<SingleVariantResult> {
330
+ const w = options.imageWidth
331
+ const h = options.imageHeight
332
+ const userHint = options.userPrompt?.trim()
333
+ const forcedViewType = options.viewType
334
+
335
+ const dimsLine =
336
+ Number.isFinite(w) && Number.isFinite(h)
337
+ ? `\nImage dimensions: ${w} x ${h} pixels.`
338
+ : ''
339
+
340
+ const viewTypeInstruction = forcedViewType
341
+ ? `\nThe view type is GIVEN: "${forcedViewType}". Do not classify; use it as-is.`
342
+ : `\nFirst, classify the view type:
343
+ - "top-down": orthographic floor plan / 2D engineering drawing seen from directly above
344
+ - "perspective-3d": 3D rendering / isometric / bird's-eye view with visible depth
345
+ - "photo": photograph of a physical space`
346
+
347
+ const coordRule = `\nCoordinate convention by viewType:
348
+ - "top-down": INTEGER PIXEL coordinates (origin top-left, x →, y ↓)${
349
+ Number.isFinite(w) && Number.isFinite(h) ? ` in [0, ${w}] x [0, ${h}]` : ''
350
+ }. Each entity ≥ 16x16 pixels.
351
+ - "perspective-3d" / "photo": INFER the FLOOR PLAN layout (where each object would be on a flat floor seen from above, NOT screen positions). Use arbitrary plan units in [0, 1000] x [0, 1000]. Object size in plan units (e.g. 50x80 for a stocker).
352
+ NEVER use normalized 0..1 coordinates.`
353
+
354
+ const prompt = `Analyze this drawing image and provide ONE interpretation with focus axis: "${axis.id}".
355
+ Focus: ${axis.instruction}${
356
+ userHint ? '\n\nUser hints (highest priority): ' + userHint : ''
357
+ }${options.context ? '\nContext: ' + options.context : ''}${
358
+ options.categories ? '\nPossible categories: ' + options.categories.join(', ') : ''
359
+ }${dimsLine}${viewTypeInstruction}${coordRule}
360
+
361
+ Return STRICT JSON (no markdown, no commentary) with this shape:
362
+ {
363
+ "viewType": "top-down" | "perspective-3d" | "photo",
364
+ "confidence": 0..1,
365
+ "reasoning": "brief one-sentence explanation of view type classification",
366
+ "variant": {
367
+ "id": "${axis.id}",
368
+ "label": "한글 라벨 (예: ${axis.label})",
369
+ "description": "한 줄 — 이 시안의 강조점",
370
+ "subjectName": "≤30 char short title — what the depicted facility is",
371
+ "subjectDescription": "1-2 sentences in Korean — what is depicted (NOT how you analyzed)",
372
+ "importStrategy": "3-5 sentence narrative explaining YOUR INTENT for this variant",
373
+ "entities": [
374
+ {
375
+ "uid": "string",
376
+ "kind": "shape" | "path" | "text",
377
+ "position": { "x": number, "y": number },
378
+ "size": { "width": number, "height": number },
379
+ "bbox": { "min": {"x": number, "y": number}, "max": {"x": number, "y": number} },
380
+ "attribs": {
381
+ "name": "string?",
382
+ "semanticType": "string?",
383
+ "text": "string?",
384
+ "layer": "string?",
385
+ "categoryHint": "container | vehicle | path | region | equipment | io | marker | structure"
386
+ }
387
+ }
388
+ ]
389
+ }
390
+ }
391
+
392
+ categoryHint should be one of the listed categories; omit only if highly uncertain.
393
+ Provide 10-30 distinct entities clearly identifiable under the "${axis.id}" axis.
394
+
395
+ attribs.name — IMPORTANT for component identity / data binding:
396
+ - If you see a TEXT LABEL on/near the entity in the drawing (e.g. "STK_001", "AGV-3", "CV1"), use that EXACTLY as the name.
397
+ - DO NOT invent labels not in the drawing — leave attribs.name empty if uncertain.
398
+ - Keep alphanumeric + hyphens / underscores. No spaces. ≤24 chars. UNIQUE within the variant.
399
+
400
+ subjectName + subjectDescription — describe WHAT the board depicts (its identity), NOT what you did.
401
+ These become the board's persistent name + description if the user picks this variant.
402
+
403
+ importStrategy — Korean 3-5 sentences: what this variant emphasizes, how you decided categories, areas of uncertainty.
404
+ Used in the wizard review card only; transient — separate from board description.`
405
+
406
+ const raw = await this.base.vision!(image as Buffer, prompt, { maxTokens: 4096 })
407
+ return parseSingleVariantResponse(raw, axis.id, axis.label, forcedViewType)
408
+ }
409
+
410
+ async parseNaturalLanguageMapping(
411
+ text: string,
412
+ availableTypes: string[]
413
+ ): Promise<ImportRule[]> {
414
+ const prompt = `Convert the following user description into ImportRule[] entries for the board-import pipeline.
415
+
416
+ Description: "${text}"
417
+ Available component types: ${availableTypes.join(', ')}
418
+
419
+ Return JSON array of:
420
+ - componentType
421
+ - category (one of: container, vehicle, path, region, equipment, io, marker, structure, unknown)
422
+ - match.cad.blockNamePattern: regex source string (e.g. "^STK_")
423
+ - match.cad.layer: optional regex source string
424
+ - build.idStrategy: 'fromAttribute' or 'auto'`
425
+
426
+ const raw = await this.base.generateJSON<any[]>(prompt, { systemPrompt: SYSTEM_HINT })
427
+ return Array.isArray(raw) ? raw.map(hydrateRuleRegexes) : []
428
+ }
429
+ }
430
+
431
+ // ── 유틸 ─────────────────────────────────────────────────────────────
432
+
433
+ const SYSTEM_HINT =
434
+ 'You are a CAD/IFC drawing analysis assistant. Output strict JSON only — no commentary, no markdown.'
435
+
436
+ const VIEW_TYPES = ['top-down', 'perspective-3d', 'photo'] as const
437
+
438
+ function parseSingleVariantResponse(
439
+ rawText: string,
440
+ axisId: string,
441
+ axisLabel: string,
442
+ forcedViewType?: 'top-down' | 'perspective-3d' | 'photo'
443
+ ): SingleVariantResult {
444
+ const parsed = parseJSONResponse<any>(rawText)
445
+ const trimmed = (s: any) => (typeof s === 'string' && s.trim() ? s.trim() : undefined)
446
+
447
+ const rawVT = parsed?.viewType
448
+ const viewType: 'top-down' | 'perspective-3d' | 'photo' =
449
+ forcedViewType ??
450
+ ((VIEW_TYPES as readonly string[]).includes(rawVT)
451
+ ? (rawVT as 'top-down' | 'perspective-3d' | 'photo')
452
+ : 'top-down')
453
+ const confidence =
454
+ typeof parsed?.confidence === 'number' && parsed.confidence >= 0 && parsed.confidence <= 1
455
+ ? parsed.confidence
456
+ : 0.5
457
+
458
+ const v = parsed?.variant ?? {}
459
+ const variant: ImageAnalysisVariant = {
460
+ id: typeof v.id === 'string' && v.id ? v.id : axisId,
461
+ label: typeof v.label === 'string' && v.label ? v.label : axisLabel,
462
+ description: trimmed(v.description),
463
+ entities: Array.isArray(v.entities) ? v.entities : [],
464
+ subjectName: trimmed(v.subjectName),
465
+ subjectDescription: trimmed(v.subjectDescription),
466
+ importStrategy: trimmed(v.importStrategy)
467
+ }
468
+
469
+ return { viewType, confidence, reasoning: trimmed(parsed?.reasoning), variant }
470
+ }
471
+
472
+ function consensusViewType(
473
+ viewTypes: Array<'top-down' | 'perspective-3d' | 'photo'>
474
+ ): 'top-down' | 'perspective-3d' | 'photo' {
475
+ const counts = new Map<string, number>()
476
+ for (const vt of viewTypes) counts.set(vt, (counts.get(vt) ?? 0) + 1)
477
+ let best: 'top-down' | 'perspective-3d' | 'photo' = 'top-down'
478
+ let max = 0
479
+ for (const [vt, count] of counts) {
480
+ if (count > max) {
481
+ max = count
482
+ best = vt as 'top-down' | 'perspective-3d' | 'photo'
483
+ }
484
+ }
485
+ return best
486
+ }
487
+
488
+ /**
489
+ * VLM 의 JSON 응답을 ImageAnalysis 형태로 정규화 — 다중 시안 (variants) 지원.
490
+ *
491
+ * 허용 입력 형태:
492
+ * - `{ viewType, confidence, reasoning?, variants: [...] }` — **최신 권장** (multi-variant)
493
+ * - `{ viewType, ..., entities }` — 구버전 single-variant (자동으로 variants:[default] 으로 감쌈)
494
+ * - `[ entity, ... ]` — 가장 구버전 (entities 만)
495
+ *
496
+ * 결손 / 비정상 viewType 은 'top-down' fallback. forcedViewType 이 주어지면 그 값 강제.
497
+ */
498
+ export function normalizeImageAnalysisResponse(
499
+ rawText: string,
500
+ forcedViewType?: 'top-down' | 'perspective-3d' | 'photo'
501
+ ): ImageAnalysis {
502
+ const parsed = parseJSONResponse<any>(rawText)
503
+ const trimmed = (s: any) => (typeof s === 'string' && s.trim() ? s.trim() : undefined)
504
+
505
+ if (Array.isArray(parsed)) {
506
+ return {
507
+ viewType: forcedViewType ?? 'top-down',
508
+ confidence: 0.5,
509
+ variants: [{ id: 'default', label: 'default', entities: parsed }]
510
+ }
511
+ }
512
+ if (!parsed || typeof parsed !== 'object') {
513
+ return {
514
+ viewType: forcedViewType ?? 'top-down',
515
+ confidence: 0,
516
+ variants: [{ id: 'default', label: 'default', entities: [] }]
517
+ }
518
+ }
519
+ const rawVT = parsed.viewType
520
+ const viewType =
521
+ forcedViewType ??
522
+ ((VIEW_TYPES as readonly string[]).includes(rawVT) ? rawVT : 'top-down')
523
+ const confidence =
524
+ typeof parsed.confidence === 'number' && parsed.confidence >= 0 && parsed.confidence <= 1
525
+ ? parsed.confidence
526
+ : 0.5
527
+
528
+ // variants 배열 정규화 — 최신 권장 형태
529
+ let variants: ImageAnalysisVariant[]
530
+ if (Array.isArray(parsed.variants) && parsed.variants.length > 0) {
531
+ variants = parsed.variants.map((v: any, i: number) => ({
532
+ id: typeof v?.id === 'string' && v.id ? v.id : `variant-${i + 1}`,
533
+ label: typeof v?.label === 'string' && v.label ? v.label : `Variant ${i + 1}`,
534
+ description: trimmed(v?.description),
535
+ entities: Array.isArray(v?.entities) ? v.entities : [],
536
+ subjectName: trimmed(v?.subjectName),
537
+ subjectDescription: trimmed(v?.subjectDescription),
538
+ importStrategy: trimmed(v?.importStrategy)
539
+ }))
540
+ } else {
541
+ // 구버전 호환 — top-level entities + subjectName 등을 단일 variant 로 감쌈
542
+ variants = [
543
+ {
544
+ id: 'default',
545
+ label: 'default',
546
+ entities: Array.isArray(parsed.entities) ? parsed.entities : [],
547
+ subjectName: trimmed(parsed.subjectName),
548
+ subjectDescription: trimmed(parsed.subjectDescription),
549
+ importStrategy: trimmed(parsed.importStrategy)
550
+ }
551
+ ]
552
+ }
553
+
554
+ return {
555
+ viewType,
556
+ confidence,
557
+ reasoning: trimmed(parsed.reasoning),
558
+ variants
559
+ }
560
+ }
561
+
562
+ /**
563
+ * LLM 응답의 regex pattern 문자열 → 실제 RegExp.
564
+ * 보통 "^STK_" 같은 source string 으로 응답.
565
+ */
566
+ function hydrateRuleRegexes<T extends { match?: any }>(rule: T): T {
567
+ if (!rule || !rule.match) return rule
568
+ const cad = rule.match.cad
569
+ if (cad) {
570
+ const newCad = { ...cad }
571
+ if (typeof newCad.blockNamePattern === 'string') {
572
+ try {
573
+ newCad.blockNamePattern = new RegExp(newCad.blockNamePattern)
574
+ } catch {
575
+ // invalid regex — 패턴 무시
576
+ delete newCad.blockNamePattern
577
+ }
578
+ }
579
+ if (typeof newCad.layer === 'string') {
580
+ const m = newCad.layer.match(/^\/(.+)\/([a-z]*)$/)
581
+ if (m) {
582
+ try { newCad.layer = new RegExp(m[1], m[2]) } catch { /* keep as string */ }
583
+ }
584
+ }
585
+ rule.match = { ...rule.match, cad: newCad }
586
+ }
587
+ return rule
588
+ }
589
+
590
+ // ── Provider 등록 ──────────────────────────────────────────────────
591
+
592
+ let currentClient: BoardImportAI | undefined
593
+ let defaultBackedClient: BoardImportAI | undefined
594
+ let defaultBackedFor: BaseAIClient | undefined
595
+
596
+ export function setAIClient(client: BoardImportAI | undefined): void {
597
+ currentClient = client
598
+ }
599
+
600
+ /**
601
+ * 등록된 BoardImportAI 가 있으면 그걸 반환.
602
+ * 없으면 ai-client-base 의 default AIClient (config.aiClient) 를 `DefaultBoardImportAI` 로
603
+ * 감싸서 반환 — board-ai 와 동일한 1회 설정만으로 image import 도 동작하게 한다.
604
+ *
605
+ * default base 가 바뀌면 (테스트 등) 캐시를 무효화하고 다시 감싼다.
606
+ */
607
+ export function getAIClient(): BoardImportAI | undefined {
608
+ if (currentClient) return currentClient
609
+ const base = getDefaultAIClient()
610
+ if (!base) return undefined
611
+ if (defaultBackedFor !== base) {
612
+ defaultBackedFor = base
613
+ defaultBackedClient = new DefaultBoardImportAI(base)
614
+ }
615
+ return defaultBackedClient
616
+ }
617
+
618
+ /** Deprecated alias — 기존 코드 호환용. 새로 작성 시 BoardImportAI 사용. */
619
+ export type AIClient = BoardImportAI