@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
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 접지 가드 — LLM 의 답이 **자기가 받은 근거 안에서** 말했는지 검사한다.
|
|
3
|
+
*
|
|
4
|
+
* ── 왜 필요한가 ─────────────────────────────────────────────────────────────
|
|
5
|
+
* LLM 이 가진 근거는 세 가지뿐이다: 시스템 프롬프트(현재 상태 요약), 사용자·이력 텍스트,
|
|
6
|
+
* 도구 결과(JSON). 답이 그 어디에도 없는 엔티티를 지목하면(존재하지 않는 노드·설비·주문
|
|
7
|
+
* 식별자) 그건 만들어 낸 것이다. 사용자는 그것을 검증할 수단이 없으므로 시스템이 표시해야 한다.
|
|
8
|
+
*
|
|
9
|
+
* ── 무엇을 하지 않는가 ──────────────────────────────────────────────────────
|
|
10
|
+
* 답을 고치거나 막지 않는다. 구조화된 경고만 낸다(비파괴) — 문장을 다시 쓰게 하는 판단은
|
|
11
|
+
* 호출부(대화면)의 정책이다. 또한 **문장으로 지어낸 상황**("지게차들이 바쁘게 움직인다")은
|
|
12
|
+
* 이 가드가 잡지 못한다. 그건 도구를 아예 부르지 않고 답한 경우이고, 별도 레버(도구 호출
|
|
13
|
+
* 강제)로 다뤄야 한다. 이 가드의 사정거리는 **식별자**다.
|
|
14
|
+
*
|
|
15
|
+
* ── 왜 식별자만 보는가 ──────────────────────────────────────────────────────
|
|
16
|
+
* 식별자는 근거집합과 문자 단위로 대조할 수 있는 유일한 요소다. 서술문의 사실성은 대조할
|
|
17
|
+
* 기준이 없다. 그래서 보수적으로 id 관례(하이픈 포함 소문자-영숫자)만 추출한다 —
|
|
18
|
+
* 한국어·하이픈 없는 일반어는 애초에 매칭하지 않는다.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** id 관례 — 소문자로 시작, 하이픈으로 이어진 소문자·숫자 토큰. `fk-1` `weld-station` `a-dc` 등. */
|
|
22
|
+
const ID_TOKEN = /[a-z][a-z0-9]*(?:-[a-z0-9]+)+/g
|
|
23
|
+
|
|
24
|
+
/** 문자열에서 id 후보 토큰을 뽑는다(중복 제거). */
|
|
25
|
+
export function extractIdTokens(s: string): Set<string> {
|
|
26
|
+
const out = new Set<string>()
|
|
27
|
+
for (const m of (s ?? '').matchAll(ID_TOKEN)) out.add(m[0])
|
|
28
|
+
return out
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 답이 언급한 id − 근거 코퍼스의 id = 미확인(환각 후보). 순서는 답에 나온 순서. */
|
|
32
|
+
export function checkGrounding(reply: string, groundedCorpus: string): string[] {
|
|
33
|
+
const grounded = extractIdTokens(groundedCorpus)
|
|
34
|
+
const mentioned = extractIdTokens(reply)
|
|
35
|
+
return [...mentioned].filter(id => !grounded.has(id))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 소음 제거 — 미확인 토큰 중 **엔티티 참조로 보이는 것만** 남긴다.
|
|
40
|
+
*
|
|
41
|
+
* 왜 필요한가: id 관례는 영어 합성어와 구별되지 않는다(`read-only` `hand-off` `real-time`
|
|
42
|
+
* `zero-to-twin`). 영어로 답하는 대화면에서 그것들을 "근거 없는 식별자" 로 경고하면 경고가
|
|
43
|
+
* 소음이 되고, 소음이 되면 사용자는 **경고 자체를 무시**한다 — 가드가 있으나 없느니만 못해진다.
|
|
44
|
+
*
|
|
45
|
+
* 판정 기준(둘 중 하나):
|
|
46
|
+
* ① 숫자를 포함한다 — `dock-3` `fk-12`. 일반 영어 합성어에는 숫자가 없다.
|
|
47
|
+
* ② 근거에 있는 어떤 id 와 **첫 마디를 공유**한다 — 근거가 `dock-1` 을 담고 있는데 답이
|
|
48
|
+
* `dock-7` 을 말하면 같은 계열의 없는 대상을 지목한 것이다.
|
|
49
|
+
*
|
|
50
|
+
* 놓치는 것: 숫자도 없고 근거에 같은 계열도 없는 순수 창작(`weld-station`). 그 경우까지
|
|
51
|
+
* 잡으려면 일반 영어 합성어를 함께 잡게 되므로, 이 가드는 **정확도를 택하고 재현율을 버린다**.
|
|
52
|
+
*/
|
|
53
|
+
export function filterEntityLike(tokens: string[], groundedCorpus: string): string[] {
|
|
54
|
+
const families = new Set<string>()
|
|
55
|
+
for (const id of extractIdTokens(groundedCorpus)) families.add(id.split('-')[0])
|
|
56
|
+
return tokens.filter(t => /[0-9]/.test(t) || families.has(t.split('-')[0]))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 접지 검사 + 소음 제거를 한 번에. 대화면에서 쓰는 기본 진입점.
|
|
61
|
+
* 반환이 비면 접지 정상(경고 없음).
|
|
62
|
+
*/
|
|
63
|
+
export function groundingWarnings(reply: string, groundedCorpus: string): string[] {
|
|
64
|
+
return filterEntityLike(checkGrounding(reply, groundedCorpus), groundedCorpus)
|
|
65
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 일시 장애 재시도 검증 — **잠깐 죽은 것에 한 번은 다시 시도한다.**
|
|
3
|
+
*
|
|
4
|
+
* 실제 사고: Gemini 503 이 왔을 때 재시도 없이 대화가 끝났다. 반대로 요청·자격 오류(400·401)를
|
|
5
|
+
* 재시도하면 원인이 늦게 드러나므로 즉시 던져야 한다 — 그 경계를 여기서 고정한다.
|
|
6
|
+
*/
|
|
7
|
+
import { isTransientAIError, retryTransient } from './transient'
|
|
8
|
+
|
|
9
|
+
const err = (message: string, extra: any = {}) => Object.assign(new Error(message), extra)
|
|
10
|
+
|
|
11
|
+
describe('isTransientAIError', () => {
|
|
12
|
+
it('429·5xx 는 일시로 본다', () => {
|
|
13
|
+
expect(isTransientAIError(err('too many requests', { status: 429 }))).toBe(true)
|
|
14
|
+
expect(isTransientAIError(err('unavailable', { status: 503 }))).toBe(true)
|
|
15
|
+
expect(isTransientAIError(err('bad gateway', { status: 502 }))).toBe(true)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('SDK 가 코드를 본문에만 담아도 찾아낸다 — 실제 Gemini 오류 모양', () => {
|
|
19
|
+
expect(
|
|
20
|
+
isTransientAIError(err('Error fetching from undefined: [503 Service Unavailable] The service is currently unavailable.'))
|
|
21
|
+
).toBe(true)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('요청·자격 문제는 일시가 아니다 — 다시 해도 같고, 원인을 늦게 드러내면 안 된다', () => {
|
|
25
|
+
expect(isTransientAIError(err('invalid api key', { status: 401 }))).toBe(false)
|
|
26
|
+
expect(isTransientAIError(err('bad request', { status: 400 }))).toBe(false)
|
|
27
|
+
expect(isTransientAIError(err('not found', { status: 404 }))).toBe(false)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('네트워크 계열은 일시로 본다', () => {
|
|
31
|
+
expect(isTransientAIError(err('socket hang up'))).toBe(true)
|
|
32
|
+
expect(isTransientAIError(err('fetch failed'))).toBe(true)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('과부하 표현은 코드가 없어도 일시로 본다', () => {
|
|
36
|
+
expect(isTransientAIError(err('The model is overloaded. Please try again later.'))).toBe(true)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('사용자 취소는 재시도 대상이 아니다 — 사용자가 멈춘 것이다', () => {
|
|
40
|
+
expect(isTransientAIError(Object.assign(new Error('aborted'), { name: 'AbortError' }))).toBe(false)
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
describe('retryTransient', () => {
|
|
45
|
+
const nosleep = async () => {}
|
|
46
|
+
|
|
47
|
+
it('일시 장애면 다시 시도해 성공한다', async () => {
|
|
48
|
+
let n = 0
|
|
49
|
+
const r = await retryTransient(
|
|
50
|
+
async () => {
|
|
51
|
+
n++
|
|
52
|
+
if (n < 3) throw err('unavailable', { status: 503 })
|
|
53
|
+
return 'ok'
|
|
54
|
+
},
|
|
55
|
+
{ attempts: 3, sleep: nosleep }
|
|
56
|
+
)
|
|
57
|
+
expect(r).toBe('ok')
|
|
58
|
+
expect(n).toBe(3)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('일시가 아니면 즉시 던진다 — 조용히 몇 번 더 시도하지 않는다', async () => {
|
|
62
|
+
let n = 0
|
|
63
|
+
await expect(
|
|
64
|
+
retryTransient(
|
|
65
|
+
async () => {
|
|
66
|
+
n++
|
|
67
|
+
throw err('invalid api key', { status: 401 })
|
|
68
|
+
},
|
|
69
|
+
{ attempts: 3, sleep: nosleep }
|
|
70
|
+
)
|
|
71
|
+
).rejects.toThrow(/invalid api key/)
|
|
72
|
+
expect(n).toBe(1)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('시도 횟수를 다 쓰면 마지막 오류를 던진다', async () => {
|
|
76
|
+
await expect(
|
|
77
|
+
retryTransient(async () => { throw err('unavailable', { status: 503 }) }, { attempts: 2, sleep: nosleep })
|
|
78
|
+
).rejects.toThrow(/unavailable/)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('대기는 시도마다 2배로 늘린다 — 같은 순간에 몰아 때리지 않는다', async () => {
|
|
82
|
+
const delays: number[] = []
|
|
83
|
+
await expect(
|
|
84
|
+
retryTransient(async () => { throw err('unavailable', { status: 503 }) }, {
|
|
85
|
+
attempts: 3,
|
|
86
|
+
baseDelayMs: 100,
|
|
87
|
+
sleep: async (ms: number) => { delays.push(ms) }
|
|
88
|
+
})
|
|
89
|
+
).rejects.toThrow()
|
|
90
|
+
expect(delays).toEqual([100, 200])
|
|
91
|
+
})
|
|
92
|
+
})
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 일시 장애 분류와 재시도 — **모델 서비스는 가끔 잠깐 죽는다.**
|
|
3
|
+
*
|
|
4
|
+
* 실제로 났다: Gemini 가 503(Service Unavailable)을 돌려주자 대화가 그 자리에서 끝나고, 사용자에게는
|
|
5
|
+
* SDK 원문("Error fetching from undefined: [503 …]")이 그대로 보였다. 두 가지가 잘못이다 —
|
|
6
|
+
* ① 잠깐 죽은 것에 한 번도 다시 시도하지 않았고, ② 사용자에게 우리 말이 아닌 남의 말을 보여줬다.
|
|
7
|
+
*
|
|
8
|
+
* 여기서는 ①의 판정과 대기만 담당한다(순수). 무엇을 보여줄지는 표면의 몫이다.
|
|
9
|
+
*
|
|
10
|
+
* ── 무엇을 일시로 보는가 ────────────────────────────────────────────────────
|
|
11
|
+
* 다시 시도하면 될 수 있는 것만: 429(과부하·쿼터 초과 순간), 5xx(서비스 불가·게이트웨이),
|
|
12
|
+
* 그리고 네트워크 계열(연결 끊김·타임아웃). **400·401·403·404 는 다시 해도 같다** — 요청이나
|
|
13
|
+
* 자격이 잘못된 것이므로 즉시 알려야 한다(조용히 몇 번 더 시도하면 원인이 늦게 드러난다).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** 상태 코드를 여러 프로바이더 SDK 의 오류 모양에서 최대한 끌어낸다. */
|
|
17
|
+
function statusOf(e: any): number | undefined {
|
|
18
|
+
const candidates = [e?.status, e?.statusCode, e?.response?.status, e?.cause?.status]
|
|
19
|
+
for (const c of candidates) {
|
|
20
|
+
const n = typeof c === 'string' ? Number(c) : c
|
|
21
|
+
if (typeof n === 'number' && Number.isFinite(n)) return n
|
|
22
|
+
}
|
|
23
|
+
/* SDK 가 코드를 본문에만 넣는 경우(예: "[503 Service Unavailable]") 메시지에서 찾는다. */
|
|
24
|
+
const m = /\[(\d{3})\s/.exec(String(e?.message ?? ''))
|
|
25
|
+
return m ? Number(m[1]) : undefined
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 네트워크 계열 오류 표식 — 코드가 없어도 다시 시도할 값이 있는 것들. */
|
|
29
|
+
const NETWORK_MARKERS = [
|
|
30
|
+
'ECONNRESET',
|
|
31
|
+
'ECONNREFUSED',
|
|
32
|
+
'ETIMEDOUT',
|
|
33
|
+
'EAI_AGAIN',
|
|
34
|
+
'ENOTFOUND',
|
|
35
|
+
'socket hang up',
|
|
36
|
+
'fetch failed',
|
|
37
|
+
'network error',
|
|
38
|
+
'terminated'
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
/** 프로바이더가 과부하를 코드 없이 알리는 표현들. */
|
|
42
|
+
const OVERLOAD_MARKERS = ['overloaded', 'rate limit', 'try again', 'temporarily unavailable', 'service unavailable']
|
|
43
|
+
|
|
44
|
+
/** 다시 시도할 가치가 있는 오류인가. 취소(abort)는 재시도 대상이 아니다 — 사용자가 멈춘 것이다. */
|
|
45
|
+
export function isTransientAIError(e: any): boolean {
|
|
46
|
+
if (!e) return false
|
|
47
|
+
if (e?.name === 'AbortError') return false
|
|
48
|
+
const status = statusOf(e)
|
|
49
|
+
if (status === 429) return true
|
|
50
|
+
if (status && status >= 500 && status < 600) return true
|
|
51
|
+
if (status && status >= 400 && status < 500) return false // 요청·자격 문제는 다시 해도 같다
|
|
52
|
+
const message = String(e?.message ?? '').toLowerCase()
|
|
53
|
+
return [...NETWORK_MARKERS, ...OVERLOAD_MARKERS].some(marker => message.includes(marker.toLowerCase()))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface RetryOptions {
|
|
57
|
+
/** 총 시도 횟수(첫 시도 포함). 기본 3. */
|
|
58
|
+
attempts?: number
|
|
59
|
+
/** 첫 대기(ms). 시도마다 2배로 늘린다. 기본 500. */
|
|
60
|
+
baseDelayMs?: number
|
|
61
|
+
/** 대기 구현 — 테스트가 즉시 진행하기 위한 이음매. */
|
|
62
|
+
sleep?: (ms: number) => Promise<void>
|
|
63
|
+
/** 재시도 직전 통지(로그·관측). */
|
|
64
|
+
onRetry?: (info: { attempt: number; delayMs: number; error: any }) => void
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const defaultSleep = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 일시 장애면 지수 백오프로 다시 시도한다. 일시가 아니면 **즉시 그대로 던진다**(감추지 않는다).
|
|
71
|
+
*
|
|
72
|
+
* 대기 시간을 길게 두지 않는다 — 사용자가 화면 앞에서 기다리는 호출이다. 몇 초 안에 안 되면
|
|
73
|
+
* 실패를 알리고 사용자가 다시 묻게 하는 것이 낫다.
|
|
74
|
+
*/
|
|
75
|
+
export async function retryTransient<T>(fn: () => Promise<T>, options: RetryOptions = {}): Promise<T> {
|
|
76
|
+
const attempts = Math.max(1, options.attempts ?? 3)
|
|
77
|
+
const base = Math.max(0, options.baseDelayMs ?? 500)
|
|
78
|
+
const sleep = options.sleep ?? defaultSleep
|
|
79
|
+
let lastError: any
|
|
80
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
81
|
+
try {
|
|
82
|
+
return await fn()
|
|
83
|
+
} catch (e) {
|
|
84
|
+
lastError = e
|
|
85
|
+
if (attempt >= attempts || !isTransientAIError(e)) throw e
|
|
86
|
+
const delayMs = base * 2 ** (attempt - 1)
|
|
87
|
+
options.onRetry?.({ attempt, delayMs, error: e })
|
|
88
|
+
await sleep(delayMs)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
throw lastError
|
|
92
|
+
}
|