@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.
- package/dist-server/index.d.ts +1 -1
- package/dist-server/index.js +1 -1
- package/dist-server/index.js.map +1 -1
- package/dist-server/service/adapters/dxf-adapter.d.ts +1 -1
- package/dist-server/service/adapters/dxf-adapter.js +18 -4
- package/dist-server/service/adapters/dxf-adapter.js.map +1 -1
- package/dist-server/service/adapters/image-adapter.d.ts +32 -0
- package/dist-server/service/adapters/image-adapter.js +420 -0
- package/dist-server/service/adapters/image-adapter.js.map +1 -0
- package/dist-server/service/ai/types.d.ts +96 -4
- package/dist-server/service/ai/types.js +221 -12
- package/dist-server/service/ai/types.js.map +1 -1
- package/dist-server/service/converters/generic-to.d.ts +17 -8
- package/dist-server/service/converters/generic-to.js +44 -44
- package/dist-server/service/converters/generic-to.js.map +1 -1
- package/dist-server/service/import-session/import-actions.d.ts +79 -0
- package/dist-server/service/import-session/import-actions.js +98 -0
- package/dist-server/service/import-session/import-actions.js.map +1 -0
- package/dist-server/service/import-session/import-session-resolver.d.ts +22 -1
- package/dist-server/service/import-session/import-session-resolver.js +166 -38
- package/dist-server/service/import-session/import-session-resolver.js.map +1 -1
- package/dist-server/service/import-session/import-worker.js +55 -13
- package/dist-server/service/import-session/import-worker.js.map +1 -1
- package/dist-server/service/import-session/index.d.ts +5 -3
- package/dist-server/service/import-session/index.js +16 -9
- package/dist-server/service/import-session/index.js.map +1 -1
- package/dist-server/service/import-session/materialize-from-session.d.ts +66 -0
- package/dist-server/service/import-session/materialize-from-session.js +104 -0
- package/dist-server/service/import-session/materialize-from-session.js.map +1 -0
- package/dist-server/service/import-session/suggest-board-name.d.ts +80 -0
- package/dist-server/service/import-session/suggest-board-name.js +195 -0
- package/dist-server/service/import-session/suggest-board-name.js.map +1 -0
- package/dist-server/service/import-tools.d.ts +10 -0
- package/dist-server/service/import-tools.js +227 -0
- package/dist-server/service/import-tools.js.map +1 -0
- package/dist-server/service/index.d.ts +22 -19
- package/dist-server/service/index.js +46 -36
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/service/pipeline/index.d.ts +42 -9
- package/dist-server/service/pipeline/index.js +92 -23
- package/dist-server/service/pipeline/index.js.map +1 -1
- package/dist-server/service/pipeline/stage2-mapping.d.ts +20 -5
- package/dist-server/service/pipeline/stage2-mapping.js +84 -26
- package/dist-server/service/pipeline/stage2-mapping.js.map +1 -1
- package/dist-server/service/pipeline/stage3-board.d.ts +2 -2
- package/dist-server/service/pipeline/stage3-board.js +65 -10
- package/dist-server/service/pipeline/stage3-board.js.map +1 -1
- package/dist-server/service/pipeline/stage4-binding.d.ts +1 -1
- package/dist-server/service/pipeline/stage4-binding.js +2 -2
- package/dist-server/service/pipeline/stage4-binding.js.map +1 -1
- package/dist-server/service/registry/index.d.ts +1 -1
- package/dist-server/service/registry/index.js.map +1 -1
- package/dist-server/service/types/index.d.ts +35 -0
- package/dist-server/service/types/index.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +7 -6
- package/server/index.ts +1 -1
- package/server/service/adapters/dxf-adapter.ts +17 -4
- package/server/service/adapters/image-adapter.test.ts +545 -0
- package/server/service/adapters/image-adapter.ts +464 -0
- package/server/service/ai/types.ts +354 -19
- package/server/service/converters/generic-to.test.ts +91 -0
- package/server/service/converters/generic-to.ts +66 -49
- package/server/service/import-session/import-actions.test.ts +185 -0
- package/server/service/import-session/import-actions.ts +164 -0
- package/server/service/import-session/import-session-resolver.ts +171 -39
- package/server/service/import-session/import-worker.ts +56 -12
- package/server/service/import-session/index.ts +19 -3
- package/server/service/import-session/materialize-from-session.test.ts +274 -0
- package/server/service/import-session/materialize-from-session.ts +158 -0
- package/server/service/import-session/suggest-board-name.test.ts +271 -0
- package/server/service/import-session/suggest-board-name.ts +279 -0
- package/server/service/import-tools.test.ts +137 -0
- package/server/service/import-tools.ts +255 -0
- package/server/service/index.ts +35 -18
- package/server/service/pipeline/index.ts +118 -23
- package/server/service/pipeline/stage2-mapping.test.ts +204 -0
- package/server/service/pipeline/stage2-mapping.ts +102 -27
- package/server/service/pipeline/stage3-board.test.ts +133 -0
- package/server/service/pipeline/stage3-board.ts +76 -11
- package/server/service/pipeline/stage4-binding.ts +2 -2
- package/server/service/registry/index.ts +1 -1
- 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 {
|
|
20
|
-
|
|
21
|
-
|
|
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<
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
214
|
-
|
|
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
|
+
|