@things-factory/board-ai 10.0.2 → 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/client/components/board-ai-chat.ts +458 -19
- package/client/components/chat-echo-dedup.test.ts +59 -3
- package/client/components/chat-echo-dedup.ts +29 -0
- package/dist-client/client/components/board-ai-chat.d.ts +63 -0
- package/dist-client/client/components/board-ai-chat.js +438 -16
- package/dist-client/client/components/board-ai-chat.js.map +1 -1
- package/dist-client/client/components/chat-echo-dedup.js +28 -0
- package/dist-client/client/components/chat-echo-dedup.js.map +1 -1
- package/dist-client/client/components/chat-echo-dedup.test.js +53 -3
- package/dist-client/client/components/chat-echo-dedup.test.js.map +1 -1
- package/dist-client/server/service/agentic-loop.d.ts +15 -0
- package/dist-client/server/service/agentic-loop.js +70 -9
- package/dist-client/server/service/agentic-loop.js.map +1 -1
- package/dist-client/server/service/assistant.js +18 -3
- package/dist-client/server/service/assistant.js.map +1 -1
- package/dist-client/server/service/types.d.ts +23 -0
- package/dist-client/server/service/types.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-server/service/agentic-loop.d.ts +15 -0
- package/dist-server/service/agentic-loop.js +71 -9
- package/dist-server/service/agentic-loop.js.map +1 -1
- package/dist-server/service/assistant.js +17 -2
- package/dist-server/service/assistant.js.map +1 -1
- package/dist-server/service/board-ai-resolver.d.ts +13 -0
- package/dist-server/service/board-ai-resolver.js +99 -1
- package/dist-server/service/board-ai-resolver.js.map +1 -1
- package/dist-server/service/chat-message/fold-history.d.ts +30 -0
- package/dist-server/service/chat-message/fold-history.js +29 -0
- package/dist-server/service/chat-message/fold-history.js.map +1 -0
- package/dist-server/service/chat-message/history-summary.d.ts +43 -0
- package/dist-server/service/chat-message/history-summary.js +77 -0
- package/dist-server/service/chat-message/history-summary.js.map +1 -0
- package/dist-server/service/chat-message/llm-history.d.ts +19 -0
- package/dist-server/service/chat-message/llm-history.js +31 -1
- package/dist-server/service/chat-message/llm-history.js.map +1 -1
- package/dist-server/service/chat-session/chat-session.d.ts +8 -0
- package/dist-server/service/chat-session/chat-session.js +5 -0
- package/dist-server/service/chat-session/chat-session.js.map +1 -1
- package/dist-server/service/types.d.ts +23 -0
- package/dist-server/service/types.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +6 -6
- package/server/service/agentic-loop.test.ts +91 -0
- package/server/service/agentic-loop.ts +80 -9
- package/server/service/assistant.ts +21 -3
- package/server/service/board-ai-resolver.ts +108 -1
- package/server/service/chat-message/fold-history.test.ts +98 -0
- package/server/service/chat-message/fold-history.ts +60 -0
- package/server/service/chat-message/history-summary.test.ts +127 -0
- package/server/service/chat-message/history-summary.ts +100 -0
- package/server/service/chat-message/llm-history.test.ts +65 -0
- package/server/service/chat-message/llm-history.ts +48 -1
- package/server/service/chat-session/chat-session.ts +11 -0
- package/server/service/dock-contract.test.ts +305 -0
- package/server/service/types.ts +23 -0
- package/translations/en.json +14 -1
- package/translations/ja.json +14 -1
- package/translations/ko.json +13 -0
- package/translations/ms.json +14 -1
- package/translations/zh.json +14 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/board-ai",
|
|
3
|
-
"version": "10.0.
|
|
3
|
+
"version": "10.0.3",
|
|
4
4
|
"main": "dist-server/index.js",
|
|
5
5
|
"browser": "dist-client/client/index.js",
|
|
6
6
|
"things-factory": true,
|
|
@@ -30,11 +30,11 @@
|
|
|
30
30
|
"@operato/graphql": "^10.0.0",
|
|
31
31
|
"@operato/i18n": "^10.0.0",
|
|
32
32
|
"@operato/styles": "^10.0.0",
|
|
33
|
-
"@things-factory/ai-client-base": "^10.0.
|
|
34
|
-
"@things-factory/auth-base": "^10.0.
|
|
35
|
-
"@things-factory/board-import": "^10.0.
|
|
33
|
+
"@things-factory/ai-client-base": "^10.0.3",
|
|
34
|
+
"@things-factory/auth-base": "^10.0.3",
|
|
35
|
+
"@things-factory/board-import": "^10.0.3",
|
|
36
36
|
"@things-factory/env": "^10.0.0",
|
|
37
|
-
"@things-factory/shell": "^10.0.
|
|
37
|
+
"@things-factory/shell": "^10.0.3",
|
|
38
38
|
"dompurify": "^3.0.0",
|
|
39
39
|
"graphql-tag": "^2.12.6",
|
|
40
40
|
"lit": "^3.1.2",
|
|
@@ -44,5 +44,5 @@
|
|
|
44
44
|
"copyfiles": "^2.4.1",
|
|
45
45
|
"rimraf": "^5.0.0"
|
|
46
46
|
},
|
|
47
|
-
"gitHead": "
|
|
47
|
+
"gitHead": "c7d253b722b6917e8911f6ef6e78e5fc2b295e82"
|
|
48
48
|
}
|
|
@@ -766,3 +766,94 @@ describe('runAgenticLoop — 첫 턴 도구 강제(근거 없는 단언 차단)'
|
|
|
766
766
|
expect(rec.choices).toEqual(['auto', 'auto'])
|
|
767
767
|
})
|
|
768
768
|
})
|
|
769
|
+
|
|
770
|
+
describe('runAgenticLoop — 제안 채널(실행은 사용자)', () => {
|
|
771
|
+
test('도구 결과에 proposed:true 가 있으면 제안으로 올린다 — 대화면이 실행 버튼을 그릴 근거', async () => {
|
|
772
|
+
let i = 0
|
|
773
|
+
const input = mkInput(
|
|
774
|
+
{
|
|
775
|
+
chat: async () => {
|
|
776
|
+
i++
|
|
777
|
+
return i === 1 ? okResult(undefined, [TC('proposeAction', { command: 'order.hold' })]) : okResult('확인해 주세요')
|
|
778
|
+
}
|
|
779
|
+
},
|
|
780
|
+
{
|
|
781
|
+
isReadTool: () => true,
|
|
782
|
+
executeReadTool: () => ({
|
|
783
|
+
proposed: true,
|
|
784
|
+
command: 'order.hold',
|
|
785
|
+
args: { orderId: 'o7' },
|
|
786
|
+
label: 'o7 보류',
|
|
787
|
+
instanceId: 'busan-wms'
|
|
788
|
+
})
|
|
789
|
+
}
|
|
790
|
+
)
|
|
791
|
+
const r = await runAgenticLoop(input)
|
|
792
|
+
expect(r.proposals).toHaveLength(1)
|
|
793
|
+
expect(r.proposals[0]).toMatchObject({ tool: 'proposeAction', command: 'order.hold', instanceId: 'busan-wms' })
|
|
794
|
+
})
|
|
795
|
+
|
|
796
|
+
test('평범한 조회 결과는 제안이 아니다 — 규약은 proposed 플래그 하나뿐', async () => {
|
|
797
|
+
const r = await runAgenticLoop(
|
|
798
|
+
mkInput(
|
|
799
|
+
{
|
|
800
|
+
chat: async () => okResult('상태 정상')
|
|
801
|
+
},
|
|
802
|
+
{ isReadTool: () => true, executeReadTool: () => ({ occupancy: 0 }) }
|
|
803
|
+
)
|
|
804
|
+
)
|
|
805
|
+
expect(r.proposals).toEqual([])
|
|
806
|
+
})
|
|
807
|
+
})
|
|
808
|
+
|
|
809
|
+
describe('runAgenticLoop — 같은 조치는 한 번만 제안된다', () => {
|
|
810
|
+
/* 실제 사고: 모델이 같은 조치를 두 번 제안했고(하나는 인자가 비어 있었다) 카드가 둘 떴다.
|
|
811
|
+
* 하나를 실행한 뒤에도 나머지가 살아 있어 **같은 명령을 두 번** 보낼 수 있었다. */
|
|
812
|
+
test('효과가 같은 제안은 하나로 접는다 — 설명 문구가 달라도 같은 조치다', async () => {
|
|
813
|
+
let i = 0
|
|
814
|
+
const input = mkInput(
|
|
815
|
+
{
|
|
816
|
+
chat: async () => {
|
|
817
|
+
i++
|
|
818
|
+
return i === 1
|
|
819
|
+
? okResult(undefined, [TC('proposeAction', { n: 1 }, 'p1'), TC('proposeAction', { n: 2 }, 'p2')])
|
|
820
|
+
: okResult('확인해 주세요')
|
|
821
|
+
}
|
|
822
|
+
},
|
|
823
|
+
{
|
|
824
|
+
isReadTool: () => true,
|
|
825
|
+
/* 같은 명령·같은 인자(키 순서만 다름), 설명만 다르게 — 같은 조치다. */
|
|
826
|
+
executeReadTool: (tc: any) =>
|
|
827
|
+
tc.id === 'p1'
|
|
828
|
+
? { proposed: true, command: 'resource.add', instanceId: 'w1', args: { kind: 'fk', count: 3 }, label: 'A', reason: '가' }
|
|
829
|
+
: { proposed: true, command: 'resource.add', instanceId: 'w1', args: { count: 3, kind: 'fk' }, label: 'B', reason: '나' }
|
|
830
|
+
}
|
|
831
|
+
)
|
|
832
|
+
const r = await runAgenticLoop(input)
|
|
833
|
+
expect(r.proposals).toHaveLength(1)
|
|
834
|
+
expect(r.proposals[0].label).toBe('A') // 먼저 온 것을 남긴다
|
|
835
|
+
})
|
|
836
|
+
|
|
837
|
+
test('효과가 다르면 각각 남는다 — 서로 다른 결정이다', async () => {
|
|
838
|
+
let i = 0
|
|
839
|
+
const input = mkInput(
|
|
840
|
+
{
|
|
841
|
+
chat: async () => {
|
|
842
|
+
i++
|
|
843
|
+
return i === 1
|
|
844
|
+
? okResult(undefined, [TC('proposeAction', {}, 'p1'), TC('proposeAction', {}, 'p2')])
|
|
845
|
+
: okResult('확인')
|
|
846
|
+
}
|
|
847
|
+
},
|
|
848
|
+
{
|
|
849
|
+
isReadTool: () => true,
|
|
850
|
+
executeReadTool: (tc: any) =>
|
|
851
|
+
tc.id === 'p1'
|
|
852
|
+
? { proposed: true, command: 'resource.add', instanceId: 'w1', args: { count: 3 } }
|
|
853
|
+
: { proposed: true, command: 'resource.add', instanceId: 'w1', args: { count: 5 } }
|
|
854
|
+
}
|
|
855
|
+
)
|
|
856
|
+
const r = await runAgenticLoop(input)
|
|
857
|
+
expect(r.proposals).toHaveLength(2)
|
|
858
|
+
})
|
|
859
|
+
})
|
|
@@ -16,6 +16,7 @@ import type {
|
|
|
16
16
|
AIToolCall,
|
|
17
17
|
AIToolChatResult
|
|
18
18
|
} from '@things-factory/ai-client-base'
|
|
19
|
+
import { retryTransient } from '@things-factory/ai-client-base'
|
|
19
20
|
import type { BoardModel } from '@things-factory/board-import'
|
|
20
21
|
import type { BoardActionOp, ToolUsage } from './types'
|
|
21
22
|
|
|
@@ -104,6 +105,14 @@ export interface AgenticLoopResult {
|
|
|
104
105
|
* 잘려 나간 식별자를 "근거 없음" 으로 오판한다. 여기 담기는 것은 tool_result 로 보낸 원문 그대로다.
|
|
105
106
|
*/
|
|
106
107
|
groundedTexts: string[]
|
|
108
|
+
/**
|
|
109
|
+
* **제안** — 도구가 "실행하지 않고 제안만 했다" 고 알린 결과들(시간순).
|
|
110
|
+
*
|
|
111
|
+
* 규약: read/external 도구의 결과에 `proposed === true` 가 있으면 그것은 조치 제안이다. 그 도구가
|
|
112
|
+
* 어느 도메인인지 이 루프는 모른다 — 결과를 그대로 실어 올리고, 해석·실행 확인은 대화면이 한다.
|
|
113
|
+
* 되돌릴 수 없는 조치를 AI 가 직접 실행하지 않게 하는 경계가 이 채널이다(사람이 버튼을 누른다).
|
|
114
|
+
*/
|
|
115
|
+
proposals: any[]
|
|
107
116
|
/** LLM 마지막 응답의 stopReason. provider-specific 문자열. */
|
|
108
117
|
stopReason: string
|
|
109
118
|
/**
|
|
@@ -152,6 +161,8 @@ export async function runAgenticLoop(
|
|
|
152
161
|
const toolUsages: ToolUsage[] = []
|
|
153
162
|
/* 접지 근거 — tool_result 로 LLM 에 보낸 원문(압축 전). 호출부가 답의 접지를 검사한다. */
|
|
154
163
|
const groundedTexts: string[] = []
|
|
164
|
+
/* 제안 — 도구가 실행하지 않고 제안만 한 것들. 실행 버튼은 대화면이 그린다. */
|
|
165
|
+
const proposals: any[] = []
|
|
155
166
|
let lastText: string | undefined
|
|
156
167
|
let stopReason: string = 'end_turn'
|
|
157
168
|
let abortReason: AgenticLoopResult['abortReason']
|
|
@@ -172,8 +183,12 @@ export async function runAgenticLoop(
|
|
|
172
183
|
log('iter %d: chat() 호출', iter)
|
|
173
184
|
let result: AIToolChatResult
|
|
174
185
|
try {
|
|
175
|
-
|
|
176
|
-
|
|
186
|
+
/* (2) Error boundary — provider 예외 시 graceful 종료.
|
|
187
|
+
*
|
|
188
|
+
* 일시 장애(429·5xx·네트워크)는 **다시 시도한다**: 모델 서비스는 가끔 잠깐 죽고(실제로 Gemini
|
|
189
|
+
* 503 으로 대화가 끊겼다), 그때 한 번도 다시 걸지 않는 것은 우리 쪽 손해다. 요청·자격 오류는
|
|
190
|
+
* 즉시 그대로 올린다(조용히 더 시도하면 원인이 늦게 드러난다). 판정·대기는 ai-client-base 가 소유. */
|
|
191
|
+
result = await retryTransient(() => chat(conversation, tools, {
|
|
177
192
|
systemPrompt: options.systemPrompt,
|
|
178
193
|
model: options.model,
|
|
179
194
|
temperature: options.temperature,
|
|
@@ -182,6 +197,9 @@ export async function runAgenticLoop(
|
|
|
182
197
|
toolChoice: iter === 0 && options.requireToolOnFirstTurn ? 'required' : 'auto',
|
|
183
198
|
allowParallelToolCalls: true,
|
|
184
199
|
signal
|
|
200
|
+
}), {
|
|
201
|
+
onRetry: ({ attempt, delayMs, error }) =>
|
|
202
|
+
log('iter %d: 일시 장애 재시도 %d회 (%dms 후): %s', iter, attempt, delayMs, error?.message)
|
|
185
203
|
})
|
|
186
204
|
} catch (e: any) {
|
|
187
205
|
// AbortError — 별도 분류
|
|
@@ -224,7 +242,9 @@ export async function runAgenticLoop(
|
|
|
224
242
|
dispatch,
|
|
225
243
|
accumulatedWriteCalls,
|
|
226
244
|
accumulatedActions,
|
|
227
|
-
toolUsages
|
|
245
|
+
toolUsages,
|
|
246
|
+
proposals,
|
|
247
|
+
iter
|
|
228
248
|
)
|
|
229
249
|
|
|
230
250
|
/* 접지 근거 누적 — 모델이 tool_result 로 받은 원문 그대로. 압축 전 값이어야 한다(위 필드 주석). */
|
|
@@ -291,11 +311,30 @@ export async function runAgenticLoop(
|
|
|
291
311
|
accumulatedActions,
|
|
292
312
|
toolUsages,
|
|
293
313
|
groundedTexts,
|
|
314
|
+
proposals,
|
|
294
315
|
stopReason,
|
|
295
316
|
abortReason
|
|
296
317
|
}
|
|
297
318
|
}
|
|
298
319
|
|
|
320
|
+
/**
|
|
321
|
+
* 제안의 **효과 키** — 도구·명령·대상·인자만 본다(설명 문구는 제외).
|
|
322
|
+
*
|
|
323
|
+
* 인자는 **키 순서를 정렬**해 문자열로 만든다: 같은 인자를 다른 순서로 받았을 뿐인데 다른 제안으로
|
|
324
|
+
* 보이면 카드가 둘이 되고, 사용자가 같은 명령을 두 번 실행할 수 있다.
|
|
325
|
+
*/
|
|
326
|
+
export function proposalEffectKey(p: any): string {
|
|
327
|
+
return [p?.tool ?? '', p?.command ?? '', p?.instanceId ?? '', stableStringify(p?.args)].join('|')
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** 키 정렬 직렬화 — 순서에 흔들리지 않는 비교를 위해. */
|
|
331
|
+
function stableStringify(value: any): string {
|
|
332
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value ?? null)
|
|
333
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`
|
|
334
|
+
const keys = Object.keys(value).sort()
|
|
335
|
+
return `{${keys.map(k => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(',')}}`
|
|
336
|
+
}
|
|
337
|
+
|
|
299
338
|
/**
|
|
300
339
|
* 같은 tool 의 validation 실패 연속 감지 — pure helper.
|
|
301
340
|
*
|
|
@@ -362,7 +401,10 @@ async function processToolCalls(
|
|
|
362
401
|
dispatch: ToolDispatchHelpers,
|
|
363
402
|
accumulatedWriteCalls: AIToolCall[],
|
|
364
403
|
accumulatedActions: BoardActionOp[],
|
|
365
|
-
toolUsages: ToolUsage[]
|
|
404
|
+
toolUsages: ToolUsage[],
|
|
405
|
+
proposals: any[],
|
|
406
|
+
/** 몇 번째 LLM 턴인가 — 기록에 남겨 화면이 단계로 읽을 수 있게 한다. */
|
|
407
|
+
iter: number
|
|
366
408
|
): Promise<any[]> {
|
|
367
409
|
const toolResultParts: any[] = []
|
|
368
410
|
|
|
@@ -372,6 +414,25 @@ async function processToolCalls(
|
|
|
372
414
|
* await 없이 JSON.stringify 하면 Promise 가 '{}' 로 직렬화되어 LLM 이 **빈 결과**를 받는다
|
|
373
415
|
* (board-import 의 getImportSession/importBoardAsync, operato-twin 의 getTwinStructure 등). */
|
|
374
416
|
const value = await dispatch.executeReadTool(tc, currentBoard, selectedRefids)
|
|
417
|
+
/* 제안 규약 — 결과가 `proposed: true` 면 조치 제안이다(도메인은 모른 채 그대로 실어 올린다).
|
|
418
|
+
* 이 채널이 없으면 대화면은 도구 추적(trace)을 뒤져야 하고, 실행 버튼을 만들 근거가 없다. */
|
|
419
|
+
/* 판정(outcome) — 화면이 단계별 배지로 보여줄 값. 조용한 무동작을 남기지 않는다. */
|
|
420
|
+
let outcome: ToolUsage['outcome'] = 'ok'
|
|
421
|
+
if (value && typeof value === 'object' && (value as any).proposed === true) {
|
|
422
|
+
/* **같은 조치는 한 번만** — 모델이 같은 것을 두 번 제안하는 일이 실제로 있었다(하나는 인자가
|
|
423
|
+
* 비어 있었다). 카드가 둘이면 사용자가 같은 명령을 두 번 실행할 수 있고, 그건 되돌릴 수 없다.
|
|
424
|
+
* 판정은 **효과**(도구·명령·대상·인자)로 한다 — 설명 문구(label·reason)가 달라도 같은 조치다. */
|
|
425
|
+
const candidate = { tool: tc.name, ...(value as any) }
|
|
426
|
+
const key = proposalEffectKey(candidate)
|
|
427
|
+
if (proposals.some(p => proposalEffectKey(p) === key)) {
|
|
428
|
+
outcome = 'folded' // 접힌 사실을 남긴다 — "왜 카드가 하나뿐인가" 가 추적 가능해야 한다
|
|
429
|
+
} else {
|
|
430
|
+
proposals.push(candidate)
|
|
431
|
+
outcome = 'proposed'
|
|
432
|
+
}
|
|
433
|
+
} else if (value && typeof value === 'object' && ((value as any).rejected === true || (value as any).error)) {
|
|
434
|
+
outcome = 'rejected'
|
|
435
|
+
}
|
|
375
436
|
toolResultParts.push({
|
|
376
437
|
type: 'tool_result',
|
|
377
438
|
toolUseId: tc.id,
|
|
@@ -381,7 +442,9 @@ async function processToolCalls(
|
|
|
381
442
|
name: tc.name,
|
|
382
443
|
arguments: tc.arguments ?? {},
|
|
383
444
|
result: dispatch.summarizeToolResult(value),
|
|
384
|
-
kind: 'read'
|
|
445
|
+
kind: 'read',
|
|
446
|
+
iter,
|
|
447
|
+
outcome
|
|
385
448
|
})
|
|
386
449
|
} else if (dispatch.isWriteTool(tc.name)) {
|
|
387
450
|
// 사전 검증 — args 가 schema / build 함수 검증 통과해야 누적.
|
|
@@ -404,7 +467,9 @@ async function processToolCalls(
|
|
|
404
467
|
name: tc.name,
|
|
405
468
|
arguments: tc.arguments ?? {},
|
|
406
469
|
result: errorResult,
|
|
407
|
-
kind: 'write'
|
|
470
|
+
kind: 'write',
|
|
471
|
+
iter,
|
|
472
|
+
outcome: 'rejected'
|
|
408
473
|
})
|
|
409
474
|
// continue — 누적 안 함. LLM 이 다음 turn 에서 정정 호출.
|
|
410
475
|
} else {
|
|
@@ -419,7 +484,9 @@ async function processToolCalls(
|
|
|
419
484
|
name: tc.name,
|
|
420
485
|
arguments: tc.arguments ?? {},
|
|
421
486
|
result: queuedResult,
|
|
422
|
-
kind: 'write'
|
|
487
|
+
kind: 'write',
|
|
488
|
+
iter,
|
|
489
|
+
outcome: 'queued'
|
|
423
490
|
})
|
|
424
491
|
}
|
|
425
492
|
} else if (dispatch.isActionTool(tc.name)) {
|
|
@@ -439,7 +506,9 @@ async function processToolCalls(
|
|
|
439
506
|
name: tc.name,
|
|
440
507
|
arguments: tc.arguments ?? {},
|
|
441
508
|
result: queuedResult,
|
|
442
|
-
kind: 'write'
|
|
509
|
+
kind: 'write',
|
|
510
|
+
iter,
|
|
511
|
+
outcome: 'queued'
|
|
443
512
|
})
|
|
444
513
|
} else {
|
|
445
514
|
const errResult = { error: `Unknown tool: ${tc.name}` }
|
|
@@ -453,7 +522,9 @@ async function processToolCalls(
|
|
|
453
522
|
name: tc.name,
|
|
454
523
|
arguments: tc.arguments ?? {},
|
|
455
524
|
result: errResult,
|
|
456
|
-
kind: 'unknown'
|
|
525
|
+
kind: 'unknown',
|
|
526
|
+
iter,
|
|
527
|
+
outcome: 'error'
|
|
457
528
|
})
|
|
458
529
|
}
|
|
459
530
|
}
|
|
@@ -47,7 +47,9 @@ import {
|
|
|
47
47
|
getToolKind,
|
|
48
48
|
findToolSpec,
|
|
49
49
|
type ToolSpec,
|
|
50
|
-
type ToolCategoryFilter
|
|
50
|
+
type ToolCategoryFilter,
|
|
51
|
+
getCategoryGuidance,
|
|
52
|
+
isTransientAIError
|
|
51
53
|
} from '@things-factory/ai-client-base'
|
|
52
54
|
import { validateWriteToolCall } from './validation/tool-validation'
|
|
53
55
|
|
|
@@ -153,7 +155,16 @@ export class DefaultBoardAIAssistant implements BoardAIAssistant {
|
|
|
153
155
|
* 정의만 숨기면 모델이 이름을 추측하거나 옛 대화가 재생될 때 숨긴 도구가 실행된다. */
|
|
154
156
|
const gate = buildToolGate({ boardTools: options.boardTools, toolCategories: options.toolCategories })
|
|
155
157
|
const tools = buildBoardEditTools(knownTypes, { boardTools: options.boardTools, toolCategories: options.toolCategories })
|
|
156
|
-
|
|
158
|
+
/* 노출된 도구 카테고리의 **사용 규율**을 프롬프트에 함께 싣는다.
|
|
159
|
+
*
|
|
160
|
+
* 도구 목록만 보내면 모델은 "무엇이 있는지" 는 알지만 "언제 반드시 불러야 하는지" 는 모른다.
|
|
161
|
+
* 실제로 그 공백에서 사고가 났다 — 조치 요청에 제안 도구를 부르지 않고 "제안해 두었습니다" 라고만
|
|
162
|
+
* 답해, 사용자에게 없는 버튼을 누르라고 안내했다. 규율은 도구를 등록한 쪽이 소유하고 여기서는
|
|
163
|
+
* 모아 붙이기만 한다(도메인 지식이 board-ai 프롬프트로 새지 않는다). */
|
|
164
|
+
const guidance = getCategoryGuidance(options.toolCategories)
|
|
165
|
+
const systemPrompt =
|
|
166
|
+
this.buildSystemPromptForTools(knownTypes, categories, schemas) +
|
|
167
|
+
(guidance.length ? `\n\n==== TOOL USE RULES (from the tools exposed to this conversation) ====\n${guidance.join('\n')}` : '')
|
|
157
168
|
|
|
158
169
|
const selectedRefids = (options.selectedRefids ?? []).filter(
|
|
159
170
|
(n): n is number => typeof n === 'number' && Number.isFinite(n)
|
|
@@ -285,6 +296,8 @@ export class DefaultBoardAIAssistant implements BoardAIAssistant {
|
|
|
285
296
|
followUp: undefined,
|
|
286
297
|
toolUsages: toolUsages.length > 0 ? toolUsages : undefined,
|
|
287
298
|
actions: accumulatedActions.length > 0 ? accumulatedActions : undefined,
|
|
299
|
+
/* 제안은 실행 전 상태다 — 대화면이 확인 버튼을 그린다. 서버는 실행하지 않는다. */
|
|
300
|
+
proposals: loopResult.proposals.length > 0 ? loopResult.proposals : undefined,
|
|
288
301
|
groundingWarnings: warnings.length > 0 ? warnings : undefined
|
|
289
302
|
}
|
|
290
303
|
}
|
|
@@ -1980,7 +1993,12 @@ export function formatAbortNotice(
|
|
|
1980
1993
|
case 'aborted':
|
|
1981
1994
|
return '\n\n_(요청이 중단되어 일부만 처리됐어요.)_'
|
|
1982
1995
|
case 'provider_error':
|
|
1983
|
-
|
|
1996
|
+
/* 남의 말(SDK 원문)을 사용자에게 보여주지 않는다 — "Error fetching from undefined: [503 …]" 은
|
|
1997
|
+
* 사용자가 할 수 있는 일을 알려주지 않는다. 일시 장애는 이미 몇 번 다시 시도한 뒤이므로,
|
|
1998
|
+
* 지금 필요한 안내는 "잠시 후 다시" 하나다. 원문은 서버 로그에 남는다. */
|
|
1999
|
+
return isTransientAIError({ message: abortReason.message })
|
|
2000
|
+
? '\n\n_(AI 서비스가 일시적으로 응답하지 않았어요. 잠시 후 다시 시도해 주세요.)_'
|
|
2001
|
+
: '\n\n_(처리 중 오류가 발생했어요. 같은 요청이 계속 실패하면 관리자에게 알려 주세요.)_'
|
|
1984
2002
|
case 'max_iterations':
|
|
1985
2003
|
return `\n\n_(작업이 너무 길어 ${abortReason.iter}회에서 중단했어요. 더 작게 나눠 요청해보세요.)_`
|
|
1986
2004
|
case 'repeated_validation_failure':
|
|
@@ -18,6 +18,25 @@ import type { LLMMessage } from './types.js'
|
|
|
18
18
|
import { ChatSession } from './chat-session/chat-session.js'
|
|
19
19
|
import { ChatMessage } from './chat-message/chat-message.js'
|
|
20
20
|
import { buildLlmHistory } from './chat-message/llm-history.js'
|
|
21
|
+
import { splitByCap, summaryInstruction } from './chat-message/history-summary.js'
|
|
22
|
+
import { foldHistorySummary } from './chat-message/fold-history.js'
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 모델에 넘길 최근 메시지 수 상한.
|
|
26
|
+
*
|
|
27
|
+
* 협의는 여럿이 며칠에 걸쳐 이어진다 — 상한 없이 전체를 매 턴 보내면 프롬프트가 무한히 자라고
|
|
28
|
+
* (비용·지연·문맥 한계) 오래된 말이 최근 상황을 덮는다. 40 은 첫 경계선이다: 한 자리의 협의를
|
|
29
|
+
* 담기에 넉넉하고, 넘치면 생략 사실을 모델에 알린다(조용히 버리지 않는다).
|
|
30
|
+
* 버린 앞부분을 **요약해서** 실어 보내는 것이 다음 단계다(ChatSession.lastSummary 미사용 상태).
|
|
31
|
+
*/
|
|
32
|
+
const LLM_HISTORY_MAX_TURNS = 40
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 요약을 다시 만드는 임계치 — 상한 밖으로 **새로 밀려난** 메시지 수.
|
|
36
|
+
*
|
|
37
|
+
* 상한을 넘긴 뒤 매 턴 요약을 만들면 대화마다 LLM 호출이 두 번씩 붙는다. 10 개씩 모아 접는다.
|
|
38
|
+
*/
|
|
39
|
+
const LLM_SUMMARY_THRESHOLD = 10
|
|
21
40
|
import { activityPatch, autoSessionName } from './chat-session/session-inbox.js'
|
|
22
41
|
import { publishSessionActivity } from './chat-session/session-activity-publish.js'
|
|
23
42
|
import { publishChatMessage } from './chat-message/chat-message-publish.js'
|
|
@@ -204,6 +223,20 @@ class BoardAIChatOutput {
|
|
|
204
223
|
})
|
|
205
224
|
actions?: any
|
|
206
225
|
|
|
226
|
+
@Field(() => GraphQLJSON, {
|
|
227
|
+
nullable: true,
|
|
228
|
+
description:
|
|
229
|
+
'Reported when the stored history exceeded the prompt cap for this turn: { omitted, summarized }. The surface uses it to tell the user the conversation grew long — a new conversation is better for a different topic. Objective fact only; no topic-change detection.'
|
|
230
|
+
})
|
|
231
|
+
historyFolded?: any
|
|
232
|
+
|
|
233
|
+
@Field(() => GraphQLJSON, {
|
|
234
|
+
nullable: true,
|
|
235
|
+
description:
|
|
236
|
+
'Action proposals the AI recorded without executing — each carries what a tool returned alongside `proposed: true`. The surface renders a confirm button; execution is the user\'s act, never the assistant\'s. Null when nothing was proposed.'
|
|
237
|
+
})
|
|
238
|
+
proposals?: any
|
|
239
|
+
|
|
207
240
|
@Field(() => GraphQLJSON, {
|
|
208
241
|
nullable: true,
|
|
209
242
|
description:
|
|
@@ -321,12 +354,21 @@ export class BoardAIChatResolver {
|
|
|
321
354
|
* 정렬은 createdAt ASC + id ASC — 같은 시각 삽입의 순서까지 결정적으로 만든다(조립 쪽에서
|
|
322
355
|
* 다시 정렬하지 않으므로 여기서 확정해야 한다). 멘션 마커는 여기서 제거해 넘긴다. */
|
|
323
356
|
let llmMessages: LLMMessage[]
|
|
357
|
+
let historyFolded: { omitted: number; summarized: boolean } | undefined
|
|
324
358
|
if (session) {
|
|
325
359
|
const rows = await messageRepo.find({
|
|
326
360
|
where: { session: { id: session.id } as any },
|
|
327
361
|
relations: { creator: true },
|
|
328
362
|
order: { createdAt: 'ASC', id: 'ASC' }
|
|
329
363
|
})
|
|
364
|
+
/* 이번 턴에 상한 밖으로 밀려난 것이 있는가 — 화면이 "대화가 길어졌다" 를 알릴 근거(사실만). */
|
|
365
|
+
const capped = splitByCap(
|
|
366
|
+
rows.map(row => ({ id: row.id, role: row.role, content: row.content ?? '' })),
|
|
367
|
+
LLM_HISTORY_MAX_TURNS
|
|
368
|
+
)
|
|
369
|
+
if (capped.dropped.length > 0) {
|
|
370
|
+
historyFolded = { omitted: capped.dropped.length, summarized: !!(session as any).lastSummary }
|
|
371
|
+
}
|
|
330
372
|
llmMessages = buildLlmHistory(
|
|
331
373
|
rows.map(row => ({
|
|
332
374
|
id: row.id,
|
|
@@ -339,7 +381,11 @@ export class BoardAIChatResolver {
|
|
|
339
381
|
{
|
|
340
382
|
truncateAfterMessageId: input.truncateAfterMessageId ?? undefined,
|
|
341
383
|
/* 방금 저장한 사용자 메시지는 접은 구간보다 뒤에 있어도 반드시 남긴다. */
|
|
342
|
-
keepMessageIds: userMessageId ? [userMessageId] : []
|
|
384
|
+
keepMessageIds: userMessageId ? [userMessageId] : [],
|
|
385
|
+
/* 이력 상한 — 협의는 며칠 이어진다. 상한이 없으면 매 턴 전체를 보내 프롬프트가 무한히 자란다. */
|
|
386
|
+
maxTurns: LLM_HISTORY_MAX_TURNS,
|
|
387
|
+
/* 밀려난 앞부분은 요약으로 대체한다(있으면). 요약은 응답 뒤에 갱신한다 — 아래 Phase D. */
|
|
388
|
+
summary: { text: (session as any).lastSummary, upToMessageId: (session as any).summaryUpToMessageId }
|
|
343
389
|
}
|
|
344
390
|
)
|
|
345
391
|
} else {
|
|
@@ -435,6 +481,12 @@ export class BoardAIChatResolver {
|
|
|
435
481
|
/* AI 응답도 세션 활동이다 — 이걸 빼면 다른 참여자에게 "새 소식 없음"으로 보인다. */
|
|
436
482
|
...activityPatch(enrichedReply)
|
|
437
483
|
})
|
|
484
|
+
|
|
485
|
+
/* ── Phase D: 이력 요약 접기 ────────────────────────────────────────────
|
|
486
|
+
* 응답을 **돌려준 뒤** 갱신한다 — 사용자 대기 시간에 요약용 LLM 호출을 얹지 않는다.
|
|
487
|
+
* 기다리지 않으므로(await 없음) 실패해도 대화에는 영향이 없다: 요약이 없으면 다음 턴은
|
|
488
|
+
* "N개 생략" 으로 정직하게 알리고 넘어간다(조용한 맥락 상실이 아니다). */
|
|
489
|
+
void this._foldHistorySummary(session.id!, domain.id, base)
|
|
438
490
|
}
|
|
439
491
|
|
|
440
492
|
return {
|
|
@@ -448,10 +500,65 @@ export class BoardAIChatResolver {
|
|
|
448
500
|
patchId,
|
|
449
501
|
toolUsages: r.toolUsages ?? null,
|
|
450
502
|
actions: r.actions && r.actions.length > 0 ? r.actions : null,
|
|
503
|
+
proposals: r.proposals && r.proposals.length > 0 ? r.proposals : null,
|
|
504
|
+
historyFolded: historyFolded ?? null,
|
|
451
505
|
groundingWarnings: r.groundingWarnings && r.groundingWarnings.length > 0 ? r.groundingWarnings : null
|
|
452
506
|
}
|
|
453
507
|
}
|
|
454
508
|
|
|
509
|
+
/**
|
|
510
|
+
* 이력 요약 접기 — 상한 밖으로 밀려난 앞부분을 요약으로 눌러 담는다.
|
|
511
|
+
*
|
|
512
|
+
* 왜 이렇게 나눠 두었나: 판정(무엇을 요약할지·다시 만들 때인지)은 순수 로직(history-summary)이
|
|
513
|
+
* 갖고, 여기서는 **호출과 저장**만 한다. 그래야 규칙을 테스트로 고정할 수 있다.
|
|
514
|
+
*
|
|
515
|
+
* 아껴 만든다: 새로 밀려난 것이 임계치를 넘을 때만 LLM 을 부른다(상한을 넘긴 뒤 매 턴 부르면
|
|
516
|
+
* 대화마다 호출이 두 번씩 붙는다). 실패는 조용히 넘긴다 — 다음 턴이 "생략" 으로 알리고,
|
|
517
|
+
* 다음 임계치에서 다시 시도한다.
|
|
518
|
+
*/
|
|
519
|
+
private async _foldHistorySummary(
|
|
520
|
+
sessionId: string,
|
|
521
|
+
domainId: string,
|
|
522
|
+
client: AIClient
|
|
523
|
+
): Promise<void> {
|
|
524
|
+
try {
|
|
525
|
+
const sessionRepo = getRepository(ChatSession)
|
|
526
|
+
const session = await sessionRepo.findOneBy({ id: sessionId, domain: { id: domainId } as any })
|
|
527
|
+
if (!session) return
|
|
528
|
+
const rows = await getRepository(ChatMessage).find({
|
|
529
|
+
where: { session: { id: sessionId } as any },
|
|
530
|
+
relations: { creator: true },
|
|
531
|
+
order: { createdAt: 'ASC', id: 'ASC' }
|
|
532
|
+
})
|
|
533
|
+
const history = rows.map(row => ({
|
|
534
|
+
id: row.id,
|
|
535
|
+
role: row.role,
|
|
536
|
+
content: stripMentionRefids(row.content ?? ''),
|
|
537
|
+
senderId: (row as any).creator?.id,
|
|
538
|
+
senderName: (row as any).creator?.name || (row as any).creator?.username
|
|
539
|
+
}))
|
|
540
|
+
const { patch } = await foldHistorySummary({
|
|
541
|
+
rows: history,
|
|
542
|
+
stored: {
|
|
543
|
+
text: (session as any).lastSummary,
|
|
544
|
+
upToMessageId: (session as any).summaryUpToMessageId
|
|
545
|
+
},
|
|
546
|
+
maxTurns: LLM_HISTORY_MAX_TURNS,
|
|
547
|
+
threshold: LLM_SUMMARY_THRESHOLD,
|
|
548
|
+
summarize: content =>
|
|
549
|
+
client.chat([{ role: 'user', content }], {
|
|
550
|
+
systemPrompt: summaryInstruction(),
|
|
551
|
+
maxTokens: 700,
|
|
552
|
+
/* 요약은 창작이 아니다 — 흔들림을 줄인다. */
|
|
553
|
+
temperature: 0
|
|
554
|
+
})
|
|
555
|
+
})
|
|
556
|
+
if (patch) await sessionRepo.update(sessionId, patch as any)
|
|
557
|
+
} catch {
|
|
558
|
+
/* 요약 실패는 대화를 막지 않는다 — 다음 턴은 "생략" 으로 정직하게 알린다. */
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
455
562
|
/**
|
|
456
563
|
* C4: 메시지에 @멘션된 사용자를 세션 참여자(member)로 자동 등록 — 초대.
|
|
457
564
|
* enriched content 의 {userId:UUID} 마커에서 추출 → 기존 참여자·자기 제외한 신규만 등록.
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 요약 접기 오케스트레이션 검증 — **"배선했지만 한 번도 안 돌아본" 코드를 만들지 않기 위해.**
|
|
3
|
+
*
|
|
4
|
+
* 이 경로는 51번째 메시지부터 처음 돌기 때문에 실제 대화로 확인하려면 오래 걸린다. 그래서 요약
|
|
5
|
+
* 생성기(LLM)만 대역으로 끼우고 **판정·조립·저장 값**을 여기서 고정한다.
|
|
6
|
+
*
|
|
7
|
+
* 무엇을 지키는가:
|
|
8
|
+
* ① 밀려난 것이 없으면 부르지 않는다(비용).
|
|
9
|
+
* ② 임계치 미만이면 부르지 않는다(매 턴 호출 방지).
|
|
10
|
+
* ③ 이전 요약을 이어 넘긴다(누적 — 전체 재요약 금지).
|
|
11
|
+
* ④ 저장 값은 본문 + 어디까지 접었는지, 항상 함께.
|
|
12
|
+
* ⑤ 생성 실패·빈 응답은 삼키되 **이유를 남긴다**(조용한 무동작 금지).
|
|
13
|
+
*/
|
|
14
|
+
import { foldHistorySummary } from './fold-history'
|
|
15
|
+
|
|
16
|
+
const rows = (n: number) =>
|
|
17
|
+
Array.from({ length: n }, (_, i) => ({
|
|
18
|
+
id: `m${i}`,
|
|
19
|
+
role: i % 2 ? 'assistant' : 'user',
|
|
20
|
+
content: `말 ${i}`
|
|
21
|
+
}))
|
|
22
|
+
|
|
23
|
+
/** 호출 여부·입력을 기록하는 요약 생성기 대역. */
|
|
24
|
+
function recorder(reply: string | undefined | null = '요약 결과') {
|
|
25
|
+
const calls: string[] = []
|
|
26
|
+
return {
|
|
27
|
+
calls,
|
|
28
|
+
summarize: async (content: string) => {
|
|
29
|
+
calls.push(content)
|
|
30
|
+
return reply
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('foldHistorySummary', () => {
|
|
36
|
+
it('상한 안이면 요약기를 부르지 않는다 — 비용을 쓰지 않는다', async () => {
|
|
37
|
+
const rec = recorder()
|
|
38
|
+
const r = await foldHistorySummary({ rows: rows(10), maxTurns: 40, threshold: 10, summarize: rec.summarize })
|
|
39
|
+
expect(r.patch).toBeNull()
|
|
40
|
+
expect(r.skipped).toBe('nothing-dropped')
|
|
41
|
+
expect(rec.calls).toHaveLength(0)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('밀려났지만 임계치 미만이면 부르지 않는다 — 상한 넘긴 뒤 매 턴 호출 방지', async () => {
|
|
45
|
+
const rec = recorder()
|
|
46
|
+
/* 45개 중 40개 유지 → 5개 밀려남(임계치 10 미만) */
|
|
47
|
+
const r = await foldHistorySummary({ rows: rows(45), maxTurns: 40, threshold: 10, summarize: rec.summarize })
|
|
48
|
+
expect(r.skipped).toBe('below-threshold')
|
|
49
|
+
expect(rec.calls).toHaveLength(0)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('임계치를 넘으면 요약을 만들고 저장 값을 돌려준다', async () => {
|
|
53
|
+
const rec = recorder('· dock-1 포화 확인\n· 지게차 1대 추가로 합의')
|
|
54
|
+
const r = await foldHistorySummary({ rows: rows(52), maxTurns: 40, threshold: 10, summarize: rec.summarize })
|
|
55
|
+
expect(rec.calls).toHaveLength(1)
|
|
56
|
+
expect(r.patch).toEqual({
|
|
57
|
+
lastSummary: '· dock-1 포화 확인\n· 지게차 1대 추가로 합의',
|
|
58
|
+
/* 밀려난 것은 m0..m11 → 마지막으로 접은 것이 m11 */
|
|
59
|
+
summaryUpToMessageId: 'm11'
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('이전 요약을 이어 넘긴다 — 원본 전체를 다시 읽지 않는다(누적)', async () => {
|
|
64
|
+
const rec = recorder()
|
|
65
|
+
await foldHistorySummary({
|
|
66
|
+
rows: rows(60),
|
|
67
|
+
stored: { text: '앞에서 정한 것: 지게차 1대 추가', upToMessageId: 'm5' },
|
|
68
|
+
maxTurns: 40,
|
|
69
|
+
threshold: 10,
|
|
70
|
+
summarize: rec.summarize
|
|
71
|
+
})
|
|
72
|
+
const sent = rec.calls[0]
|
|
73
|
+
expect(sent).toContain('앞에서 정한 것: 지게차 1대 추가')
|
|
74
|
+
/* 이미 접은 m0..m5 는 다시 넘기지 않는다 */
|
|
75
|
+
expect(sent).not.toContain('말 0')
|
|
76
|
+
expect(sent).toContain('말 6')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('빈 요약은 저장하지 않는다 — 이유를 남긴다', async () => {
|
|
80
|
+
const rec = recorder(' ')
|
|
81
|
+
const r = await foldHistorySummary({ rows: rows(52), maxTurns: 40, threshold: 10, summarize: rec.summarize })
|
|
82
|
+
expect(r.patch).toBeNull()
|
|
83
|
+
expect(r.skipped).toBe('empty-summary')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('요약기가 실패해도 대화를 막지 않는다 — 이유를 남기고 넘어간다', async () => {
|
|
87
|
+
const r = await foldHistorySummary({
|
|
88
|
+
rows: rows(52),
|
|
89
|
+
maxTurns: 40,
|
|
90
|
+
threshold: 10,
|
|
91
|
+
summarize: async () => {
|
|
92
|
+
throw new Error('provider down')
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
expect(r.patch).toBeNull()
|
|
96
|
+
expect(r.skipped).toBe('failed')
|
|
97
|
+
})
|
|
98
|
+
})
|