@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,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
+ })