@things-factory/board-import 10.0.0-beta.108
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/config/config.development.js +44 -0
- package/config/config.production.js +18 -0
- package/dist-server/index.d.ts +7 -0
- package/dist-server/index.js +11 -0
- package/dist-server/index.js.map +1 -0
- package/dist-server/service/adapters/dxf-adapter.d.ts +8 -0
- package/dist-server/service/adapters/dxf-adapter.js +220 -0
- package/dist-server/service/adapters/dxf-adapter.js.map +1 -0
- package/dist-server/service/adapters/image-adapter.d.ts +32 -0
- package/dist-server/service/adapters/image-adapter.js +420 -0
- package/dist-server/service/adapters/image-adapter.js.map +1 -0
- package/dist-server/service/ai/types.d.ts +185 -0
- package/dist-server/service/ai/types.js +378 -0
- package/dist-server/service/ai/types.js.map +1 -0
- package/dist-server/service/converters/generic-to.d.ts +31 -0
- package/dist-server/service/converters/generic-to.js +84 -0
- package/dist-server/service/converters/generic-to.js.map +1 -0
- package/dist-server/service/extraction/contracts.d.ts +112 -0
- package/dist-server/service/extraction/contracts.js +3 -0
- package/dist-server/service/extraction/contracts.js.map +1 -0
- package/dist-server/service/extraction/index.d.ts +17 -0
- package/dist-server/service/extraction/index.js +62 -0
- package/dist-server/service/extraction/index.js.map +1 -0
- package/dist-server/service/extraction/label-scale-calibrator.d.ts +5 -0
- package/dist-server/service/extraction/label-scale-calibrator.js +111 -0
- package/dist-server/service/extraction/label-scale-calibrator.js.map +1 -0
- package/dist-server/service/extraction/layout-refiner.d.ts +16 -0
- package/dist-server/service/extraction/layout-refiner.js +88 -0
- package/dist-server/service/extraction/layout-refiner.js.map +1 -0
- package/dist-server/service/extraction/pipeline.d.ts +11 -0
- package/dist-server/service/extraction/pipeline.js +124 -0
- package/dist-server/service/extraction/pipeline.js.map +1 -0
- package/dist-server/service/extraction/source-router.d.ts +17 -0
- package/dist-server/service/extraction/source-router.js +40 -0
- package/dist-server/service/extraction/source-router.js.map +1 -0
- package/dist-server/service/extraction/stubs.d.ts +18 -0
- package/dist-server/service/extraction/stubs.js +38 -0
- package/dist-server/service/extraction/stubs.js.map +1 -0
- package/dist-server/service/extraction/vector-geometry.d.ts +7 -0
- package/dist-server/service/extraction/vector-geometry.js +21 -0
- package/dist-server/service/extraction/vector-geometry.js.map +1 -0
- package/dist-server/service/extraction/vlm-recognizer.d.ts +18 -0
- package/dist-server/service/extraction/vlm-recognizer.js +78 -0
- package/dist-server/service/extraction/vlm-recognizer.js.map +1 -0
- package/dist-server/service/import-session/import-actions.d.ts +79 -0
- package/dist-server/service/import-session/import-actions.js +98 -0
- package/dist-server/service/import-session/import-actions.js.map +1 -0
- package/dist-server/service/import-session/import-session-resolver.d.ts +39 -0
- package/dist-server/service/import-session/import-session-resolver.js +263 -0
- package/dist-server/service/import-session/import-session-resolver.js.map +1 -0
- package/dist-server/service/import-session/import-session.d.ts +28 -0
- package/dist-server/service/import-session/import-session.js +152 -0
- package/dist-server/service/import-session/import-session.js.map +1 -0
- package/dist-server/service/import-session/import-worker.d.ts +1 -0
- package/dist-server/service/import-session/import-worker.js +121 -0
- package/dist-server/service/import-session/import-worker.js.map +1 -0
- package/dist-server/service/import-session/index.d.ts +8 -0
- package/dist-server/service/import-session/index.js +19 -0
- package/dist-server/service/import-session/index.js.map +1 -0
- package/dist-server/service/import-session/materialize-from-session.d.ts +66 -0
- package/dist-server/service/import-session/materialize-from-session.js +104 -0
- package/dist-server/service/import-session/materialize-from-session.js.map +1 -0
- package/dist-server/service/import-session/suggest-board-name.d.ts +80 -0
- package/dist-server/service/import-session/suggest-board-name.js +195 -0
- package/dist-server/service/import-session/suggest-board-name.js.map +1 -0
- package/dist-server/service/import-tools.d.ts +10 -0
- package/dist-server/service/import-tools.js +227 -0
- package/dist-server/service/import-tools.js.map +1 -0
- package/dist-server/service/index.d.ts +26 -0
- package/dist-server/service/index.js +60 -0
- package/dist-server/service/index.js.map +1 -0
- package/dist-server/service/pipeline/index.d.ts +66 -0
- package/dist-server/service/pipeline/index.js +127 -0
- package/dist-server/service/pipeline/index.js.map +1 -0
- package/dist-server/service/pipeline/stage2-mapping.d.ts +29 -0
- package/dist-server/service/pipeline/stage2-mapping.js +231 -0
- package/dist-server/service/pipeline/stage2-mapping.js.map +1 -0
- package/dist-server/service/pipeline/stage3-board.d.ts +52 -0
- package/dist-server/service/pipeline/stage3-board.js +108 -0
- package/dist-server/service/pipeline/stage3-board.js.map +1 -0
- package/dist-server/service/pipeline/stage4-binding.d.ts +9 -0
- package/dist-server/service/pipeline/stage4-binding.js +28 -0
- package/dist-server/service/pipeline/stage4-binding.js.map +1 -0
- package/dist-server/service/registry/index.d.ts +27 -0
- package/dist-server/service/registry/index.js +108 -0
- package/dist-server/service/registry/index.js.map +1 -0
- package/dist-server/service/types/index.d.ts +152 -0
- package/dist-server/service/types/index.js +3 -0
- package/dist-server/service/types/index.js.map +1 -0
- package/dist-server/service/types/registry.d.ts +175 -0
- package/dist-server/service/types/registry.js +3 -0
- package/dist-server/service/types/registry.js.map +1 -0
- package/dist-server/tsconfig.tsbuildinfo +1 -0
- package/package.json +35 -0
- package/probe/gcp-vision-probe.mjs +114 -0
- package/probe/opencv-geometry-probe.py +98 -0
- package/server/index.ts +7 -0
- package/server/service/adapters/dxf-adapter.ts +230 -0
- package/server/service/adapters/image-adapter.test.ts +545 -0
- package/server/service/adapters/image-adapter.ts +464 -0
- package/server/service/ai/types.ts +619 -0
- package/server/service/converters/generic-to.test.ts +105 -0
- package/server/service/converters/generic-to.ts +121 -0
- package/server/service/extraction/contracts.ts +142 -0
- package/server/service/extraction/extraction.test.ts +374 -0
- package/server/service/extraction/index.ts +51 -0
- package/server/service/extraction/label-scale-calibrator.ts +124 -0
- package/server/service/extraction/layout-refiner.ts +104 -0
- package/server/service/extraction/pipeline.ts +163 -0
- package/server/service/extraction/source-router.ts +45 -0
- package/server/service/extraction/stubs.ts +46 -0
- package/server/service/extraction/vector-geometry.ts +26 -0
- package/server/service/extraction/vlm-recognizer.ts +87 -0
- package/server/service/import-session/import-actions.test.ts +185 -0
- package/server/service/import-session/import-actions.ts +164 -0
- package/server/service/import-session/import-session-resolver.ts +245 -0
- package/server/service/import-session/import-session.ts +141 -0
- package/server/service/import-session/import-worker.ts +132 -0
- package/server/service/import-session/index.ts +24 -0
- package/server/service/import-session/materialize-from-session.test.ts +274 -0
- package/server/service/import-session/materialize-from-session.ts +158 -0
- package/server/service/import-session/suggest-board-name.test.ts +271 -0
- package/server/service/import-session/suggest-board-name.ts +279 -0
- package/server/service/import-tools.test.ts +137 -0
- package/server/service/import-tools.ts +255 -0
- package/server/service/index.ts +117 -0
- package/server/service/pipeline/index.ts +184 -0
- package/server/service/pipeline/stage2-mapping.test.ts +204 -0
- package/server/service/pipeline/stage2-mapping.ts +270 -0
- package/server/service/pipeline/stage3-board.test.ts +133 -0
- package/server/service/pipeline/stage3-board.ts +179 -0
- package/server/service/pipeline/stage4-binding.ts +31 -0
- package/server/service/registry/index.ts +127 -0
- package/server/service/types/index.ts +169 -0
- package/server/service/types/registry.ts +186 -0
- package/things-factory.config.js +1 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,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
|
+
}
|