@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.
Files changed (60) hide show
  1. package/client/components/board-ai-chat.ts +458 -19
  2. package/client/components/chat-echo-dedup.test.ts +59 -3
  3. package/client/components/chat-echo-dedup.ts +29 -0
  4. package/dist-client/client/components/board-ai-chat.d.ts +63 -0
  5. package/dist-client/client/components/board-ai-chat.js +438 -16
  6. package/dist-client/client/components/board-ai-chat.js.map +1 -1
  7. package/dist-client/client/components/chat-echo-dedup.js +28 -0
  8. package/dist-client/client/components/chat-echo-dedup.js.map +1 -1
  9. package/dist-client/client/components/chat-echo-dedup.test.js +53 -3
  10. package/dist-client/client/components/chat-echo-dedup.test.js.map +1 -1
  11. package/dist-client/server/service/agentic-loop.d.ts +15 -0
  12. package/dist-client/server/service/agentic-loop.js +70 -9
  13. package/dist-client/server/service/agentic-loop.js.map +1 -1
  14. package/dist-client/server/service/assistant.js +18 -3
  15. package/dist-client/server/service/assistant.js.map +1 -1
  16. package/dist-client/server/service/types.d.ts +23 -0
  17. package/dist-client/server/service/types.js.map +1 -1
  18. package/dist-client/tsconfig.tsbuildinfo +1 -1
  19. package/dist-server/service/agentic-loop.d.ts +15 -0
  20. package/dist-server/service/agentic-loop.js +71 -9
  21. package/dist-server/service/agentic-loop.js.map +1 -1
  22. package/dist-server/service/assistant.js +17 -2
  23. package/dist-server/service/assistant.js.map +1 -1
  24. package/dist-server/service/board-ai-resolver.d.ts +13 -0
  25. package/dist-server/service/board-ai-resolver.js +99 -1
  26. package/dist-server/service/board-ai-resolver.js.map +1 -1
  27. package/dist-server/service/chat-message/fold-history.d.ts +30 -0
  28. package/dist-server/service/chat-message/fold-history.js +29 -0
  29. package/dist-server/service/chat-message/fold-history.js.map +1 -0
  30. package/dist-server/service/chat-message/history-summary.d.ts +43 -0
  31. package/dist-server/service/chat-message/history-summary.js +77 -0
  32. package/dist-server/service/chat-message/history-summary.js.map +1 -0
  33. package/dist-server/service/chat-message/llm-history.d.ts +19 -0
  34. package/dist-server/service/chat-message/llm-history.js +31 -1
  35. package/dist-server/service/chat-message/llm-history.js.map +1 -1
  36. package/dist-server/service/chat-session/chat-session.d.ts +8 -0
  37. package/dist-server/service/chat-session/chat-session.js +5 -0
  38. package/dist-server/service/chat-session/chat-session.js.map +1 -1
  39. package/dist-server/service/types.d.ts +23 -0
  40. package/dist-server/service/types.js.map +1 -1
  41. package/dist-server/tsconfig.tsbuildinfo +1 -1
  42. package/package.json +6 -6
  43. package/server/service/agentic-loop.test.ts +91 -0
  44. package/server/service/agentic-loop.ts +80 -9
  45. package/server/service/assistant.ts +21 -3
  46. package/server/service/board-ai-resolver.ts +108 -1
  47. package/server/service/chat-message/fold-history.test.ts +98 -0
  48. package/server/service/chat-message/fold-history.ts +60 -0
  49. package/server/service/chat-message/history-summary.test.ts +127 -0
  50. package/server/service/chat-message/history-summary.ts +100 -0
  51. package/server/service/chat-message/llm-history.test.ts +65 -0
  52. package/server/service/chat-message/llm-history.ts +48 -1
  53. package/server/service/chat-session/chat-session.ts +11 -0
  54. package/server/service/dock-contract.test.ts +305 -0
  55. package/server/service/types.ts +23 -0
  56. package/translations/en.json +14 -1
  57. package/translations/ja.json +14 -1
  58. package/translations/ko.json +13 -0
  59. package/translations/ms.json +14 -1
  60. 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.2",
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.2",
34
- "@things-factory/auth-base": "^10.0.0",
35
- "@things-factory/board-import": "^10.0.2",
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.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": "a69c3592a44e27bfa8212bc06268bff6140afc9a"
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
- // (2) Error boundary — provider 예외 시 graceful 종료
176
- result = await chat(conversation, tools, {
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
- const systemPrompt = this.buildSystemPromptForTools(knownTypes, categories, schemas)
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
- return `\n\n_(처리 오류가 발생했어요: ${abortReason.message})_`
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
+ })