@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.
- package/config/config.development.js +44 -0
- package/config/config.production.js +18 -0
- package/dist-server/index.d.ts +7 -0
- package/dist-server/index.js +11 -0
- package/dist-server/index.js.map +1 -0
- package/dist-server/service/adapters/dxf-adapter.d.ts +8 -0
- package/dist-server/service/adapters/dxf-adapter.js +220 -0
- package/dist-server/service/adapters/dxf-adapter.js.map +1 -0
- 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 +185 -0
- package/dist-server/service/ai/types.js +378 -0
- package/dist-server/service/ai/types.js.map +1 -0
- package/dist-server/service/converters/generic-to.d.ts +31 -0
- package/dist-server/service/converters/generic-to.js +84 -0
- package/dist-server/service/converters/generic-to.js.map +1 -0
- package/dist-server/service/extraction/contracts.d.ts +112 -0
- package/dist-server/service/extraction/contracts.js +3 -0
- package/dist-server/service/extraction/contracts.js.map +1 -0
- package/dist-server/service/extraction/index.d.ts +17 -0
- package/dist-server/service/extraction/index.js +62 -0
- package/dist-server/service/extraction/index.js.map +1 -0
- package/dist-server/service/extraction/label-scale-calibrator.d.ts +5 -0
- package/dist-server/service/extraction/label-scale-calibrator.js +111 -0
- package/dist-server/service/extraction/label-scale-calibrator.js.map +1 -0
- package/dist-server/service/extraction/layout-refiner.d.ts +16 -0
- package/dist-server/service/extraction/layout-refiner.js +88 -0
- package/dist-server/service/extraction/layout-refiner.js.map +1 -0
- package/dist-server/service/extraction/pipeline.d.ts +11 -0
- package/dist-server/service/extraction/pipeline.js +124 -0
- package/dist-server/service/extraction/pipeline.js.map +1 -0
- package/dist-server/service/extraction/source-router.d.ts +17 -0
- package/dist-server/service/extraction/source-router.js +40 -0
- package/dist-server/service/extraction/source-router.js.map +1 -0
- package/dist-server/service/extraction/stubs.d.ts +18 -0
- package/dist-server/service/extraction/stubs.js +38 -0
- package/dist-server/service/extraction/stubs.js.map +1 -0
- package/dist-server/service/extraction/vector-geometry.d.ts +7 -0
- package/dist-server/service/extraction/vector-geometry.js +21 -0
- package/dist-server/service/extraction/vector-geometry.js.map +1 -0
- package/dist-server/service/extraction/vlm-recognizer.d.ts +18 -0
- package/dist-server/service/extraction/vlm-recognizer.js +78 -0
- package/dist-server/service/extraction/vlm-recognizer.js.map +1 -0
- 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 +39 -0
- package/dist-server/service/import-session/import-session-resolver.js +263 -0
- package/dist-server/service/import-session/import-session-resolver.js.map +1 -0
- package/dist-server/service/import-session/import-session.d.ts +28 -0
- package/dist-server/service/import-session/import-session.js +152 -0
- package/dist-server/service/import-session/import-session.js.map +1 -0
- package/dist-server/service/import-session/import-worker.d.ts +1 -0
- package/dist-server/service/import-session/import-worker.js +121 -0
- package/dist-server/service/import-session/import-worker.js.map +1 -0
- package/dist-server/service/import-session/index.d.ts +8 -0
- package/dist-server/service/import-session/index.js +19 -0
- package/dist-server/service/import-session/index.js.map +1 -0
- 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 +26 -0
- package/dist-server/service/index.js +60 -0
- package/dist-server/service/index.js.map +1 -0
- package/dist-server/service/pipeline/index.d.ts +66 -0
- package/dist-server/service/pipeline/index.js +127 -0
- package/dist-server/service/pipeline/index.js.map +1 -0
- package/dist-server/service/pipeline/stage2-mapping.d.ts +29 -0
- package/dist-server/service/pipeline/stage2-mapping.js +231 -0
- package/dist-server/service/pipeline/stage2-mapping.js.map +1 -0
- package/dist-server/service/pipeline/stage3-board.d.ts +52 -0
- package/dist-server/service/pipeline/stage3-board.js +108 -0
- package/dist-server/service/pipeline/stage3-board.js.map +1 -0
- package/dist-server/service/pipeline/stage4-binding.d.ts +9 -0
- package/dist-server/service/pipeline/stage4-binding.js +28 -0
- package/dist-server/service/pipeline/stage4-binding.js.map +1 -0
- package/dist-server/service/registry/index.d.ts +27 -0
- package/dist-server/service/registry/index.js +108 -0
- package/dist-server/service/registry/index.js.map +1 -0
- package/dist-server/service/types/index.d.ts +152 -0
- package/dist-server/service/types/index.js +3 -0
- package/dist-server/service/types/index.js.map +1 -0
- package/dist-server/service/types/registry.d.ts +175 -0
- package/dist-server/service/types/registry.js +3 -0
- package/dist-server/service/types/registry.js.map +1 -0
- package/dist-server/tsconfig.tsbuildinfo +1 -0
- package/package.json +35 -0
- package/probe/gcp-vision-probe.mjs +114 -0
- package/probe/opencv-geometry-probe.py +98 -0
- package/server/index.ts +7 -0
- package/server/service/adapters/dxf-adapter.ts +230 -0
- 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 +619 -0
- package/server/service/converters/generic-to.test.ts +105 -0
- package/server/service/converters/generic-to.ts +121 -0
- package/server/service/extraction/contracts.ts +142 -0
- package/server/service/extraction/extraction.test.ts +374 -0
- package/server/service/extraction/index.ts +51 -0
- package/server/service/extraction/label-scale-calibrator.ts +124 -0
- package/server/service/extraction/layout-refiner.ts +104 -0
- package/server/service/extraction/pipeline.ts +163 -0
- package/server/service/extraction/source-router.ts +45 -0
- package/server/service/extraction/stubs.ts +46 -0
- package/server/service/extraction/vector-geometry.ts +26 -0
- package/server/service/extraction/vlm-recognizer.ts +87 -0
- 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 +245 -0
- package/server/service/import-session/import-session.ts +141 -0
- package/server/service/import-session/import-worker.ts +132 -0
- package/server/service/import-session/index.ts +24 -0
- 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 +117 -0
- package/server/service/pipeline/index.ts +184 -0
- package/server/service/pipeline/stage2-mapping.test.ts +204 -0
- package/server/service/pipeline/stage2-mapping.ts +270 -0
- package/server/service/pipeline/stage3-board.test.ts +133 -0
- package/server/service/pipeline/stage3-board.ts +179 -0
- package/server/service/pipeline/stage4-binding.ts +31 -0
- package/server/service/registry/index.ts +127 -0
- package/server/service/types/index.ts +169 -0
- package/server/service/types/registry.ts +186 -0
- package/things-factory.config.js +1 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 하이브리드 추출 파이프라인 (골격) — Phase Board-AI §3.8.
|
|
3
|
+
*
|
|
4
|
+
* detect → geometry + labels → [VariantStrategy 또는 단일 fuse] → 시안별 scale → refine
|
|
5
|
+
*
|
|
6
|
+
* 기하는 *공유* (정확) → 시안들은 해석만 다름 → 3안 선택 UX 보존하되 셋 다 레이아웃 정상.
|
|
7
|
+
* impl 은 ExtractorSet 으로 주입 (스캐폴딩엔 stub). 기존 어댑터 경로와 독립.
|
|
8
|
+
*/
|
|
9
|
+
import type { UniversalEntity, BoundingBox, AdapterInput } from '../types/index'
|
|
10
|
+
import type {
|
|
11
|
+
SourceKind,
|
|
12
|
+
ExtractorSet,
|
|
13
|
+
GeometricPrimitive,
|
|
14
|
+
Label,
|
|
15
|
+
VariantDraft,
|
|
16
|
+
ScaleResult,
|
|
17
|
+
ExtractionContext
|
|
18
|
+
} from './contracts'
|
|
19
|
+
import { detectSourceKind, getExtractorSet } from './source-router'
|
|
20
|
+
|
|
21
|
+
export interface HybridExtractResult {
|
|
22
|
+
/** default 시안 entities (= variants[0]). */
|
|
23
|
+
entities: UniversalEntity[]
|
|
24
|
+
/** 다중 시안 (선택 UX). */
|
|
25
|
+
variants: VariantDraft[]
|
|
26
|
+
bbox: BoundingBox
|
|
27
|
+
sourceKind: SourceKind
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function extractHybrid(
|
|
31
|
+
input: AdapterInput,
|
|
32
|
+
ctx: ExtractionContext = {},
|
|
33
|
+
filename?: string
|
|
34
|
+
): Promise<HybridExtractResult> {
|
|
35
|
+
const kind = await detectSourceKind(input, filename)
|
|
36
|
+
const set = getExtractorSet(kind)
|
|
37
|
+
if (!set) {
|
|
38
|
+
throw new Error(`[board-import/extraction] '${kind}' ExtractorSet 미등록 — registerExtractorSet 로 주입 필요`)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// 1. 기하(위치) + 2. 라벨(식별/스케일 단서) — 공유 추출.
|
|
42
|
+
const primitives = await set.geometry.extract(input, ctx)
|
|
43
|
+
const labels: Label[] = set.labels ? await set.labels.extract(input, ctx) : []
|
|
44
|
+
|
|
45
|
+
// 3. 시안 생성 — 전략 있으면 N안, 없으면 단일 default.
|
|
46
|
+
let drafts: VariantDraft[] = set.variants
|
|
47
|
+
? await set.variants.interpret(primitives, labels, set, ctx)
|
|
48
|
+
: [{ id: 'default', label: 'default', entities: await fuse(primitives, labels, set, ctx) }]
|
|
49
|
+
if (drafts.length === 0) drafts = [{ id: 'default', label: 'default', entities: [] }]
|
|
50
|
+
|
|
51
|
+
// 4. 시안별 스케일 보정 + 5. 레이아웃 정리.
|
|
52
|
+
for (const d of drafts) {
|
|
53
|
+
if (set.scale) applyScale(d.entities, await set.scale.calibrate(labels, primitives, ctx))
|
|
54
|
+
if (set.refiner) d.entities = set.refiner.refine(d.entities, ctx)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
entities: drafts[0].entities,
|
|
59
|
+
variants: drafts,
|
|
60
|
+
bbox: outerBbox(drafts[0].entities),
|
|
61
|
+
sourceKind: kind
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── 기본 융합 (스캐폴딩) ────────────────────────────────────────────────────────
|
|
66
|
+
// 기하 프리미티브 → UniversalEntity, 가장 가까운 라벨을 name 으로 앵커.
|
|
67
|
+
// TODO(본격): region 별 set.recognizer.recognize → attribs.semanticType/componentType + categoryHint.
|
|
68
|
+
async function fuse(
|
|
69
|
+
primitives: GeometricPrimitive[],
|
|
70
|
+
labels: Label[],
|
|
71
|
+
set: ExtractorSet,
|
|
72
|
+
ctx?: ExtractionContext
|
|
73
|
+
): Promise<UniversalEntity[]> {
|
|
74
|
+
return Promise.all(
|
|
75
|
+
primitives.map(async (p, i) => {
|
|
76
|
+
const lbl = nearestLabel(p, labels)
|
|
77
|
+
|
|
78
|
+
if (p.entity) {
|
|
79
|
+
// 벡터: 이미 완전한 엔티티(타입/속성 보유) — 무손실 사용. name 없으면 근접 라벨로 보강.
|
|
80
|
+
const e = p.entity
|
|
81
|
+
if (!e.attribs.name && lbl) e.attribs = { ...e.attribs, name: lbl.text }
|
|
82
|
+
return e
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const e = primitiveToEntity(p, i)
|
|
86
|
+
if (lbl) e.attribs.name = lbl.text
|
|
87
|
+
|
|
88
|
+
// CV 등 bare 프리미티브 — Recognizer 로 의미(componentType) 부여. (좌표 아님)
|
|
89
|
+
if (set.recognizer) {
|
|
90
|
+
const region = lbl ? { ...p, meta: { ...p.meta, label: lbl.text } } : p
|
|
91
|
+
const r = await set.recognizer.recognize(region, ctx)
|
|
92
|
+
if (r.componentType) {
|
|
93
|
+
e.attribs.semanticType = r.componentType
|
|
94
|
+
e.attribs.confidence = r.confidence
|
|
95
|
+
}
|
|
96
|
+
if (r.attribs) Object.assign(e.attribs, r.attribs)
|
|
97
|
+
}
|
|
98
|
+
return e
|
|
99
|
+
})
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function primitiveToEntity(p: GeometricPrimitive, i: number): UniversalEntity {
|
|
104
|
+
const w = p.bbox.max.x - p.bbox.min.x
|
|
105
|
+
const h = p.bbox.max.y - p.bbox.min.y
|
|
106
|
+
return {
|
|
107
|
+
uid: `ext-${i}`,
|
|
108
|
+
kind: 'shape',
|
|
109
|
+
position: { x: p.bbox.min.x, y: p.bbox.min.y },
|
|
110
|
+
size: { width: w, height: h },
|
|
111
|
+
bbox: p.bbox,
|
|
112
|
+
attribs: {}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function center(b: BoundingBox) {
|
|
117
|
+
return { x: (b.min.x + b.max.x) / 2, y: (b.min.y + b.max.y) / 2 }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function nearestLabel(p: GeometricPrimitive, labels: Label[]): Label | undefined {
|
|
121
|
+
if (!labels.length) return undefined
|
|
122
|
+
const c = center(p.bbox)
|
|
123
|
+
let best: Label | undefined
|
|
124
|
+
let bestD = Infinity
|
|
125
|
+
for (const l of labels) {
|
|
126
|
+
const lc = center(l.bbox)
|
|
127
|
+
const d = (lc.x - c.x) ** 2 + (lc.y - c.y) ** 2
|
|
128
|
+
if (d < bestD) {
|
|
129
|
+
bestD = d
|
|
130
|
+
best = l
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return best
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function applyScale(entities: UniversalEntity[], s: ScaleResult): void {
|
|
137
|
+
const f = s.unitsPerPixel
|
|
138
|
+
const ox = s.origin.x
|
|
139
|
+
const oy = s.origin.y
|
|
140
|
+
for (const e of entities) {
|
|
141
|
+
e.position = { x: (e.position.x - ox) * f, y: (e.position.y - oy) * f }
|
|
142
|
+
if (e.size) e.size = { width: e.size.width * f, height: e.size.height * f }
|
|
143
|
+
e.bbox = {
|
|
144
|
+
min: { x: (e.bbox.min.x - ox) * f, y: (e.bbox.min.y - oy) * f },
|
|
145
|
+
max: { x: (e.bbox.max.x - ox) * f, y: (e.bbox.max.y - oy) * f }
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function outerBbox(entities: UniversalEntity[]): BoundingBox {
|
|
151
|
+
if (!entities.length) return { min: { x: 0, y: 0 }, max: { x: 0, y: 0 } }
|
|
152
|
+
let minX = Infinity
|
|
153
|
+
let minY = Infinity
|
|
154
|
+
let maxX = -Infinity
|
|
155
|
+
let maxY = -Infinity
|
|
156
|
+
for (const e of entities) {
|
|
157
|
+
minX = Math.min(minX, e.bbox.min.x)
|
|
158
|
+
minY = Math.min(minY, e.bbox.min.y)
|
|
159
|
+
maxX = Math.max(maxX, e.bbox.max.x)
|
|
160
|
+
maxY = Math.max(maxY, e.bbox.max.y)
|
|
161
|
+
}
|
|
162
|
+
return { min: { x: minX, y: minY }, max: { x: maxX, y: maxY } }
|
|
163
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 소스 라우팅 — 입력(+파일명)을 SourceKind 로 분류하고, 그 경로의 ExtractorSet 을 돌려준다.
|
|
3
|
+
* impl 묶음은 register 로 주입 (스캐폴딩 단계엔 stub 묶음). 기존 어댑터 경로는 무관/보존.
|
|
4
|
+
*/
|
|
5
|
+
import type { AdapterInput } from '../types/index'
|
|
6
|
+
import type { SourceKind, ExtractorSet } from './contracts'
|
|
7
|
+
|
|
8
|
+
const VECTOR_EXT = new Set(['dxf', 'svg', 'ifc'])
|
|
9
|
+
const RASTER_EXT = new Set(['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp'])
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 입력 → 소스 분류. 현재는 확장자 기반의 1차 분류.
|
|
13
|
+
* - pdf: vector 우선 가정 (실제론 vector/raster 페이지 판별 필요 — 추후).
|
|
14
|
+
* - 비트맵: 일단 raster-topdown. top-down vs photo 세분은 VLM 1-pass 분류로 (추후).
|
|
15
|
+
*/
|
|
16
|
+
export async function detectSourceKind(_input: AdapterInput, filename?: string): Promise<SourceKind> {
|
|
17
|
+
const ext = (filename?.split('.').pop() || '').toLowerCase()
|
|
18
|
+
if (VECTOR_EXT.has(ext) || ext === 'pdf') return 'vector'
|
|
19
|
+
if (RASTER_EXT.has(ext)) return 'raster-topdown'
|
|
20
|
+
return 'unknown'
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type ExtractorSetFactory = () => ExtractorSet
|
|
24
|
+
|
|
25
|
+
const _registry = new Map<SourceKind, ExtractorSetFactory>()
|
|
26
|
+
|
|
27
|
+
/** 소스별 추출기 묶음 등록 (impl 주입 지점). */
|
|
28
|
+
export function registerExtractorSet(kind: SourceKind, factory: ExtractorSetFactory): void {
|
|
29
|
+
_registry.set(kind, factory)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 소스별 추출기 묶음 조회. 미등록 시 undefined. */
|
|
33
|
+
export function getExtractorSet(kind: SourceKind): ExtractorSet | undefined {
|
|
34
|
+
return _registry.get(kind)?.()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 등록된 소스 종류 (진단/검사용). */
|
|
38
|
+
export function registeredSourceKinds(): SourceKind[] {
|
|
39
|
+
return [..._registry.keys()]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 등록 초기화 (테스트 격리 / 재구성용). */
|
|
43
|
+
export function clearExtractorSets(): void {
|
|
44
|
+
_registry.clear()
|
|
45
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 스캐폴딩 stub 구현 — 구조가 컴파일·배선되도록 자리만 잡는다.
|
|
3
|
+
* 남은 stub (본격 테스트 단계에서 소스 적합 impl 로 교체):
|
|
4
|
+
* CvGeometryExtractor → OpenCV(선/사각/연결성분) 또는 Python 사이드카
|
|
5
|
+
* CloudOcrLabelExtractor → GCP Vision / AWS Textract / Tesseract
|
|
6
|
+
* 미구현 stub 은 호출 시 명확히 throw — 실수로 live 경로에 붙어도 즉시 드러난다.
|
|
7
|
+
* (실구현: VectorGeometryExtractor / GridSnapLayoutRefiner / VlmRecognizer / LabelScaleCalibrator)
|
|
8
|
+
*/
|
|
9
|
+
import type { UniversalEntity } from '../types/index'
|
|
10
|
+
import type {
|
|
11
|
+
GeometryExtractor,
|
|
12
|
+
LabelExtractor,
|
|
13
|
+
LayoutRefiner,
|
|
14
|
+
GeometricPrimitive,
|
|
15
|
+
Label,
|
|
16
|
+
ExtractionContext
|
|
17
|
+
} from './contracts'
|
|
18
|
+
|
|
19
|
+
const NI = (what: string): never => {
|
|
20
|
+
throw new Error(`[board-import/extraction] ${what} 미구현 (스캐폴딩) — Phase B 본격 테스트 단계에서 구현`)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class CvGeometryExtractor implements GeometryExtractor {
|
|
24
|
+
readonly id = 'cv-geometry'
|
|
25
|
+
async extract(): Promise<GeometricPrimitive[]> {
|
|
26
|
+
return NI('CvGeometryExtractor.extract (OpenCV 선/사각/연결성분)')
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class CloudOcrLabelExtractor implements LabelExtractor {
|
|
31
|
+
readonly id = 'cloud-ocr'
|
|
32
|
+
async extract(): Promise<Label[]> {
|
|
33
|
+
return NI('CloudOcrLabelExtractor.extract (GCP Vision / Textract)')
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 레이아웃 정리 stub — 정렬/스냅/겹침해소는 미구현이라 *그대로 통과*(no-op).
|
|
39
|
+
* (throw 가 아니라 안전한 항등 — refiner 미구현이어도 파이프라인이 깨지지 않도록.)
|
|
40
|
+
*/
|
|
41
|
+
export class IdentityLayoutRefiner implements LayoutRefiner {
|
|
42
|
+
readonly id = 'identity'
|
|
43
|
+
refine(entities: UniversalEntity[], _ctx?: ExtractionContext): UniversalEntity[] {
|
|
44
|
+
return entities
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* VectorGeometryExtractor — 벡터 소스(DXF 등)를 *직접 파싱*해 정밀 기하 추출.
|
|
3
|
+
* Phase Board-AI §3.8 의 "벡터 = 1순위 정밀 경로". 기존 DxfAdapter 를 흡수(델리게이트):
|
|
4
|
+
* DXF entity 는 이미 위치+크기+회전+attribs(name/layer/shape) 를 가진 UniversalEntity 이므로,
|
|
5
|
+
* 각 엔티티를 GeometricPrimitive.entity 로 그대로 실어 *무손실* 전달한다.
|
|
6
|
+
*
|
|
7
|
+
* 추후: SVG / IFC 어댑터를 detect 로 분기해 같은 방식으로 흡수.
|
|
8
|
+
*/
|
|
9
|
+
import type { AdapterInput } from '../types/index'
|
|
10
|
+
import type { GeometryExtractor, GeometricPrimitive, ExtractionContext } from './contracts'
|
|
11
|
+
import { DxfAdapter } from '../adapters/dxf-adapter'
|
|
12
|
+
|
|
13
|
+
export class VectorGeometryExtractor implements GeometryExtractor {
|
|
14
|
+
readonly id = 'vector-geometry'
|
|
15
|
+
private readonly dxf = new DxfAdapter()
|
|
16
|
+
|
|
17
|
+
async extract(input: AdapterInput, _ctx?: ExtractionContext): Promise<GeometricPrimitive[]> {
|
|
18
|
+
// 현재 DXF 만 지원. (SVG/IFC 확장 시 detect 로 어댑터 선택.)
|
|
19
|
+
const result = await this.dxf.parse(input)
|
|
20
|
+
return result.entities.map(e => ({
|
|
21
|
+
kind: e.kind === 'path' ? ('line' as const) : ('rect' as const),
|
|
22
|
+
bbox: e.bbox,
|
|
23
|
+
entity: e // 무손실 보존 — fuse 가 그대로 사용
|
|
24
|
+
}))
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* VlmRecognizer — region 이 *어떤 컴포넌트인지* 식별 (Phase Board-AI §3.8 의 Recognizer).
|
|
3
|
+
*
|
|
4
|
+
* 좌표/레이아웃은 절대 추정하지 않는다(그건 Geometry/CV 의 몫). VLM/LLM 의 강점인
|
|
5
|
+
* *의미 인식* 만: 주어진 신호(라벨·모양·크기·레이어) + 카탈로그 → componentType.
|
|
6
|
+
* 카탈로그(ctx.catalog) 가 있으면 그 안의 값만 허용(그라운딩).
|
|
7
|
+
*
|
|
8
|
+
* provider 는 ai-client-base 의 AIClient. 생성자 주입 가능(테스트), 미주입 시 기본 클라이언트.
|
|
9
|
+
*/
|
|
10
|
+
import { getDefaultAIClient, type AIClient } from '@things-factory/ai-client-base'
|
|
11
|
+
import type { Recognizer, RecognitionResult, GeometricPrimitive, ExtractionContext } from './contracts'
|
|
12
|
+
|
|
13
|
+
interface RawRecognition {
|
|
14
|
+
componentType?: string | null
|
|
15
|
+
confidence?: number
|
|
16
|
+
attribs?: Record<string, any>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const SYSTEM =
|
|
20
|
+
'당신은 시설 도면의 한 영역(region)이 어떤 보드 컴포넌트 type 인지 판정하는 분류기다. ' +
|
|
21
|
+
'좌표/위치/크기를 추정하지 말고, 주어진 신호(라벨/모양/크기/레이어)와 카탈로그만으로 *type* 만 판정한다. ' +
|
|
22
|
+
'STRICT JSON 만 반환(마크다운/주석 금지).'
|
|
23
|
+
|
|
24
|
+
export class VlmRecognizer implements Recognizer {
|
|
25
|
+
readonly id = 'vlm-recognizer'
|
|
26
|
+
|
|
27
|
+
constructor(private readonly client?: AIClient) {}
|
|
28
|
+
|
|
29
|
+
async recognize(region: GeometricPrimitive, ctx?: ExtractionContext): Promise<RecognitionResult> {
|
|
30
|
+
const client = this.client ?? getDefaultAIClient()
|
|
31
|
+
if (!client) {
|
|
32
|
+
throw new Error('[board-import/extraction] VlmRecognizer 는 AIClient 가 필요합니다 (setDefaultAIClient / config.aiClient).')
|
|
33
|
+
}
|
|
34
|
+
const raw = await client.generateJSON<RawRecognition>(buildPrompt(region, ctx), {
|
|
35
|
+
systemPrompt: SYSTEM,
|
|
36
|
+
temperature: 0,
|
|
37
|
+
maxTokens: 300
|
|
38
|
+
})
|
|
39
|
+
return normalize(raw, ctx?.catalog)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** region 신호 + 카탈로그 → 인식 프롬프트. */
|
|
44
|
+
export function buildPrompt(region: GeometricPrimitive, ctx?: ExtractionContext): string {
|
|
45
|
+
const w = region.bbox.max.x - region.bbox.min.x
|
|
46
|
+
const h = region.bbox.max.y - region.bbox.min.y
|
|
47
|
+
const aspect = h !== 0 ? w / h : 0
|
|
48
|
+
const a = region.entity?.attribs ?? {}
|
|
49
|
+
const m = region.meta ?? {}
|
|
50
|
+
|
|
51
|
+
const signals: string[] = [`kind=${region.kind}`, `size=${round(w)}x${round(h)}`, `aspect=${aspect.toFixed(2)}`]
|
|
52
|
+
const name = a.name ?? m.name ?? m.label
|
|
53
|
+
if (name) signals.push(`label="${name}"`)
|
|
54
|
+
if (a.shape ?? m.shape) signals.push(`shape=${a.shape ?? m.shape}`)
|
|
55
|
+
if (a.layer ?? m.layer) signals.push(`layer=${a.layer ?? m.layer}`)
|
|
56
|
+
if (a.text ?? m.text) signals.push(`text="${a.text ?? m.text}"`)
|
|
57
|
+
|
|
58
|
+
const catalogLine =
|
|
59
|
+
ctx?.catalog && ctx.catalog.length
|
|
60
|
+
? `\n카탈로그(이 중에서만 선택, 맞는 게 없으면 componentType=null):\n${ctx.catalog.join(', ')}`
|
|
61
|
+
: '\n카탈로그 미지정 — 가장 적절한 컴포넌트 type 을 자유롭게 판정.'
|
|
62
|
+
const hint = ctx?.userPrompt ? `\n사용자 힌트: ${ctx.userPrompt}` : ''
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
`다음 영역의 신호로 보드 컴포넌트 type 을 판정하라.\n신호: ${signals.join(', ')}${catalogLine}${hint}\n` +
|
|
66
|
+
`반환 JSON: { "componentType": string|null, "confidence": 0..1, "attribs": { ... } }`
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 응답 정규화 + 카탈로그 그라운딩 + confidence 클램프. */
|
|
71
|
+
export function normalize(raw: RawRecognition | null | undefined, catalog?: string[]): RecognitionResult {
|
|
72
|
+
let type =
|
|
73
|
+
typeof raw?.componentType === 'string' && raw.componentType.trim() ? raw.componentType.trim() : undefined
|
|
74
|
+
// 카탈로그 있으면 그 안의 값만 허용 — 그라운딩(환각 type 차단).
|
|
75
|
+
if (type && catalog && catalog.length && !catalog.includes(type)) type = undefined
|
|
76
|
+
|
|
77
|
+
let conf = typeof raw?.confidence === 'number' && Number.isFinite(raw.confidence) ? raw.confidence : 0.5
|
|
78
|
+
conf = Math.max(0, Math.min(1, conf))
|
|
79
|
+
if (!type) conf = Math.min(conf, 0.3) // 타입 미상이면 신뢰도 상한
|
|
80
|
+
|
|
81
|
+
const attribs = raw?.attribs && typeof raw.attribs === 'object' ? raw.attribs : undefined
|
|
82
|
+
return { componentType: type, confidence: conf, attribs }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function round(n: number): number {
|
|
86
|
+
return Math.round(n * 100) / 100
|
|
87
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* import-actions — 단위 테스트.
|
|
3
|
+
*
|
|
4
|
+
* ResolverContext 무관 service 함수의 동작 검증. mock SessionRepoLike 로 격리.
|
|
5
|
+
* 비동기 worker (runImportJob) 는 setImmediate side-effect 로 분리되어 있어
|
|
6
|
+
* 본 테스트 범위 밖 — startImportJobAsync 는 session 저장 후 즉시 return 만 검증.
|
|
7
|
+
*/
|
|
8
|
+
import {
|
|
9
|
+
startImportJobAsync,
|
|
10
|
+
fetchImportSession,
|
|
11
|
+
listImportSessionsByChatSession,
|
|
12
|
+
describeAdapter,
|
|
13
|
+
type ImportActionsContext,
|
|
14
|
+
type SessionRepoLike
|
|
15
|
+
} from './import-actions'
|
|
16
|
+
|
|
17
|
+
const DOMAIN = { id: 'domain-1', name: 'test' }
|
|
18
|
+
const USER = { id: 'user-1' }
|
|
19
|
+
const CTX: ImportActionsContext = { domain: DOMAIN, user: USER }
|
|
20
|
+
|
|
21
|
+
function makeRepo(overrides: Partial<SessionRepoLike> = {}) {
|
|
22
|
+
const findOneByCalls: any[] = []
|
|
23
|
+
const saveCalls: any[] = []
|
|
24
|
+
const findCalls: any[] = []
|
|
25
|
+
const repo: any = {
|
|
26
|
+
findOneByCalls,
|
|
27
|
+
saveCalls,
|
|
28
|
+
findCalls,
|
|
29
|
+
findOneBy: async (criteria: any) => {
|
|
30
|
+
findOneByCalls.push(criteria)
|
|
31
|
+
return null
|
|
32
|
+
},
|
|
33
|
+
save: async (entity: any) => {
|
|
34
|
+
saveCalls.push(entity)
|
|
35
|
+
return { ...entity, id: 'session-' + (saveCalls.length) }
|
|
36
|
+
},
|
|
37
|
+
find: async (options: any) => {
|
|
38
|
+
findCalls.push(options)
|
|
39
|
+
return []
|
|
40
|
+
},
|
|
41
|
+
...overrides
|
|
42
|
+
}
|
|
43
|
+
return repo
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ── startImportJobAsync ────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
describe('startImportJobAsync', () => {
|
|
49
|
+
test('session 을 status=queued / progress=0 으로 저장', async () => {
|
|
50
|
+
const repo = makeRepo()
|
|
51
|
+
await startImportJobAsync({ attachmentId: 'a1' }, CTX, repo, () => {})
|
|
52
|
+
expect(repo.saveCalls).toHaveLength(1)
|
|
53
|
+
expect(repo.saveCalls[0]).toMatchObject({
|
|
54
|
+
domain: DOMAIN,
|
|
55
|
+
attachmentId: 'a1',
|
|
56
|
+
status: 'queued',
|
|
57
|
+
progress: 0,
|
|
58
|
+
message: 'Queued',
|
|
59
|
+
creator: USER
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('options 가 optionsJson 으로 직렬화', async () => {
|
|
64
|
+
const repo = makeRepo()
|
|
65
|
+
await startImportJobAsync(
|
|
66
|
+
{
|
|
67
|
+
attachmentId: 'a1',
|
|
68
|
+
scopes: ['fmsim'],
|
|
69
|
+
normalizeOrigin: true,
|
|
70
|
+
flipY: false,
|
|
71
|
+
scale: 2,
|
|
72
|
+
applyBinding: false,
|
|
73
|
+
parseOptions: { excludeLayers: ['ANNO'] }
|
|
74
|
+
},
|
|
75
|
+
CTX,
|
|
76
|
+
repo,
|
|
77
|
+
() => {}
|
|
78
|
+
)
|
|
79
|
+
const json = JSON.parse(repo.saveCalls[0].optionsJson)
|
|
80
|
+
expect(json).toEqual({
|
|
81
|
+
scopes: ['fmsim'],
|
|
82
|
+
normalizeOrigin: true,
|
|
83
|
+
flipY: false,
|
|
84
|
+
scale: 2,
|
|
85
|
+
applyBinding: false,
|
|
86
|
+
parseOptions: { excludeLayers: ['ANNO'] }
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('chatSessionId 가 옵션이라 미지정 시 undefined 로 저장', async () => {
|
|
91
|
+
const repo = makeRepo()
|
|
92
|
+
await startImportJobAsync({ attachmentId: 'a1' }, CTX, repo, () => {})
|
|
93
|
+
expect(repo.saveCalls[0].chatSessionId).toBeUndefined()
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
// ── fetchImportSession ────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
describe('fetchImportSession', () => {
|
|
100
|
+
test('domain id 기반 격리 조회', async () => {
|
|
101
|
+
const repo = makeRepo()
|
|
102
|
+
await fetchImportSession('s1', CTX, repo)
|
|
103
|
+
expect(repo.findOneByCalls[0]).toEqual({
|
|
104
|
+
id: 's1',
|
|
105
|
+
domain: { id: DOMAIN.id }
|
|
106
|
+
})
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('미존재 시 null', async () => {
|
|
110
|
+
const repo = makeRepo()
|
|
111
|
+
const r = await fetchImportSession('s1', CTX, repo)
|
|
112
|
+
expect(r).toBeNull()
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('존재 시 session 그대로 반환', async () => {
|
|
116
|
+
const session = { id: 's1', status: 'completed' } as any
|
|
117
|
+
const repo = makeRepo({ findOneBy: async () => session })
|
|
118
|
+
const r = await fetchImportSession('s1', CTX, repo)
|
|
119
|
+
expect(r).toBe(session)
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
// ── listImportSessionsByChatSession ────────────────────────────────
|
|
124
|
+
|
|
125
|
+
describe('listImportSessionsByChatSession', () => {
|
|
126
|
+
test('chatSessionId + domain 기반, 최신순 50개', async () => {
|
|
127
|
+
const repo = makeRepo()
|
|
128
|
+
await listImportSessionsByChatSession('chat-1', CTX, repo)
|
|
129
|
+
expect(repo.findCalls[0]).toEqual({
|
|
130
|
+
where: { chatSessionId: 'chat-1', domain: { id: DOMAIN.id } },
|
|
131
|
+
order: { createdAt: 'DESC' },
|
|
132
|
+
take: 50
|
|
133
|
+
})
|
|
134
|
+
})
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
// ── describeAdapter ───────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
describe('describeAdapter', () => {
|
|
140
|
+
test('FormatAdapter 의 능력을 직렬화 가능 평면 객체로', () => {
|
|
141
|
+
const adapter = {
|
|
142
|
+
format: 'dxf',
|
|
143
|
+
supportedExtensions: ['.dxf'],
|
|
144
|
+
capabilities: {
|
|
145
|
+
has2D: true,
|
|
146
|
+
has3D: false,
|
|
147
|
+
hasSemanticLabels: false,
|
|
148
|
+
hasTextLabels: true,
|
|
149
|
+
hasAttributes: true
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
expect(describeAdapter(adapter)).toEqual({
|
|
153
|
+
format: 'dxf',
|
|
154
|
+
supportedExtensions: ['.dxf'],
|
|
155
|
+
capabilities: {
|
|
156
|
+
has2D: true,
|
|
157
|
+
has3D: false,
|
|
158
|
+
hasSemanticLabels: false,
|
|
159
|
+
hasTextLabels: true,
|
|
160
|
+
hasAttributes: true
|
|
161
|
+
}
|
|
162
|
+
})
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
test('falsy capability 도 false 로 정규화', () => {
|
|
166
|
+
const adapter = {
|
|
167
|
+
format: 'image',
|
|
168
|
+
supportedExtensions: ['.png'],
|
|
169
|
+
capabilities: {
|
|
170
|
+
has2D: 1, // truthy non-bool
|
|
171
|
+
has3D: undefined,
|
|
172
|
+
hasSemanticLabels: 'yes',
|
|
173
|
+
hasTextLabels: 0,
|
|
174
|
+
hasAttributes: null
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
expect(describeAdapter(adapter as any).capabilities).toEqual({
|
|
178
|
+
has2D: true,
|
|
179
|
+
has3D: false,
|
|
180
|
+
hasSemanticLabels: true,
|
|
181
|
+
hasTextLabels: false,
|
|
182
|
+
hasAttributes: false
|
|
183
|
+
})
|
|
184
|
+
})
|
|
185
|
+
})
|