@things-factory/board-import 10.0.0-beta.71 → 10.0.0-beta.73

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 (83) hide show
  1. package/dist-server/index.d.ts +1 -1
  2. package/dist-server/index.js +1 -1
  3. package/dist-server/index.js.map +1 -1
  4. package/dist-server/service/adapters/dxf-adapter.d.ts +1 -1
  5. package/dist-server/service/adapters/dxf-adapter.js +18 -4
  6. package/dist-server/service/adapters/dxf-adapter.js.map +1 -1
  7. package/dist-server/service/adapters/image-adapter.d.ts +32 -0
  8. package/dist-server/service/adapters/image-adapter.js +420 -0
  9. package/dist-server/service/adapters/image-adapter.js.map +1 -0
  10. package/dist-server/service/ai/types.d.ts +96 -4
  11. package/dist-server/service/ai/types.js +221 -12
  12. package/dist-server/service/ai/types.js.map +1 -1
  13. package/dist-server/service/converters/generic-to.d.ts +17 -8
  14. package/dist-server/service/converters/generic-to.js +44 -44
  15. package/dist-server/service/converters/generic-to.js.map +1 -1
  16. package/dist-server/service/import-session/import-actions.d.ts +79 -0
  17. package/dist-server/service/import-session/import-actions.js +98 -0
  18. package/dist-server/service/import-session/import-actions.js.map +1 -0
  19. package/dist-server/service/import-session/import-session-resolver.d.ts +22 -1
  20. package/dist-server/service/import-session/import-session-resolver.js +166 -38
  21. package/dist-server/service/import-session/import-session-resolver.js.map +1 -1
  22. package/dist-server/service/import-session/import-worker.js +55 -13
  23. package/dist-server/service/import-session/import-worker.js.map +1 -1
  24. package/dist-server/service/import-session/index.d.ts +5 -3
  25. package/dist-server/service/import-session/index.js +16 -9
  26. package/dist-server/service/import-session/index.js.map +1 -1
  27. package/dist-server/service/import-session/materialize-from-session.d.ts +66 -0
  28. package/dist-server/service/import-session/materialize-from-session.js +104 -0
  29. package/dist-server/service/import-session/materialize-from-session.js.map +1 -0
  30. package/dist-server/service/import-session/suggest-board-name.d.ts +80 -0
  31. package/dist-server/service/import-session/suggest-board-name.js +195 -0
  32. package/dist-server/service/import-session/suggest-board-name.js.map +1 -0
  33. package/dist-server/service/import-tools.d.ts +10 -0
  34. package/dist-server/service/import-tools.js +227 -0
  35. package/dist-server/service/import-tools.js.map +1 -0
  36. package/dist-server/service/index.d.ts +22 -19
  37. package/dist-server/service/index.js +46 -36
  38. package/dist-server/service/index.js.map +1 -1
  39. package/dist-server/service/pipeline/index.d.ts +42 -9
  40. package/dist-server/service/pipeline/index.js +92 -23
  41. package/dist-server/service/pipeline/index.js.map +1 -1
  42. package/dist-server/service/pipeline/stage2-mapping.d.ts +20 -5
  43. package/dist-server/service/pipeline/stage2-mapping.js +84 -26
  44. package/dist-server/service/pipeline/stage2-mapping.js.map +1 -1
  45. package/dist-server/service/pipeline/stage3-board.d.ts +2 -2
  46. package/dist-server/service/pipeline/stage3-board.js +65 -10
  47. package/dist-server/service/pipeline/stage3-board.js.map +1 -1
  48. package/dist-server/service/pipeline/stage4-binding.d.ts +1 -1
  49. package/dist-server/service/pipeline/stage4-binding.js +2 -2
  50. package/dist-server/service/pipeline/stage4-binding.js.map +1 -1
  51. package/dist-server/service/registry/index.d.ts +1 -1
  52. package/dist-server/service/registry/index.js.map +1 -1
  53. package/dist-server/service/types/index.d.ts +35 -0
  54. package/dist-server/service/types/index.js.map +1 -1
  55. package/dist-server/tsconfig.tsbuildinfo +1 -1
  56. package/package.json +7 -6
  57. package/server/index.ts +1 -1
  58. package/server/service/adapters/dxf-adapter.ts +17 -4
  59. package/server/service/adapters/image-adapter.test.ts +545 -0
  60. package/server/service/adapters/image-adapter.ts +464 -0
  61. package/server/service/ai/types.ts +354 -19
  62. package/server/service/converters/generic-to.test.ts +91 -0
  63. package/server/service/converters/generic-to.ts +66 -49
  64. package/server/service/import-session/import-actions.test.ts +185 -0
  65. package/server/service/import-session/import-actions.ts +164 -0
  66. package/server/service/import-session/import-session-resolver.ts +171 -39
  67. package/server/service/import-session/import-worker.ts +56 -12
  68. package/server/service/import-session/index.ts +19 -3
  69. package/server/service/import-session/materialize-from-session.test.ts +274 -0
  70. package/server/service/import-session/materialize-from-session.ts +158 -0
  71. package/server/service/import-session/suggest-board-name.test.ts +271 -0
  72. package/server/service/import-session/suggest-board-name.ts +279 -0
  73. package/server/service/import-tools.test.ts +137 -0
  74. package/server/service/import-tools.ts +255 -0
  75. package/server/service/index.ts +35 -18
  76. package/server/service/pipeline/index.ts +118 -23
  77. package/server/service/pipeline/stage2-mapping.test.ts +204 -0
  78. package/server/service/pipeline/stage2-mapping.ts +102 -27
  79. package/server/service/pipeline/stage3-board.test.ts +133 -0
  80. package/server/service/pipeline/stage3-board.ts +76 -11
  81. package/server/service/pipeline/stage4-binding.ts +2 -2
  82. package/server/service/registry/index.ts +1 -1
  83. package/server/service/types/index.ts +36 -0
@@ -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":"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","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
@@ -0,0 +1 @@
1
+ {"version":3,"file":"suggest-board-name.js","sourceRoot":"","sources":["../../../server/service/import-session/suggest-board-name.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * suggestBoardName — ImportSession 으로부터 새 Board 의 후보 이름을 추천한다.\n *\n * 정책:\n * 1) base 후보 우선순위: input.hint → session.options.userPrompt → session.result.metadata.viewTypeReasoning\n * → attachment.name stem → 'Imported board'\n * 2) base 정규화: 확장자 제거, _-. → 공백, 다중 공백 단일화, 50자 절단, trim\n * 3) 도메인 격리 + 단일 LIKE query: `WHERE domain_id = ? AND (name = base OR name LIKE 'base (%)')`\n * 한 번에 충돌 후보 다 가져오고 메모리에서 사용 가능한 가장 작은 (n) 결정\n * 4) 결과: base 가 충돌 없으면 base, 있으면 'base (2)' / 'base (3)' / ...\n *\n * materializeFromSession 과 동일한 *RepoLike 패턴 — typeorm 의존성 주입으로 단위테스트 가능.\n */\n\nconst FALLBACK = 'Imported board'\n\nexport interface SuggestBoardNameInput {\n /** ImportSession id — 옵션. 있으면 거기서 hint / attachment 정보 추출. */\n sessionId?: string\n /**\n * 명시 hint. session 정보보다 우선. 사용자가 wizard 에서 직접 메모를 쳐서 그것 기반 추천을\n * 받고 싶을 때 사용.\n */\n hint?: string\n}\n\nexport interface SessionForSuggest {\n id: string\n attachmentId?: string\n optionsJson?: string | null\n resultJson?: string | null\n}\n\nexport interface AttachmentForSuggest {\n id: string\n name?: string\n}\n\nexport interface SuggestSessionRepoLike {\n findOneBy(criteria: any): Promise<SessionForSuggest | null>\n}\n\nexport interface SuggestAttachmentRepoLike {\n findOneBy(criteria: any): Promise<AttachmentForSuggest | null>\n}\n\nexport interface SuggestBoardRepoLike {\n /**\n * 도메인 격리된 LIKE 쿼리. find({ where: { name: Like('base%'), domain: { id } } }) 형태로\n * 호출자가 wiring. typeorm Like operator 노출을 피하기 위해 단순 string 으로 받음.\n */\n findExistingNames(opts: { domainId: string; basePrefix: string }): Promise<string[]>\n}\n\nexport interface SuggestContext {\n domain: { id: string; [k: string]: any }\n}\n\nexport interface SuggestDeps {\n sessionRepo: SuggestSessionRepoLike\n attachmentRepo: SuggestAttachmentRepoLike\n boardRepo: SuggestBoardRepoLike\n}\n\nexport async function suggestBoardName(\n input: SuggestBoardNameInput,\n context: SuggestContext,\n deps: SuggestDeps\n): Promise<string> {\n const base = await resolveBaseName(input, context, deps)\n const cleaned = sanitize(base) || FALLBACK\n\n const existingNames = await deps.boardRepo.findExistingNames({\n domainId: context.domain.id,\n basePrefix: cleaned\n })\n return pickAvailableSuffix(cleaned, existingNames)\n}\n\n/**\n * Board 이름 + 설명을 함께 추천. 이름은 짧고 심플 (운영 카드/리스트 표시용),\n * 설명은 자세히 (사용자 hint + AI importStrategy + 통계 요약).\n *\n * 정책:\n * - **name**: hint / userPrompt / VLM reasoning / attachment stem 에서 짧은 base 추출 후\n * 30자 이내로 자르고, 충돌 검사 + (n) suffix\n * - **description**: userPrompt + AI importStrategy + (있으면) reasoning + 카테고리 분포\n * 를 자연어 단락으로 합성. 없으면 빈 문자열\n */\nexport interface SuggestBoardMetaResult {\n name: string\n description: string\n}\n\nexport async function suggestBoardMeta(\n input: SuggestBoardNameInput,\n context: SuggestContext,\n deps: SuggestDeps\n): Promise<SuggestBoardMetaResult> {\n // 보드 정체성 = AI 가 도면을 보고 식별한 subject. import 처리 메타 (importStrategy /\n // categoryDist / userPrompt — AI 에게 한 지시) 와는 별 차원이라 영속 데이터에 섞지 않는다.\n const { subjectName, subjectDescription } = await resolveSubject(input, context, deps)\n\n // name: subjectName > input.hint > attachment.name stem > FALLBACK\n // 사용자가 명시 hint 를 줬으면 그것이 우선 (직접 의도). VLM subjectName 은 그 다음.\n const baseName =\n (input.hint && input.hint.trim() && firstSentence(input.hint)) ||\n subjectName ||\n (await resolveAttachmentStem(input, context, deps)) ||\n FALLBACK\n const cleanedName = shortenName(sanitize(baseName) || FALLBACK)\n\n const existingNames = await deps.boardRepo.findExistingNames({\n domainId: context.domain.id,\n basePrefix: cleanedName\n })\n const name = pickAvailableSuffix(cleanedName, existingNames)\n\n // description: subjectDescription > input.hint (full) > 빈 문자열\n // AI 가 도면을 본 자세한 묘사가 있으면 그것이 가장 가치 있음.\n const description =\n subjectDescription || (input.hint && input.hint.trim()) || ''\n\n return { name, description }\n}\n\n/** AI 가 도면을 보고 식별한 subject (보드 정체성) — VLM 응답의 subjectName / subjectDescription. */\nasync function resolveSubject(\n input: SuggestBoardNameInput,\n context: SuggestContext,\n deps: SuggestDeps\n): Promise<{ subjectName?: string; subjectDescription?: string }> {\n if (!input.sessionId) return {}\n const session = await deps.sessionRepo.findOneBy({\n id: input.sessionId,\n domain: { id: context.domain.id }\n })\n if (!session) return {}\n const result = parseJsonSafe(session.resultJson)\n const subjectName = typeof result?.metadata?.subjectName === 'string'\n ? result.metadata.subjectName.trim()\n : undefined\n const subjectDescription = typeof result?.metadata?.subjectDescription === 'string'\n ? result.metadata.subjectDescription.trim()\n : undefined\n return { subjectName, subjectDescription }\n}\n\n/** subject 미산출 시 fallback — attachment.name stem 만. */\nasync function resolveAttachmentStem(\n input: SuggestBoardNameInput,\n context: SuggestContext,\n deps: SuggestDeps\n): Promise<string | undefined> {\n if (!input.sessionId) return undefined\n const session = await deps.sessionRepo.findOneBy({\n id: input.sessionId,\n domain: { id: context.domain.id }\n })\n if (!session?.attachmentId) return undefined\n const att = await deps.attachmentRepo.findOneBy({\n id: session.attachmentId,\n domain: { id: context.domain.id }\n })\n return stemOf(att?.name) || undefined\n}\n\n/**\n * 짧은 이름 — 단순 30자 단위로 단어 경계에서 자르기. 너무 길면 그대로 자르되 단어 보존.\n */\nfunction shortenName(s: string): string {\n if (s.length <= 30) return s\n // 30자 이내 마지막 공백에서 자르기\n const truncated = s.slice(0, 30)\n const lastSpace = truncated.lastIndexOf(' ')\n if (lastSpace > 10) return truncated.slice(0, lastSpace).trim()\n return truncated.trim()\n}\n\n\n// ── helpers ──────────────────────────────────────────────────────────\n\nasync function resolveBaseName(\n input: SuggestBoardNameInput,\n context: SuggestContext,\n deps: SuggestDeps\n): Promise<string> {\n // 1) 명시 hint\n if (input.hint && input.hint.trim()) return firstSentence(input.hint)\n\n // 2) session 정보\n if (input.sessionId) {\n const session = await deps.sessionRepo.findOneBy({\n id: input.sessionId,\n domain: { id: context.domain.id }\n })\n if (session) {\n // 2a) userPrompt 우선\n const opts = parseJsonSafe(session.optionsJson)\n const userPrompt = typeof opts?.userPrompt === 'string' ? opts.userPrompt.trim() : ''\n if (userPrompt) return firstSentence(userPrompt)\n\n // 2b) VLM reasoning\n const result = parseJsonSafe(session.resultJson)\n const reasoning =\n typeof result?.boardModel?.importMeta?.viewTypeReasoning === 'string'\n ? result.boardModel.importMeta.viewTypeReasoning.trim()\n : typeof result?.metadata?.viewTypeReasoning === 'string'\n ? result.metadata.viewTypeReasoning.trim()\n : ''\n if (reasoning) return firstSentence(reasoning)\n\n // 2c) attachment name stem\n if (session.attachmentId) {\n const att = await deps.attachmentRepo.findOneBy({\n id: session.attachmentId,\n domain: { id: context.domain.id }\n })\n const stem = stemOf(att?.name)\n if (stem) return stem\n }\n }\n }\n\n return FALLBACK\n}\n\nfunction parseJsonSafe(s: string | null | undefined): any {\n if (!s) return undefined\n try {\n return JSON.parse(s)\n } catch {\n return undefined\n }\n}\n\nfunction firstSentence(text: string): string {\n // 첫 문장 종결 부호 또는 줄바꿈까지. 미발견 시 전체.\n const m = text.match(/^([^.\\n!?]{1,120})[.\\n!?]?/)\n return (m?.[1] ?? text).trim()\n}\n\nfunction stemOf(name: string | undefined | null): string {\n if (!name) return ''\n // 마지막 . 이후 (확장자) 제거. 점이 여러 번이면 가장 마지막만.\n const lastDot = name.lastIndexOf('.')\n if (lastDot > 0) return name.slice(0, lastDot)\n return name\n}\n\n/**\n * 정규화: _-. → 공백, 다중 공백 단일화, trim, 50자 절단.\n * 결과가 빈 문자열이면 '' 반환 (호출자가 fallback).\n */\nfunction sanitize(s: string): string {\n if (!s) return ''\n let cleaned = s.replace(/[_\\-.]+/g, ' ').replace(/\\s+/g, ' ').trim()\n if (cleaned.length > 50) cleaned = cleaned.slice(0, 50).trim()\n return cleaned\n}\n\n/**\n * existingNames 안에서 base 가 그대로 비어있으면 base 반환, 아니면 'base (2)', '(3)', ...\n * case-insensitive 비교 (postgres ilike 호환).\n */\nexport function pickAvailableSuffix(base: string, existingNames: string[]): string {\n const lower = base.toLowerCase()\n const taken = new Set<string>()\n for (const name of existingNames) {\n if (typeof name === 'string') taken.add(name.toLowerCase())\n }\n if (!taken.has(lower)) return base\n for (let n = 2; n < 10000; n++) {\n const candidate = `${base} (${n})`\n if (!taken.has(candidate.toLowerCase())) return candidate\n }\n // 9999개 충돌 이라는 비현실 시나리오 — 마지막 fallback\n return `${base} (${Date.now()})`\n}\n"]}
@@ -0,0 +1,10 @@
1
+ import { type ToolCategory } from '@things-factory/ai-client-base';
2
+ /**
3
+ * 명시적 register 함수 — 테스트나 lazy bootstrap 에서 import 만으로 발동되는 side-effect 가
4
+ * 부담스러울 때 import 후 명시 호출 가능.
5
+ */
6
+ export declare function registerBoardImportTools(): void;
7
+ /**
8
+ * 테스트용 — registerToolCategory 의 입력으로 사용된 카테고리 객체. 외부에서 inspect 가능.
9
+ */
10
+ export declare const BOARD_IMPORT_TOOL_CATEGORY: ToolCategory;