@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,164 @@
1
+ /**
2
+ * board-import 의 ResolverContext 무관 service-level 함수.
3
+ *
4
+ * import-session-resolver 의 mutation/query 핵심 로직을 typeorm getRepository /
5
+ * ResolverContext 외부 의존성을 인자로 분리해 단위 테스트 + LLM tool builder 에서
6
+ * 재사용 가능하도록 추출. materializeFromSession 의 패턴 동일.
7
+ *
8
+ * 호출자 책임:
9
+ * - resolver: getRepository / context.state 에서 도메인/유저 추출 후 deps 채워 호출
10
+ * - import-tools.ts: ToolDispatchHelpers 가 builder 호출 시 ctx 에서 추출
11
+ */
12
+
13
+ // type-only import — module level 에서 typeorm decorator chain 을 평가하지 않는다.
14
+ // 단위 테스트 환경이 typeorm DataSource init 전에도 service 함수 검증할 수 있게 함.
15
+ import type { ImportSession } from './import-session'
16
+
17
+ // ── 입력/의존성 타입 ──────────────────────────────────────────────────
18
+
19
+ export interface SessionRepoLike {
20
+ findOneBy(criteria: any): Promise<ImportSession | null>
21
+ save(entity: any): Promise<ImportSession>
22
+ find(options: any): Promise<ImportSession[]>
23
+ }
24
+
25
+ export interface ImportActionsContext {
26
+ domain: { id: string; [k: string]: any }
27
+ user: any
28
+ }
29
+
30
+ export interface StartImportJobInput {
31
+ attachmentId: string
32
+ chatSessionId?: string
33
+ scopes?: string[]
34
+ normalizeOrigin?: boolean
35
+ flipY?: boolean
36
+ scale?: number
37
+ applyBinding?: boolean
38
+ parseOptions?: any
39
+ /**
40
+ * 사용자 자유서술 — 도면 설명 / 강조 부분 / 분류 힌트 등.
41
+ * VLM 이 view type 분류 + entity 추출 양쪽 패스에 모두 추가 컨텍스트로 사용.
42
+ */
43
+ userPrompt?: string
44
+ }
45
+
46
+ // ── service 함수들 ────────────────────────────────────────────────────
47
+
48
+ /**
49
+ * 비동기 import job 시작. ImportSession 즉시 반환, 백그라운드에서 worker 실행.
50
+ *
51
+ * resolver 와 LLM tool builder 양쪽이 공유하는 진입점. side-effect 인 worker 가동도
52
+ * 본 함수에 묶음 — 사용자 입장에선 "단일 호출 = import 시작" 의미.
53
+ *
54
+ * `startWorker` 는 의존성 주입 — default 는 setImmediate(runImportJob). 단위 테스트는
55
+ * noop 주입해 worker chain 이 jest 환경에서 평가되지 않도록 한다 (typeorm DataSource init
56
+ * 안 된 환경에서 entity decorator 평가 시 fail 회피).
57
+ */
58
+ export async function startImportJobAsync(
59
+ input: StartImportJobInput,
60
+ context: ImportActionsContext,
61
+ sessionRepo: SessionRepoLike,
62
+ startWorker: (sessionId: string) => void = defaultStartWorker
63
+ ): Promise<ImportSession> {
64
+ const session = await sessionRepo.save({
65
+ domain: context.domain,
66
+ attachmentId: input.attachmentId,
67
+ chatSessionId: input.chatSessionId,
68
+ status: 'queued',
69
+ progress: 0,
70
+ message: 'Queued',
71
+ optionsJson: JSON.stringify({
72
+ scopes: input.scopes,
73
+ normalizeOrigin: input.normalizeOrigin,
74
+ flipY: input.flipY,
75
+ scale: input.scale,
76
+ applyBinding: input.applyBinding,
77
+ parseOptions: input.parseOptions,
78
+ userPrompt: input.userPrompt
79
+ }),
80
+ creator: context.user
81
+ } as any)
82
+
83
+ // 백그라운드 worker 시작 — 의존성 주입으로 분리 (테스트 격리 + lazy require).
84
+ startWorker(session.id!)
85
+ return session
86
+ }
87
+
88
+ /**
89
+ * Default startWorker — setImmediate(runImportJob). worker module 은 lazy require 로
90
+ * module-level 평가 chain (attachment-base STORAGE, pipeline 의 adapters 등) 을 회피.
91
+ */
92
+ function defaultStartWorker(sessionId: string): void {
93
+ setImmediate(() => {
94
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
95
+ const { runImportJob } = require('./import-worker')
96
+ runImportJob(sessionId).catch((err: Error) => {
97
+ console.error('[board-import] runImportJob unhandled:', err)
98
+ })
99
+ })
100
+ }
101
+
102
+ /**
103
+ * 단일 ImportSession 조회 (도메인 격리). 미존재면 null.
104
+ */
105
+ export async function fetchImportSession(
106
+ sessionId: string,
107
+ context: ImportActionsContext,
108
+ sessionRepo: SessionRepoLike
109
+ ): Promise<ImportSession | null> {
110
+ return await sessionRepo.findOneBy({
111
+ id: sessionId,
112
+ domain: { id: context.domain.id }
113
+ })
114
+ }
115
+
116
+ /**
117
+ * 한 ChatSession 안에서 발동된 ImportSession 목록 (최신순 50개). board-ai 의 chat 흐름이
118
+ * "이 chat 의 import 들 보여줘" 같은 호출에서 사용.
119
+ */
120
+ export async function listImportSessionsByChatSession(
121
+ chatSessionId: string,
122
+ context: ImportActionsContext,
123
+ sessionRepo: SessionRepoLike
124
+ ): Promise<ImportSession[]> {
125
+ return await sessionRepo.find({
126
+ where: { chatSessionId, domain: { id: context.domain.id } },
127
+ order: { createdAt: 'DESC' },
128
+ take: 50
129
+ })
130
+ }
131
+
132
+ /**
133
+ * 등록된 FormatAdapter 들의 능력 정보. context 무관 (인스턴스만 inspect).
134
+ * 외부 RPC 의존성도 없어 LLM 이 안전하게 호출 가능한 read tool.
135
+ */
136
+ export interface AdapterInfo {
137
+ format: string
138
+ supportedExtensions: string[]
139
+ capabilities: {
140
+ has2D: boolean
141
+ has3D: boolean
142
+ hasSemanticLabels: boolean
143
+ hasTextLabels: boolean
144
+ hasAttributes: boolean
145
+ }
146
+ }
147
+
148
+ export function describeAdapter(adapter: {
149
+ format: string
150
+ supportedExtensions: string[]
151
+ capabilities: any
152
+ }): AdapterInfo {
153
+ return {
154
+ format: adapter.format,
155
+ supportedExtensions: [...adapter.supportedExtensions],
156
+ capabilities: {
157
+ has2D: !!adapter.capabilities.has2D,
158
+ has3D: !!adapter.capabilities.has3D,
159
+ hasSemanticLabels: !!adapter.capabilities.hasSemanticLabels,
160
+ hasTextLabels: !!adapter.capabilities.hasTextLabels,
161
+ hasAttributes: !!adapter.capabilities.hasAttributes
162
+ }
163
+ }
164
+ }
@@ -0,0 +1,245 @@
1
+ /**
2
+ * ImportSession resolver + 비동기 worker.
3
+ *
4
+ * Mutation:
5
+ * - importBoardAsync(attachmentId, options): ImportSession (즉시 반환, 백그라운드에서 처리)
6
+ *
7
+ * Query:
8
+ * - importSession(id): ImportSession (진행 상황 polling)
9
+ *
10
+ * 비동기 worker 는 단일 프로세스 내 setImmediate 기반. 추후 큐(Redis/BullMQ) 로 이전 가능.
11
+ */
12
+ import {
13
+ Resolver,
14
+ Query,
15
+ Mutation,
16
+ Arg,
17
+ Ctx,
18
+ Directive,
19
+ Field,
20
+ ID,
21
+ InputType,
22
+ ObjectType
23
+ } from 'type-graphql'
24
+ import { GraphQLJSON } from 'graphql-scalars'
25
+ import { Like } from 'typeorm'
26
+ import '@things-factory/auth-base'
27
+
28
+ import { getRepository } from '@things-factory/shell'
29
+ import { Board, Group } from '@things-factory/board-service'
30
+ import { Attachment } from '@things-factory/attachment-base'
31
+
32
+ import { ImportSession } from './import-session'
33
+ import { materializeFromSession } from './materialize-from-session'
34
+ import {
35
+ suggestBoardName,
36
+ suggestBoardMeta,
37
+ type SuggestBoardMetaResult,
38
+ type SuggestBoardRepoLike
39
+ } from './suggest-board-name'
40
+ import {
41
+ startImportJobAsync,
42
+ fetchImportSession,
43
+ listImportSessionsByChatSession
44
+ } from './import-actions'
45
+
46
+ @InputType()
47
+ class MaterializeImportSessionInput {
48
+ @Field(() => ID, { description: 'ImportSession id (must be in completed state).' })
49
+ sessionId!: string
50
+
51
+ @Field({ description: 'New Board name.' })
52
+ name!: string
53
+
54
+ @Field({ nullable: true, description: 'Optional Board description.' })
55
+ description?: string
56
+
57
+ @Field(() => ID, { nullable: true, description: 'Optional Group id to attach the Board to.' })
58
+ groupId?: string
59
+
60
+ @Field({ nullable: true, description: "Board type — 'main' | 'sub' | 'popup'. Default 'main'." })
61
+ type?: 'main' | 'sub' | 'popup'
62
+
63
+ @Field({ nullable: true, description: 'Base64 thumbnail. If omitted, board-service default empty thumbnail is used.' })
64
+ thumbnail?: string
65
+
66
+ @Field({
67
+ nullable: true,
68
+ description:
69
+ "ImportSession 의 결과 시안 중 하나의 id. 'as-is' / 'scene' / 'auto-fit'. " +
70
+ '미지정 시 default variant (보통 \'scene\') 사용.'
71
+ })
72
+ variantId?: string
73
+ }
74
+
75
+ @ObjectType()
76
+ class BoardMetaSuggestion {
77
+ @Field({ description: 'Suggested short board name (collision-free with current domain).' })
78
+ name!: string
79
+
80
+ @Field({
81
+ description:
82
+ 'Suggested detailed description — combines user prompt, AI importStrategy narrative, and category distribution stats.'
83
+ })
84
+ description!: string
85
+ }
86
+
87
+ @InputType()
88
+ class SuggestBoardNameInput {
89
+ @Field(() => ID, {
90
+ nullable: true,
91
+ description: 'ImportSession id — 있으면 거기서 userPrompt / VLM reasoning / attachment.name 추출.'
92
+ })
93
+ sessionId?: string
94
+
95
+ @Field({
96
+ nullable: true,
97
+ description: '명시 hint. session 정보보다 우선. 사용자가 직접 적은 메모를 기반으로 추천받고 싶을 때.'
98
+ })
99
+ hint?: string
100
+ }
101
+
102
+ @InputType()
103
+ class ImportBoardAsyncInput {
104
+ @Field({ description: 'Attachment id of the source drawing file (uploaded via attachment-base).' })
105
+ attachmentId!: string
106
+
107
+ @Field({ nullable: true, description: 'ChatSession id when triggered from board-ai chat.' })
108
+ chatSessionId?: string
109
+
110
+ @Field(() => [String], { nullable: true, description: 'Registry scopes to use (board-import).' })
111
+ scopes?: string[]
112
+
113
+ @Field({ nullable: true, description: 'Normalize coordinates so minX,minY=0' })
114
+ normalizeOrigin?: boolean
115
+
116
+ @Field({ nullable: true, description: 'Flip Y axis (CAD Y-up → scene Y-down).' })
117
+ flipY?: boolean
118
+
119
+ @Field({ nullable: true, description: 'Scale factor (e.g. 1 for mm:1unit).' })
120
+ scale?: number
121
+
122
+ @Field({ nullable: true, description: 'Apply Stage 4 (data binding).' })
123
+ applyBinding?: boolean
124
+
125
+ @Field(() => GraphQLJSON, { nullable: true, description: 'Adapter parse options (excludeLayers, maxEntities, ...).' })
126
+ parseOptions?: any
127
+
128
+ @Field({
129
+ nullable: true,
130
+ description:
131
+ 'User-provided context / hints about the drawing — passed to VLM as additional guidance. ' +
132
+ 'e.g. "fab lithography zone, central rectangles are stockers, thin lines are OHT rails."'
133
+ })
134
+ userPrompt?: string
135
+ }
136
+
137
+ @Resolver()
138
+ export class ImportSessionResolver {
139
+ @Query(() => ImportSession, { nullable: true, description: 'Get import session progress.' })
140
+ @Directive('@privilege(category: "board-import", privilege: "query")')
141
+ async importSession(
142
+ @Arg('id') id: string,
143
+ @Ctx() context: ResolverContext
144
+ ): Promise<ImportSession | null> {
145
+ const { domain } = context.state
146
+ return await fetchImportSession(id, { domain, user: context.state.user }, getRepository(ImportSession) as any)
147
+ }
148
+
149
+ @Query(() => [ImportSession], { description: 'List recent import sessions for a chat session.' })
150
+ @Directive('@privilege(category: "board-import", privilege: "query")')
151
+ async importSessionsByChatSession(
152
+ @Arg('chatSessionId') chatSessionId: string,
153
+ @Ctx() context: ResolverContext
154
+ ): Promise<ImportSession[]> {
155
+ const { domain, user } = context.state
156
+ return await listImportSessionsByChatSession(chatSessionId, { domain, user }, getRepository(ImportSession) as any)
157
+ }
158
+
159
+ @Mutation(() => ImportSession, {
160
+ description: '도면 → 보드 변환을 비동기로 시작. 즉시 ImportSession 반환, 백그라운드에서 처리.'
161
+ })
162
+ @Directive('@privilege(category: "board-import", privilege: "mutation")')
163
+ async importBoardAsync(
164
+ @Arg('input') input: ImportBoardAsyncInput,
165
+ @Ctx() context: ResolverContext
166
+ ): Promise<ImportSession> {
167
+ const { domain, user } = context.state
168
+ return await startImportJobAsync(input, { domain, user }, getRepository(ImportSession) as any)
169
+ }
170
+
171
+ @Query(() => String, {
172
+ description:
173
+ '@deprecated suggestBoardMeta 사용 권장. 단순 string name 만 반환하는 구버전 query.'
174
+ })
175
+ @Directive('@privilege(category: "board-import", privilege: "query")')
176
+ async suggestBoardName(
177
+ @Arg('input') input: SuggestBoardNameInput,
178
+ @Ctx() context: ResolverContext
179
+ ): Promise<string> {
180
+ const { domain } = context.state
181
+ const deps = this._buildSuggestDeps()
182
+ return await suggestBoardName(input, { domain }, deps)
183
+ }
184
+
185
+ @Query(() => BoardMetaSuggestion, {
186
+ description:
187
+ '도메인 안에서 충돌하지 않는 Board name + 상세 description 제안. ' +
188
+ 'name 은 짧고 심플 (30자 이내, 충돌 회피 (n) suffix), description 은 자세히 ' +
189
+ '(사용자 prompt + AI importStrategy + 카테고리 분포 통계 합성).'
190
+ })
191
+ @Directive('@privilege(category: "board-import", privilege: "query")')
192
+ async suggestBoardMeta(
193
+ @Arg('input') input: SuggestBoardNameInput,
194
+ @Ctx() context: ResolverContext
195
+ ): Promise<SuggestBoardMetaResult> {
196
+ const { domain } = context.state
197
+ const deps = this._buildSuggestDeps()
198
+ return await suggestBoardMeta(input, { domain }, deps)
199
+ }
200
+
201
+ /** 두 query 가 공유하는 deps wiring — typeorm find idiomatic 패턴 (distinctAlias 회귀 회피). */
202
+ private _buildSuggestDeps() {
203
+ const sessionRepo = getRepository(ImportSession) as any
204
+ const attachmentRepo = getRepository(Attachment) as any
205
+ const boardRepo: SuggestBoardRepoLike = {
206
+ findExistingNames: async ({ domainId, basePrefix }) => {
207
+ // 도메인 격리 + 'basePrefix' 로 시작하는 name 후보 한 번에 조회.
208
+ // 단순 find({ where, select }) — take 없음 → DISTINCT 서브쿼리 미발생.
209
+ const escaped = basePrefix.replace(/[%_\\]/g, ch => '\\' + ch)
210
+ const items = await getRepository(Board).find({
211
+ select: ['name'],
212
+ where: { domain: { id: domainId }, name: Like(`${escaped}%`) }
213
+ })
214
+ return items.map(b => b.name as string)
215
+ }
216
+ }
217
+ return { sessionRepo, attachmentRepo, boardRepo }
218
+ }
219
+
220
+ @Mutation(() => Board, {
221
+ description:
222
+ '완료된 ImportSession 의 결과 boardModel 을 새 Board entity 로 영속화한다. ' +
223
+ '검수 단계 (사용자/AI 가 import 결과를 확인 후 명시적으로 호출) 에서 사용. ' +
224
+ 'Board.state="draft" 로 생성되므로 release mutation 으로 별도 발행 필요.'
225
+ })
226
+ @Directive('@privilege(category: "board-import", privilege: "mutation")')
227
+ async materializeImportSession(
228
+ @Arg('input') input: MaterializeImportSessionInput,
229
+ @Ctx() context: ResolverContext
230
+ ): Promise<Board> {
231
+ const { domain, user } = context.state
232
+ const sessionRepo = getRepository(ImportSession)
233
+ const boardRepo = getRepository(Board)
234
+ const groupRepo = getRepository(Group)
235
+
236
+ const session = await sessionRepo.findOneBy({ id: input.sessionId, domain: { id: domain.id } })
237
+
238
+ return (await materializeFromSession(
239
+ session as any,
240
+ input,
241
+ { domain, user },
242
+ { boardRepo: boardRepo as any, groupRepo: groupRepo as any }
243
+ )) as Board
244
+ }
245
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * ImportSession — 도면(Attachment) → BoardModel 변환 작업 단위.
3
+ *
4
+ * 비동기 워크플로우:
5
+ * 1) 클라이언트가 importBoardAsync(attachmentId) 호출 → ImportSession 즉시 반환 (status:'queued')
6
+ * 2) 백그라운드 worker 가 attachment 본문 → importBoard() 실행 → status 갱신
7
+ * 3) 클라이언트는 polling 또는 subscription 으로 진행 상황 추적
8
+ * 4) status='completed' 시 result 에 BoardModel + stats + warnings 포함
9
+ *
10
+ * board-ai 의 ChatSession 과 연결 가능 (chatSessionId) — chat 안에서 자동 발동되면 진행 메시지가
11
+ * 자동으로 ChatMessage(role:'system') 으로 기록되어 AI 컨텍스트에 흐름.
12
+ */
13
+ import {
14
+ Column,
15
+ CreateDateColumn,
16
+ Entity,
17
+ Index,
18
+ ManyToOne,
19
+ PrimaryGeneratedColumn,
20
+ RelationId,
21
+ UpdateDateColumn
22
+ } from 'typeorm'
23
+ import { Field, Float, ID, Int, ObjectType } from 'type-graphql'
24
+ import { GraphQLJSON } from 'graphql-scalars'
25
+
26
+ import { Domain } from '@things-factory/shell'
27
+ import { User } from '@things-factory/auth-base'
28
+ import { config } from '@things-factory/env'
29
+
30
+ const ORMCONFIG = config.get('ormconfig', {})
31
+ const DATABASE_TYPE = ORMCONFIG.type
32
+
33
+ @Entity()
34
+ @Index('ix_import_session_1', (session: ImportSession) => [session.domain, session.createdAt])
35
+ @Index('ix_import_session_2', (session: ImportSession) => [session.chatSessionId])
36
+ @ObjectType({ description: '도면 → 보드 변환 작업의 영속 단위 (비동기 진행상태 추적).' })
37
+ export class ImportSession {
38
+ @PrimaryGeneratedColumn('uuid')
39
+ @Field(type => ID, { nullable: true })
40
+ readonly id?: string
41
+
42
+ @ManyToOne(type => Domain)
43
+ @Field(type => Domain, { nullable: true })
44
+ domain?: Domain
45
+
46
+ @RelationId((session: ImportSession) => session.domain)
47
+ domainId?: string
48
+
49
+ /** Attachment.id (도면 파일). attachment-base 의존이지만 직접 ManyToOne 대신 ID 만 보유. */
50
+ @Column()
51
+ @Field({ description: 'Attachment id (the source drawing file)' })
52
+ attachmentId!: string
53
+
54
+ /** 어느 ChatSession 에서 발동됐는지 (board-ai 와 연동). 없으면 직접 호출. */
55
+ @Column({ nullable: true })
56
+ @Field({ nullable: true, description: 'ChatSession id when triggered from chat (board-ai).' })
57
+ chatSessionId?: string
58
+
59
+ /** 'queued' | 'parsing' | 'mapping' | 'assembling' | 'binding' | 'completed' | 'failed' */
60
+ @Column({ type: 'varchar', length: 32, default: 'queued' })
61
+ @Field({ description: 'queued | parsing | mapping | assembling | binding | completed | failed' })
62
+ status!: string
63
+
64
+ /** 0..100 */
65
+ @Column({ type: 'float', default: 0 })
66
+ @Field(type => Float, { description: 'Progress percentage 0..100' })
67
+ progress!: number
68
+
69
+ /** 현재 단계 설명 또는 에러 메시지 */
70
+ @Column({ nullable: true, type: 'text' })
71
+ @Field({ nullable: true })
72
+ message?: string
73
+
74
+ /** import 옵션 (scopes, parseOptions, normalizeOrigin 등) */
75
+ @Column({
76
+ nullable: true,
77
+ type:
78
+ DATABASE_TYPE == 'mysql' || DATABASE_TYPE == 'mariadb'
79
+ ? 'longtext'
80
+ : DATABASE_TYPE == 'oracle'
81
+ ? 'clob'
82
+ : DATABASE_TYPE == 'mssql'
83
+ ? 'nvarchar'
84
+ : 'text',
85
+ length: DATABASE_TYPE == 'mssql' ? 'MAX' : undefined
86
+ })
87
+ optionsJson?: string
88
+
89
+ @Field(type => GraphQLJSON, { nullable: true, description: 'Options used (parsed JSON).' })
90
+ get options(): any {
91
+ try {
92
+ return this.optionsJson ? JSON.parse(this.optionsJson) : null
93
+ } catch {
94
+ return null
95
+ }
96
+ }
97
+
98
+ /** 결과: { boardModel, mapping: { stats, warnings } } */
99
+ @Column({
100
+ nullable: true,
101
+ type:
102
+ DATABASE_TYPE == 'mysql' || DATABASE_TYPE == 'mariadb'
103
+ ? 'longtext'
104
+ : DATABASE_TYPE == 'oracle'
105
+ ? 'clob'
106
+ : DATABASE_TYPE == 'mssql'
107
+ ? 'nvarchar'
108
+ : 'text',
109
+ length: DATABASE_TYPE == 'mssql' ? 'MAX' : undefined
110
+ })
111
+ resultJson?: string
112
+
113
+ @Field(type => GraphQLJSON, { nullable: true, description: 'Result (parsed JSON): { boardModel, stats, warnings }.' })
114
+ get result(): any {
115
+ try {
116
+ return this.resultJson ? JSON.parse(this.resultJson) : null
117
+ } catch {
118
+ return null
119
+ }
120
+ }
121
+
122
+ @Column({ nullable: true })
123
+ @Field(type => Int, { nullable: true, description: 'Total entities counted (when known).' })
124
+ totalEntities?: number
125
+
126
+ @ManyToOne(type => User)
127
+ @Field(type => User, { nullable: true })
128
+ creator?: User
129
+
130
+ @CreateDateColumn()
131
+ @Field({ nullable: true })
132
+ createdAt?: Date
133
+
134
+ @UpdateDateColumn()
135
+ @Field({ nullable: true })
136
+ updatedAt?: Date
137
+
138
+ @Column({ nullable: true })
139
+ @Field({ nullable: true })
140
+ completedAt?: Date
141
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Background worker — ImportSession 처리.
3
+ *
4
+ * 흐름:
5
+ * 1) ImportSession 로드 (status: queued)
6
+ * 2) Attachment 본문 읽기 (attachment-base STORAGE.readFile)
7
+ * 3) importBoard() 실행 (단계별로 status/progress 갱신)
8
+ * 4) result 저장 + status='completed'
9
+ * 5) 실패 시 status='failed' + message
10
+ *
11
+ * 단일 프로세스 내 setImmediate 기반. 추후 외부 큐(Redis/BullMQ) 로 이전 가능.
12
+ */
13
+ import { getRepository } from '@things-factory/shell'
14
+
15
+ import { Attachment } from '@things-factory/attachment-base'
16
+ import { STORAGE } from '@things-factory/attachment-base'
17
+
18
+ import { importBoardVariants } from '../pipeline/index'
19
+ import { ImportSession } from './import-session'
20
+
21
+ export async function runImportJob(sessionId: string): Promise<void> {
22
+ const repo = getRepository(ImportSession)
23
+ const session = await repo.findOneBy({ id: sessionId })
24
+ if (!session) {
25
+ console.error(`[board-import] ImportSession ${sessionId} not found`)
26
+ return
27
+ }
28
+
29
+ try {
30
+ await update(repo, sessionId, { status: 'parsing', progress: 5, message: 'Reading attachment' })
31
+
32
+ const attachment = await getRepository(Attachment).findOneBy({ id: session.attachmentId })
33
+ if (!attachment) {
34
+ throw new Error(`Attachment ${session.attachmentId} not found`)
35
+ }
36
+
37
+ // attachment-base STORAGE.readFile 으로 본문 로드 (Buffer).
38
+ // file storage 는 sync, db/s3/azure 는 async — await 으로 양쪽 다 흡수.
39
+ const fileBuffer = (await STORAGE.readFile(attachment.path!, null)) as Buffer
40
+
41
+ await update(repo, sessionId, { status: 'parsing', progress: 20, message: 'Parsing drawing' })
42
+
43
+ const opts = session.options || {}
44
+ const filename = attachment.name || attachment.path
45
+
46
+ // importBoard 는 내부에서 stage 1~4 모두 수행. 단계별 progress 는 우선 거시적 업데이트.
47
+ await update(repo, sessionId, { status: 'mapping', progress: 50, message: 'Mapping entities' })
48
+
49
+ // userPrompt 는 parseOptions 로 합류 — image-adapter 가 VLM 프롬프트에 사용.
50
+ // 다른 adapter (DXF) 에는 무해 (몰라도 무시).
51
+ const mergedParseOptions = {
52
+ ...(opts.parseOptions || {}),
53
+ ...(opts.userPrompt ? { userPrompt: opts.userPrompt } : {})
54
+ }
55
+ // AI 가 같은 도면을 다른 관점으로 해석한 multi-variant. parse 한 번, 시안별 mapping + assemble.
56
+ // DXF 등 single-variant adapter 는 1 boardModel 만.
57
+ const result = await importBoardVariants({
58
+ file: fileBuffer,
59
+ filename,
60
+ scopes: opts.scopes,
61
+ normalizeOrigin: opts.normalizeOrigin,
62
+ flipY: opts.flipY,
63
+ scale: opts.scale,
64
+ applyBinding: opts.applyBinding,
65
+ parseOptions: mergedParseOptions
66
+ })
67
+
68
+ await update(repo, sessionId, { status: 'assembling', progress: 80, message: 'Assembling boards' })
69
+
70
+ // 시안별 카테고리 분포 — 각 variant 의 entities 가 다르므로.
71
+ const variantsWithStats = result.variants.map((v, i) => {
72
+ const dist: Record<string, number> = {}
73
+ for (const c of v.boardModel.components ?? []) {
74
+ const k = c.category ?? 'unknown'
75
+ dist[k] = (dist[k] ?? 0) + 1
76
+ }
77
+ return { variant: v, mapping: result.mappings[i], categoryDistribution: dist }
78
+ })
79
+
80
+ // default variantId — 첫 variant.
81
+ const defaultVariantId = result.variants[0]?.id
82
+
83
+ // resultJson 구조:
84
+ // - variants: 풀 시안 데이터 (선택용 — boardModel + subject + importStrategy + stats + categoryDist)
85
+ // - boardModel: default variant 의 boardModel (back-compat)
86
+ // - defaultVariantId: 첫 variant 의 id
87
+ // - metadata: viewType / aiClientId / userPrompt 등 시안 무관 공통
88
+ const defaultVariant = variantsWithStats[0]
89
+ const resultJson = JSON.stringify({
90
+ variants: variantsWithStats.map(({ variant, mapping, categoryDistribution }) => ({
91
+ id: variant.id,
92
+ label: variant.label,
93
+ description: variant.description,
94
+ subjectName: variant.subjectName,
95
+ subjectDescription: variant.subjectDescription,
96
+ importStrategy: variant.importStrategy,
97
+ boardModel: variant.boardModel,
98
+ stats: mapping.stats,
99
+ warnings: mapping.warnings,
100
+ categoryDistribution
101
+ })),
102
+ defaultVariantId,
103
+ boardModel: defaultVariant?.variant.boardModel,
104
+ stats: defaultVariant?.mapping.stats,
105
+ warnings: defaultVariant?.mapping.warnings,
106
+ metadata: result.parse.metadata,
107
+ categoryDistribution: defaultVariant?.categoryDistribution
108
+ })
109
+
110
+ const totalAcross = result.mappings.reduce((s, m) => s + (m.stats.total ?? 0), 0)
111
+ await update(repo, sessionId, {
112
+ status: 'completed',
113
+ progress: 100,
114
+ message: `Completed: ${result.variants.length} variants, ${totalAcross} entities total`,
115
+ totalEntities: defaultVariant?.mapping.stats.total ?? 0,
116
+ resultJson,
117
+ completedAt: new Date()
118
+ })
119
+ } catch (err) {
120
+ const message = (err as Error)?.message ?? String(err)
121
+ console.error(`[board-import] ImportSession ${sessionId} failed:`, err)
122
+ await update(repo, sessionId, {
123
+ status: 'failed',
124
+ message: `Failed: ${message}`,
125
+ completedAt: new Date()
126
+ })
127
+ }
128
+ }
129
+
130
+ async function update(repo: any, id: string, patch: Partial<ImportSession>): Promise<void> {
131
+ await repo.update(id, patch as any)
132
+ }