@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,121 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.runImportJob = runImportJob;
|
|
4
|
+
/**
|
|
5
|
+
* Background worker — ImportSession 처리.
|
|
6
|
+
*
|
|
7
|
+
* 흐름:
|
|
8
|
+
* 1) ImportSession 로드 (status: queued)
|
|
9
|
+
* 2) Attachment 본문 읽기 (attachment-base STORAGE.readFile)
|
|
10
|
+
* 3) importBoard() 실행 (단계별로 status/progress 갱신)
|
|
11
|
+
* 4) result 저장 + status='completed'
|
|
12
|
+
* 5) 실패 시 status='failed' + message
|
|
13
|
+
*
|
|
14
|
+
* 단일 프로세스 내 setImmediate 기반. 추후 외부 큐(Redis/BullMQ) 로 이전 가능.
|
|
15
|
+
*/
|
|
16
|
+
const shell_1 = require("@things-factory/shell");
|
|
17
|
+
const attachment_base_1 = require("@things-factory/attachment-base");
|
|
18
|
+
const attachment_base_2 = require("@things-factory/attachment-base");
|
|
19
|
+
const index_1 = require("../pipeline/index");
|
|
20
|
+
const import_session_1 = require("./import-session");
|
|
21
|
+
async function runImportJob(sessionId) {
|
|
22
|
+
const repo = (0, shell_1.getRepository)(import_session_1.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
|
+
try {
|
|
29
|
+
await update(repo, sessionId, { status: 'parsing', progress: 5, message: 'Reading attachment' });
|
|
30
|
+
const attachment = await (0, shell_1.getRepository)(attachment_base_1.Attachment).findOneBy({ id: session.attachmentId });
|
|
31
|
+
if (!attachment) {
|
|
32
|
+
throw new Error(`Attachment ${session.attachmentId} not found`);
|
|
33
|
+
}
|
|
34
|
+
// attachment-base STORAGE.readFile 으로 본문 로드 (Buffer).
|
|
35
|
+
// file storage 는 sync, db/s3/azure 는 async — await 으로 양쪽 다 흡수.
|
|
36
|
+
const fileBuffer = (await attachment_base_2.STORAGE.readFile(attachment.path, null));
|
|
37
|
+
await update(repo, sessionId, { status: 'parsing', progress: 20, message: 'Parsing drawing' });
|
|
38
|
+
const opts = session.options || {};
|
|
39
|
+
const filename = attachment.name || attachment.path;
|
|
40
|
+
// importBoard 는 내부에서 stage 1~4 모두 수행. 단계별 progress 는 우선 거시적 업데이트.
|
|
41
|
+
await update(repo, sessionId, { status: 'mapping', progress: 50, message: 'Mapping entities' });
|
|
42
|
+
// userPrompt 는 parseOptions 로 합류 — image-adapter 가 VLM 프롬프트에 사용.
|
|
43
|
+
// 다른 adapter (DXF) 에는 무해 (몰라도 무시).
|
|
44
|
+
const mergedParseOptions = {
|
|
45
|
+
...(opts.parseOptions || {}),
|
|
46
|
+
...(opts.userPrompt ? { userPrompt: opts.userPrompt } : {})
|
|
47
|
+
};
|
|
48
|
+
// AI 가 같은 도면을 다른 관점으로 해석한 multi-variant. parse 한 번, 시안별 mapping + assemble.
|
|
49
|
+
// DXF 등 single-variant adapter 는 1 boardModel 만.
|
|
50
|
+
const result = await (0, index_1.importBoardVariants)({
|
|
51
|
+
file: fileBuffer,
|
|
52
|
+
filename,
|
|
53
|
+
scopes: opts.scopes,
|
|
54
|
+
normalizeOrigin: opts.normalizeOrigin,
|
|
55
|
+
flipY: opts.flipY,
|
|
56
|
+
scale: opts.scale,
|
|
57
|
+
applyBinding: opts.applyBinding,
|
|
58
|
+
parseOptions: mergedParseOptions
|
|
59
|
+
});
|
|
60
|
+
await update(repo, sessionId, { status: 'assembling', progress: 80, message: 'Assembling boards' });
|
|
61
|
+
// 시안별 카테고리 분포 — 각 variant 의 entities 가 다르므로.
|
|
62
|
+
const variantsWithStats = result.variants.map((v, i) => {
|
|
63
|
+
const dist = {};
|
|
64
|
+
for (const c of v.boardModel.components ?? []) {
|
|
65
|
+
const k = c.category ?? 'unknown';
|
|
66
|
+
dist[k] = (dist[k] ?? 0) + 1;
|
|
67
|
+
}
|
|
68
|
+
return { variant: v, mapping: result.mappings[i], categoryDistribution: dist };
|
|
69
|
+
});
|
|
70
|
+
// default variantId — 첫 variant.
|
|
71
|
+
const defaultVariantId = result.variants[0]?.id;
|
|
72
|
+
// resultJson 구조:
|
|
73
|
+
// - variants: 풀 시안 데이터 (선택용 — boardModel + subject + importStrategy + stats + categoryDist)
|
|
74
|
+
// - boardModel: default variant 의 boardModel (back-compat)
|
|
75
|
+
// - defaultVariantId: 첫 variant 의 id
|
|
76
|
+
// - metadata: viewType / aiClientId / userPrompt 등 시안 무관 공통
|
|
77
|
+
const defaultVariant = variantsWithStats[0];
|
|
78
|
+
const resultJson = JSON.stringify({
|
|
79
|
+
variants: variantsWithStats.map(({ variant, mapping, categoryDistribution }) => ({
|
|
80
|
+
id: variant.id,
|
|
81
|
+
label: variant.label,
|
|
82
|
+
description: variant.description,
|
|
83
|
+
subjectName: variant.subjectName,
|
|
84
|
+
subjectDescription: variant.subjectDescription,
|
|
85
|
+
importStrategy: variant.importStrategy,
|
|
86
|
+
boardModel: variant.boardModel,
|
|
87
|
+
stats: mapping.stats,
|
|
88
|
+
warnings: mapping.warnings,
|
|
89
|
+
categoryDistribution
|
|
90
|
+
})),
|
|
91
|
+
defaultVariantId,
|
|
92
|
+
boardModel: defaultVariant?.variant.boardModel,
|
|
93
|
+
stats: defaultVariant?.mapping.stats,
|
|
94
|
+
warnings: defaultVariant?.mapping.warnings,
|
|
95
|
+
metadata: result.parse.metadata,
|
|
96
|
+
categoryDistribution: defaultVariant?.categoryDistribution
|
|
97
|
+
});
|
|
98
|
+
const totalAcross = result.mappings.reduce((s, m) => s + (m.stats.total ?? 0), 0);
|
|
99
|
+
await update(repo, sessionId, {
|
|
100
|
+
status: 'completed',
|
|
101
|
+
progress: 100,
|
|
102
|
+
message: `Completed: ${result.variants.length} variants, ${totalAcross} entities total`,
|
|
103
|
+
totalEntities: defaultVariant?.mapping.stats.total ?? 0,
|
|
104
|
+
resultJson,
|
|
105
|
+
completedAt: new Date()
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
const message = err?.message ?? String(err);
|
|
110
|
+
console.error(`[board-import] ImportSession ${sessionId} failed:`, err);
|
|
111
|
+
await update(repo, sessionId, {
|
|
112
|
+
status: 'failed',
|
|
113
|
+
message: `Failed: ${message}`,
|
|
114
|
+
completedAt: new Date()
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
async function update(repo, id, patch) {
|
|
119
|
+
await repo.update(id, patch);
|
|
120
|
+
}
|
|
121
|
+
//# sourceMappingURL=import-worker.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"import-worker.js","sourceRoot":"","sources":["../../../server/service/import-session/import-worker.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Background worker — ImportSession 처리.\n *\n * 흐름:\n * 1) ImportSession 로드 (status: queued)\n * 2) Attachment 본문 읽기 (attachment-base STORAGE.readFile)\n * 3) importBoard() 실행 (단계별로 status/progress 갱신)\n * 4) result 저장 + status='completed'\n * 5) 실패 시 status='failed' + message\n *\n * 단일 프로세스 내 setImmediate 기반. 추후 외부 큐(Redis/BullMQ) 로 이전 가능.\n */\nimport { getRepository } from '@things-factory/shell'\n\nimport { Attachment } from '@things-factory/attachment-base'\nimport { STORAGE } from '@things-factory/attachment-base'\n\nimport { importBoardVariants } from '../pipeline/index'\nimport { ImportSession } from './import-session'\n\nexport async function runImportJob(sessionId: string): Promise<void> {\n const repo = getRepository(ImportSession)\n const session = await repo.findOneBy({ id: sessionId })\n if (!session) {\n console.error(`[board-import] ImportSession ${sessionId} not found`)\n return\n }\n\n try {\n await update(repo, sessionId, { status: 'parsing', progress: 5, message: 'Reading attachment' })\n\n const attachment = await getRepository(Attachment).findOneBy({ id: session.attachmentId })\n if (!attachment) {\n throw new Error(`Attachment ${session.attachmentId} not found`)\n }\n\n // attachment-base STORAGE.readFile 으로 본문 로드 (Buffer).\n // file storage 는 sync, db/s3/azure 는 async — await 으로 양쪽 다 흡수.\n const fileBuffer = (await STORAGE.readFile(attachment.path!, null)) as Buffer\n\n await update(repo, sessionId, { status: 'parsing', progress: 20, message: 'Parsing drawing' })\n\n const opts = session.options || {}\n const filename = attachment.name || attachment.path\n\n // importBoard 는 내부에서 stage 1~4 모두 수행. 단계별 progress 는 우선 거시적 업데이트.\n await update(repo, sessionId, { status: 'mapping', progress: 50, message: 'Mapping entities' })\n\n // userPrompt 는 parseOptions 로 합류 — image-adapter 가 VLM 프롬프트에 사용.\n // 다른 adapter (DXF) 에는 무해 (몰라도 무시).\n const mergedParseOptions = {\n ...(opts.parseOptions || {}),\n ...(opts.userPrompt ? { userPrompt: opts.userPrompt } : {})\n }\n // AI 가 같은 도면을 다른 관점으로 해석한 multi-variant. parse 한 번, 시안별 mapping + assemble.\n // DXF 등 single-variant adapter 는 1 boardModel 만.\n const result = await importBoardVariants({\n file: fileBuffer,\n filename,\n scopes: opts.scopes,\n normalizeOrigin: opts.normalizeOrigin,\n flipY: opts.flipY,\n scale: opts.scale,\n applyBinding: opts.applyBinding,\n parseOptions: mergedParseOptions\n })\n\n await update(repo, sessionId, { status: 'assembling', progress: 80, message: 'Assembling boards' })\n\n // 시안별 카테고리 분포 — 각 variant 의 entities 가 다르므로.\n const variantsWithStats = result.variants.map((v, i) => {\n const dist: Record<string, number> = {}\n for (const c of v.boardModel.components ?? []) {\n const k = c.category ?? 'unknown'\n dist[k] = (dist[k] ?? 0) + 1\n }\n return { variant: v, mapping: result.mappings[i], categoryDistribution: dist }\n })\n\n // default variantId — 첫 variant.\n const defaultVariantId = result.variants[0]?.id\n\n // resultJson 구조:\n // - variants: 풀 시안 데이터 (선택용 — boardModel + subject + importStrategy + stats + categoryDist)\n // - boardModel: default variant 의 boardModel (back-compat)\n // - defaultVariantId: 첫 variant 의 id\n // - metadata: viewType / aiClientId / userPrompt 등 시안 무관 공통\n const defaultVariant = variantsWithStats[0]\n const resultJson = JSON.stringify({\n variants: variantsWithStats.map(({ variant, mapping, categoryDistribution }) => ({\n id: variant.id,\n label: variant.label,\n description: variant.description,\n subjectName: variant.subjectName,\n subjectDescription: variant.subjectDescription,\n importStrategy: variant.importStrategy,\n boardModel: variant.boardModel,\n stats: mapping.stats,\n warnings: mapping.warnings,\n categoryDistribution\n })),\n defaultVariantId,\n boardModel: defaultVariant?.variant.boardModel,\n stats: defaultVariant?.mapping.stats,\n warnings: defaultVariant?.mapping.warnings,\n metadata: result.parse.metadata,\n categoryDistribution: defaultVariant?.categoryDistribution\n })\n\n const totalAcross = result.mappings.reduce((s, m) => s + (m.stats.total ?? 0), 0)\n await update(repo, sessionId, {\n status: 'completed',\n progress: 100,\n message: `Completed: ${result.variants.length} variants, ${totalAcross} entities total`,\n totalEntities: defaultVariant?.mapping.stats.total ?? 0,\n resultJson,\n completedAt: new Date()\n })\n } catch (err) {\n const message = (err as Error)?.message ?? String(err)\n console.error(`[board-import] ImportSession ${sessionId} failed:`, err)\n await update(repo, sessionId, {\n status: 'failed',\n message: `Failed: ${message}`,\n completedAt: new Date()\n })\n }\n}\n\nasync function update(repo: any, id: string, patch: Partial<ImportSession>): Promise<void> {\n await repo.update(id, patch as any)\n}\n"]}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { ImportSession } from './import-session';
|
|
2
|
+
import { ImportSessionResolver } from './import-session-resolver';
|
|
3
|
+
export { ImportSession, ImportSessionResolver };
|
|
4
|
+
export { runImportJob } from './import-worker';
|
|
5
|
+
export { startImportJobAsync, fetchImportSession, listImportSessionsByChatSession, describeAdapter, type StartImportJobInput, type ImportActionsContext, type SessionRepoLike, type AdapterInfo } from './import-actions';
|
|
6
|
+
export { materializeFromSession, type MaterializeRequest, type MaterializeContext, type MaterializeDeps } from './materialize-from-session';
|
|
7
|
+
export declare const entities: (typeof ImportSession)[];
|
|
8
|
+
export declare const resolverClasses: (typeof ImportSessionResolver)[];
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.resolverClasses = exports.entities = exports.materializeFromSession = exports.describeAdapter = exports.listImportSessionsByChatSession = exports.fetchImportSession = exports.startImportJobAsync = exports.runImportJob = exports.ImportSessionResolver = exports.ImportSession = void 0;
|
|
4
|
+
const import_session_1 = require("./import-session");
|
|
5
|
+
Object.defineProperty(exports, "ImportSession", { enumerable: true, get: function () { return import_session_1.ImportSession; } });
|
|
6
|
+
const import_session_resolver_1 = require("./import-session-resolver");
|
|
7
|
+
Object.defineProperty(exports, "ImportSessionResolver", { enumerable: true, get: function () { return import_session_resolver_1.ImportSessionResolver; } });
|
|
8
|
+
var import_worker_1 = require("./import-worker");
|
|
9
|
+
Object.defineProperty(exports, "runImportJob", { enumerable: true, get: function () { return import_worker_1.runImportJob; } });
|
|
10
|
+
var import_actions_1 = require("./import-actions");
|
|
11
|
+
Object.defineProperty(exports, "startImportJobAsync", { enumerable: true, get: function () { return import_actions_1.startImportJobAsync; } });
|
|
12
|
+
Object.defineProperty(exports, "fetchImportSession", { enumerable: true, get: function () { return import_actions_1.fetchImportSession; } });
|
|
13
|
+
Object.defineProperty(exports, "listImportSessionsByChatSession", { enumerable: true, get: function () { return import_actions_1.listImportSessionsByChatSession; } });
|
|
14
|
+
Object.defineProperty(exports, "describeAdapter", { enumerable: true, get: function () { return import_actions_1.describeAdapter; } });
|
|
15
|
+
var materialize_from_session_1 = require("./materialize-from-session");
|
|
16
|
+
Object.defineProperty(exports, "materializeFromSession", { enumerable: true, get: function () { return materialize_from_session_1.materializeFromSession; } });
|
|
17
|
+
exports.entities = [import_session_1.ImportSession];
|
|
18
|
+
exports.resolverClasses = [import_session_resolver_1.ImportSessionResolver];
|
|
19
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../server/service/import-session/index.ts"],"names":[],"mappings":";;;AAAA,qDAAgD;AAGvC,8FAHA,8BAAa,OAGA;AAFtB,uEAAiE;AAEzC,sGAFf,+CAAqB,OAEe;AAC7C,iDAA8C;AAArC,6GAAA,YAAY,OAAA;AACrB,mDASyB;AARvB,qHAAA,mBAAmB,OAAA;AACnB,oHAAA,kBAAkB,OAAA;AAClB,iIAAA,+BAA+B,OAAA;AAC/B,iHAAA,eAAe,OAAA;AAMjB,uEAKmC;AAJjC,kIAAA,sBAAsB,OAAA;AAMX,QAAA,QAAQ,GAAG,CAAC,8BAAa,CAAC,CAAA;AAC1B,QAAA,eAAe,GAAG,CAAC,+CAAqB,CAAC,CAAA","sourcesContent":["import { ImportSession } from './import-session'\nimport { ImportSessionResolver } from './import-session-resolver'\n\nexport { ImportSession, ImportSessionResolver }\nexport { runImportJob } from './import-worker'\nexport {\n startImportJobAsync,\n fetchImportSession,\n listImportSessionsByChatSession,\n describeAdapter,\n type StartImportJobInput,\n type ImportActionsContext,\n type SessionRepoLike,\n type AdapterInfo\n} from './import-actions'\nexport {\n materializeFromSession,\n type MaterializeRequest,\n type MaterializeContext,\n type MaterializeDeps\n} from './materialize-from-session'\n\nexport const entities = [ImportSession]\nexport const resolverClasses = [ImportSessionResolver]\n"]}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* materializeFromSession — ImportSession.result.boardModel → 신규 Board 영속화의 **순수 비즈니스
|
|
3
|
+
* 로직**. typeorm `getRepository` / `ResolverContext` 같은 외부 의존성을 인자로 분리해서 단위
|
|
4
|
+
* 테스트 가능하도록 구성. resolver 측 mutation 은 IO (repo, ctx) 만 wiring 해서 이 함수를 호출.
|
|
5
|
+
*
|
|
6
|
+
* 호출 책임:
|
|
7
|
+
* - resolver: getRepository / context.state 에서 도메인/유저 추출
|
|
8
|
+
* - 이 함수: 검증 → 정책 적용 → save 호출 → 결과 반환
|
|
9
|
+
*
|
|
10
|
+
* 정책:
|
|
11
|
+
* - ImportSession.status 가 'completed' 가 아니면 거절
|
|
12
|
+
* - result.boardModel 이 없으면 거절
|
|
13
|
+
* - Board.name 이 같은 도메인에 이미 있으면 거절 (createBoard 와 동일 정책)
|
|
14
|
+
* - 새 보드는 sortOrder = (현재 최소 sortOrder - 1) 또는 0 (createBoard 와 동일 정책)
|
|
15
|
+
* - state='draft', sourceImportSessionId=session.id, model=JSON.stringify(boardModel)
|
|
16
|
+
*/
|
|
17
|
+
export interface MaterializeRequest {
|
|
18
|
+
sessionId: string;
|
|
19
|
+
name: string;
|
|
20
|
+
description?: string;
|
|
21
|
+
groupId?: string;
|
|
22
|
+
type?: 'main' | 'sub' | 'popup';
|
|
23
|
+
thumbnail?: string;
|
|
24
|
+
/** 사용자가 선택한 시안 id. 미지정 시 session.result.defaultVariantId 또는 첫 variant. */
|
|
25
|
+
variantId?: string;
|
|
26
|
+
}
|
|
27
|
+
export interface SessionLike {
|
|
28
|
+
id: string;
|
|
29
|
+
status: string;
|
|
30
|
+
result?: {
|
|
31
|
+
boardModel?: any;
|
|
32
|
+
variants?: Array<{
|
|
33
|
+
id: string;
|
|
34
|
+
boardModel: any;
|
|
35
|
+
}>;
|
|
36
|
+
defaultVariantId?: string;
|
|
37
|
+
} | null;
|
|
38
|
+
}
|
|
39
|
+
export interface BoardRepoLike {
|
|
40
|
+
findOneBy(criteria: any): Promise<any | null>;
|
|
41
|
+
save(entity: any): Promise<any>;
|
|
42
|
+
/** sortOrder 결정용 — 미존재 시 0 으로 fallback. */
|
|
43
|
+
createQueryBuilder?(alias: string): {
|
|
44
|
+
select(expr: string, alias: string): any;
|
|
45
|
+
where(cond: string, params: any): any;
|
|
46
|
+
getRawOne(): Promise<any>;
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
export interface GroupRepoLike {
|
|
50
|
+
findOneBy(criteria: any): Promise<any | null>;
|
|
51
|
+
}
|
|
52
|
+
export interface MaterializeContext {
|
|
53
|
+
domain: {
|
|
54
|
+
id: string;
|
|
55
|
+
[k: string]: any;
|
|
56
|
+
};
|
|
57
|
+
user: any;
|
|
58
|
+
}
|
|
59
|
+
export interface MaterializeDeps {
|
|
60
|
+
boardRepo: BoardRepoLike;
|
|
61
|
+
groupRepo: GroupRepoLike;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* ImportSession 결과를 새 Board 로 저장. 검증 실패 시 throw, 성공 시 저장된 Board 반환.
|
|
65
|
+
*/
|
|
66
|
+
export declare function materializeFromSession(session: SessionLike | null, input: MaterializeRequest, context: MaterializeContext, deps: MaterializeDeps): Promise<any>;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* materializeFromSession — ImportSession.result.boardModel → 신규 Board 영속화의 **순수 비즈니스
|
|
4
|
+
* 로직**. typeorm `getRepository` / `ResolverContext` 같은 외부 의존성을 인자로 분리해서 단위
|
|
5
|
+
* 테스트 가능하도록 구성. resolver 측 mutation 은 IO (repo, ctx) 만 wiring 해서 이 함수를 호출.
|
|
6
|
+
*
|
|
7
|
+
* 호출 책임:
|
|
8
|
+
* - resolver: getRepository / context.state 에서 도메인/유저 추출
|
|
9
|
+
* - 이 함수: 검증 → 정책 적용 → save 호출 → 결과 반환
|
|
10
|
+
*
|
|
11
|
+
* 정책:
|
|
12
|
+
* - ImportSession.status 가 'completed' 가 아니면 거절
|
|
13
|
+
* - result.boardModel 이 없으면 거절
|
|
14
|
+
* - Board.name 이 같은 도메인에 이미 있으면 거절 (createBoard 와 동일 정책)
|
|
15
|
+
* - 새 보드는 sortOrder = (현재 최소 sortOrder - 1) 또는 0 (createBoard 와 동일 정책)
|
|
16
|
+
* - state='draft', sourceImportSessionId=session.id, model=JSON.stringify(boardModel)
|
|
17
|
+
*/
|
|
18
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
19
|
+
exports.materializeFromSession = materializeFromSession;
|
|
20
|
+
const EMPTY_THUMBNAIL = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
|
|
21
|
+
/**
|
|
22
|
+
* ImportSession 결과를 새 Board 로 저장. 검증 실패 시 throw, 성공 시 저장된 Board 반환.
|
|
23
|
+
*/
|
|
24
|
+
async function materializeFromSession(session, input, context, deps) {
|
|
25
|
+
if (!session) {
|
|
26
|
+
throw new Error(`ImportSession ${input.sessionId} not found`);
|
|
27
|
+
}
|
|
28
|
+
if (session.status !== 'completed') {
|
|
29
|
+
throw new Error(`ImportSession ${input.sessionId} is not completed (status=${session.status}); cannot materialize.`);
|
|
30
|
+
}
|
|
31
|
+
const boardModel = pickVariantBoardModel(session.result, input.variantId);
|
|
32
|
+
if (!boardModel) {
|
|
33
|
+
throw new Error(`ImportSession ${input.sessionId} has no boardModel in result.`);
|
|
34
|
+
}
|
|
35
|
+
const existing = await deps.boardRepo.findOneBy({
|
|
36
|
+
name: input.name,
|
|
37
|
+
domain: { id: context.domain.id }
|
|
38
|
+
});
|
|
39
|
+
if (existing) {
|
|
40
|
+
throw new Error(`Board name '${input.name}' is already taken`);
|
|
41
|
+
}
|
|
42
|
+
const group = input.groupId ? (await deps.groupRepo.findOneBy({ id: input.groupId })) ?? undefined : undefined;
|
|
43
|
+
const nextSortOrder = await resolveNextSortOrder(deps.boardRepo, context.domain.id);
|
|
44
|
+
return await deps.boardRepo.save({
|
|
45
|
+
domain: context.domain,
|
|
46
|
+
name: input.name,
|
|
47
|
+
description: input.description,
|
|
48
|
+
type: input.type ?? 'main',
|
|
49
|
+
thumbnail: input.thumbnail || EMPTY_THUMBNAIL,
|
|
50
|
+
model: JSON.stringify(boardModel),
|
|
51
|
+
sourceImportSessionId: session.id,
|
|
52
|
+
group,
|
|
53
|
+
sortOrder: nextSortOrder,
|
|
54
|
+
state: 'draft',
|
|
55
|
+
creator: context.user,
|
|
56
|
+
updater: context.user
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* 시안 (variant) 선택 — 사용자가 명시한 variantId > session.result.defaultVariantId > 첫 variant
|
|
61
|
+
* > 구버전 호환 (session.result.boardModel).
|
|
62
|
+
*/
|
|
63
|
+
function pickVariantBoardModel(result, variantId) {
|
|
64
|
+
if (!result)
|
|
65
|
+
return undefined;
|
|
66
|
+
const variants = Array.isArray(result.variants) ? result.variants : [];
|
|
67
|
+
if (variants.length > 0) {
|
|
68
|
+
if (variantId) {
|
|
69
|
+
const found = variants.find(v => v.id === variantId);
|
|
70
|
+
if (found?.boardModel)
|
|
71
|
+
return found.boardModel;
|
|
72
|
+
}
|
|
73
|
+
if (result.defaultVariantId) {
|
|
74
|
+
const found = variants.find(v => v.id === result.defaultVariantId);
|
|
75
|
+
if (found?.boardModel)
|
|
76
|
+
return found.boardModel;
|
|
77
|
+
}
|
|
78
|
+
if (variants[0]?.boardModel)
|
|
79
|
+
return variants[0].boardModel;
|
|
80
|
+
}
|
|
81
|
+
// back-compat — 구버전 ImportSession (variants 미존재) 의 단일 boardModel
|
|
82
|
+
return result.boardModel;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* sortOrder = MIN(sortOrder) - 1, 미존재 시 0. createBoard 의 "맨 앞에 배치" 정책과 동일.
|
|
86
|
+
* createQueryBuilder 미지원 환경 (예: 단위 테스트의 minimal mock) 에서는 0 fallback.
|
|
87
|
+
*/
|
|
88
|
+
async function resolveNextSortOrder(repo, domainId) {
|
|
89
|
+
if (!repo.createQueryBuilder)
|
|
90
|
+
return 0;
|
|
91
|
+
try {
|
|
92
|
+
const raw = await repo
|
|
93
|
+
.createQueryBuilder('board')
|
|
94
|
+
.select('MIN(board.sortOrder)', 'minSortOrder')
|
|
95
|
+
.where('board.domain = :domainId', { domainId })
|
|
96
|
+
.getRawOne();
|
|
97
|
+
const min = raw?.minSortOrder;
|
|
98
|
+
return typeof min === 'number' && Number.isFinite(min) ? min - 1 : 0;
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return 0;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
//# sourceMappingURL=materialize-from-session.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"materialize-from-session.js","sourceRoot":"","sources":["../../../server/service/import-session/materialize-from-session.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;GAeG;;AAqDH,wDA6CC;AAhGD,MAAM,eAAe,GAAG,4EAA4E,CAAA;AAgDpG;;GAEG;AACI,KAAK,UAAU,sBAAsB,CAC1C,OAA2B,EAC3B,KAAyB,EACzB,OAA2B,EAC3B,IAAqB;IAErB,IAAI,CAAC,OAAO,EAAE,CAAC;QACb,MAAM,IAAI,KAAK,CAAC,iBAAiB,KAAK,CAAC,SAAS,YAAY,CAAC,CAAA;IAC/D,CAAC;IACD,IAAI,OAAO,CAAC,MAAM,KAAK,WAAW,EAAE,CAAC;QACnC,MAAM,IAAI,KAAK,CACb,iBAAiB,KAAK,CAAC,SAAS,6BAA6B,OAAO,CAAC,MAAM,wBAAwB,CACpG,CAAA;IACH,CAAC;IACD,MAAM,UAAU,GAAG,qBAAqB,CAAC,OAAO,CAAC,MAAM,EAAE,KAAK,CAAC,SAAS,CAAC,CAAA;IACzE,IAAI,CAAC,UAAU,EAAE,CAAC;QAChB,MAAM,IAAI,KAAK,CAAC,iBAAiB,KAAK,CAAC,SAAS,+BAA+B,CAAC,CAAA;IAClF,CAAC;IAED,MAAM,QAAQ,GAAG,MAAM,IAAI,CAAC,SAAS,CAAC,SAAS,CAAC;QAC9C,IAAI,EAAE,KAAK,CAAC,IAAI;QAChB,MAAM,EAAE,EAAE,EAAE,EAAE,OAAO,CAAC,MAAM,CAAC,EAAE,EAAE;KAClC,CAAC,CAAA;IACF,IAAI,QAAQ,EAAE,CAAC;QACb,MAAM,IAAI,KAAK,CAAC,eAAe,KAAK,CAAC,IAAI,oBAAoB,CAAC,CAAA;IAChE,CAAC;IAED,MAAM,KAAK,GAAG,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,MAAM,IAAI,CAAC,SAAS,CAAC,SAAS,CAAC,EAAE,EAAE,EAAE,KAAK,CAAC,OAAO,EAAE,CAAC,CAAC,IAAI,SAAS,CAAC,CAAC,CAAC,SAAS,CAAA;IAE9G,MAAM,aAAa,GAAG,MAAM,oBAAoB,CAAC,IAAI,CAAC,SAAS,EAAE,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC,CAAA;IAEnF,OAAO,MAAM,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC;QAC/B,MAAM,EAAE,OAAO,CAAC,MAAM;QACtB,IAAI,EAAE,KAAK,CAAC,IAAI;QAChB,WAAW,EAAE,KAAK,CAAC,WAAW;QAC9B,IAAI,EAAE,KAAK,CAAC,IAAI,IAAI,MAAM;QAC1B,SAAS,EAAE,KAAK,CAAC,SAAS,IAAI,eAAe;QAC7C,KAAK,EAAE,IAAI,CAAC,SAAS,CAAC,UAAU,CAAC;QACjC,qBAAqB,EAAE,OAAO,CAAC,EAAE;QACjC,KAAK;QACL,SAAS,EAAE,aAAa;QACxB,KAAK,EAAE,OAAO;QACd,OAAO,EAAE,OAAO,CAAC,IAAI;QACrB,OAAO,EAAE,OAAO,CAAC,IAAI;KACtB,CAAC,CAAA;AACJ,CAAC;AAED;;;GAGG;AACH,SAAS,qBAAqB,CAC5B,MAA6B,EAC7B,SAAkB;IAElB,IAAI,CAAC,MAAM;QAAE,OAAO,SAAS,CAAA;IAC7B,MAAM,QAAQ,GAAG,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,CAAA;IACtE,IAAI,QAAQ,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QACxB,IAAI,SAAS,EAAE,CAAC;YACd,MAAM,KAAK,GAAG,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,KAAK,SAAS,CAAC,CAAA;YACpD,IAAI,KAAK,EAAE,UAAU;gBAAE,OAAO,KAAK,CAAC,UAAU,CAAA;QAChD,CAAC;QACD,IAAI,MAAM,CAAC,gBAAgB,EAAE,CAAC;YAC5B,MAAM,KAAK,GAAG,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,KAAK,MAAM,CAAC,gBAAgB,CAAC,CAAA;YAClE,IAAI,KAAK,EAAE,UAAU;gBAAE,OAAO,KAAK,CAAC,UAAU,CAAA;QAChD,CAAC;QACD,IAAI,QAAQ,CAAC,CAAC,CAAC,EAAE,UAAU;YAAE,OAAO,QAAQ,CAAC,CAAC,CAAC,CAAC,UAAU,CAAA;IAC5D,CAAC;IACD,iEAAiE;IACjE,OAAO,MAAM,CAAC,UAAU,CAAA;AAC1B,CAAC;AAED;;;GAGG;AACH,KAAK,UAAU,oBAAoB,CAAC,IAAmB,EAAE,QAAgB;IACvE,IAAI,CAAC,IAAI,CAAC,kBAAkB;QAAE,OAAO,CAAC,CAAA;IACtC,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,MAAM,IAAI;aACnB,kBAAkB,CAAC,OAAO,CAAC;aAC3B,MAAM,CAAC,sBAAsB,EAAE,cAAc,CAAC;aAC9C,KAAK,CAAC,0BAA0B,EAAE,EAAE,QAAQ,EAAE,CAAC;aAC/C,SAAS,EAAE,CAAA;QACd,MAAM,GAAG,GAAG,GAAG,EAAE,YAAY,CAAA;QAC7B,OAAO,OAAO,GAAG,KAAK,QAAQ,IAAI,MAAM,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAA;IACtE,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,CAAC,CAAA;IACV,CAAC;AACH,CAAC","sourcesContent":["/**\n * materializeFromSession — ImportSession.result.boardModel → 신규 Board 영속화의 **순수 비즈니스\n * 로직**. typeorm `getRepository` / `ResolverContext` 같은 외부 의존성을 인자로 분리해서 단위\n * 테스트 가능하도록 구성. resolver 측 mutation 은 IO (repo, ctx) 만 wiring 해서 이 함수를 호출.\n *\n * 호출 책임:\n * - resolver: getRepository / context.state 에서 도메인/유저 추출\n * - 이 함수: 검증 → 정책 적용 → save 호출 → 결과 반환\n *\n * 정책:\n * - ImportSession.status 가 'completed' 가 아니면 거절\n * - result.boardModel 이 없으면 거절\n * - Board.name 이 같은 도메인에 이미 있으면 거절 (createBoard 와 동일 정책)\n * - 새 보드는 sortOrder = (현재 최소 sortOrder - 1) 또는 0 (createBoard 와 동일 정책)\n * - state='draft', sourceImportSessionId=session.id, model=JSON.stringify(boardModel)\n */\n\nconst EMPTY_THUMBNAIL = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='\n\nexport interface MaterializeRequest {\n sessionId: string\n name: string\n description?: string\n groupId?: string\n type?: 'main' | 'sub' | 'popup'\n thumbnail?: string\n /** 사용자가 선택한 시안 id. 미지정 시 session.result.defaultVariantId 또는 첫 variant. */\n variantId?: string\n}\n\nexport interface SessionLike {\n id: string\n status: string\n result?: {\n boardModel?: any\n variants?: Array<{ id: string; boardModel: any }>\n defaultVariantId?: string\n } | null\n}\n\nexport interface BoardRepoLike {\n findOneBy(criteria: any): Promise<any | null>\n save(entity: any): Promise<any>\n /** sortOrder 결정용 — 미존재 시 0 으로 fallback. */\n createQueryBuilder?(alias: string): {\n select(expr: string, alias: string): any\n where(cond: string, params: any): any\n getRawOne(): Promise<any>\n }\n}\n\nexport interface GroupRepoLike {\n findOneBy(criteria: any): Promise<any | null>\n}\n\nexport interface MaterializeContext {\n domain: { id: string; [k: string]: any }\n user: any\n}\n\nexport interface MaterializeDeps {\n boardRepo: BoardRepoLike\n groupRepo: GroupRepoLike\n}\n\n/**\n * ImportSession 결과를 새 Board 로 저장. 검증 실패 시 throw, 성공 시 저장된 Board 반환.\n */\nexport async function materializeFromSession(\n session: SessionLike | null,\n input: MaterializeRequest,\n context: MaterializeContext,\n deps: MaterializeDeps\n): Promise<any> {\n if (!session) {\n throw new Error(`ImportSession ${input.sessionId} not found`)\n }\n if (session.status !== 'completed') {\n throw new Error(\n `ImportSession ${input.sessionId} is not completed (status=${session.status}); cannot materialize.`\n )\n }\n const boardModel = pickVariantBoardModel(session.result, input.variantId)\n if (!boardModel) {\n throw new Error(`ImportSession ${input.sessionId} has no boardModel in result.`)\n }\n\n const existing = await deps.boardRepo.findOneBy({\n name: input.name,\n domain: { id: context.domain.id }\n })\n if (existing) {\n throw new Error(`Board name '${input.name}' is already taken`)\n }\n\n const group = input.groupId ? (await deps.groupRepo.findOneBy({ id: input.groupId })) ?? undefined : undefined\n\n const nextSortOrder = await resolveNextSortOrder(deps.boardRepo, context.domain.id)\n\n return await deps.boardRepo.save({\n domain: context.domain,\n name: input.name,\n description: input.description,\n type: input.type ?? 'main',\n thumbnail: input.thumbnail || EMPTY_THUMBNAIL,\n model: JSON.stringify(boardModel),\n sourceImportSessionId: session.id,\n group,\n sortOrder: nextSortOrder,\n state: 'draft',\n creator: context.user,\n updater: context.user\n })\n}\n\n/**\n * 시안 (variant) 선택 — 사용자가 명시한 variantId > session.result.defaultVariantId > 첫 variant\n * > 구버전 호환 (session.result.boardModel).\n */\nfunction pickVariantBoardModel(\n result: SessionLike['result'],\n variantId?: string\n): any | undefined {\n if (!result) return undefined\n const variants = Array.isArray(result.variants) ? result.variants : []\n if (variants.length > 0) {\n if (variantId) {\n const found = variants.find(v => v.id === variantId)\n if (found?.boardModel) return found.boardModel\n }\n if (result.defaultVariantId) {\n const found = variants.find(v => v.id === result.defaultVariantId)\n if (found?.boardModel) return found.boardModel\n }\n if (variants[0]?.boardModel) return variants[0].boardModel\n }\n // back-compat — 구버전 ImportSession (variants 미존재) 의 단일 boardModel\n return result.boardModel\n}\n\n/**\n * sortOrder = MIN(sortOrder) - 1, 미존재 시 0. createBoard 의 \"맨 앞에 배치\" 정책과 동일.\n * createQueryBuilder 미지원 환경 (예: 단위 테스트의 minimal mock) 에서는 0 fallback.\n */\nasync function resolveNextSortOrder(repo: BoardRepoLike, domainId: string): Promise<number> {\n if (!repo.createQueryBuilder) return 0\n try {\n const raw = await repo\n .createQueryBuilder('board')\n .select('MIN(board.sortOrder)', 'minSortOrder')\n .where('board.domain = :domainId', { domainId })\n .getRawOne()\n const min = raw?.minSortOrder\n return typeof min === 'number' && Number.isFinite(min) ? min - 1 : 0\n } catch {\n return 0\n }\n}\n"]}
|
|
@@ -0,0 +1,80 @@
|
|
|
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
|
+
export interface SuggestBoardNameInput {
|
|
15
|
+
/** ImportSession id — 옵션. 있으면 거기서 hint / attachment 정보 추출. */
|
|
16
|
+
sessionId?: string;
|
|
17
|
+
/**
|
|
18
|
+
* 명시 hint. session 정보보다 우선. 사용자가 wizard 에서 직접 메모를 쳐서 그것 기반 추천을
|
|
19
|
+
* 받고 싶을 때 사용.
|
|
20
|
+
*/
|
|
21
|
+
hint?: string;
|
|
22
|
+
}
|
|
23
|
+
export interface SessionForSuggest {
|
|
24
|
+
id: string;
|
|
25
|
+
attachmentId?: string;
|
|
26
|
+
optionsJson?: string | null;
|
|
27
|
+
resultJson?: string | null;
|
|
28
|
+
}
|
|
29
|
+
export interface AttachmentForSuggest {
|
|
30
|
+
id: string;
|
|
31
|
+
name?: string;
|
|
32
|
+
}
|
|
33
|
+
export interface SuggestSessionRepoLike {
|
|
34
|
+
findOneBy(criteria: any): Promise<SessionForSuggest | null>;
|
|
35
|
+
}
|
|
36
|
+
export interface SuggestAttachmentRepoLike {
|
|
37
|
+
findOneBy(criteria: any): Promise<AttachmentForSuggest | null>;
|
|
38
|
+
}
|
|
39
|
+
export interface SuggestBoardRepoLike {
|
|
40
|
+
/**
|
|
41
|
+
* 도메인 격리된 LIKE 쿼리. find({ where: { name: Like('base%'), domain: { id } } }) 형태로
|
|
42
|
+
* 호출자가 wiring. typeorm Like operator 노출을 피하기 위해 단순 string 으로 받음.
|
|
43
|
+
*/
|
|
44
|
+
findExistingNames(opts: {
|
|
45
|
+
domainId: string;
|
|
46
|
+
basePrefix: string;
|
|
47
|
+
}): Promise<string[]>;
|
|
48
|
+
}
|
|
49
|
+
export interface SuggestContext {
|
|
50
|
+
domain: {
|
|
51
|
+
id: string;
|
|
52
|
+
[k: string]: any;
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
export interface SuggestDeps {
|
|
56
|
+
sessionRepo: SuggestSessionRepoLike;
|
|
57
|
+
attachmentRepo: SuggestAttachmentRepoLike;
|
|
58
|
+
boardRepo: SuggestBoardRepoLike;
|
|
59
|
+
}
|
|
60
|
+
export declare function suggestBoardName(input: SuggestBoardNameInput, context: SuggestContext, deps: SuggestDeps): Promise<string>;
|
|
61
|
+
/**
|
|
62
|
+
* Board 이름 + 설명을 함께 추천. 이름은 짧고 심플 (운영 카드/리스트 표시용),
|
|
63
|
+
* 설명은 자세히 (사용자 hint + AI importStrategy + 통계 요약).
|
|
64
|
+
*
|
|
65
|
+
* 정책:
|
|
66
|
+
* - **name**: hint / userPrompt / VLM reasoning / attachment stem 에서 짧은 base 추출 후
|
|
67
|
+
* 30자 이내로 자르고, 충돌 검사 + (n) suffix
|
|
68
|
+
* - **description**: userPrompt + AI importStrategy + (있으면) reasoning + 카테고리 분포
|
|
69
|
+
* 를 자연어 단락으로 합성. 없으면 빈 문자열
|
|
70
|
+
*/
|
|
71
|
+
export interface SuggestBoardMetaResult {
|
|
72
|
+
name: string;
|
|
73
|
+
description: string;
|
|
74
|
+
}
|
|
75
|
+
export declare function suggestBoardMeta(input: SuggestBoardNameInput, context: SuggestContext, deps: SuggestDeps): Promise<SuggestBoardMetaResult>;
|
|
76
|
+
/**
|
|
77
|
+
* existingNames 안에서 base 가 그대로 비어있으면 base 반환, 아니면 'base (2)', '(3)', ...
|
|
78
|
+
* case-insensitive 비교 (postgres ilike 호환).
|
|
79
|
+
*/
|
|
80
|
+
export declare function pickAvailableSuffix(base: string, existingNames: string[]): string;
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* suggestBoardName — ImportSession 으로부터 새 Board 의 후보 이름을 추천한다.
|
|
4
|
+
*
|
|
5
|
+
* 정책:
|
|
6
|
+
* 1) base 후보 우선순위: input.hint → session.options.userPrompt → session.result.metadata.viewTypeReasoning
|
|
7
|
+
* → attachment.name stem → 'Imported board'
|
|
8
|
+
* 2) base 정규화: 확장자 제거, _-. → 공백, 다중 공백 단일화, 50자 절단, trim
|
|
9
|
+
* 3) 도메인 격리 + 단일 LIKE query: `WHERE domain_id = ? AND (name = base OR name LIKE 'base (%)')`
|
|
10
|
+
* 한 번에 충돌 후보 다 가져오고 메모리에서 사용 가능한 가장 작은 (n) 결정
|
|
11
|
+
* 4) 결과: base 가 충돌 없으면 base, 있으면 'base (2)' / 'base (3)' / ...
|
|
12
|
+
*
|
|
13
|
+
* materializeFromSession 과 동일한 *RepoLike 패턴 — typeorm 의존성 주입으로 단위테스트 가능.
|
|
14
|
+
*/
|
|
15
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
16
|
+
exports.suggestBoardName = suggestBoardName;
|
|
17
|
+
exports.suggestBoardMeta = suggestBoardMeta;
|
|
18
|
+
exports.pickAvailableSuffix = pickAvailableSuffix;
|
|
19
|
+
const FALLBACK = 'Imported board';
|
|
20
|
+
async function suggestBoardName(input, context, deps) {
|
|
21
|
+
const base = await resolveBaseName(input, context, deps);
|
|
22
|
+
const cleaned = sanitize(base) || FALLBACK;
|
|
23
|
+
const existingNames = await deps.boardRepo.findExistingNames({
|
|
24
|
+
domainId: context.domain.id,
|
|
25
|
+
basePrefix: cleaned
|
|
26
|
+
});
|
|
27
|
+
return pickAvailableSuffix(cleaned, existingNames);
|
|
28
|
+
}
|
|
29
|
+
async function suggestBoardMeta(input, context, deps) {
|
|
30
|
+
// 보드 정체성 = AI 가 도면을 보고 식별한 subject. import 처리 메타 (importStrategy /
|
|
31
|
+
// categoryDist / userPrompt — AI 에게 한 지시) 와는 별 차원이라 영속 데이터에 섞지 않는다.
|
|
32
|
+
const { subjectName, subjectDescription } = await resolveSubject(input, context, deps);
|
|
33
|
+
// name: subjectName > input.hint > attachment.name stem > FALLBACK
|
|
34
|
+
// 사용자가 명시 hint 를 줬으면 그것이 우선 (직접 의도). VLM subjectName 은 그 다음.
|
|
35
|
+
const baseName = (input.hint && input.hint.trim() && firstSentence(input.hint)) ||
|
|
36
|
+
subjectName ||
|
|
37
|
+
(await resolveAttachmentStem(input, context, deps)) ||
|
|
38
|
+
FALLBACK;
|
|
39
|
+
const cleanedName = shortenName(sanitize(baseName) || FALLBACK);
|
|
40
|
+
const existingNames = await deps.boardRepo.findExistingNames({
|
|
41
|
+
domainId: context.domain.id,
|
|
42
|
+
basePrefix: cleanedName
|
|
43
|
+
});
|
|
44
|
+
const name = pickAvailableSuffix(cleanedName, existingNames);
|
|
45
|
+
// description: subjectDescription > input.hint (full) > 빈 문자열
|
|
46
|
+
// AI 가 도면을 본 자세한 묘사가 있으면 그것이 가장 가치 있음.
|
|
47
|
+
const description = subjectDescription || (input.hint && input.hint.trim()) || '';
|
|
48
|
+
return { name, description };
|
|
49
|
+
}
|
|
50
|
+
/** AI 가 도면을 보고 식별한 subject (보드 정체성) — VLM 응답의 subjectName / subjectDescription. */
|
|
51
|
+
async function resolveSubject(input, context, deps) {
|
|
52
|
+
if (!input.sessionId)
|
|
53
|
+
return {};
|
|
54
|
+
const session = await deps.sessionRepo.findOneBy({
|
|
55
|
+
id: input.sessionId,
|
|
56
|
+
domain: { id: context.domain.id }
|
|
57
|
+
});
|
|
58
|
+
if (!session)
|
|
59
|
+
return {};
|
|
60
|
+
const result = parseJsonSafe(session.resultJson);
|
|
61
|
+
const subjectName = typeof result?.metadata?.subjectName === 'string'
|
|
62
|
+
? result.metadata.subjectName.trim()
|
|
63
|
+
: undefined;
|
|
64
|
+
const subjectDescription = typeof result?.metadata?.subjectDescription === 'string'
|
|
65
|
+
? result.metadata.subjectDescription.trim()
|
|
66
|
+
: undefined;
|
|
67
|
+
return { subjectName, subjectDescription };
|
|
68
|
+
}
|
|
69
|
+
/** subject 미산출 시 fallback — attachment.name stem 만. */
|
|
70
|
+
async function resolveAttachmentStem(input, context, deps) {
|
|
71
|
+
if (!input.sessionId)
|
|
72
|
+
return undefined;
|
|
73
|
+
const session = await deps.sessionRepo.findOneBy({
|
|
74
|
+
id: input.sessionId,
|
|
75
|
+
domain: { id: context.domain.id }
|
|
76
|
+
});
|
|
77
|
+
if (!session?.attachmentId)
|
|
78
|
+
return undefined;
|
|
79
|
+
const att = await deps.attachmentRepo.findOneBy({
|
|
80
|
+
id: session.attachmentId,
|
|
81
|
+
domain: { id: context.domain.id }
|
|
82
|
+
});
|
|
83
|
+
return stemOf(att?.name) || undefined;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* 짧은 이름 — 단순 30자 단위로 단어 경계에서 자르기. 너무 길면 그대로 자르되 단어 보존.
|
|
87
|
+
*/
|
|
88
|
+
function shortenName(s) {
|
|
89
|
+
if (s.length <= 30)
|
|
90
|
+
return s;
|
|
91
|
+
// 30자 이내 마지막 공백에서 자르기
|
|
92
|
+
const truncated = s.slice(0, 30);
|
|
93
|
+
const lastSpace = truncated.lastIndexOf(' ');
|
|
94
|
+
if (lastSpace > 10)
|
|
95
|
+
return truncated.slice(0, lastSpace).trim();
|
|
96
|
+
return truncated.trim();
|
|
97
|
+
}
|
|
98
|
+
// ── helpers ──────────────────────────────────────────────────────────
|
|
99
|
+
async function resolveBaseName(input, context, deps) {
|
|
100
|
+
// 1) 명시 hint
|
|
101
|
+
if (input.hint && input.hint.trim())
|
|
102
|
+
return firstSentence(input.hint);
|
|
103
|
+
// 2) session 정보
|
|
104
|
+
if (input.sessionId) {
|
|
105
|
+
const session = await deps.sessionRepo.findOneBy({
|
|
106
|
+
id: input.sessionId,
|
|
107
|
+
domain: { id: context.domain.id }
|
|
108
|
+
});
|
|
109
|
+
if (session) {
|
|
110
|
+
// 2a) userPrompt 우선
|
|
111
|
+
const opts = parseJsonSafe(session.optionsJson);
|
|
112
|
+
const userPrompt = typeof opts?.userPrompt === 'string' ? opts.userPrompt.trim() : '';
|
|
113
|
+
if (userPrompt)
|
|
114
|
+
return firstSentence(userPrompt);
|
|
115
|
+
// 2b) VLM reasoning
|
|
116
|
+
const result = parseJsonSafe(session.resultJson);
|
|
117
|
+
const reasoning = typeof result?.boardModel?.importMeta?.viewTypeReasoning === 'string'
|
|
118
|
+
? result.boardModel.importMeta.viewTypeReasoning.trim()
|
|
119
|
+
: typeof result?.metadata?.viewTypeReasoning === 'string'
|
|
120
|
+
? result.metadata.viewTypeReasoning.trim()
|
|
121
|
+
: '';
|
|
122
|
+
if (reasoning)
|
|
123
|
+
return firstSentence(reasoning);
|
|
124
|
+
// 2c) attachment name stem
|
|
125
|
+
if (session.attachmentId) {
|
|
126
|
+
const att = await deps.attachmentRepo.findOneBy({
|
|
127
|
+
id: session.attachmentId,
|
|
128
|
+
domain: { id: context.domain.id }
|
|
129
|
+
});
|
|
130
|
+
const stem = stemOf(att?.name);
|
|
131
|
+
if (stem)
|
|
132
|
+
return stem;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return FALLBACK;
|
|
137
|
+
}
|
|
138
|
+
function parseJsonSafe(s) {
|
|
139
|
+
if (!s)
|
|
140
|
+
return undefined;
|
|
141
|
+
try {
|
|
142
|
+
return JSON.parse(s);
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
function firstSentence(text) {
|
|
149
|
+
// 첫 문장 종결 부호 또는 줄바꿈까지. 미발견 시 전체.
|
|
150
|
+
const m = text.match(/^([^.\n!?]{1,120})[.\n!?]?/);
|
|
151
|
+
return (m?.[1] ?? text).trim();
|
|
152
|
+
}
|
|
153
|
+
function stemOf(name) {
|
|
154
|
+
if (!name)
|
|
155
|
+
return '';
|
|
156
|
+
// 마지막 . 이후 (확장자) 제거. 점이 여러 번이면 가장 마지막만.
|
|
157
|
+
const lastDot = name.lastIndexOf('.');
|
|
158
|
+
if (lastDot > 0)
|
|
159
|
+
return name.slice(0, lastDot);
|
|
160
|
+
return name;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* 정규화: _-. → 공백, 다중 공백 단일화, trim, 50자 절단.
|
|
164
|
+
* 결과가 빈 문자열이면 '' 반환 (호출자가 fallback).
|
|
165
|
+
*/
|
|
166
|
+
function sanitize(s) {
|
|
167
|
+
if (!s)
|
|
168
|
+
return '';
|
|
169
|
+
let cleaned = s.replace(/[_\-.]+/g, ' ').replace(/\s+/g, ' ').trim();
|
|
170
|
+
if (cleaned.length > 50)
|
|
171
|
+
cleaned = cleaned.slice(0, 50).trim();
|
|
172
|
+
return cleaned;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* existingNames 안에서 base 가 그대로 비어있으면 base 반환, 아니면 'base (2)', '(3)', ...
|
|
176
|
+
* case-insensitive 비교 (postgres ilike 호환).
|
|
177
|
+
*/
|
|
178
|
+
function pickAvailableSuffix(base, existingNames) {
|
|
179
|
+
const lower = base.toLowerCase();
|
|
180
|
+
const taken = new Set();
|
|
181
|
+
for (const name of existingNames) {
|
|
182
|
+
if (typeof name === 'string')
|
|
183
|
+
taken.add(name.toLowerCase());
|
|
184
|
+
}
|
|
185
|
+
if (!taken.has(lower))
|
|
186
|
+
return base;
|
|
187
|
+
for (let n = 2; n < 10000; n++) {
|
|
188
|
+
const candidate = `${base} (${n})`;
|
|
189
|
+
if (!taken.has(candidate.toLowerCase()))
|
|
190
|
+
return candidate;
|
|
191
|
+
}
|
|
192
|
+
// 9999개 충돌 이라는 비현실 시나리오 — 마지막 fallback
|
|
193
|
+
return `${base} (${Date.now()})`;
|
|
194
|
+
}
|
|
195
|
+
//# sourceMappingURL=suggest-board-name.js.map
|