@things-factory/ai-client-base 10.0.0 → 10.0.3
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/dist-server/service/tool-registry/index.d.ts +1 -1
- package/dist-server/service/tool-registry/index.js +2 -1
- package/dist-server/service/tool-registry/index.js.map +1 -1
- package/dist-server/service/tool-registry/registry.d.ts +7 -0
- package/dist-server/service/tool-registry/registry.js +18 -0
- package/dist-server/service/tool-registry/registry.js.map +1 -1
- package/dist-server/service/tool-registry/types.d.ts +11 -0
- package/dist-server/service/tool-registry/types.js.map +1 -1
- package/dist-server/service/utils/grounding.d.ts +44 -0
- package/dist-server/service/utils/grounding.js +69 -0
- package/dist-server/service/utils/grounding.js.map +1 -0
- package/dist-server/service/utils/index.d.ts +2 -0
- package/dist-server/service/utils/index.js +2 -0
- package/dist-server/service/utils/index.js.map +1 -1
- package/dist-server/service/utils/transient.d.ts +23 -0
- package/dist-server/service/utils/transient.js +88 -0
- package/dist-server/service/utils/transient.js.map +1 -0
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/server/service/tool-registry/index.ts +1 -0
- package/server/service/tool-registry/registry.test.ts +22 -2
- package/server/service/tool-registry/registry.ts +16 -0
- package/server/service/tool-registry/types.ts +11 -0
- package/server/service/utils/grounding.test.ts +70 -0
- package/server/service/utils/grounding.ts +65 -0
- package/server/service/utils/index.ts +2 -0
- package/server/service/utils/transient.test.ts +92 -0
- package/server/service/utils/transient.ts +92 -0
|
@@ -3,6 +3,6 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 자세한 설계는 ./types 와 ./registry 의 헤더 주석 참조.
|
|
5
5
|
*/
|
|
6
|
-
export { registerToolCategory, getToolCategories, getAllToolSpecs, getToolSpecsFor, findToolSpec, getToolKind, hasTool, getCategorySpecs, clearToolRegistry } from './registry';
|
|
6
|
+
export { registerToolCategory, getToolCategories, getAllToolSpecs, getToolSpecsFor, getCategoryGuidance, findToolSpec, getToolKind, hasTool, getCategorySpecs, clearToolRegistry } from './registry';
|
|
7
7
|
export type { ToolCategoryFilter } from './registry';
|
|
8
8
|
export type { ToolSpec, ToolSpecBase, ReadToolSpec, WriteToolSpec, ActionToolSpec, ExternalToolSpec, ToolCategory, ReadToolBuilder, WriteToolBuilder, ActionToolBuilder, ExternalToolBuilder, ReadToolContext, WriteToolContext, ExternalToolContext } from './types';
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.clearToolRegistry = exports.getCategorySpecs = exports.hasTool = exports.getToolKind = exports.findToolSpec = exports.getToolSpecsFor = exports.getAllToolSpecs = exports.getToolCategories = exports.registerToolCategory = void 0;
|
|
3
|
+
exports.clearToolRegistry = exports.getCategorySpecs = exports.hasTool = exports.getToolKind = exports.findToolSpec = exports.getCategoryGuidance = exports.getToolSpecsFor = exports.getAllToolSpecs = exports.getToolCategories = exports.registerToolCategory = void 0;
|
|
4
4
|
/**
|
|
5
5
|
* Tool Registry — board-ai 의 외부 도메인 LLM tool 노출 plugin point.
|
|
6
6
|
*
|
|
@@ -11,6 +11,7 @@ Object.defineProperty(exports, "registerToolCategory", { enumerable: true, get:
|
|
|
11
11
|
Object.defineProperty(exports, "getToolCategories", { enumerable: true, get: function () { return registry_1.getToolCategories; } });
|
|
12
12
|
Object.defineProperty(exports, "getAllToolSpecs", { enumerable: true, get: function () { return registry_1.getAllToolSpecs; } });
|
|
13
13
|
Object.defineProperty(exports, "getToolSpecsFor", { enumerable: true, get: function () { return registry_1.getToolSpecsFor; } });
|
|
14
|
+
Object.defineProperty(exports, "getCategoryGuidance", { enumerable: true, get: function () { return registry_1.getCategoryGuidance; } });
|
|
14
15
|
Object.defineProperty(exports, "findToolSpec", { enumerable: true, get: function () { return registry_1.findToolSpec; } });
|
|
15
16
|
Object.defineProperty(exports, "getToolKind", { enumerable: true, get: function () { return registry_1.getToolKind; } });
|
|
16
17
|
Object.defineProperty(exports, "hasTool", { enumerable: true, get: function () { return registry_1.hasTool; } });
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../server/service/tool-registry/index.ts"],"names":[],"mappings":";;;AAAA;;;;GAIG;AACH,
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../server/service/tool-registry/index.ts"],"names":[],"mappings":";;;AAAA;;;;GAIG;AACH,uCAWmB;AAVjB,gHAAA,oBAAoB,OAAA;AACpB,6GAAA,iBAAiB,OAAA;AACjB,2GAAA,eAAe,OAAA;AACf,2GAAA,eAAe,OAAA;AACf,+GAAA,mBAAmB,OAAA;AACnB,wGAAA,YAAY,OAAA;AACZ,uGAAA,WAAW,OAAA;AACX,mGAAA,OAAO,OAAA;AACP,4GAAA,gBAAgB,OAAA;AAChB,6GAAA,iBAAiB,OAAA","sourcesContent":["/**\n * Tool Registry — board-ai 의 외부 도메인 LLM tool 노출 plugin point.\n *\n * 자세한 설계는 ./types 와 ./registry 의 헤더 주석 참조.\n */\nexport {\n registerToolCategory,\n getToolCategories,\n getAllToolSpecs,\n getToolSpecsFor,\n getCategoryGuidance,\n findToolSpec,\n getToolKind,\n hasTool,\n getCategorySpecs,\n clearToolRegistry\n} from './registry'\n\n/* 대화별 노출 제한 타입은 registry 소유(정의가 거기 있다). */\nexport type { ToolCategoryFilter } from './registry'\n\nexport type {\n ToolSpec,\n ToolSpecBase,\n ReadToolSpec,\n WriteToolSpec,\n ActionToolSpec,\n ExternalToolSpec,\n ToolCategory,\n ReadToolBuilder,\n WriteToolBuilder,\n ActionToolBuilder,\n ExternalToolBuilder,\n ReadToolContext,\n WriteToolContext,\n ExternalToolContext\n} from './types'\n"]}
|
|
@@ -52,6 +52,13 @@ export declare function getAllToolSpecs(): ReadonlyArray<ToolSpec>;
|
|
|
52
52
|
* LLM 도구 정의를 만들 때 이 함수를 쓰면 대화별 노출 제한이 그대로 반영된다.
|
|
53
53
|
*/
|
|
54
54
|
export declare function getToolSpecsFor(filter: ToolCategoryFilter): ReadonlyArray<ToolSpec>;
|
|
55
|
+
/**
|
|
56
|
+
* 노출된 카테고리들의 **사용 규율** 모음(등록 순서). 시스템 프롬프트에 붙이는 용도.
|
|
57
|
+
*
|
|
58
|
+
* 도구 목록만 보내면 모델은 "무엇이 있는지" 는 알지만 "언제 반드시 불러야 하는지" 는 모른다.
|
|
59
|
+
* 규율은 도구를 등록한 쪽이 소유하고, 호스트는 노출된 것만 모아 붙인다(도메인 지식이 새지 않는다).
|
|
60
|
+
*/
|
|
61
|
+
export declare function getCategoryGuidance(filter: ToolCategoryFilter): string[];
|
|
55
62
|
/**
|
|
56
63
|
* tool name 으로 spec 검색. 없으면 undefined.
|
|
57
64
|
*/
|
|
@@ -4,6 +4,7 @@ exports.registerToolCategory = registerToolCategory;
|
|
|
4
4
|
exports.getToolCategories = getToolCategories;
|
|
5
5
|
exports.getAllToolSpecs = getAllToolSpecs;
|
|
6
6
|
exports.getToolSpecsFor = getToolSpecsFor;
|
|
7
|
+
exports.getCategoryGuidance = getCategoryGuidance;
|
|
7
8
|
exports.findToolSpec = findToolSpec;
|
|
8
9
|
exports.getToolKind = getToolKind;
|
|
9
10
|
exports.hasTool = hasTool;
|
|
@@ -63,6 +64,23 @@ function getToolSpecsFor(filter) {
|
|
|
63
64
|
}
|
|
64
65
|
return out;
|
|
65
66
|
}
|
|
67
|
+
/**
|
|
68
|
+
* 노출된 카테고리들의 **사용 규율** 모음(등록 순서). 시스템 프롬프트에 붙이는 용도.
|
|
69
|
+
*
|
|
70
|
+
* 도구 목록만 보내면 모델은 "무엇이 있는지" 는 알지만 "언제 반드시 불러야 하는지" 는 모른다.
|
|
71
|
+
* 규율은 도구를 등록한 쪽이 소유하고, 호스트는 노출된 것만 모아 붙인다(도메인 지식이 새지 않는다).
|
|
72
|
+
*/
|
|
73
|
+
function getCategoryGuidance(filter) {
|
|
74
|
+
const out = [];
|
|
75
|
+
for (const [catName, cat] of categories) {
|
|
76
|
+
if (!allowed(catName, filter))
|
|
77
|
+
continue;
|
|
78
|
+
const guidance = (cat.guidance ?? '').trim();
|
|
79
|
+
if (guidance)
|
|
80
|
+
out.push(guidance);
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
66
84
|
/**
|
|
67
85
|
* tool name 으로 spec 검색. 없으면 undefined.
|
|
68
86
|
*/
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"registry.js","sourceRoot":"","sources":["../../../server/service/tool-registry/registry.ts"],"names":[],"mappings":";;AAkCA,oDAuBC;AAmBD,8CAEC;AAMD,0CAEC;AAMD,0CAOC;AAKD,oCAOC;AAMD,kCAEC;AAKD,0BAEC;AAKD,4CAEC;AASD,8CAEC;
|
|
1
|
+
{"version":3,"file":"registry.js","sourceRoot":"","sources":["../../../server/service/tool-registry/registry.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * ToolCategoryRegistry — 외부 도메인 패키지가 LLM tool category 를 동적 등록하는 점.\n *\n * 사용 패턴 (외부 패키지의 server bootstrap):\n * ```ts\n * import { registerToolCategory } from '@things-factory/board-ai'\n *\n * registerToolCategory({\n * name: 'board-import',\n * specs: [\n * { kind: 'external', name: 'importBoardAsync', schema: {...}, builder: ... },\n * { kind: 'read', name: 'getImportSession', schema: {...}, builder: ... },\n * ...\n * ]\n * })\n * ```\n *\n * board-ai assistant.ts 의 buildBoardEditTools / dispatch / isWriteTool 등이\n * 본 registry 에서 자동 도출.\n *\n * 정책:\n * - 카테고리 이름은 unique. 같은 이름으로 재등록하면 덮어쓰기 (외부 패키지의\n * hot-reload / dev 환경 대응). console.warn 으로 알림.\n * - tool name 은 카테고리 간 통틀어 unique. 충돌 시 등록 거부 + 명확한 에러\n * (LLM dispatch 가 모호해지지 않도록).\n * - clear() 는 테스트 전용. 운영 환경에서 호출 금지.\n */\nimport type { ToolCategory, ToolSpec } from './types'\n\nconst categories: Map<string, ToolCategory> = new Map()\n\n/**\n * 카테고리 등록. tool name 충돌 시 throw, 이름 중복 등록 시 덮어쓰기 + warn.\n */\nexport function registerToolCategory(category: ToolCategory): void {\n if (categories.has(category.name)) {\n console.warn(\n `[board-ai/tool-registry] category '${category.name}' already registered — overwriting. ` +\n `(dev hot-reload 시 정상이지만 운영 환경에서 발생하면 buildup bug)`\n )\n }\n\n // tool name 충돌 검사 — 다른 카테고리에 같은 name 있으면 거부\n const newNames = new Set(category.specs.map(s => s.name))\n for (const [otherName, other] of categories) {\n if (otherName === category.name) continue // 같은 카테고리 재등록은 덮어쓰기 OK\n for (const spec of other.specs) {\n if (newNames.has(spec.name)) {\n throw new Error(\n `[board-ai/tool-registry] tool name '${spec.name}' already registered in category ` +\n `'${otherName}', cannot register in '${category.name}'. tool names must be globally unique.`\n )\n }\n }\n }\n\n categories.set(category.name, category)\n}\n\n/**\n * 대화별 도구 노출 제한 — 허용 카테고리 목록.\n *\n * `undefined` 는 **제한 없음**(등록된 전부). 호스트가 목록을 주면 그 카테고리에 속한 spec 만\n * 정의·실행 대상이 된다. 정의를 숨기는 것만으로는 부족하다 — 모델이 이름을 추측하거나 옛 대화가\n * 재생되면 숨긴 도구도 호출되므로 **dispatch 조회(findToolSpec)에도 같은 목록을 넘겨야** 한다.\n */\nexport type ToolCategoryFilter = ReadonlyArray<string> | undefined\n\n/** 허용 목록에 든 카테고리인가. 목록 없음 = 전부 허용. */\nfunction allowed(name: string, filter: ToolCategoryFilter): boolean {\n return !filter || filter.includes(name)\n}\n\n/**\n * 등록된 모든 카테고리 (등록 순서 보존). 외부에서 불변 read-only 로 사용.\n */\nexport function getToolCategories(): ReadonlyArray<ToolCategory> {\n return Array.from(categories.values())\n}\n\n/**\n * 등록된 모든 tool spec 의 평면 view. assistant.ts 의 buildBoardEditTools 가\n * LLM tool 정의 합집합으로 사용.\n */\nexport function getAllToolSpecs(): ReadonlyArray<ToolSpec> {\n return getToolSpecsFor(undefined)\n}\n\n/**\n * 허용 카테고리의 spec 평면 view. `undefined` = 전부(= getAllToolSpecs 와 동일).\n * LLM 도구 정의를 만들 때 이 함수를 쓰면 대화별 노출 제한이 그대로 반영된다.\n */\nexport function getToolSpecsFor(filter: ToolCategoryFilter): ReadonlyArray<ToolSpec> {\n const out: ToolSpec[] = []\n for (const [catName, cat] of categories) {\n if (!allowed(catName, filter)) continue\n for (const spec of cat.specs) out.push(spec)\n }\n return out\n}\n\n/**\n * 노출된 카테고리들의 **사용 규율** 모음(등록 순서). 시스템 프롬프트에 붙이는 용도.\n *\n * 도구 목록만 보내면 모델은 \"무엇이 있는지\" 는 알지만 \"언제 반드시 불러야 하는지\" 는 모른다.\n * 규율은 도구를 등록한 쪽이 소유하고, 호스트는 노출된 것만 모아 붙인다(도메인 지식이 새지 않는다).\n */\nexport function getCategoryGuidance(filter: ToolCategoryFilter): string[] {\n const out: string[] = []\n for (const [catName, cat] of categories) {\n if (!allowed(catName, filter)) continue\n const guidance = (cat.guidance ?? '').trim()\n if (guidance) out.push(guidance)\n }\n return out\n}\n\n/**\n * tool name 으로 spec 검색. 없으면 undefined.\n */\nexport function findToolSpec(name: string, filter?: ToolCategoryFilter): ToolSpec | undefined {\n for (const [catName, cat] of categories) {\n if (!allowed(catName, filter)) continue\n const spec = cat.specs.find(s => s.name === name)\n if (spec) return spec\n }\n return undefined\n}\n\n/**\n * tool name 의 카테고리 (read/write/action/external). 미등록 tool 은 undefined.\n * assistant.ts 의 isReadTool / isWriteTool / isActionTool 이 이 함수에 위임할 수 있다.\n */\nexport function getToolKind(name: string, filter?: ToolCategoryFilter): ToolSpec['kind'] | undefined {\n return findToolSpec(name, filter)?.kind\n}\n\n/**\n * 등록된 tool 인지. dispatch path 가 hardcoded set 와 registry 양쪽 fallback 시 사용.\n */\nexport function hasTool(name: string, filter?: ToolCategoryFilter): boolean {\n return findToolSpec(name, filter) !== undefined\n}\n\n/**\n * 카테고리 이름으로 spec 만 추출.\n */\nexport function getCategorySpecs(categoryName: string): ReadonlyArray<ToolSpec> {\n return categories.get(categoryName)?.specs ?? []\n}\n\n/**\n * 테스트 전용 — 모든 등록 해제. 운영 환경에서 호출 금지.\n *\n * jest 의 setupFilesAfterEach 또는 명시적 afterEach 에서 호출해 테스트 간 격리.\n * 실제로 STYLING_TOOLS 는 board-ai 자체 부트스트랩에서 다시 등록되므로 clear 후\n * 명시 register 가 필요한 것은 동적 카테고리 (board-import 등) 뿐.\n */\nexport function clearToolRegistry(): void {\n categories.clear()\n}\n"]}
|
|
@@ -115,4 +115,15 @@ export interface ToolCategory {
|
|
|
115
115
|
specs: ToolSpec[];
|
|
116
116
|
/** 선택 — 디버깅용 짧은 설명. */
|
|
117
117
|
description?: string;
|
|
118
|
+
/**
|
|
119
|
+
* 이 카테고리 도구를 **어떻게 써야 하는지**에 대한 규율 — 노출된 대화의 시스템 프롬프트에 실린다.
|
|
120
|
+
*
|
|
121
|
+
* 왜 필요한가: 도구 목록(이름·설명·스키마)만 보내면 모델은 "무엇이 있는지" 는 알지만 "언제 반드시
|
|
122
|
+
* 불러야 하는지" 는 모른다. 실제로 그 공백에서 사고가 났다 — 조치 요청에 `proposeAction` 을 부르지
|
|
123
|
+
* 않고 "제안해 두었습니다" 라고만 답해, 사용자에게는 없는 버튼을 누르라고 안내했다.
|
|
124
|
+
*
|
|
125
|
+
* 규율은 도구를 등록한 쪽이 소유한다. 호스트(board-ai)는 도메인을 모르는 채 **노출된 카테고리의
|
|
126
|
+
* 규율만 모아 붙인다** — 도메인 지식이 호스트 프롬프트로 새지 않는다.
|
|
127
|
+
*/
|
|
128
|
+
guidance?: string;
|
|
118
129
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.js","sourceRoot":"","sources":["../../../server/service/tool-registry/types.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,uEAAuE;AACvE,kEAAkE;AAClE,iFAAiF;AACjF,mEAAmE;AACnE,wDAAwD","sourcesContent":["/**\n * Tool Registry — board-ai 의 LLM tool 노출 plugin point.\n *\n * 동기:\n * 기존엔 board-ai 가 자기 도메인 (board edit) tool 들만 hardcoded 정의했고, styling\n * 영역만 STYLING_TOOLS registry 로 분리되어 있었다. 외부 도메인 패키지 (board-import,\n * fmsim 등) 가 자기 tool 을 LLM 에 노출하려면 board-ai 자체를 fork/수정해야 했다.\n *\n * 본 registry 는 외부 패키지가 server bootstrap 시 자기 tool category 를 등록하면\n * assistant.ts 의 buildBoardEditTools / dispatch / isWriteTool 등이 자동으로 통합\n * 하도록 한다. styling-tools 의 single-source-of-truth 패턴을 일반화한 형태.\n *\n * 설계 원칙:\n * - **카테고리별 builder 시그니처 다양성을 통합**: read 는 (args, ctx) → any,\n * write 는 (args, components) → BoardEditOp[], action 은 (args) → BoardActionOp.\n * 하나의 ToolSpec 에 모든 시그니처를 union 으로 담지 않고, 카테고리에 맞는\n * builder 만 채우면 되도록 카테고리별 sub-interface 분리.\n * - **kind 필드로 자가 분류**: registry 가 isReadTool / isWriteTool 자동 도출.\n * hardcoded set 동기화 누락 위험 제거.\n * - **점진 마이그레이션**: 기존 STYLING_TOOLS 가 본 registry 의 첫 사용자.\n * 점진적으로 다른 hardcoded tool 들 (addComponent / alignComponents 등) 도\n * 같은 registry 로 흡수 가능. 한 단계씩 byte-단위 동등성 단위 테스트 보장.\n */\n// 본 registry 는 LLM tool 노출 plugin point 에 한정되므로 도메인 type (BoardEditOp,\n// BoardActionOp 등) 은 알지 않는다. builder return 은 unknown 으로 두고, 카테고리\n// dispatcher (board-ai 의 toolCallToBoardEditOp / toolCallToBoardActionOp 등) 가 자기\n// 도메인 type 으로 좁힌다. 사용자가 ToolSpec<TWrite, TAction> generic 까지 정밀화하면\n// 좋겠지만, 11~50 개 spec 마다 type 인자 전파가 부담이라 unknown 으로 합의.\n\n// ── 카테고리별 builder 시그니처 ──────────────────────────────────────\n\n/**\n * Read tool — server 측 즉시 실행, 결과를 LLM 에 회신. board model 변경 X.\n * 예: getSelection / findComponents / importSession (query)\n */\nexport type ReadToolBuilder = (\n args: any,\n context: ReadToolContext\n) => any | Promise<any>\n\nexport interface ReadToolContext {\n /** 현재 board model — 일부 read tool 이 사용 (getSelection 등). */\n board?: any\n /** 현재 선택된 refids. */\n selectedRefids?: number[]\n /** 호출자 도메인 / 사용자 / 트랜잭션. registry 외부에서 주입. */\n state?: any\n /** 이외 주입은 카테고리별로 자유. tool 등록자가 type 좁힐 수 있도록 unknown 으로 보존. */\n [k: string]: any\n}\n\n/**\n * Write tool — domain edit op 발생. host 가 patch 적용 후 영속화.\n * 예: board-ai 의 addComponent / setFill / modifyBoard.\n *\n * builder 의 return type 은 unknown 으로 둔다 — 도메인별 dispatcher (예: board-ai 의\n * toolCallToBoardEditOp) 가 자기 EditOp[] 로 cast. ops/errors wrapper 형태도 동일 의미.\n */\nexport type WriteToolBuilder = (\n args: any,\n context: WriteToolContext\n) => any[] | { ops: any[]; errors?: string[] } | null\n\nexport interface WriteToolContext {\n components?: ReadonlyArray<any>\n [k: string]: any\n}\n\n/**\n * Action tool — ephemeral scene 조작 (select / center / fit / sceneMode).\n * board model 변경 X, undo 영향 X. host 가 별도 channel 로 적용.\n *\n * return type 은 unknown — 도메인별 dispatcher (예: board-ai 의 toolCallToBoardActionOp)\n * 가 자기 BoardActionOp 으로 cast.\n */\nexport type ActionToolBuilder = (args: any) => any | null\n\n/**\n * External-call tool — 외부 service / mutation / RPC 호출. 직접 board model 영향 X\n * (별도 entity 변경은 가능). 결과는 LLM 에 회신.\n *\n * board-import 의 importBoardAsync / materializeImportSession 같이 board-ai 외부\n * 도메인을 호출하는 tool 의 카테고리.\n */\nexport type ExternalToolBuilder = (\n args: any,\n context: ExternalToolContext\n) => any | Promise<any>\n\nexport interface ExternalToolContext {\n state?: any\n [k: string]: any\n}\n\n// ── ToolSpec — 카테고리별 변형 ──────────────────────────────────────\n\n/**\n * 모든 ToolSpec 공통 메타데이터.\n */\nexport interface ToolSpecBase {\n /** AIToolCall.name 매칭. 전 카테고리 통틀어 unique. */\n name: string\n /** 기능 설명 (영문 위주). LLM 의 tool description 으로 사용. */\n description: string\n /**\n * 선택 — 정책 / 테마 가이드 단문. tool description 끝에 자동 부착.\n * styling-tools 의 themeNote 와 동일 의미. 기타 카테고리도 사용 가능.\n */\n themeNote?: string\n /** JSON Schema for parameters — LLM tool schema 에 그대로 노출. */\n schema: any\n}\n\nexport interface ReadToolSpec extends ToolSpecBase {\n kind: 'read'\n builder: ReadToolBuilder\n}\n\nexport interface WriteToolSpec extends ToolSpecBase {\n kind: 'write'\n builder: WriteToolBuilder\n}\n\nexport interface ActionToolSpec extends ToolSpecBase {\n kind: 'action'\n builder: ActionToolBuilder\n}\n\nexport interface ExternalToolSpec extends ToolSpecBase {\n kind: 'external'\n builder: ExternalToolBuilder\n}\n\nexport type ToolSpec = ReadToolSpec | WriteToolSpec | ActionToolSpec | ExternalToolSpec\n\n// ── 카테고리 (등록 단위) ─────────────────────────────────────────────\n\n/**\n * 한 도메인 / 패키지가 등록하는 tool 묶음.\n * 카테고리 이름은 식별자 (debug log / 분쟁 시 origin 추적). unique.\n */\nexport interface ToolCategory {\n /** 등록 식별자. 예: 'styling', 'board-import', 'fmsim'. */\n name: string\n /** 이 카테고리에 속한 tool spec 들. */\n specs: ToolSpec[]\n /** 선택 — 디버깅용 짧은 설명. */\n description?: string\n}\n"]}
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../../../server/service/tool-registry/types.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,uEAAuE;AACvE,kEAAkE;AAClE,iFAAiF;AACjF,mEAAmE;AACnE,wDAAwD","sourcesContent":["/**\n * Tool Registry — board-ai 의 LLM tool 노출 plugin point.\n *\n * 동기:\n * 기존엔 board-ai 가 자기 도메인 (board edit) tool 들만 hardcoded 정의했고, styling\n * 영역만 STYLING_TOOLS registry 로 분리되어 있었다. 외부 도메인 패키지 (board-import,\n * fmsim 등) 가 자기 tool 을 LLM 에 노출하려면 board-ai 자체를 fork/수정해야 했다.\n *\n * 본 registry 는 외부 패키지가 server bootstrap 시 자기 tool category 를 등록하면\n * assistant.ts 의 buildBoardEditTools / dispatch / isWriteTool 등이 자동으로 통합\n * 하도록 한다. styling-tools 의 single-source-of-truth 패턴을 일반화한 형태.\n *\n * 설계 원칙:\n * - **카테고리별 builder 시그니처 다양성을 통합**: read 는 (args, ctx) → any,\n * write 는 (args, components) → BoardEditOp[], action 은 (args) → BoardActionOp.\n * 하나의 ToolSpec 에 모든 시그니처를 union 으로 담지 않고, 카테고리에 맞는\n * builder 만 채우면 되도록 카테고리별 sub-interface 분리.\n * - **kind 필드로 자가 분류**: registry 가 isReadTool / isWriteTool 자동 도출.\n * hardcoded set 동기화 누락 위험 제거.\n * - **점진 마이그레이션**: 기존 STYLING_TOOLS 가 본 registry 의 첫 사용자.\n * 점진적으로 다른 hardcoded tool 들 (addComponent / alignComponents 등) 도\n * 같은 registry 로 흡수 가능. 한 단계씩 byte-단위 동등성 단위 테스트 보장.\n */\n// 본 registry 는 LLM tool 노출 plugin point 에 한정되므로 도메인 type (BoardEditOp,\n// BoardActionOp 등) 은 알지 않는다. builder return 은 unknown 으로 두고, 카테고리\n// dispatcher (board-ai 의 toolCallToBoardEditOp / toolCallToBoardActionOp 등) 가 자기\n// 도메인 type 으로 좁힌다. 사용자가 ToolSpec<TWrite, TAction> generic 까지 정밀화하면\n// 좋겠지만, 11~50 개 spec 마다 type 인자 전파가 부담이라 unknown 으로 합의.\n\n// ── 카테고리별 builder 시그니처 ──────────────────────────────────────\n\n/**\n * Read tool — server 측 즉시 실행, 결과를 LLM 에 회신. board model 변경 X.\n * 예: getSelection / findComponents / importSession (query)\n */\nexport type ReadToolBuilder = (\n args: any,\n context: ReadToolContext\n) => any | Promise<any>\n\nexport interface ReadToolContext {\n /** 현재 board model — 일부 read tool 이 사용 (getSelection 등). */\n board?: any\n /** 현재 선택된 refids. */\n selectedRefids?: number[]\n /** 호출자 도메인 / 사용자 / 트랜잭션. registry 외부에서 주입. */\n state?: any\n /** 이외 주입은 카테고리별로 자유. tool 등록자가 type 좁힐 수 있도록 unknown 으로 보존. */\n [k: string]: any\n}\n\n/**\n * Write tool — domain edit op 발생. host 가 patch 적용 후 영속화.\n * 예: board-ai 의 addComponent / setFill / modifyBoard.\n *\n * builder 의 return type 은 unknown 으로 둔다 — 도메인별 dispatcher (예: board-ai 의\n * toolCallToBoardEditOp) 가 자기 EditOp[] 로 cast. ops/errors wrapper 형태도 동일 의미.\n */\nexport type WriteToolBuilder = (\n args: any,\n context: WriteToolContext\n) => any[] | { ops: any[]; errors?: string[] } | null\n\nexport interface WriteToolContext {\n components?: ReadonlyArray<any>\n [k: string]: any\n}\n\n/**\n * Action tool — ephemeral scene 조작 (select / center / fit / sceneMode).\n * board model 변경 X, undo 영향 X. host 가 별도 channel 로 적용.\n *\n * return type 은 unknown — 도메인별 dispatcher (예: board-ai 의 toolCallToBoardActionOp)\n * 가 자기 BoardActionOp 으로 cast.\n */\nexport type ActionToolBuilder = (args: any) => any | null\n\n/**\n * External-call tool — 외부 service / mutation / RPC 호출. 직접 board model 영향 X\n * (별도 entity 변경은 가능). 결과는 LLM 에 회신.\n *\n * board-import 의 importBoardAsync / materializeImportSession 같이 board-ai 외부\n * 도메인을 호출하는 tool 의 카테고리.\n */\nexport type ExternalToolBuilder = (\n args: any,\n context: ExternalToolContext\n) => any | Promise<any>\n\nexport interface ExternalToolContext {\n state?: any\n [k: string]: any\n}\n\n// ── ToolSpec — 카테고리별 변형 ──────────────────────────────────────\n\n/**\n * 모든 ToolSpec 공통 메타데이터.\n */\nexport interface ToolSpecBase {\n /** AIToolCall.name 매칭. 전 카테고리 통틀어 unique. */\n name: string\n /** 기능 설명 (영문 위주). LLM 의 tool description 으로 사용. */\n description: string\n /**\n * 선택 — 정책 / 테마 가이드 단문. tool description 끝에 자동 부착.\n * styling-tools 의 themeNote 와 동일 의미. 기타 카테고리도 사용 가능.\n */\n themeNote?: string\n /** JSON Schema for parameters — LLM tool schema 에 그대로 노출. */\n schema: any\n}\n\nexport interface ReadToolSpec extends ToolSpecBase {\n kind: 'read'\n builder: ReadToolBuilder\n}\n\nexport interface WriteToolSpec extends ToolSpecBase {\n kind: 'write'\n builder: WriteToolBuilder\n}\n\nexport interface ActionToolSpec extends ToolSpecBase {\n kind: 'action'\n builder: ActionToolBuilder\n}\n\nexport interface ExternalToolSpec extends ToolSpecBase {\n kind: 'external'\n builder: ExternalToolBuilder\n}\n\nexport type ToolSpec = ReadToolSpec | WriteToolSpec | ActionToolSpec | ExternalToolSpec\n\n// ── 카테고리 (등록 단위) ─────────────────────────────────────────────\n\n/**\n * 한 도메인 / 패키지가 등록하는 tool 묶음.\n * 카테고리 이름은 식별자 (debug log / 분쟁 시 origin 추적). unique.\n */\nexport interface ToolCategory {\n /** 등록 식별자. 예: 'styling', 'board-import', 'fmsim'. */\n name: string\n /** 이 카테고리에 속한 tool spec 들. */\n specs: ToolSpec[]\n /** 선택 — 디버깅용 짧은 설명. */\n description?: string\n /**\n * 이 카테고리 도구를 **어떻게 써야 하는지**에 대한 규율 — 노출된 대화의 시스템 프롬프트에 실린다.\n *\n * 왜 필요한가: 도구 목록(이름·설명·스키마)만 보내면 모델은 \"무엇이 있는지\" 는 알지만 \"언제 반드시\n * 불러야 하는지\" 는 모른다. 실제로 그 공백에서 사고가 났다 — 조치 요청에 `proposeAction` 을 부르지\n * 않고 \"제안해 두었습니다\" 라고만 답해, 사용자에게는 없는 버튼을 누르라고 안내했다.\n *\n * 규율은 도구를 등록한 쪽이 소유한다. 호스트(board-ai)는 도메인을 모르는 채 **노출된 카테고리의\n * 규율만 모아 붙인다** — 도메인 지식이 호스트 프롬프트로 새지 않는다.\n */\n guidance?: string\n}\n"]}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 접지 가드 — LLM 의 답이 **자기가 받은 근거 안에서** 말했는지 검사한다.
|
|
3
|
+
*
|
|
4
|
+
* ── 왜 필요한가 ─────────────────────────────────────────────────────────────
|
|
5
|
+
* LLM 이 가진 근거는 세 가지뿐이다: 시스템 프롬프트(현재 상태 요약), 사용자·이력 텍스트,
|
|
6
|
+
* 도구 결과(JSON). 답이 그 어디에도 없는 엔티티를 지목하면(존재하지 않는 노드·설비·주문
|
|
7
|
+
* 식별자) 그건 만들어 낸 것이다. 사용자는 그것을 검증할 수단이 없으므로 시스템이 표시해야 한다.
|
|
8
|
+
*
|
|
9
|
+
* ── 무엇을 하지 않는가 ──────────────────────────────────────────────────────
|
|
10
|
+
* 답을 고치거나 막지 않는다. 구조화된 경고만 낸다(비파괴) — 문장을 다시 쓰게 하는 판단은
|
|
11
|
+
* 호출부(대화면)의 정책이다. 또한 **문장으로 지어낸 상황**("지게차들이 바쁘게 움직인다")은
|
|
12
|
+
* 이 가드가 잡지 못한다. 그건 도구를 아예 부르지 않고 답한 경우이고, 별도 레버(도구 호출
|
|
13
|
+
* 강제)로 다뤄야 한다. 이 가드의 사정거리는 **식별자**다.
|
|
14
|
+
*
|
|
15
|
+
* ── 왜 식별자만 보는가 ──────────────────────────────────────────────────────
|
|
16
|
+
* 식별자는 근거집합과 문자 단위로 대조할 수 있는 유일한 요소다. 서술문의 사실성은 대조할
|
|
17
|
+
* 기준이 없다. 그래서 보수적으로 id 관례(하이픈 포함 소문자-영숫자)만 추출한다 —
|
|
18
|
+
* 한국어·하이픈 없는 일반어는 애초에 매칭하지 않는다.
|
|
19
|
+
*/
|
|
20
|
+
/** 문자열에서 id 후보 토큰을 뽑는다(중복 제거). */
|
|
21
|
+
export declare function extractIdTokens(s: string): Set<string>;
|
|
22
|
+
/** 답이 언급한 id − 근거 코퍼스의 id = 미확인(환각 후보). 순서는 답에 나온 순서. */
|
|
23
|
+
export declare function checkGrounding(reply: string, groundedCorpus: string): string[];
|
|
24
|
+
/**
|
|
25
|
+
* 소음 제거 — 미확인 토큰 중 **엔티티 참조로 보이는 것만** 남긴다.
|
|
26
|
+
*
|
|
27
|
+
* 왜 필요한가: id 관례는 영어 합성어와 구별되지 않는다(`read-only` `hand-off` `real-time`
|
|
28
|
+
* `zero-to-twin`). 영어로 답하는 대화면에서 그것들을 "근거 없는 식별자" 로 경고하면 경고가
|
|
29
|
+
* 소음이 되고, 소음이 되면 사용자는 **경고 자체를 무시**한다 — 가드가 있으나 없느니만 못해진다.
|
|
30
|
+
*
|
|
31
|
+
* 판정 기준(둘 중 하나):
|
|
32
|
+
* ① 숫자를 포함한다 — `dock-3` `fk-12`. 일반 영어 합성어에는 숫자가 없다.
|
|
33
|
+
* ② 근거에 있는 어떤 id 와 **첫 마디를 공유**한다 — 근거가 `dock-1` 을 담고 있는데 답이
|
|
34
|
+
* `dock-7` 을 말하면 같은 계열의 없는 대상을 지목한 것이다.
|
|
35
|
+
*
|
|
36
|
+
* 놓치는 것: 숫자도 없고 근거에 같은 계열도 없는 순수 창작(`weld-station`). 그 경우까지
|
|
37
|
+
* 잡으려면 일반 영어 합성어를 함께 잡게 되므로, 이 가드는 **정확도를 택하고 재현율을 버린다**.
|
|
38
|
+
*/
|
|
39
|
+
export declare function filterEntityLike(tokens: string[], groundedCorpus: string): string[];
|
|
40
|
+
/**
|
|
41
|
+
* 접지 검사 + 소음 제거를 한 번에. 대화면에서 쓰는 기본 진입점.
|
|
42
|
+
* 반환이 비면 접지 정상(경고 없음).
|
|
43
|
+
*/
|
|
44
|
+
export declare function groundingWarnings(reply: string, groundedCorpus: string): string[];
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* 접지 가드 — LLM 의 답이 **자기가 받은 근거 안에서** 말했는지 검사한다.
|
|
4
|
+
*
|
|
5
|
+
* ── 왜 필요한가 ─────────────────────────────────────────────────────────────
|
|
6
|
+
* LLM 이 가진 근거는 세 가지뿐이다: 시스템 프롬프트(현재 상태 요약), 사용자·이력 텍스트,
|
|
7
|
+
* 도구 결과(JSON). 답이 그 어디에도 없는 엔티티를 지목하면(존재하지 않는 노드·설비·주문
|
|
8
|
+
* 식별자) 그건 만들어 낸 것이다. 사용자는 그것을 검증할 수단이 없으므로 시스템이 표시해야 한다.
|
|
9
|
+
*
|
|
10
|
+
* ── 무엇을 하지 않는가 ──────────────────────────────────────────────────────
|
|
11
|
+
* 답을 고치거나 막지 않는다. 구조화된 경고만 낸다(비파괴) — 문장을 다시 쓰게 하는 판단은
|
|
12
|
+
* 호출부(대화면)의 정책이다. 또한 **문장으로 지어낸 상황**("지게차들이 바쁘게 움직인다")은
|
|
13
|
+
* 이 가드가 잡지 못한다. 그건 도구를 아예 부르지 않고 답한 경우이고, 별도 레버(도구 호출
|
|
14
|
+
* 강제)로 다뤄야 한다. 이 가드의 사정거리는 **식별자**다.
|
|
15
|
+
*
|
|
16
|
+
* ── 왜 식별자만 보는가 ──────────────────────────────────────────────────────
|
|
17
|
+
* 식별자는 근거집합과 문자 단위로 대조할 수 있는 유일한 요소다. 서술문의 사실성은 대조할
|
|
18
|
+
* 기준이 없다. 그래서 보수적으로 id 관례(하이픈 포함 소문자-영숫자)만 추출한다 —
|
|
19
|
+
* 한국어·하이픈 없는 일반어는 애초에 매칭하지 않는다.
|
|
20
|
+
*/
|
|
21
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
22
|
+
exports.extractIdTokens = extractIdTokens;
|
|
23
|
+
exports.checkGrounding = checkGrounding;
|
|
24
|
+
exports.filterEntityLike = filterEntityLike;
|
|
25
|
+
exports.groundingWarnings = groundingWarnings;
|
|
26
|
+
/** id 관례 — 소문자로 시작, 하이픈으로 이어진 소문자·숫자 토큰. `fk-1` `weld-station` `a-dc` 등. */
|
|
27
|
+
const ID_TOKEN = /[a-z][a-z0-9]*(?:-[a-z0-9]+)+/g;
|
|
28
|
+
/** 문자열에서 id 후보 토큰을 뽑는다(중복 제거). */
|
|
29
|
+
function extractIdTokens(s) {
|
|
30
|
+
const out = new Set();
|
|
31
|
+
for (const m of (s ?? '').matchAll(ID_TOKEN))
|
|
32
|
+
out.add(m[0]);
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
/** 답이 언급한 id − 근거 코퍼스의 id = 미확인(환각 후보). 순서는 답에 나온 순서. */
|
|
36
|
+
function checkGrounding(reply, groundedCorpus) {
|
|
37
|
+
const grounded = extractIdTokens(groundedCorpus);
|
|
38
|
+
const mentioned = extractIdTokens(reply);
|
|
39
|
+
return [...mentioned].filter(id => !grounded.has(id));
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* 소음 제거 — 미확인 토큰 중 **엔티티 참조로 보이는 것만** 남긴다.
|
|
43
|
+
*
|
|
44
|
+
* 왜 필요한가: id 관례는 영어 합성어와 구별되지 않는다(`read-only` `hand-off` `real-time`
|
|
45
|
+
* `zero-to-twin`). 영어로 답하는 대화면에서 그것들을 "근거 없는 식별자" 로 경고하면 경고가
|
|
46
|
+
* 소음이 되고, 소음이 되면 사용자는 **경고 자체를 무시**한다 — 가드가 있으나 없느니만 못해진다.
|
|
47
|
+
*
|
|
48
|
+
* 판정 기준(둘 중 하나):
|
|
49
|
+
* ① 숫자를 포함한다 — `dock-3` `fk-12`. 일반 영어 합성어에는 숫자가 없다.
|
|
50
|
+
* ② 근거에 있는 어떤 id 와 **첫 마디를 공유**한다 — 근거가 `dock-1` 을 담고 있는데 답이
|
|
51
|
+
* `dock-7` 을 말하면 같은 계열의 없는 대상을 지목한 것이다.
|
|
52
|
+
*
|
|
53
|
+
* 놓치는 것: 숫자도 없고 근거에 같은 계열도 없는 순수 창작(`weld-station`). 그 경우까지
|
|
54
|
+
* 잡으려면 일반 영어 합성어를 함께 잡게 되므로, 이 가드는 **정확도를 택하고 재현율을 버린다**.
|
|
55
|
+
*/
|
|
56
|
+
function filterEntityLike(tokens, groundedCorpus) {
|
|
57
|
+
const families = new Set();
|
|
58
|
+
for (const id of extractIdTokens(groundedCorpus))
|
|
59
|
+
families.add(id.split('-')[0]);
|
|
60
|
+
return tokens.filter(t => /[0-9]/.test(t) || families.has(t.split('-')[0]));
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* 접지 검사 + 소음 제거를 한 번에. 대화면에서 쓰는 기본 진입점.
|
|
64
|
+
* 반환이 비면 접지 정상(경고 없음).
|
|
65
|
+
*/
|
|
66
|
+
function groundingWarnings(reply, groundedCorpus) {
|
|
67
|
+
return filterEntityLike(checkGrounding(reply, groundedCorpus), groundedCorpus);
|
|
68
|
+
}
|
|
69
|
+
//# sourceMappingURL=grounding.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"grounding.js","sourceRoot":"","sources":["../../../server/service/utils/grounding.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;;;GAkBG;;AAMH,0CAIC;AAGD,wCAIC;AAiBD,4CAIC;AAMD,8CAEC;AA5CD,4EAA4E;AAC5E,MAAM,QAAQ,GAAG,gCAAgC,CAAA;AAEjD,kCAAkC;AAClC,SAAgB,eAAe,CAAC,CAAS;IACvC,MAAM,GAAG,GAAG,IAAI,GAAG,EAAU,CAAA;IAC7B,KAAK,MAAM,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,QAAQ,CAAC,QAAQ,CAAC;QAAE,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAA;IAC3D,OAAO,GAAG,CAAA;AACZ,CAAC;AAED,yDAAyD;AACzD,SAAgB,cAAc,CAAC,KAAa,EAAE,cAAsB;IAClE,MAAM,QAAQ,GAAG,eAAe,CAAC,cAAc,CAAC,CAAA;IAChD,MAAM,SAAS,GAAG,eAAe,CAAC,KAAK,CAAC,CAAA;IACxC,OAAO,CAAC,GAAG,SAAS,CAAC,CAAC,MAAM,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,CAAA;AACvD,CAAC;AAED;;;;;;;;;;;;;;GAcG;AACH,SAAgB,gBAAgB,CAAC,MAAgB,EAAE,cAAsB;IACvE,MAAM,QAAQ,GAAG,IAAI,GAAG,EAAU,CAAA;IAClC,KAAK,MAAM,EAAE,IAAI,eAAe,CAAC,cAAc,CAAC;QAAE,QAAQ,CAAC,GAAG,CAAC,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAA;IAChF,OAAO,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAA;AAC7E,CAAC;AAED;;;GAGG;AACH,SAAgB,iBAAiB,CAAC,KAAa,EAAE,cAAsB;IACrE,OAAO,gBAAgB,CAAC,cAAc,CAAC,KAAK,EAAE,cAAc,CAAC,EAAE,cAAc,CAAC,CAAA;AAChF,CAAC","sourcesContent":["/**\n * 접지 가드 — LLM 의 답이 **자기가 받은 근거 안에서** 말했는지 검사한다.\n *\n * ── 왜 필요한가 ─────────────────────────────────────────────────────────────\n * LLM 이 가진 근거는 세 가지뿐이다: 시스템 프롬프트(현재 상태 요약), 사용자·이력 텍스트,\n * 도구 결과(JSON). 답이 그 어디에도 없는 엔티티를 지목하면(존재하지 않는 노드·설비·주문\n * 식별자) 그건 만들어 낸 것이다. 사용자는 그것을 검증할 수단이 없으므로 시스템이 표시해야 한다.\n *\n * ── 무엇을 하지 않는가 ──────────────────────────────────────────────────────\n * 답을 고치거나 막지 않는다. 구조화된 경고만 낸다(비파괴) — 문장을 다시 쓰게 하는 판단은\n * 호출부(대화면)의 정책이다. 또한 **문장으로 지어낸 상황**(\"지게차들이 바쁘게 움직인다\")은\n * 이 가드가 잡지 못한다. 그건 도구를 아예 부르지 않고 답한 경우이고, 별도 레버(도구 호출\n * 강제)로 다뤄야 한다. 이 가드의 사정거리는 **식별자**다.\n *\n * ── 왜 식별자만 보는가 ──────────────────────────────────────────────────────\n * 식별자는 근거집합과 문자 단위로 대조할 수 있는 유일한 요소다. 서술문의 사실성은 대조할\n * 기준이 없다. 그래서 보수적으로 id 관례(하이픈 포함 소문자-영숫자)만 추출한다 —\n * 한국어·하이픈 없는 일반어는 애초에 매칭하지 않는다.\n */\n\n/** id 관례 — 소문자로 시작, 하이픈으로 이어진 소문자·숫자 토큰. `fk-1` `weld-station` `a-dc` 등. */\nconst ID_TOKEN = /[a-z][a-z0-9]*(?:-[a-z0-9]+)+/g\n\n/** 문자열에서 id 후보 토큰을 뽑는다(중복 제거). */\nexport function extractIdTokens(s: string): Set<string> {\n const out = new Set<string>()\n for (const m of (s ?? '').matchAll(ID_TOKEN)) out.add(m[0])\n return out\n}\n\n/** 답이 언급한 id − 근거 코퍼스의 id = 미확인(환각 후보). 순서는 답에 나온 순서. */\nexport function checkGrounding(reply: string, groundedCorpus: string): string[] {\n const grounded = extractIdTokens(groundedCorpus)\n const mentioned = extractIdTokens(reply)\n return [...mentioned].filter(id => !grounded.has(id))\n}\n\n/**\n * 소음 제거 — 미확인 토큰 중 **엔티티 참조로 보이는 것만** 남긴다.\n *\n * 왜 필요한가: id 관례는 영어 합성어와 구별되지 않는다(`read-only` `hand-off` `real-time`\n * `zero-to-twin`). 영어로 답하는 대화면에서 그것들을 \"근거 없는 식별자\" 로 경고하면 경고가\n * 소음이 되고, 소음이 되면 사용자는 **경고 자체를 무시**한다 — 가드가 있으나 없느니만 못해진다.\n *\n * 판정 기준(둘 중 하나):\n * ① 숫자를 포함한다 — `dock-3` `fk-12`. 일반 영어 합성어에는 숫자가 없다.\n * ② 근거에 있는 어떤 id 와 **첫 마디를 공유**한다 — 근거가 `dock-1` 을 담고 있는데 답이\n * `dock-7` 을 말하면 같은 계열의 없는 대상을 지목한 것이다.\n *\n * 놓치는 것: 숫자도 없고 근거에 같은 계열도 없는 순수 창작(`weld-station`). 그 경우까지\n * 잡으려면 일반 영어 합성어를 함께 잡게 되므로, 이 가드는 **정확도를 택하고 재현율을 버린다**.\n */\nexport function filterEntityLike(tokens: string[], groundedCorpus: string): string[] {\n const families = new Set<string>()\n for (const id of extractIdTokens(groundedCorpus)) families.add(id.split('-')[0])\n return tokens.filter(t => /[0-9]/.test(t) || families.has(t.split('-')[0]))\n}\n\n/**\n * 접지 검사 + 소음 제거를 한 번에. 대화면에서 쓰는 기본 진입점.\n * 반환이 비면 접지 정상(경고 없음).\n */\nexport function groundingWarnings(reply: string, groundedCorpus: string): string[] {\n return filterEntityLike(checkGrounding(reply, groundedCorpus), groundedCorpus)\n}\n"]}
|
|
@@ -2,4 +2,6 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
const tslib_1 = require("tslib");
|
|
4
4
|
tslib_1.__exportStar(require("./parse-json.js"), exports);
|
|
5
|
+
tslib_1.__exportStar(require("./grounding.js"), exports);
|
|
6
|
+
tslib_1.__exportStar(require("./transient.js"), exports);
|
|
5
7
|
//# sourceMappingURL=index.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../server/service/utils/index.ts"],"names":[],"mappings":";;;AAAA,0DAA+B","sourcesContent":["export * from './parse-json.js'\n"]}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../server/service/utils/index.ts"],"names":[],"mappings":";;;AAAA,0DAA+B;AAC/B,yDAA8B;AAC9B,yDAA8B","sourcesContent":["export * from './parse-json.js'\nexport * from './grounding.js'\nexport * from './transient.js'\n"]}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** 다시 시도할 가치가 있는 오류인가. 취소(abort)는 재시도 대상이 아니다 — 사용자가 멈춘 것이다. */
|
|
2
|
+
export declare function isTransientAIError(e: any): boolean;
|
|
3
|
+
export interface RetryOptions {
|
|
4
|
+
/** 총 시도 횟수(첫 시도 포함). 기본 3. */
|
|
5
|
+
attempts?: number;
|
|
6
|
+
/** 첫 대기(ms). 시도마다 2배로 늘린다. 기본 500. */
|
|
7
|
+
baseDelayMs?: number;
|
|
8
|
+
/** 대기 구현 — 테스트가 즉시 진행하기 위한 이음매. */
|
|
9
|
+
sleep?: (ms: number) => Promise<void>;
|
|
10
|
+
/** 재시도 직전 통지(로그·관측). */
|
|
11
|
+
onRetry?: (info: {
|
|
12
|
+
attempt: number;
|
|
13
|
+
delayMs: number;
|
|
14
|
+
error: any;
|
|
15
|
+
}) => void;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* 일시 장애면 지수 백오프로 다시 시도한다. 일시가 아니면 **즉시 그대로 던진다**(감추지 않는다).
|
|
19
|
+
*
|
|
20
|
+
* 대기 시간을 길게 두지 않는다 — 사용자가 화면 앞에서 기다리는 호출이다. 몇 초 안에 안 되면
|
|
21
|
+
* 실패를 알리고 사용자가 다시 묻게 하는 것이 낫다.
|
|
22
|
+
*/
|
|
23
|
+
export declare function retryTransient<T>(fn: () => Promise<T>, options?: RetryOptions): Promise<T>;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/*
|
|
3
|
+
* 일시 장애 분류와 재시도 — **모델 서비스는 가끔 잠깐 죽는다.**
|
|
4
|
+
*
|
|
5
|
+
* 실제로 났다: Gemini 가 503(Service Unavailable)을 돌려주자 대화가 그 자리에서 끝나고, 사용자에게는
|
|
6
|
+
* SDK 원문("Error fetching from undefined: [503 …]")이 그대로 보였다. 두 가지가 잘못이다 —
|
|
7
|
+
* ① 잠깐 죽은 것에 한 번도 다시 시도하지 않았고, ② 사용자에게 우리 말이 아닌 남의 말을 보여줬다.
|
|
8
|
+
*
|
|
9
|
+
* 여기서는 ①의 판정과 대기만 담당한다(순수). 무엇을 보여줄지는 표면의 몫이다.
|
|
10
|
+
*
|
|
11
|
+
* ── 무엇을 일시로 보는가 ────────────────────────────────────────────────────
|
|
12
|
+
* 다시 시도하면 될 수 있는 것만: 429(과부하·쿼터 초과 순간), 5xx(서비스 불가·게이트웨이),
|
|
13
|
+
* 그리고 네트워크 계열(연결 끊김·타임아웃). **400·401·403·404 는 다시 해도 같다** — 요청이나
|
|
14
|
+
* 자격이 잘못된 것이므로 즉시 알려야 한다(조용히 몇 번 더 시도하면 원인이 늦게 드러난다).
|
|
15
|
+
*/
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
exports.isTransientAIError = isTransientAIError;
|
|
18
|
+
exports.retryTransient = retryTransient;
|
|
19
|
+
/** 상태 코드를 여러 프로바이더 SDK 의 오류 모양에서 최대한 끌어낸다. */
|
|
20
|
+
function statusOf(e) {
|
|
21
|
+
const candidates = [e?.status, e?.statusCode, e?.response?.status, e?.cause?.status];
|
|
22
|
+
for (const c of candidates) {
|
|
23
|
+
const n = typeof c === 'string' ? Number(c) : c;
|
|
24
|
+
if (typeof n === 'number' && Number.isFinite(n))
|
|
25
|
+
return n;
|
|
26
|
+
}
|
|
27
|
+
/* SDK 가 코드를 본문에만 넣는 경우(예: "[503 Service Unavailable]") 메시지에서 찾는다. */
|
|
28
|
+
const m = /\[(\d{3})\s/.exec(String(e?.message ?? ''));
|
|
29
|
+
return m ? Number(m[1]) : undefined;
|
|
30
|
+
}
|
|
31
|
+
/** 네트워크 계열 오류 표식 — 코드가 없어도 다시 시도할 값이 있는 것들. */
|
|
32
|
+
const NETWORK_MARKERS = [
|
|
33
|
+
'ECONNRESET',
|
|
34
|
+
'ECONNREFUSED',
|
|
35
|
+
'ETIMEDOUT',
|
|
36
|
+
'EAI_AGAIN',
|
|
37
|
+
'ENOTFOUND',
|
|
38
|
+
'socket hang up',
|
|
39
|
+
'fetch failed',
|
|
40
|
+
'network error',
|
|
41
|
+
'terminated'
|
|
42
|
+
];
|
|
43
|
+
/** 프로바이더가 과부하를 코드 없이 알리는 표현들. */
|
|
44
|
+
const OVERLOAD_MARKERS = ['overloaded', 'rate limit', 'try again', 'temporarily unavailable', 'service unavailable'];
|
|
45
|
+
/** 다시 시도할 가치가 있는 오류인가. 취소(abort)는 재시도 대상이 아니다 — 사용자가 멈춘 것이다. */
|
|
46
|
+
function isTransientAIError(e) {
|
|
47
|
+
if (!e)
|
|
48
|
+
return false;
|
|
49
|
+
if (e?.name === 'AbortError')
|
|
50
|
+
return false;
|
|
51
|
+
const status = statusOf(e);
|
|
52
|
+
if (status === 429)
|
|
53
|
+
return true;
|
|
54
|
+
if (status && status >= 500 && status < 600)
|
|
55
|
+
return true;
|
|
56
|
+
if (status && status >= 400 && status < 500)
|
|
57
|
+
return false; // 요청·자격 문제는 다시 해도 같다
|
|
58
|
+
const message = String(e?.message ?? '').toLowerCase();
|
|
59
|
+
return [...NETWORK_MARKERS, ...OVERLOAD_MARKERS].some(marker => message.includes(marker.toLowerCase()));
|
|
60
|
+
}
|
|
61
|
+
const defaultSleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
62
|
+
/**
|
|
63
|
+
* 일시 장애면 지수 백오프로 다시 시도한다. 일시가 아니면 **즉시 그대로 던진다**(감추지 않는다).
|
|
64
|
+
*
|
|
65
|
+
* 대기 시간을 길게 두지 않는다 — 사용자가 화면 앞에서 기다리는 호출이다. 몇 초 안에 안 되면
|
|
66
|
+
* 실패를 알리고 사용자가 다시 묻게 하는 것이 낫다.
|
|
67
|
+
*/
|
|
68
|
+
async function retryTransient(fn, options = {}) {
|
|
69
|
+
const attempts = Math.max(1, options.attempts ?? 3);
|
|
70
|
+
const base = Math.max(0, options.baseDelayMs ?? 500);
|
|
71
|
+
const sleep = options.sleep ?? defaultSleep;
|
|
72
|
+
let lastError;
|
|
73
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
74
|
+
try {
|
|
75
|
+
return await fn();
|
|
76
|
+
}
|
|
77
|
+
catch (e) {
|
|
78
|
+
lastError = e;
|
|
79
|
+
if (attempt >= attempts || !isTransientAIError(e))
|
|
80
|
+
throw e;
|
|
81
|
+
const delayMs = base * 2 ** (attempt - 1);
|
|
82
|
+
options.onRetry?.({ attempt, delayMs, error: e });
|
|
83
|
+
await sleep(delayMs);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
throw lastError;
|
|
87
|
+
}
|
|
88
|
+
//# sourceMappingURL=transient.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"transient.js","sourceRoot":"","sources":["../../../server/service/utils/transient.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * 일시 장애 분류와 재시도 — **모델 서비스는 가끔 잠깐 죽는다.**\n *\n * 실제로 났다: Gemini 가 503(Service Unavailable)을 돌려주자 대화가 그 자리에서 끝나고, 사용자에게는\n * SDK 원문(\"Error fetching from undefined: [503 …]\")이 그대로 보였다. 두 가지가 잘못이다 —\n * ① 잠깐 죽은 것에 한 번도 다시 시도하지 않았고, ② 사용자에게 우리 말이 아닌 남의 말을 보여줬다.\n *\n * 여기서는 ①의 판정과 대기만 담당한다(순수). 무엇을 보여줄지는 표면의 몫이다.\n *\n * ── 무엇을 일시로 보는가 ────────────────────────────────────────────────────\n * 다시 시도하면 될 수 있는 것만: 429(과부하·쿼터 초과 순간), 5xx(서비스 불가·게이트웨이),\n * 그리고 네트워크 계열(연결 끊김·타임아웃). **400·401·403·404 는 다시 해도 같다** — 요청이나\n * 자격이 잘못된 것이므로 즉시 알려야 한다(조용히 몇 번 더 시도하면 원인이 늦게 드러난다).\n */\n\n/** 상태 코드를 여러 프로바이더 SDK 의 오류 모양에서 최대한 끌어낸다. */\nfunction statusOf(e: any): number | undefined {\n const candidates = [e?.status, e?.statusCode, e?.response?.status, e?.cause?.status]\n for (const c of candidates) {\n const n = typeof c === 'string' ? Number(c) : c\n if (typeof n === 'number' && Number.isFinite(n)) return n\n }\n /* SDK 가 코드를 본문에만 넣는 경우(예: \"[503 Service Unavailable]\") 메시지에서 찾는다. */\n const m = /\\[(\\d{3})\\s/.exec(String(e?.message ?? ''))\n return m ? Number(m[1]) : undefined\n}\n\n/** 네트워크 계열 오류 표식 — 코드가 없어도 다시 시도할 값이 있는 것들. */\nconst NETWORK_MARKERS = [\n 'ECONNRESET',\n 'ECONNREFUSED',\n 'ETIMEDOUT',\n 'EAI_AGAIN',\n 'ENOTFOUND',\n 'socket hang up',\n 'fetch failed',\n 'network error',\n 'terminated'\n]\n\n/** 프로바이더가 과부하를 코드 없이 알리는 표현들. */\nconst OVERLOAD_MARKERS = ['overloaded', 'rate limit', 'try again', 'temporarily unavailable', 'service unavailable']\n\n/** 다시 시도할 가치가 있는 오류인가. 취소(abort)는 재시도 대상이 아니다 — 사용자가 멈춘 것이다. */\nexport function isTransientAIError(e: any): boolean {\n if (!e) return false\n if (e?.name === 'AbortError') return false\n const status = statusOf(e)\n if (status === 429) return true\n if (status && status >= 500 && status < 600) return true\n if (status && status >= 400 && status < 500) return false // 요청·자격 문제는 다시 해도 같다\n const message = String(e?.message ?? '').toLowerCase()\n return [...NETWORK_MARKERS, ...OVERLOAD_MARKERS].some(marker => message.includes(marker.toLowerCase()))\n}\n\nexport interface RetryOptions {\n /** 총 시도 횟수(첫 시도 포함). 기본 3. */\n attempts?: number\n /** 첫 대기(ms). 시도마다 2배로 늘린다. 기본 500. */\n baseDelayMs?: number\n /** 대기 구현 — 테스트가 즉시 진행하기 위한 이음매. */\n sleep?: (ms: number) => Promise<void>\n /** 재시도 직전 통지(로그·관측). */\n onRetry?: (info: { attempt: number; delayMs: number; error: any }) => void\n}\n\nconst defaultSleep = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))\n\n/**\n * 일시 장애면 지수 백오프로 다시 시도한다. 일시가 아니면 **즉시 그대로 던진다**(감추지 않는다).\n *\n * 대기 시간을 길게 두지 않는다 — 사용자가 화면 앞에서 기다리는 호출이다. 몇 초 안에 안 되면\n * 실패를 알리고 사용자가 다시 묻게 하는 것이 낫다.\n */\nexport async function retryTransient<T>(fn: () => Promise<T>, options: RetryOptions = {}): Promise<T> {\n const attempts = Math.max(1, options.attempts ?? 3)\n const base = Math.max(0, options.baseDelayMs ?? 500)\n const sleep = options.sleep ?? defaultSleep\n let lastError: any\n for (let attempt = 1; attempt <= attempts; attempt++) {\n try {\n return await fn()\n } catch (e) {\n lastError = e\n if (attempt >= attempts || !isTransientAIError(e)) throw e\n const delayMs = base * 2 ** (attempt - 1)\n options.onRetry?.({ attempt, delayMs, error: e })\n await sleep(delayMs)\n }\n }\n throw lastError\n}\n"]}
|