@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,271 @@
1
+ /**
2
+ * suggestBoardName — 단위 테스트.
3
+ *
4
+ * 검증 포인트:
5
+ * - base 후보 우선순위 (hint > userPrompt > viewTypeReasoning > attachment stem > fallback)
6
+ * - 정규화 (확장자 제거, 특수문자 → 공백, 다중공백 단일화, 50자 절단)
7
+ * - 충돌 검사 (도메인 격리, case-insensitive, 가장 작은 (n) 선택)
8
+ * - 도메인 안에서 충돌 없으면 base 그대로
9
+ */
10
+ import {
11
+ suggestBoardName,
12
+ suggestBoardMeta,
13
+ pickAvailableSuffix,
14
+ type SuggestSessionRepoLike,
15
+ type SuggestAttachmentRepoLike,
16
+ type SuggestBoardRepoLike
17
+ } from './suggest-board-name'
18
+
19
+ function makeDeps(opts: {
20
+ session?: any
21
+ attachment?: any
22
+ existingNames?: string[]
23
+ }) {
24
+ const sessionRepo: SuggestSessionRepoLike = {
25
+ findOneBy: async () => opts.session ?? null
26
+ }
27
+ const attachmentRepo: SuggestAttachmentRepoLike = {
28
+ findOneBy: async () => opts.attachment ?? null
29
+ }
30
+ const boardRepo: SuggestBoardRepoLike = {
31
+ findExistingNames: async () => opts.existingNames ?? []
32
+ }
33
+ return { sessionRepo, attachmentRepo, boardRepo }
34
+ }
35
+
36
+ const ctx = { domain: { id: 'domain-1' } }
37
+
38
+ describe('suggestBoardName — base 후보 우선순위', () => {
39
+ test('hint 가 있으면 우선 — session 무시', async () => {
40
+ const deps = makeDeps({
41
+ session: { id: 's1', optionsJson: JSON.stringify({ userPrompt: 'should be ignored' }) },
42
+ existingNames: []
43
+ })
44
+ const r = await suggestBoardName({ sessionId: 's1', hint: 'My drawing' }, ctx, deps)
45
+ expect(r).toBe('My drawing')
46
+ })
47
+
48
+ test('hint 없으면 session.options.userPrompt 의 첫 문장', async () => {
49
+ const deps = makeDeps({
50
+ session: {
51
+ id: 's1',
52
+ optionsJson: JSON.stringify({
53
+ userPrompt: 'fab lithography zone with stockers. Multiple stockers visible.'
54
+ })
55
+ },
56
+ existingNames: []
57
+ })
58
+ const r = await suggestBoardName({ sessionId: 's1' }, ctx, deps)
59
+ expect(r).toBe('fab lithography zone with stockers')
60
+ })
61
+
62
+ test('userPrompt 없으면 viewTypeReasoning 사용', async () => {
63
+ const deps = makeDeps({
64
+ session: {
65
+ id: 's1',
66
+ optionsJson: JSON.stringify({}),
67
+ resultJson: JSON.stringify({
68
+ boardModel: {
69
+ importMeta: { viewTypeReasoning: 'isometric 3D rendering with depth' }
70
+ }
71
+ })
72
+ },
73
+ existingNames: []
74
+ })
75
+ const r = await suggestBoardName({ sessionId: 's1' }, ctx, deps)
76
+ expect(r).toBe('isometric 3D rendering with depth')
77
+ })
78
+
79
+ test('hint/userPrompt/reasoning 모두 없으면 attachment.name stem', async () => {
80
+ const deps = makeDeps({
81
+ session: { id: 's1', attachmentId: 'a1', optionsJson: '{}', resultJson: null },
82
+ attachment: { id: 'a1', name: 'factory_plan_v3.png' },
83
+ existingNames: []
84
+ })
85
+ const r = await suggestBoardName({ sessionId: 's1' }, ctx, deps)
86
+ expect(r).toBe('factory plan v3') // _-. → 공백
87
+ })
88
+
89
+ test('어떤 후보도 없으면 fallback "Imported board"', async () => {
90
+ const deps = makeDeps({})
91
+ const r = await suggestBoardName({}, ctx, deps)
92
+ expect(r).toBe('Imported board')
93
+ })
94
+
95
+ test('session 자체가 미존재면 fallback', async () => {
96
+ const deps = makeDeps({ session: null })
97
+ const r = await suggestBoardName({ sessionId: 'x' }, ctx, deps)
98
+ expect(r).toBe('Imported board')
99
+ })
100
+ })
101
+
102
+ describe('suggestBoardName — 정규화', () => {
103
+ test('확장자 제거', async () => {
104
+ const deps = makeDeps({
105
+ session: { id: 's1', attachmentId: 'a1', optionsJson: '{}' },
106
+ attachment: { id: 'a1', name: 'plant.dxf' },
107
+ existingNames: []
108
+ })
109
+ const r = await suggestBoardName({ sessionId: 's1' }, ctx, deps)
110
+ expect(r).toBe('plant')
111
+ })
112
+
113
+ test('_-. 다중 → 단일 공백', async () => {
114
+ const r = await suggestBoardName({ hint: 'foo___bar---baz' }, ctx, makeDeps({}))
115
+ expect(r).toBe('foo bar baz')
116
+ })
117
+
118
+ test('50자 초과 절단 + trim', async () => {
119
+ const longHint = 'a'.repeat(80) + ' final'
120
+ const r = await suggestBoardName({ hint: longHint }, ctx, makeDeps({}))
121
+ expect(r.length).toBeLessThanOrEqual(50)
122
+ })
123
+ })
124
+
125
+ describe('suggestBoardName — 충돌 회피', () => {
126
+ test('충돌 없으면 base 그대로', async () => {
127
+ const deps = makeDeps({ existingNames: [] })
128
+ const r = await suggestBoardName({ hint: 'My drawing' }, ctx, deps)
129
+ expect(r).toBe('My drawing')
130
+ })
131
+
132
+ test('정확 충돌 시 (2) 부여', async () => {
133
+ const deps = makeDeps({ existingNames: ['My drawing'] })
134
+ const r = await suggestBoardName({ hint: 'My drawing' }, ctx, deps)
135
+ expect(r).toBe('My drawing (2)')
136
+ })
137
+
138
+ test('(2) 까지 있으면 (3)', async () => {
139
+ const deps = makeDeps({
140
+ existingNames: ['My drawing', 'My drawing (2)']
141
+ })
142
+ const r = await suggestBoardName({ hint: 'My drawing' }, ctx, deps)
143
+ expect(r).toBe('My drawing (3)')
144
+ })
145
+
146
+ test('case-insensitive 비교 — "MY DRAWING" 도 충돌로 간주', async () => {
147
+ const deps = makeDeps({ existingNames: ['MY DRAWING'] })
148
+ const r = await suggestBoardName({ hint: 'My drawing' }, ctx, deps)
149
+ expect(r).toBe('My drawing (2)')
150
+ })
151
+
152
+ test('중간 빈 슬롯 — (3) 만 있으면 base 가 비어있으니 base 그대로', async () => {
153
+ // 'Foo' 가 비어있으면 그것 반환. (3) 만 있어도 base 가 비어있으니 base 우선.
154
+ const deps = makeDeps({ existingNames: ['Foo (3)'] })
155
+ const r = await suggestBoardName({ hint: 'Foo' }, ctx, deps)
156
+ expect(r).toBe('Foo')
157
+ })
158
+ })
159
+
160
+ describe('pickAvailableSuffix 단위', () => {
161
+ test('빈 existingNames → base', () => {
162
+ expect(pickAvailableSuffix('foo', [])).toBe('foo')
163
+ })
164
+ test('base 만 충돌 → (2)', () => {
165
+ expect(pickAvailableSuffix('foo', ['foo'])).toBe('foo (2)')
166
+ })
167
+ test('base, (2), (3) 다 충돌 → (4)', () => {
168
+ expect(pickAvailableSuffix('foo', ['foo', 'foo (2)', 'foo (3)'])).toBe('foo (4)')
169
+ })
170
+ test('(2) 만 차있고 base 비어있으면 base 반환', () => {
171
+ expect(pickAvailableSuffix('foo', ['foo (2)'])).toBe('foo')
172
+ })
173
+ })
174
+
175
+ // ── suggestBoardMeta — 짧은 이름 + 자세한 description ──────────────────
176
+
177
+ describe('suggestBoardMeta — 보드 정체성 (subject) 기반', () => {
178
+ test('VLM subjectName + subjectDescription 이 우선 — 보드 정체성', async () => {
179
+ const deps = makeDeps({
180
+ session: {
181
+ id: 's1',
182
+ optionsJson: JSON.stringify({ userPrompt: '강조하고 싶은 부분' }),
183
+ resultJson: JSON.stringify({
184
+ metadata: {
185
+ subjectName: 'Lithography zone',
186
+ subjectDescription:
187
+ 'fab lithography 구역 — 6개 stocker 가 좌측 벽을 따라 배치, 중앙에 컨베이어 라인.',
188
+ // 다음은 transient 메타 — description 에 들어가면 안 됨
189
+ importStrategy: 'AI 가 stocker 6개와 conveyor 라인을 식별했습니다.',
190
+ userPrompt: '강조하고 싶은 부분'
191
+ },
192
+ categoryDistribution: { container: 6, path: 3 }
193
+ })
194
+ },
195
+ existingNames: []
196
+ })
197
+ const r = await suggestBoardMeta({ sessionId: 's1' }, ctx, deps)
198
+ expect(r.name).toBe('Lithography zone')
199
+ expect(r.description).toContain('fab lithography 구역')
200
+ // transient 메타가 description 에 안 들어가야 함
201
+ expect(r.description).not.toContain('AI 가 stocker')
202
+ expect(r.description).not.toContain('총 ')
203
+ expect(r.description).not.toContain('container: 6')
204
+ })
205
+
206
+ test('명시 input.hint 가 있으면 name 우선 — 사용자 의도가 가장 직접적', async () => {
207
+ const deps = makeDeps({
208
+ session: {
209
+ id: 's1',
210
+ resultJson: JSON.stringify({
211
+ metadata: { subjectName: 'AI subject' }
212
+ })
213
+ },
214
+ existingNames: []
215
+ })
216
+ const r = await suggestBoardMeta({ sessionId: 's1', hint: 'My drawing' }, ctx, deps)
217
+ expect(r.name).toBe('My drawing')
218
+ })
219
+
220
+ test('subject 가 없으면 attachment.name stem fallback', async () => {
221
+ const deps = makeDeps({
222
+ session: { id: 's1', attachmentId: 'a1', optionsJson: '{}', resultJson: '{}' },
223
+ attachment: { id: 'a1', name: 'plant.dxf' },
224
+ existingNames: []
225
+ })
226
+ const r = await suggestBoardMeta({ sessionId: 's1' }, ctx, deps)
227
+ expect(r.name).toBe('plant')
228
+ })
229
+
230
+ test('description 우선순위 — subjectDescription > input.hint > 빈 문자열', async () => {
231
+ const deps = makeDeps({})
232
+ const r1 = await suggestBoardMeta({ hint: 'hint description' }, ctx, deps)
233
+ expect(r1.description).toBe('hint description')
234
+
235
+ const deps2 = makeDeps({})
236
+ const r2 = await suggestBoardMeta({}, ctx, deps2)
237
+ expect(r2.description).toBe('')
238
+ })
239
+
240
+ test('subjectName 이 30자 초과면 잘림', async () => {
241
+ const longSubject = 'A very long subject name that exceeds thirty characters limit'
242
+ const deps = makeDeps({
243
+ session: {
244
+ id: 's1',
245
+ resultJson: JSON.stringify({ metadata: { subjectName: longSubject } })
246
+ },
247
+ existingNames: []
248
+ })
249
+ const r = await suggestBoardMeta({ sessionId: 's1' }, ctx, deps)
250
+ expect(r.name.length).toBeLessThanOrEqual(30)
251
+ })
252
+
253
+ test('아무 정보 없으면 fallback name + 빈 description', async () => {
254
+ const deps = makeDeps({})
255
+ const r = await suggestBoardMeta({}, ctx, deps)
256
+ expect(r.name).toBe('Imported board')
257
+ expect(r.description).toBe('')
258
+ })
259
+
260
+ test('이름 충돌 검사는 동일 — (2) suffix 부여', async () => {
261
+ const deps = makeDeps({
262
+ session: {
263
+ id: 's1',
264
+ resultJson: JSON.stringify({ metadata: { subjectName: 'Lithography zone' } })
265
+ },
266
+ existingNames: ['Lithography zone']
267
+ })
268
+ const r = await suggestBoardMeta({ sessionId: 's1' }, ctx, deps)
269
+ expect(r.name).toBe('Lithography zone (2)')
270
+ })
271
+ })
@@ -0,0 +1,279 @@
1
+ /**
2
+ * suggestBoardName — ImportSession 으로부터 새 Board 의 후보 이름을 추천한다.
3
+ *
4
+ * 정책:
5
+ * 1) base 후보 우선순위: input.hint → session.options.userPrompt → session.result.metadata.viewTypeReasoning
6
+ * → attachment.name stem → 'Imported board'
7
+ * 2) base 정규화: 확장자 제거, _-. → 공백, 다중 공백 단일화, 50자 절단, trim
8
+ * 3) 도메인 격리 + 단일 LIKE query: `WHERE domain_id = ? AND (name = base OR name LIKE 'base (%)')`
9
+ * 한 번에 충돌 후보 다 가져오고 메모리에서 사용 가능한 가장 작은 (n) 결정
10
+ * 4) 결과: base 가 충돌 없으면 base, 있으면 'base (2)' / 'base (3)' / ...
11
+ *
12
+ * materializeFromSession 과 동일한 *RepoLike 패턴 — typeorm 의존성 주입으로 단위테스트 가능.
13
+ */
14
+
15
+ const FALLBACK = 'Imported board'
16
+
17
+ export interface SuggestBoardNameInput {
18
+ /** ImportSession id — 옵션. 있으면 거기서 hint / attachment 정보 추출. */
19
+ sessionId?: string
20
+ /**
21
+ * 명시 hint. session 정보보다 우선. 사용자가 wizard 에서 직접 메모를 쳐서 그것 기반 추천을
22
+ * 받고 싶을 때 사용.
23
+ */
24
+ hint?: string
25
+ }
26
+
27
+ export interface SessionForSuggest {
28
+ id: string
29
+ attachmentId?: string
30
+ optionsJson?: string | null
31
+ resultJson?: string | null
32
+ }
33
+
34
+ export interface AttachmentForSuggest {
35
+ id: string
36
+ name?: string
37
+ }
38
+
39
+ export interface SuggestSessionRepoLike {
40
+ findOneBy(criteria: any): Promise<SessionForSuggest | null>
41
+ }
42
+
43
+ export interface SuggestAttachmentRepoLike {
44
+ findOneBy(criteria: any): Promise<AttachmentForSuggest | null>
45
+ }
46
+
47
+ export interface SuggestBoardRepoLike {
48
+ /**
49
+ * 도메인 격리된 LIKE 쿼리. find({ where: { name: Like('base%'), domain: { id } } }) 형태로
50
+ * 호출자가 wiring. typeorm Like operator 노출을 피하기 위해 단순 string 으로 받음.
51
+ */
52
+ findExistingNames(opts: { domainId: string; basePrefix: string }): Promise<string[]>
53
+ }
54
+
55
+ export interface SuggestContext {
56
+ domain: { id: string; [k: string]: any }
57
+ }
58
+
59
+ export interface SuggestDeps {
60
+ sessionRepo: SuggestSessionRepoLike
61
+ attachmentRepo: SuggestAttachmentRepoLike
62
+ boardRepo: SuggestBoardRepoLike
63
+ }
64
+
65
+ export async function suggestBoardName(
66
+ input: SuggestBoardNameInput,
67
+ context: SuggestContext,
68
+ deps: SuggestDeps
69
+ ): Promise<string> {
70
+ const base = await resolveBaseName(input, context, deps)
71
+ const cleaned = sanitize(base) || FALLBACK
72
+
73
+ const existingNames = await deps.boardRepo.findExistingNames({
74
+ domainId: context.domain.id,
75
+ basePrefix: cleaned
76
+ })
77
+ return pickAvailableSuffix(cleaned, existingNames)
78
+ }
79
+
80
+ /**
81
+ * Board 이름 + 설명을 함께 추천. 이름은 짧고 심플 (운영 카드/리스트 표시용),
82
+ * 설명은 자세히 (사용자 hint + AI importStrategy + 통계 요약).
83
+ *
84
+ * 정책:
85
+ * - **name**: hint / userPrompt / VLM reasoning / attachment stem 에서 짧은 base 추출 후
86
+ * 30자 이내로 자르고, 충돌 검사 + (n) suffix
87
+ * - **description**: userPrompt + AI importStrategy + (있으면) reasoning + 카테고리 분포
88
+ * 를 자연어 단락으로 합성. 없으면 빈 문자열
89
+ */
90
+ export interface SuggestBoardMetaResult {
91
+ name: string
92
+ description: string
93
+ }
94
+
95
+ export async function suggestBoardMeta(
96
+ input: SuggestBoardNameInput,
97
+ context: SuggestContext,
98
+ deps: SuggestDeps
99
+ ): Promise<SuggestBoardMetaResult> {
100
+ // 보드 정체성 = AI 가 도면을 보고 식별한 subject. import 처리 메타 (importStrategy /
101
+ // categoryDist / userPrompt — AI 에게 한 지시) 와는 별 차원이라 영속 데이터에 섞지 않는다.
102
+ const { subjectName, subjectDescription } = await resolveSubject(input, context, deps)
103
+
104
+ // name: subjectName > input.hint > attachment.name stem > FALLBACK
105
+ // 사용자가 명시 hint 를 줬으면 그것이 우선 (직접 의도). VLM subjectName 은 그 다음.
106
+ const baseName =
107
+ (input.hint && input.hint.trim() && firstSentence(input.hint)) ||
108
+ subjectName ||
109
+ (await resolveAttachmentStem(input, context, deps)) ||
110
+ FALLBACK
111
+ const cleanedName = shortenName(sanitize(baseName) || FALLBACK)
112
+
113
+ const existingNames = await deps.boardRepo.findExistingNames({
114
+ domainId: context.domain.id,
115
+ basePrefix: cleanedName
116
+ })
117
+ const name = pickAvailableSuffix(cleanedName, existingNames)
118
+
119
+ // description: subjectDescription > input.hint (full) > 빈 문자열
120
+ // AI 가 도면을 본 자세한 묘사가 있으면 그것이 가장 가치 있음.
121
+ const description =
122
+ subjectDescription || (input.hint && input.hint.trim()) || ''
123
+
124
+ return { name, description }
125
+ }
126
+
127
+ /** AI 가 도면을 보고 식별한 subject (보드 정체성) — VLM 응답의 subjectName / subjectDescription. */
128
+ async function resolveSubject(
129
+ input: SuggestBoardNameInput,
130
+ context: SuggestContext,
131
+ deps: SuggestDeps
132
+ ): Promise<{ subjectName?: string; subjectDescription?: string }> {
133
+ if (!input.sessionId) return {}
134
+ const session = await deps.sessionRepo.findOneBy({
135
+ id: input.sessionId,
136
+ domain: { id: context.domain.id }
137
+ })
138
+ if (!session) return {}
139
+ const result = parseJsonSafe(session.resultJson)
140
+ const subjectName = typeof result?.metadata?.subjectName === 'string'
141
+ ? result.metadata.subjectName.trim()
142
+ : undefined
143
+ const subjectDescription = typeof result?.metadata?.subjectDescription === 'string'
144
+ ? result.metadata.subjectDescription.trim()
145
+ : undefined
146
+ return { subjectName, subjectDescription }
147
+ }
148
+
149
+ /** subject 미산출 시 fallback — attachment.name stem 만. */
150
+ async function resolveAttachmentStem(
151
+ input: SuggestBoardNameInput,
152
+ context: SuggestContext,
153
+ deps: SuggestDeps
154
+ ): Promise<string | undefined> {
155
+ if (!input.sessionId) return undefined
156
+ const session = await deps.sessionRepo.findOneBy({
157
+ id: input.sessionId,
158
+ domain: { id: context.domain.id }
159
+ })
160
+ if (!session?.attachmentId) return undefined
161
+ const att = await deps.attachmentRepo.findOneBy({
162
+ id: session.attachmentId,
163
+ domain: { id: context.domain.id }
164
+ })
165
+ return stemOf(att?.name) || undefined
166
+ }
167
+
168
+ /**
169
+ * 짧은 이름 — 단순 30자 단위로 단어 경계에서 자르기. 너무 길면 그대로 자르되 단어 보존.
170
+ */
171
+ function shortenName(s: string): string {
172
+ if (s.length <= 30) return s
173
+ // 30자 이내 마지막 공백에서 자르기
174
+ const truncated = s.slice(0, 30)
175
+ const lastSpace = truncated.lastIndexOf(' ')
176
+ if (lastSpace > 10) return truncated.slice(0, lastSpace).trim()
177
+ return truncated.trim()
178
+ }
179
+
180
+
181
+ // ── helpers ──────────────────────────────────────────────────────────
182
+
183
+ async function resolveBaseName(
184
+ input: SuggestBoardNameInput,
185
+ context: SuggestContext,
186
+ deps: SuggestDeps
187
+ ): Promise<string> {
188
+ // 1) 명시 hint
189
+ if (input.hint && input.hint.trim()) return firstSentence(input.hint)
190
+
191
+ // 2) session 정보
192
+ if (input.sessionId) {
193
+ const session = await deps.sessionRepo.findOneBy({
194
+ id: input.sessionId,
195
+ domain: { id: context.domain.id }
196
+ })
197
+ if (session) {
198
+ // 2a) userPrompt 우선
199
+ const opts = parseJsonSafe(session.optionsJson)
200
+ const userPrompt = typeof opts?.userPrompt === 'string' ? opts.userPrompt.trim() : ''
201
+ if (userPrompt) return firstSentence(userPrompt)
202
+
203
+ // 2b) VLM reasoning
204
+ const result = parseJsonSafe(session.resultJson)
205
+ const reasoning =
206
+ typeof result?.boardModel?.importMeta?.viewTypeReasoning === 'string'
207
+ ? result.boardModel.importMeta.viewTypeReasoning.trim()
208
+ : typeof result?.metadata?.viewTypeReasoning === 'string'
209
+ ? result.metadata.viewTypeReasoning.trim()
210
+ : ''
211
+ if (reasoning) return firstSentence(reasoning)
212
+
213
+ // 2c) attachment name stem
214
+ if (session.attachmentId) {
215
+ const att = await deps.attachmentRepo.findOneBy({
216
+ id: session.attachmentId,
217
+ domain: { id: context.domain.id }
218
+ })
219
+ const stem = stemOf(att?.name)
220
+ if (stem) return stem
221
+ }
222
+ }
223
+ }
224
+
225
+ return FALLBACK
226
+ }
227
+
228
+ function parseJsonSafe(s: string | null | undefined): any {
229
+ if (!s) return undefined
230
+ try {
231
+ return JSON.parse(s)
232
+ } catch {
233
+ return undefined
234
+ }
235
+ }
236
+
237
+ function firstSentence(text: string): string {
238
+ // 첫 문장 종결 부호 또는 줄바꿈까지. 미발견 시 전체.
239
+ const m = text.match(/^([^.\n!?]{1,120})[.\n!?]?/)
240
+ return (m?.[1] ?? text).trim()
241
+ }
242
+
243
+ function stemOf(name: string | undefined | null): string {
244
+ if (!name) return ''
245
+ // 마지막 . 이후 (확장자) 제거. 점이 여러 번이면 가장 마지막만.
246
+ const lastDot = name.lastIndexOf('.')
247
+ if (lastDot > 0) return name.slice(0, lastDot)
248
+ return name
249
+ }
250
+
251
+ /**
252
+ * 정규화: _-. → 공백, 다중 공백 단일화, trim, 50자 절단.
253
+ * 결과가 빈 문자열이면 '' 반환 (호출자가 fallback).
254
+ */
255
+ function sanitize(s: string): string {
256
+ if (!s) return ''
257
+ let cleaned = s.replace(/[_\-.]+/g, ' ').replace(/\s+/g, ' ').trim()
258
+ if (cleaned.length > 50) cleaned = cleaned.slice(0, 50).trim()
259
+ return cleaned
260
+ }
261
+
262
+ /**
263
+ * existingNames 안에서 base 가 그대로 비어있으면 base 반환, 아니면 'base (2)', '(3)', ...
264
+ * case-insensitive 비교 (postgres ilike 호환).
265
+ */
266
+ export function pickAvailableSuffix(base: string, existingNames: string[]): string {
267
+ const lower = base.toLowerCase()
268
+ const taken = new Set<string>()
269
+ for (const name of existingNames) {
270
+ if (typeof name === 'string') taken.add(name.toLowerCase())
271
+ }
272
+ if (!taken.has(lower)) return base
273
+ for (let n = 2; n < 10000; n++) {
274
+ const candidate = `${base} (${n})`
275
+ if (!taken.has(candidate.toLowerCase())) return candidate
276
+ }
277
+ // 9999개 충돌 이라는 비현실 시나리오 — 마지막 fallback
278
+ return `${base} (${Date.now()})`
279
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * board-import 의 LLM tool 등록 + builder wiring — 단위 테스트.
3
+ *
4
+ * registry 통합 (등록 / kind 분류 / schema 노출) 외에 builder 가 service 함수와 정확히
5
+ * 결합되었는지 검증. service 함수 본체는 별도 파일 (import-actions.test, materialize-from-
6
+ * session.test) 에서 검증되어 있으므로 본 파일은 wiring 만 — ctx 누락 / placeholder 미사용
7
+ * 회귀 방지.
8
+ */
9
+ import {
10
+ clearToolRegistry,
11
+ findToolSpec,
12
+ getToolKind,
13
+ getCategorySpecs
14
+ } from '@things-factory/ai-client-base'
15
+ import { registerBoardImportTools } from './import-tools'
16
+
17
+ beforeEach(() => {
18
+ clearToolRegistry()
19
+ registerBoardImportTools()
20
+ })
21
+
22
+ afterEach(() => {
23
+ clearToolRegistry()
24
+ })
25
+
26
+ // ── 카테고리 등록 검증 ─────────────────────────────────────────────
27
+
28
+ describe('import-tools — 카테고리 등록', () => {
29
+ test("'board-import' 카테고리 등록", () => {
30
+ expect(getCategorySpecs('board-import').length).toBeGreaterThan(0)
31
+ })
32
+
33
+ test('listImportAdapters / importBoardAsync / getImportSession / materializeImportSession 모두 등록', () => {
34
+ expect(findToolSpec('listImportAdapters')).toBeDefined()
35
+ expect(findToolSpec('importBoardAsync')).toBeDefined()
36
+ expect(findToolSpec('getImportSession')).toBeDefined()
37
+ expect(findToolSpec('materializeImportSession')).toBeDefined()
38
+ })
39
+ })
40
+
41
+ // ── kind 분류 ────────────────────────────────────────────────────
42
+
43
+ describe('import-tools — kind 분류', () => {
44
+ test('listImportAdapters / getImportSession 은 read', () => {
45
+ expect(getToolKind('listImportAdapters')).toBe('read')
46
+ expect(getToolKind('getImportSession')).toBe('read')
47
+ })
48
+
49
+ test('importBoardAsync / materializeImportSession 은 external', () => {
50
+ expect(getToolKind('importBoardAsync')).toBe('external')
51
+ expect(getToolKind('materializeImportSession')).toBe('external')
52
+ })
53
+ })
54
+
55
+ // ── schema 노출 ──────────────────────────────────────────────────
56
+
57
+ describe('import-tools — schema 노출', () => {
58
+ test('importBoardAsync schema 가 attachmentId required', () => {
59
+ const spec = findToolSpec('importBoardAsync')
60
+ expect(spec?.schema?.required).toContain('attachmentId')
61
+ })
62
+
63
+ test('materializeImportSession schema 가 sessionId / name required + type enum', () => {
64
+ const spec = findToolSpec('materializeImportSession')
65
+ expect(spec?.schema?.required).toEqual(expect.arrayContaining(['sessionId', 'name']))
66
+ expect(spec?.schema?.properties?.type?.enum).toEqual(['main', 'sub', 'popup'])
67
+ })
68
+
69
+ test('materializeImportSession 은 사용자 confirm 강조 themeNote 보유', () => {
70
+ const spec = findToolSpec('materializeImportSession')
71
+ expect(spec?.themeNote).toContain('confirm')
72
+ })
73
+ })
74
+
75
+ // ── builder wiring — ctx 누락 시 명확 에러 ───────────────────────────
76
+
77
+ describe('import-tools — builder ctx 누락', () => {
78
+ test('importBoardAsync 가 ctx.state 없으면 의미있는 에러', async () => {
79
+ const spec = findToolSpec('importBoardAsync')
80
+ if (spec?.kind !== 'external') throw new Error('expected external')
81
+ await expect(spec.builder({ attachmentId: 'a1' }, {})).rejects.toThrow(/ctx\.state\.domain/)
82
+ })
83
+
84
+ test('getImportSession 가 ctx.state 없으면 의미있는 에러', async () => {
85
+ const spec = findToolSpec('getImportSession')
86
+ if (spec?.kind !== 'read') throw new Error('expected read')
87
+ await expect(spec.builder({ sessionId: 's1' }, {})).rejects.toThrow(/ctx\.state\.domain/)
88
+ })
89
+
90
+ test('materializeImportSession 가 ctx.state 없으면 의미있는 에러', async () => {
91
+ const spec = findToolSpec('materializeImportSession')
92
+ if (spec?.kind !== 'external') throw new Error('expected external')
93
+ await expect(
94
+ spec.builder({ sessionId: 's1', name: 'New' }, {})
95
+ ).rejects.toThrow(/ctx\.state\.domain/)
96
+ })
97
+ })
98
+
99
+ // ── builder wiring — listImportAdapters (ctx 무관) ───────────────────
100
+ //
101
+ // listImportAdapters builder 는 require('./pipeline/index') 로 DEFAULT_ADAPTERS 를
102
+ // inspect 한다. pipeline 모듈 load chain (DxfAdapter / ImageAdapter / ai-client-base /
103
+ // 외부 라이브러리) 의 typeorm-bound 또는 type-graphql Field reflection 평가가 단위 test
104
+ // 환경에서 fail 하므로 본 단위 테스트에서는 격리 어렵다 — 통합 test (실 server bootstrap
105
+ // 환경) 또는 e2e 에서 검증해야 한다. 핵심 wiring (kind / schema / ctx 누락 에러) 은
106
+ // 위 describe 블록들이 충분히 보호.
107
+ //
108
+ // 향후 jest setup 에서 type-graphql / typeorm DataSource init 을 mock 해 격리 가능하면
109
+ // describe.skip → describe 로 복원.
110
+ describe.skip('import-tools — listImportAdapters (통합 test 영역)', () => {
111
+ test('AdapterInfo 배열 반환 — DXF 와 image 적어도 노출', async () => {
112
+ const spec = findToolSpec('listImportAdapters')
113
+ if (spec?.kind !== 'read') throw new Error('expected read')
114
+ const r = await spec.builder({}, {})
115
+ expect(Array.isArray(r)).toBe(true)
116
+ const formats = (r as any[]).map(a => a.format)
117
+ expect(formats).toContain('dxf')
118
+ expect(formats).toContain('image')
119
+ })
120
+
121
+ test('AdapterInfo 의 capabilities 가 모든 boolean 5 필드 보유', async () => {
122
+ const spec = findToolSpec('listImportAdapters')
123
+ if (spec?.kind !== 'read') throw new Error('expected read')
124
+ const r = await spec.builder({}, {})
125
+ for (const info of r as any[]) {
126
+ expect(info.capabilities).toEqual(
127
+ expect.objectContaining({
128
+ has2D: expect.any(Boolean),
129
+ has3D: expect.any(Boolean),
130
+ hasSemanticLabels: expect.any(Boolean),
131
+ hasTextLabels: expect.any(Boolean),
132
+ hasAttributes: expect.any(Boolean)
133
+ })
134
+ )
135
+ }
136
+ })
137
+ })