@things-factory/board-import 10.0.0-beta.71 → 10.0.0-beta.73

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 (83) hide show
  1. package/dist-server/index.d.ts +1 -1
  2. package/dist-server/index.js +1 -1
  3. package/dist-server/index.js.map +1 -1
  4. package/dist-server/service/adapters/dxf-adapter.d.ts +1 -1
  5. package/dist-server/service/adapters/dxf-adapter.js +18 -4
  6. package/dist-server/service/adapters/dxf-adapter.js.map +1 -1
  7. package/dist-server/service/adapters/image-adapter.d.ts +32 -0
  8. package/dist-server/service/adapters/image-adapter.js +420 -0
  9. package/dist-server/service/adapters/image-adapter.js.map +1 -0
  10. package/dist-server/service/ai/types.d.ts +96 -4
  11. package/dist-server/service/ai/types.js +221 -12
  12. package/dist-server/service/ai/types.js.map +1 -1
  13. package/dist-server/service/converters/generic-to.d.ts +17 -8
  14. package/dist-server/service/converters/generic-to.js +44 -44
  15. package/dist-server/service/converters/generic-to.js.map +1 -1
  16. package/dist-server/service/import-session/import-actions.d.ts +79 -0
  17. package/dist-server/service/import-session/import-actions.js +98 -0
  18. package/dist-server/service/import-session/import-actions.js.map +1 -0
  19. package/dist-server/service/import-session/import-session-resolver.d.ts +22 -1
  20. package/dist-server/service/import-session/import-session-resolver.js +166 -38
  21. package/dist-server/service/import-session/import-session-resolver.js.map +1 -1
  22. package/dist-server/service/import-session/import-worker.js +55 -13
  23. package/dist-server/service/import-session/import-worker.js.map +1 -1
  24. package/dist-server/service/import-session/index.d.ts +5 -3
  25. package/dist-server/service/import-session/index.js +16 -9
  26. package/dist-server/service/import-session/index.js.map +1 -1
  27. package/dist-server/service/import-session/materialize-from-session.d.ts +66 -0
  28. package/dist-server/service/import-session/materialize-from-session.js +104 -0
  29. package/dist-server/service/import-session/materialize-from-session.js.map +1 -0
  30. package/dist-server/service/import-session/suggest-board-name.d.ts +80 -0
  31. package/dist-server/service/import-session/suggest-board-name.js +195 -0
  32. package/dist-server/service/import-session/suggest-board-name.js.map +1 -0
  33. package/dist-server/service/import-tools.d.ts +10 -0
  34. package/dist-server/service/import-tools.js +227 -0
  35. package/dist-server/service/import-tools.js.map +1 -0
  36. package/dist-server/service/index.d.ts +22 -19
  37. package/dist-server/service/index.js +46 -36
  38. package/dist-server/service/index.js.map +1 -1
  39. package/dist-server/service/pipeline/index.d.ts +42 -9
  40. package/dist-server/service/pipeline/index.js +92 -23
  41. package/dist-server/service/pipeline/index.js.map +1 -1
  42. package/dist-server/service/pipeline/stage2-mapping.d.ts +20 -5
  43. package/dist-server/service/pipeline/stage2-mapping.js +84 -26
  44. package/dist-server/service/pipeline/stage2-mapping.js.map +1 -1
  45. package/dist-server/service/pipeline/stage3-board.d.ts +2 -2
  46. package/dist-server/service/pipeline/stage3-board.js +65 -10
  47. package/dist-server/service/pipeline/stage3-board.js.map +1 -1
  48. package/dist-server/service/pipeline/stage4-binding.d.ts +1 -1
  49. package/dist-server/service/pipeline/stage4-binding.js +2 -2
  50. package/dist-server/service/pipeline/stage4-binding.js.map +1 -1
  51. package/dist-server/service/registry/index.d.ts +1 -1
  52. package/dist-server/service/registry/index.js.map +1 -1
  53. package/dist-server/service/types/index.d.ts +35 -0
  54. package/dist-server/service/types/index.js.map +1 -1
  55. package/dist-server/tsconfig.tsbuildinfo +1 -1
  56. package/package.json +7 -6
  57. package/server/index.ts +1 -1
  58. package/server/service/adapters/dxf-adapter.ts +17 -4
  59. package/server/service/adapters/image-adapter.test.ts +545 -0
  60. package/server/service/adapters/image-adapter.ts +464 -0
  61. package/server/service/ai/types.ts +354 -19
  62. package/server/service/converters/generic-to.test.ts +91 -0
  63. package/server/service/converters/generic-to.ts +66 -49
  64. package/server/service/import-session/import-actions.test.ts +185 -0
  65. package/server/service/import-session/import-actions.ts +164 -0
  66. package/server/service/import-session/import-session-resolver.ts +171 -39
  67. package/server/service/import-session/import-worker.ts +56 -12
  68. package/server/service/import-session/index.ts +19 -3
  69. package/server/service/import-session/materialize-from-session.test.ts +274 -0
  70. package/server/service/import-session/materialize-from-session.ts +158 -0
  71. package/server/service/import-session/suggest-board-name.test.ts +271 -0
  72. package/server/service/import-session/suggest-board-name.ts +279 -0
  73. package/server/service/import-tools.test.ts +137 -0
  74. package/server/service/import-tools.ts +255 -0
  75. package/server/service/index.ts +35 -18
  76. package/server/service/pipeline/index.ts +118 -23
  77. package/server/service/pipeline/stage2-mapping.test.ts +204 -0
  78. package/server/service/pipeline/stage2-mapping.ts +102 -27
  79. package/server/service/pipeline/stage3-board.test.ts +133 -0
  80. package/server/service/pipeline/stage3-board.ts +76 -11
  81. package/server/service/pipeline/stage4-binding.ts +2 -2
  82. package/server/service/registry/index.ts +1 -1
  83. package/server/service/types/index.ts +36 -0
@@ -16,9 +16,13 @@
16
16
  *
17
17
  * Phase 1-2 결정적 파이프라인은 AI 미사용. 위 등록은 Phase 3+ 메서드 활성화용.
18
18
  */
19
- import { parseJSONResponse, type AIClient as BaseAIClient } from '@things-factory/ai-client-base'
20
- import type { ComponentCategory, ImportRule } from '../types/registry.js'
21
- import type { UniversalEntity } from '../types/index.js'
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'
22
26
 
23
27
  export interface BoardImportAI {
24
28
  /** 식별자 — 보통 base.id 와 동일 (provider:model) */
@@ -45,12 +49,19 @@ export interface BoardImportAI {
45
49
 
46
50
  /**
47
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
+ *
48
59
  * Phase 3 (이미지 어댑터) 에서 사용.
49
60
  */
50
61
  parseFromImage?(
51
62
  image: Buffer | Uint8Array,
52
63
  options?: ImageParseOptions
53
- ): Promise<UniversalEntity[]>
64
+ ): Promise<ImageAnalysis | UniversalEntity[]>
54
65
 
55
66
  /**
56
67
  * 자연어 매핑 정의 ("STK는 stocker, BUF는 buffer") → ImportRule[] 로 변환.
@@ -103,12 +114,105 @@ export interface ImageParseOptions {
103
114
  /** 자연어 컨텍스트. 예: "factory floor plan with AGV paths" */
104
115
  context?: string
105
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
106
184
  }
107
185
 
108
186
  // ── 기본 구현 — base AIClient 위에 LLM 프롬프트로 ────────────────────
109
187
 
188
+ interface SingleVariantResult {
189
+ viewType: 'top-down' | 'perspective-3d' | 'photo'
190
+ confidence: number
191
+ reasoning?: string
192
+ variant: ImageAnalysisVariant
193
+ }
194
+
110
195
  export class DefaultBoardImportAI implements BoardImportAI {
111
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
+
112
216
  constructor(private base: BaseAIClient) {
113
217
  this.id = base.id
114
218
  }
@@ -192,26 +296,115 @@ ${JSON.stringify(targets, null, 2)}`
192
296
  async parseFromImage(
193
297
  image: Buffer | Uint8Array,
194
298
  options: ImageParseOptions = {}
195
- ): Promise<UniversalEntity[]> {
299
+ ): Promise<ImageAnalysis> {
196
300
  if (!this.base.vision) {
197
301
  throw new Error(`AI client (${this.base.id}) does not support vision input`)
198
302
  }
199
- const prompt = `Detect distinct objects in this drawing image.${
200
- options.context ? '\nContext: ' + options.context : ''
201
- }${
202
- options.categories ? '\nPossible categories: ' + options.categories.join(', ') : ''
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('; ')}`)
203
315
  }
204
316
 
205
- Return JSON array of UniversalEntity-like objects:
206
- - uid: string
207
- - kind: 'shape' | 'path' | 'text'
208
- - position: {x, y}
209
- - size: {width, height}
210
- - bbox: {min:{x,y}, max:{x,y}}
211
- - attribs: {name?, semanticType?, text?, layer?}`
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
212
321
 
213
- const text = await this.base.vision(image as Buffer, prompt)
214
- return parseJSONResponse<UniversalEntity[]>(text)
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)
215
408
  }
216
409
 
217
410
  async parseNaturalLanguageMapping(
@@ -240,6 +433,132 @@ Return JSON array of:
240
433
  const SYSTEM_HINT =
241
434
  'You are a CAD/IFC drawing analysis assistant. Output strict JSON only — no commentary, no markdown.'
242
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
+
243
562
  /**
244
563
  * LLM 응답의 regex pattern 문자열 → 실제 RegExp.
245
564
  * 보통 "^STK_" 같은 source string 으로 응답.
@@ -271,13 +590,29 @@ function hydrateRuleRegexes<T extends { match?: any }>(rule: T): T {
271
590
  // ── Provider 등록 ──────────────────────────────────────────────────
272
591
 
273
592
  let currentClient: BoardImportAI | undefined
593
+ let defaultBackedClient: BoardImportAI | undefined
594
+ let defaultBackedFor: BaseAIClient | undefined
274
595
 
275
596
  export function setAIClient(client: BoardImportAI | undefined): void {
276
597
  currentClient = client
277
598
  }
278
599
 
600
+ /**
601
+ * 등록된 BoardImportAI 가 있으면 그걸 반환.
602
+ * 없으면 ai-client-base 의 default AIClient (config.aiClient) 를 `DefaultBoardImportAI` 로
603
+ * 감싸서 반환 — board-ai 와 동일한 1회 설정만으로 image import 도 동작하게 한다.
604
+ *
605
+ * default base 가 바뀌면 (테스트 등) 캐시를 무효화하고 다시 감싼다.
606
+ */
279
607
  export function getAIClient(): BoardImportAI | undefined {
280
- return currentClient
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
281
616
  }
282
617
 
283
618
  /** Deprecated alias — 기존 코드 호환용. 새로 작성 시 BoardImportAI 사용. */
@@ -0,0 +1,91 @@
1
+ /**
2
+ * convertPlaceholderTo / attachGlbToPlaceholder — 단위 테스트.
3
+ *
4
+ * 코어 placeholder type (placeholder) + 도메인 추상 (GenericTransport 등 호출자 명시)
5
+ * 모두 변환 가능한지 검증. 좌표/크기/source 보존, GLB attach morph 정책 회귀 방지.
6
+ */
7
+ import {
8
+ convertPlaceholderTo,
9
+ isPlaceholderType,
10
+ CORE_PLACEHOLDER_TYPES
11
+ } from './generic-to'
12
+
13
+ // 호출자가 도메인 추상까지 인정하는 경우의 fixtures.
14
+ const EXTRA = ['GenericTransport', 'GenericFacility'] as const
15
+
16
+ describe('isPlaceholderType', () => {
17
+ test('코어 type 들은 인정', () => {
18
+ for (const t of CORE_PLACEHOLDER_TYPES) {
19
+ expect(isPlaceholderType(t)).toBe(true)
20
+ }
21
+ })
22
+
23
+ test('extra list 의 도메인 추상도 인정', () => {
24
+ expect(isPlaceholderType('GenericTransport', EXTRA)).toBe(true)
25
+ expect(isPlaceholderType('GenericFacility', EXTRA)).toBe(true)
26
+ })
27
+
28
+ test('미등록 type 은 거부', () => {
29
+ expect(isPlaceholderType('Stocker')).toBe(false)
30
+ expect(isPlaceholderType(undefined)).toBe(false)
31
+ })
32
+
33
+ test('extra 미지정 시 도메인 추상 거부', () => {
34
+ expect(isPlaceholderType('GenericTransport')).toBe(false)
35
+ })
36
+ })
37
+
38
+ describe('convertPlaceholderTo', () => {
39
+ const base = {
40
+ type: 'placeholder',
41
+ id: 'src-1',
42
+ left: 100,
43
+ top: 200,
44
+ width: 80,
45
+ height: 60,
46
+ rotation: 0.5,
47
+ fillStyle: 'rgba(180,180,180,0.18)',
48
+ placeholderLabel: 'maybe stocker',
49
+ placeholderHint: 'STK_001'
50
+ }
51
+
52
+ test('placeholder 가 아닌 type 은 throw', () => {
53
+ expect(() =>
54
+ convertPlaceholderTo({ ...base, type: 'rect' } as any, 'Stocker')
55
+ ).toThrow(/not a generic placeholder/)
56
+ })
57
+
58
+ test('좌표/크기/회전/id 보존', () => {
59
+ const r = convertPlaceholderTo(base as any, 'Stocker') as any
60
+ expect(r.left).toBe(100)
61
+ expect(r.top).toBe(200)
62
+ expect(r.width).toBe(80)
63
+ expect(r.height).toBe(60)
64
+ expect(r.rotation).toBe(0.5)
65
+ expect(r.id).toBe('src-1')
66
+ })
67
+
68
+ test('새 type 으로 변환 + category=unknown (rule 미등록)', () => {
69
+ const r = convertPlaceholderTo(base as any, 'Stocker') as any
70
+ expect(r.type).toBe('Stocker')
71
+ expect(r.category).toBe('unknown')
72
+ })
73
+
74
+ test('placeholder 시각 메타 (fillStyle / placeholderLabel) 는 제거', () => {
75
+ const r = convertPlaceholderTo(base as any, 'Stocker') as any
76
+ expect(r.fillStyle).toBeUndefined()
77
+ expect(r.placeholderLabel).toBeUndefined()
78
+ expect(r.placeholderHint).toBeUndefined()
79
+ expect(r.convertedFrom).toBeUndefined()
80
+ })
81
+
82
+ test('도메인 추상 (extraPlaceholderTypes 명시) 도 변환 가능', () => {
83
+ const transport = { ...base, type: 'GenericTransport', id: 'tx-1' }
84
+ const r = convertPlaceholderTo(transport as any, 'Forklift', {
85
+ extraPlaceholderTypes: ['GenericTransport']
86
+ }) as any
87
+ expect(r.type).toBe('Forklift')
88
+ expect(r.convertedFrom).toBeUndefined()
89
+ })
90
+ })
91
+