@things-factory/ai-client-base 10.0.0-beta.64 → 10.0.0-beta.72

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.
@@ -0,0 +1,185 @@
1
+ /**
2
+ * ToolCategoryRegistry — 단위 테스트.
3
+ *
4
+ * plugin point 의 회귀 방지:
5
+ * - 등록 / 조회 / 평면 합집합 / kind 분류 / 충돌 / clear
6
+ * - 카테고리 간 격리 (한 카테고리 변경이 다른 것에 영향 X)
7
+ * - 빈 등록 시 baseline (assistant 의 hardcoded tool 만 동작)
8
+ */
9
+ import {
10
+ registerToolCategory,
11
+ getToolCategories,
12
+ getAllToolSpecs,
13
+ findToolSpec,
14
+ getToolKind,
15
+ hasTool,
16
+ getCategorySpecs,
17
+ clearToolRegistry
18
+ } from './registry'
19
+ import type { ToolCategory, ToolSpec } from './types'
20
+
21
+ // 테스트 간 격리
22
+ afterEach(() => {
23
+ clearToolRegistry()
24
+ })
25
+
26
+ // 헬퍼 — 카테고리 fixture
27
+ function makeCategory(name: string, specs: Partial<ToolSpec>[]): ToolCategory {
28
+ return {
29
+ name,
30
+ specs: specs.map(s => ({
31
+ kind: 'read' as const,
32
+ name: 'unnamed',
33
+ description: 'desc',
34
+ schema: {},
35
+ builder: () => ({}),
36
+ ...s
37
+ })) as ToolSpec[]
38
+ }
39
+ }
40
+
41
+ // ── 기본 등록 / 조회 ─────────────────────────────────────────────────
42
+
43
+ describe('registry — 기본 등록/조회', () => {
44
+ test('빈 registry 는 빈 결과', () => {
45
+ expect(getToolCategories()).toEqual([])
46
+ expect(getAllToolSpecs()).toEqual([])
47
+ expect(findToolSpec('any')).toBeUndefined()
48
+ expect(getToolKind('any')).toBeUndefined()
49
+ expect(hasTool('any')).toBe(false)
50
+ })
51
+
52
+ test('카테고리 등록 후 조회', () => {
53
+ registerToolCategory(makeCategory('cat1', [{ name: 't1' }, { name: 't2' }]))
54
+ expect(getToolCategories()).toHaveLength(1)
55
+ expect(getToolCategories()[0].name).toBe('cat1')
56
+ expect(getCategorySpecs('cat1')).toHaveLength(2)
57
+ })
58
+
59
+ test('getAllToolSpecs 는 카테고리 간 평면 합집합', () => {
60
+ registerToolCategory(makeCategory('cat1', [{ name: 't1' }]))
61
+ registerToolCategory(makeCategory('cat2', [{ name: 't2' }, { name: 't3' }]))
62
+ expect(getAllToolSpecs()).toHaveLength(3)
63
+ expect(getAllToolSpecs().map(s => s.name)).toEqual(['t1', 't2', 't3'])
64
+ })
65
+
66
+ test('findToolSpec 은 카테고리 무관 검색', () => {
67
+ registerToolCategory(makeCategory('cat1', [{ name: 'foo' }]))
68
+ registerToolCategory(makeCategory('cat2', [{ name: 'bar' }]))
69
+ expect(findToolSpec('foo')?.name).toBe('foo')
70
+ expect(findToolSpec('bar')?.name).toBe('bar')
71
+ expect(findToolSpec('baz')).toBeUndefined()
72
+ })
73
+
74
+ test('hasTool 은 등록 여부 boolean', () => {
75
+ registerToolCategory(makeCategory('cat1', [{ name: 'foo' }]))
76
+ expect(hasTool('foo')).toBe(true)
77
+ expect(hasTool('bar')).toBe(false)
78
+ })
79
+ })
80
+
81
+ // ── kind 분류 ────────────────────────────────────────────────────────
82
+
83
+ describe('registry — kind 분류', () => {
84
+ test("getToolKind 는 spec.kind 그대로 반환", () => {
85
+ registerToolCategory(
86
+ makeCategory('mixed', [
87
+ { name: 'r1', kind: 'read' },
88
+ { name: 'w1', kind: 'write' },
89
+ { name: 'a1', kind: 'action' },
90
+ { name: 'e1', kind: 'external' }
91
+ ])
92
+ )
93
+ expect(getToolKind('r1')).toBe('read')
94
+ expect(getToolKind('w1')).toBe('write')
95
+ expect(getToolKind('a1')).toBe('action')
96
+ expect(getToolKind('e1')).toBe('external')
97
+ expect(getToolKind('x1')).toBeUndefined()
98
+ })
99
+ })
100
+
101
+ // ── 충돌 / 격리 ──────────────────────────────────────────────────────
102
+
103
+ describe('registry — 충돌 / 격리', () => {
104
+ test('서로 다른 카테고리에 같은 tool name 등록 거부 (throw)', () => {
105
+ registerToolCategory(makeCategory('cat1', [{ name: 'duplicate' }]))
106
+ expect(() =>
107
+ registerToolCategory(makeCategory('cat2', [{ name: 'duplicate' }]))
108
+ ).toThrow(/already registered in category/)
109
+ })
110
+
111
+ test('같은 카테고리 재등록은 덮어쓰기 (warn 출력)', () => {
112
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {})
113
+ registerToolCategory(makeCategory('cat1', [{ name: 'old' }]))
114
+ registerToolCategory(makeCategory('cat1', [{ name: 'new1' }, { name: 'new2' }]))
115
+ expect(warnSpy).toHaveBeenCalled()
116
+ expect(getCategorySpecs('cat1').map(s => s.name)).toEqual(['new1', 'new2'])
117
+ warnSpy.mockRestore()
118
+ })
119
+
120
+ test('한 카테고리 변경이 다른 카테고리에 영향 X', () => {
121
+ registerToolCategory(makeCategory('cat1', [{ name: 't1' }]))
122
+ registerToolCategory(makeCategory('cat2', [{ name: 't2' }]))
123
+ // cat1 재등록 — cat2 는 보존되어야
124
+ registerToolCategory(makeCategory('cat1', [{ name: 't1-new' }]))
125
+ expect(getCategorySpecs('cat1').map(s => s.name)).toEqual(['t1-new'])
126
+ expect(getCategorySpecs('cat2').map(s => s.name)).toEqual(['t2'])
127
+ })
128
+
129
+ test('clearToolRegistry 는 모든 등록 해제', () => {
130
+ registerToolCategory(makeCategory('cat1', [{ name: 't1' }]))
131
+ registerToolCategory(makeCategory('cat2', [{ name: 't2' }]))
132
+ clearToolRegistry()
133
+ expect(getToolCategories()).toEqual([])
134
+ expect(getAllToolSpecs()).toEqual([])
135
+ })
136
+ })
137
+
138
+ // ── builder 호출 — 등록만 하고 실제 dispatch 는 assistant 측 ──────────
139
+
140
+ describe('registry — spec 보존', () => {
141
+ test('등록한 builder 가 spec 안에 그대로 보존', () => {
142
+ const builder = jest.fn(() => ({ ok: true }))
143
+ registerToolCategory({
144
+ name: 'cat1',
145
+ specs: [
146
+ {
147
+ kind: 'read',
148
+ name: 'mybuilder',
149
+ description: 'desc',
150
+ schema: {},
151
+ builder
152
+ } as ToolSpec
153
+ ]
154
+ })
155
+ const spec = findToolSpec('mybuilder')
156
+ expect(spec).toBeDefined()
157
+ expect(spec!.kind).toBe('read')
158
+ // ToolSpec 의 builder 는 카테고리 별 다른 시그니처지만 본 테스트는 단순 호출
159
+ if (spec!.kind === 'read') {
160
+ const r = (spec.builder as any)({}, {})
161
+ expect(r).toEqual({ ok: true })
162
+ expect(builder).toHaveBeenCalled()
163
+ }
164
+ })
165
+
166
+ test('description / themeNote / schema 보존', () => {
167
+ registerToolCategory({
168
+ name: 'cat1',
169
+ specs: [
170
+ {
171
+ kind: 'write',
172
+ name: 't1',
173
+ description: 'main desc',
174
+ themeNote: '⚠ note',
175
+ schema: { type: 'object' },
176
+ builder: () => null
177
+ } as ToolSpec
178
+ ]
179
+ })
180
+ const spec = findToolSpec('t1')
181
+ expect(spec?.description).toBe('main desc')
182
+ expect(spec?.themeNote).toBe('⚠ note')
183
+ expect(spec?.schema).toEqual({ type: 'object' })
184
+ })
185
+ })
@@ -0,0 +1,121 @@
1
+ /**
2
+ * ToolCategoryRegistry — 외부 도메인 패키지가 LLM tool category 를 동적 등록하는 점.
3
+ *
4
+ * 사용 패턴 (외부 패키지의 server bootstrap):
5
+ * ```ts
6
+ * import { registerToolCategory } from '@things-factory/board-ai'
7
+ *
8
+ * registerToolCategory({
9
+ * name: 'board-import',
10
+ * specs: [
11
+ * { kind: 'external', name: 'importBoardAsync', schema: {...}, builder: ... },
12
+ * { kind: 'read', name: 'getImportSession', schema: {...}, builder: ... },
13
+ * ...
14
+ * ]
15
+ * })
16
+ * ```
17
+ *
18
+ * board-ai assistant.ts 의 buildBoardEditTools / dispatch / isWriteTool 등이
19
+ * 본 registry 에서 자동 도출.
20
+ *
21
+ * 정책:
22
+ * - 카테고리 이름은 unique. 같은 이름으로 재등록하면 덮어쓰기 (외부 패키지의
23
+ * hot-reload / dev 환경 대응). console.warn 으로 알림.
24
+ * - tool name 은 카테고리 간 통틀어 unique. 충돌 시 등록 거부 + 명확한 에러
25
+ * (LLM dispatch 가 모호해지지 않도록).
26
+ * - clear() 는 테스트 전용. 운영 환경에서 호출 금지.
27
+ */
28
+ import type { ToolCategory, ToolSpec } from './types'
29
+
30
+ const categories: Map<string, ToolCategory> = new Map()
31
+
32
+ /**
33
+ * 카테고리 등록. tool name 충돌 시 throw, 이름 중복 등록 시 덮어쓰기 + warn.
34
+ */
35
+ export function registerToolCategory(category: ToolCategory): void {
36
+ if (categories.has(category.name)) {
37
+ console.warn(
38
+ `[board-ai/tool-registry] category '${category.name}' already registered — overwriting. ` +
39
+ `(dev hot-reload 시 정상이지만 운영 환경에서 발생하면 buildup bug)`
40
+ )
41
+ }
42
+
43
+ // tool name 충돌 검사 — 다른 카테고리에 같은 name 있으면 거부
44
+ const newNames = new Set(category.specs.map(s => s.name))
45
+ for (const [otherName, other] of categories) {
46
+ if (otherName === category.name) continue // 같은 카테고리 재등록은 덮어쓰기 OK
47
+ for (const spec of other.specs) {
48
+ if (newNames.has(spec.name)) {
49
+ throw new Error(
50
+ `[board-ai/tool-registry] tool name '${spec.name}' already registered in category ` +
51
+ `'${otherName}', cannot register in '${category.name}'. tool names must be globally unique.`
52
+ )
53
+ }
54
+ }
55
+ }
56
+
57
+ categories.set(category.name, category)
58
+ }
59
+
60
+ /**
61
+ * 등록된 모든 카테고리 (등록 순서 보존). 외부에서 불변 read-only 로 사용.
62
+ */
63
+ export function getToolCategories(): ReadonlyArray<ToolCategory> {
64
+ return Array.from(categories.values())
65
+ }
66
+
67
+ /**
68
+ * 등록된 모든 tool spec 의 평면 view. assistant.ts 의 buildBoardEditTools 가
69
+ * LLM tool 정의 합집합으로 사용.
70
+ */
71
+ export function getAllToolSpecs(): ReadonlyArray<ToolSpec> {
72
+ const out: ToolSpec[] = []
73
+ for (const cat of categories.values()) {
74
+ for (const spec of cat.specs) out.push(spec)
75
+ }
76
+ return out
77
+ }
78
+
79
+ /**
80
+ * tool name 으로 spec 검색. 없으면 undefined.
81
+ */
82
+ export function findToolSpec(name: string): ToolSpec | undefined {
83
+ for (const cat of categories.values()) {
84
+ const spec = cat.specs.find(s => s.name === name)
85
+ if (spec) return spec
86
+ }
87
+ return undefined
88
+ }
89
+
90
+ /**
91
+ * tool name 의 카테고리 (read/write/action/external). 미등록 tool 은 undefined.
92
+ * assistant.ts 의 isReadTool / isWriteTool / isActionTool 이 이 함수에 위임할 수 있다.
93
+ */
94
+ export function getToolKind(name: string): ToolSpec['kind'] | undefined {
95
+ return findToolSpec(name)?.kind
96
+ }
97
+
98
+ /**
99
+ * 등록된 tool 인지. dispatch path 가 hardcoded set 와 registry 양쪽 fallback 시 사용.
100
+ */
101
+ export function hasTool(name: string): boolean {
102
+ return findToolSpec(name) !== undefined
103
+ }
104
+
105
+ /**
106
+ * 카테고리 이름으로 spec 만 추출.
107
+ */
108
+ export function getCategorySpecs(categoryName: string): ReadonlyArray<ToolSpec> {
109
+ return categories.get(categoryName)?.specs ?? []
110
+ }
111
+
112
+ /**
113
+ * 테스트 전용 — 모든 등록 해제. 운영 환경에서 호출 금지.
114
+ *
115
+ * jest 의 setupFilesAfterEach 또는 명시적 afterEach 에서 호출해 테스트 간 격리.
116
+ * 실제로 STYLING_TOOLS 는 board-ai 자체 부트스트랩에서 다시 등록되므로 clear 후
117
+ * 명시 register 가 필요한 것은 동적 카테고리 (board-import 등) 뿐.
118
+ */
119
+ export function clearToolRegistry(): void {
120
+ categories.clear()
121
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Tool Registry — board-ai 의 LLM tool 노출 plugin point.
3
+ *
4
+ * 동기:
5
+ * 기존엔 board-ai 가 자기 도메인 (board edit) tool 들만 hardcoded 정의했고, styling
6
+ * 영역만 STYLING_TOOLS registry 로 분리되어 있었다. 외부 도메인 패키지 (board-import,
7
+ * fmsim 등) 가 자기 tool 을 LLM 에 노출하려면 board-ai 자체를 fork/수정해야 했다.
8
+ *
9
+ * 본 registry 는 외부 패키지가 server bootstrap 시 자기 tool category 를 등록하면
10
+ * assistant.ts 의 buildBoardEditTools / dispatch / isWriteTool 등이 자동으로 통합
11
+ * 하도록 한다. styling-tools 의 single-source-of-truth 패턴을 일반화한 형태.
12
+ *
13
+ * 설계 원칙:
14
+ * - **카테고리별 builder 시그니처 다양성을 통합**: read 는 (args, ctx) → any,
15
+ * write 는 (args, components) → BoardEditOp[], action 은 (args) → BoardActionOp.
16
+ * 하나의 ToolSpec 에 모든 시그니처를 union 으로 담지 않고, 카테고리에 맞는
17
+ * builder 만 채우면 되도록 카테고리별 sub-interface 분리.
18
+ * - **kind 필드로 자가 분류**: registry 가 isReadTool / isWriteTool 자동 도출.
19
+ * hardcoded set 동기화 누락 위험 제거.
20
+ * - **점진 마이그레이션**: 기존 STYLING_TOOLS 가 본 registry 의 첫 사용자.
21
+ * 점진적으로 다른 hardcoded tool 들 (addComponent / alignComponents 등) 도
22
+ * 같은 registry 로 흡수 가능. 한 단계씩 byte-단위 동등성 단위 테스트 보장.
23
+ */
24
+ // 본 registry 는 LLM tool 노출 plugin point 에 한정되므로 도메인 type (BoardEditOp,
25
+ // BoardActionOp 등) 은 알지 않는다. builder return 은 unknown 으로 두고, 카테고리
26
+ // dispatcher (board-ai 의 toolCallToBoardEditOp / toolCallToBoardActionOp 등) 가 자기
27
+ // 도메인 type 으로 좁힌다. 사용자가 ToolSpec<TWrite, TAction> generic 까지 정밀화하면
28
+ // 좋겠지만, 11~50 개 spec 마다 type 인자 전파가 부담이라 unknown 으로 합의.
29
+
30
+ // ── 카테고리별 builder 시그니처 ──────────────────────────────────────
31
+
32
+ /**
33
+ * Read tool — server 측 즉시 실행, 결과를 LLM 에 회신. board model 변경 X.
34
+ * 예: getSelection / findComponents / importSession (query)
35
+ */
36
+ export type ReadToolBuilder = (
37
+ args: any,
38
+ context: ReadToolContext
39
+ ) => any | Promise<any>
40
+
41
+ export interface ReadToolContext {
42
+ /** 현재 board model — 일부 read tool 이 사용 (getSelection 등). */
43
+ board?: any
44
+ /** 현재 선택된 refids. */
45
+ selectedRefids?: number[]
46
+ /** 호출자 도메인 / 사용자 / 트랜잭션. registry 외부에서 주입. */
47
+ state?: any
48
+ /** 이외 주입은 카테고리별로 자유. tool 등록자가 type 좁힐 수 있도록 unknown 으로 보존. */
49
+ [k: string]: any
50
+ }
51
+
52
+ /**
53
+ * Write tool — domain edit op 발생. host 가 patch 적용 후 영속화.
54
+ * 예: board-ai 의 addComponent / setFill / modifyBoard.
55
+ *
56
+ * builder 의 return type 은 unknown 으로 둔다 — 도메인별 dispatcher (예: board-ai 의
57
+ * toolCallToBoardEditOp) 가 자기 EditOp[] 로 cast. ops/errors wrapper 형태도 동일 의미.
58
+ */
59
+ export type WriteToolBuilder = (
60
+ args: any,
61
+ context: WriteToolContext
62
+ ) => any[] | { ops: any[]; errors?: string[] } | null
63
+
64
+ export interface WriteToolContext {
65
+ components?: ReadonlyArray<any>
66
+ [k: string]: any
67
+ }
68
+
69
+ /**
70
+ * Action tool — ephemeral scene 조작 (select / center / fit / sceneMode).
71
+ * board model 변경 X, undo 영향 X. host 가 별도 channel 로 적용.
72
+ *
73
+ * return type 은 unknown — 도메인별 dispatcher (예: board-ai 의 toolCallToBoardActionOp)
74
+ * 가 자기 BoardActionOp 으로 cast.
75
+ */
76
+ export type ActionToolBuilder = (args: any) => any | null
77
+
78
+ /**
79
+ * External-call tool — 외부 service / mutation / RPC 호출. 직접 board model 영향 X
80
+ * (별도 entity 변경은 가능). 결과는 LLM 에 회신.
81
+ *
82
+ * board-import 의 importBoardAsync / materializeImportSession 같이 board-ai 외부
83
+ * 도메인을 호출하는 tool 의 카테고리.
84
+ */
85
+ export type ExternalToolBuilder = (
86
+ args: any,
87
+ context: ExternalToolContext
88
+ ) => any | Promise<any>
89
+
90
+ export interface ExternalToolContext {
91
+ state?: any
92
+ [k: string]: any
93
+ }
94
+
95
+ // ── ToolSpec — 카테고리별 변형 ──────────────────────────────────────
96
+
97
+ /**
98
+ * 모든 ToolSpec 공통 메타데이터.
99
+ */
100
+ export interface ToolSpecBase {
101
+ /** AIToolCall.name 매칭. 전 카테고리 통틀어 unique. */
102
+ name: string
103
+ /** 기능 설명 (영문 위주). LLM 의 tool description 으로 사용. */
104
+ description: string
105
+ /**
106
+ * 선택 — 정책 / 테마 가이드 단문. tool description 끝에 자동 부착.
107
+ * styling-tools 의 themeNote 와 동일 의미. 기타 카테고리도 사용 가능.
108
+ */
109
+ themeNote?: string
110
+ /** JSON Schema for parameters — LLM tool schema 에 그대로 노출. */
111
+ schema: any
112
+ }
113
+
114
+ export interface ReadToolSpec extends ToolSpecBase {
115
+ kind: 'read'
116
+ builder: ReadToolBuilder
117
+ }
118
+
119
+ export interface WriteToolSpec extends ToolSpecBase {
120
+ kind: 'write'
121
+ builder: WriteToolBuilder
122
+ }
123
+
124
+ export interface ActionToolSpec extends ToolSpecBase {
125
+ kind: 'action'
126
+ builder: ActionToolBuilder
127
+ }
128
+
129
+ export interface ExternalToolSpec extends ToolSpecBase {
130
+ kind: 'external'
131
+ builder: ExternalToolBuilder
132
+ }
133
+
134
+ export type ToolSpec = ReadToolSpec | WriteToolSpec | ActionToolSpec | ExternalToolSpec
135
+
136
+ // ── 카테고리 (등록 단위) ─────────────────────────────────────────────
137
+
138
+ /**
139
+ * 한 도메인 / 패키지가 등록하는 tool 묶음.
140
+ * 카테고리 이름은 식별자 (debug log / 분쟁 시 origin 추적). unique.
141
+ */
142
+ export interface ToolCategory {
143
+ /** 등록 식별자. 예: 'styling', 'board-import', 'fmsim'. */
144
+ name: string
145
+ /** 이 카테고리에 속한 tool spec 들. */
146
+ specs: ToolSpec[]
147
+ /** 선택 — 디버깅용 짧은 설명. */
148
+ description?: string
149
+ }