@things-factory/board-ai 10.0.1 → 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 +565 -21
- package/client/components/chat-echo-dedup.test.ts +59 -3
- package/client/components/chat-echo-dedup.ts +32 -0
- package/client/components/chat-input-builder.ts +6 -0
- package/dist-client/client/components/board-ai-chat.d.ts +80 -0
- package/dist-client/client/components/board-ai-chat.js +540 -18
- package/dist-client/client/components/board-ai-chat.js.map +1 -1
- package/dist-client/client/components/chat-echo-dedup.d.ts +2 -0
- package/dist-client/client/components/chat-echo-dedup.js +29 -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/client/components/chat-input-builder.d.ts +5 -0
- package/dist-client/client/components/chat-input-builder.js +1 -0
- package/dist-client/client/components/chat-input-builder.js.map +1 -1
- package/dist-client/server/service/agentic-loop.d.ts +33 -0
- package/dist-client/server/service/agentic-loop.js +80 -10
- package/dist-client/server/service/agentic-loop.js.map +1 -1
- package/dist-client/server/service/assistant.js +32 -5
- package/dist-client/server/service/assistant.js.map +1 -1
- package/dist-client/server/service/grounding.d.ts +17 -0
- package/dist-client/server/service/grounding.js +42 -0
- package/dist-client/server/service/grounding.js.map +1 -0
- package/dist-client/server/service/types.d.ts +39 -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 +33 -0
- package/dist-server/service/agentic-loop.js +81 -10
- package/dist-server/service/agentic-loop.js.map +1 -1
- package/dist-server/service/assistant.js +31 -4
- package/dist-server/service/assistant.js.map +1 -1
- package/dist-server/service/board-ai-resolver.d.ts +15 -0
- package/dist-server/service/board-ai-resolver.js +121 -2
- package/dist-server/service/board-ai-resolver.js.map +1 -1
- package/dist-server/service/chat-message/chat-message.d.ts +12 -0
- package/dist-server/service/chat-message/chat-message.js +23 -0
- package/dist-server/service/chat-message/chat-message.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/chat-session/session-inbox.d.ts +26 -0
- package/dist-server/service/chat-session/session-inbox.js +41 -0
- package/dist-server/service/chat-session/session-inbox.js.map +1 -1
- package/dist-server/service/chat-session-participant/chat-session-participant.d.ts +11 -0
- package/dist-server/service/chat-session-participant/chat-session-participant.js +17 -1
- package/dist-server/service/chat-session-participant/chat-session-participant.js.map +1 -1
- package/dist-server/service/chat-session-resolver.d.ts +44 -1
- package/dist-server/service/chat-session-resolver.js +306 -6
- package/dist-server/service/chat-session-resolver.js.map +1 -1
- package/dist-server/service/grounding.d.ts +17 -0
- package/dist-server/service/grounding.js +46 -0
- package/dist-server/service/grounding.js.map +1 -0
- package/dist-server/service/types.d.ts +39 -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 +154 -0
- package/server/service/agentic-loop.ts +108 -10
- package/server/service/assistant.ts +36 -5
- package/server/service/board-ai-resolver.ts +131 -2
- package/server/service/chat-message/chat-message.ts +26 -0
- 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/chat-session/session-inbox.test.ts +69 -1
- package/server/service/chat-session/session-inbox.ts +45 -0
- package/server/service/chat-session-participant/chat-session-participant.ts +14 -0
- package/server/service/chat-session-resolver.ts +297 -5
- package/server/service/dock-contract.test.ts +305 -0
- package/server/service/grounding.test.ts +55 -0
- package/server/service/grounding.ts +53 -0
- package/server/service/types.ts +39 -0
- package/translations/en.json +16 -1
- package/translations/ja.json +16 -1
- package/translations/ko.json +15 -0
- package/translations/ms.json +16 -1
- package/translations/zh.json +16 -1
|
@@ -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'
|
|
@@ -105,6 +124,13 @@ class BoardAIChatInput {
|
|
|
105
124
|
})
|
|
106
125
|
boardTools?: boolean
|
|
107
126
|
|
|
127
|
+
@Field({
|
|
128
|
+
nullable: true,
|
|
129
|
+
description:
|
|
130
|
+
'Force a tool call on the first turn (toolChoice: required). Turn this on for surfaces that discuss live state: the grounding guard catches invented identifiers but not an invented situation stated without any, which only happens when the model answers without querying at all. Concept / how-to questions are unaffected — they are answered through a documentation tool.'
|
|
131
|
+
})
|
|
132
|
+
requireGroundingTools?: boolean
|
|
133
|
+
|
|
108
134
|
@Field(() => [String], {
|
|
109
135
|
nullable: true,
|
|
110
136
|
description: 'Component types the LLM is allowed to emit (e.g. ["rect", "label"]). Constrains generation to the current solution.'
|
|
@@ -196,6 +222,27 @@ class BoardAIChatOutput {
|
|
|
196
222
|
'Ephemeral scene actions (selection/view/mode) — sequence of BoardActionOp. Applied via board-action-execute event on the host. Distinct from `patch` which carries persistent model changes.'
|
|
197
223
|
})
|
|
198
224
|
actions?: any
|
|
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
|
+
|
|
240
|
+
@Field(() => GraphQLJSON, {
|
|
241
|
+
nullable: true,
|
|
242
|
+
description:
|
|
243
|
+
'Ungrounded identifiers mentioned by the reply — present in the answer but absent from everything the model received (prompt, board context, history, tool results). Hallucination candidates surfaced to the user; the reply itself is not altered. Null when the answer is grounded.'
|
|
244
|
+
})
|
|
245
|
+
groundingWarnings?: any
|
|
199
246
|
}
|
|
200
247
|
|
|
201
248
|
@Resolver()
|
|
@@ -307,12 +354,21 @@ export class BoardAIChatResolver {
|
|
|
307
354
|
* 정렬은 createdAt ASC + id ASC — 같은 시각 삽입의 순서까지 결정적으로 만든다(조립 쪽에서
|
|
308
355
|
* 다시 정렬하지 않으므로 여기서 확정해야 한다). 멘션 마커는 여기서 제거해 넘긴다. */
|
|
309
356
|
let llmMessages: LLMMessage[]
|
|
357
|
+
let historyFolded: { omitted: number; summarized: boolean } | undefined
|
|
310
358
|
if (session) {
|
|
311
359
|
const rows = await messageRepo.find({
|
|
312
360
|
where: { session: { id: session.id } as any },
|
|
313
361
|
relations: { creator: true },
|
|
314
362
|
order: { createdAt: 'ASC', id: 'ASC' }
|
|
315
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
|
+
}
|
|
316
372
|
llmMessages = buildLlmHistory(
|
|
317
373
|
rows.map(row => ({
|
|
318
374
|
id: row.id,
|
|
@@ -325,7 +381,11 @@ export class BoardAIChatResolver {
|
|
|
325
381
|
{
|
|
326
382
|
truncateAfterMessageId: input.truncateAfterMessageId ?? undefined,
|
|
327
383
|
/* 방금 저장한 사용자 메시지는 접은 구간보다 뒤에 있어도 반드시 남긴다. */
|
|
328
|
-
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 }
|
|
329
389
|
}
|
|
330
390
|
)
|
|
331
391
|
} else {
|
|
@@ -341,6 +401,7 @@ export class BoardAIChatResolver {
|
|
|
341
401
|
/* 대화면별 도구 노출 — 미지정이면 현행(전부 허용) 그대로. */
|
|
342
402
|
toolCategories: input.toolCategories ?? undefined,
|
|
343
403
|
boardTools: input.boardTools ?? undefined,
|
|
404
|
+
requireGroundingTools: input.requireGroundingTools ?? undefined,
|
|
344
405
|
knownTypes: input.knownTypes,
|
|
345
406
|
categories: input.categories as ComponentCategory[] | undefined,
|
|
346
407
|
selectedRefids: input.selectedRefids,
|
|
@@ -385,6 +446,12 @@ export class BoardAIChatResolver {
|
|
|
385
446
|
content: enrichedReply,
|
|
386
447
|
relatedPatchId: patchEntry?.id,
|
|
387
448
|
toolUsages: r.toolUsages && r.toolUsages.length > 0 ? JSON.stringify(r.toolUsages) : undefined,
|
|
449
|
+
/* 접지 경고를 함께 영속 — 나중에 읽는 참여자도 같은 주의를 봐야 한다(재계산 불가: 근거 원문이 남지 않는다).
|
|
450
|
+
* 컬럼 길이(1024) 안에 들도록 앞 20개까지만 — 그 이상이면 목록이 아니라 답 자체가 문제다. */
|
|
451
|
+
groundingWarnings:
|
|
452
|
+
r.groundingWarnings && r.groundingWarnings.length > 0
|
|
453
|
+
? JSON.stringify(r.groundingWarnings.slice(0, 20))
|
|
454
|
+
: undefined,
|
|
388
455
|
// assistant 메시지의 creator = 이 응답을 트리거한 user (= 직전 user 메시지의 작성자).
|
|
389
456
|
// 같은 turn 으로 묶임 → 향후 사용자별 필터에서 user/assistant 가 함께 따라옴.
|
|
390
457
|
creator: user,
|
|
@@ -414,6 +481,12 @@ export class BoardAIChatResolver {
|
|
|
414
481
|
/* AI 응답도 세션 활동이다 — 이걸 빼면 다른 참여자에게 "새 소식 없음"으로 보인다. */
|
|
415
482
|
...activityPatch(enrichedReply)
|
|
416
483
|
})
|
|
484
|
+
|
|
485
|
+
/* ── Phase D: 이력 요약 접기 ────────────────────────────────────────────
|
|
486
|
+
* 응답을 **돌려준 뒤** 갱신한다 — 사용자 대기 시간에 요약용 LLM 호출을 얹지 않는다.
|
|
487
|
+
* 기다리지 않으므로(await 없음) 실패해도 대화에는 영향이 없다: 요약이 없으면 다음 턴은
|
|
488
|
+
* "N개 생략" 으로 정직하게 알리고 넘어간다(조용한 맥락 상실이 아니다). */
|
|
489
|
+
void this._foldHistorySummary(session.id!, domain.id, base)
|
|
417
490
|
}
|
|
418
491
|
|
|
419
492
|
return {
|
|
@@ -426,7 +499,63 @@ export class BoardAIChatResolver {
|
|
|
426
499
|
assistantMessageId,
|
|
427
500
|
patchId,
|
|
428
501
|
toolUsages: r.toolUsages ?? null,
|
|
429
|
-
actions: r.actions && r.actions.length > 0 ? r.actions : null
|
|
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,
|
|
505
|
+
groundingWarnings: r.groundingWarnings && r.groundingWarnings.length > 0 ? r.groundingWarnings : null
|
|
506
|
+
}
|
|
507
|
+
}
|
|
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
|
+
/* 요약 실패는 대화를 막지 않는다 — 다음 턴은 "생략" 으로 정직하게 알린다. */
|
|
430
559
|
}
|
|
431
560
|
}
|
|
432
561
|
|
|
@@ -107,6 +107,32 @@ export class ChatMessage {
|
|
|
107
107
|
}
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
/**
|
|
111
|
+
* 접지 경고 — 이 답이 언급했으나 근거(프롬프트·보드 문맥·이력·도구 결과)에 없던 식별자 목록.
|
|
112
|
+
*
|
|
113
|
+
* **왜 영속하는가**: 도크의 협의는 여러 사람이 며칠에 걸쳐 읽는다. 경고가 그 자리에서만
|
|
114
|
+
* 보이고 새로 고치면 사라지면, 나중에 읽는 사람은 검증되지 않은 문장을 검증된 것으로 읽는다.
|
|
115
|
+
* 다시 계산할 수도 없다 — 근거였던 도구 결과 원문은 남지 않고 압축본만 남기 때문이다.
|
|
116
|
+
*
|
|
117
|
+
* 형태: string[] 를 JSON-stringify. 비었으면 null(= 접지 정상).
|
|
118
|
+
*/
|
|
119
|
+
@Column({ type: 'varchar', length: 1024, nullable: true })
|
|
120
|
+
groundingWarnings?: string
|
|
121
|
+
|
|
122
|
+
/** GraphQL 노출용 — 식별자 배열로 parse. */
|
|
123
|
+
@Field(() => GraphQLJSON, {
|
|
124
|
+
nullable: true,
|
|
125
|
+
description: 'Ungrounded identifiers mentioned by this reply (hallucination candidates). Null when grounded.'
|
|
126
|
+
})
|
|
127
|
+
get groundingWarningsJson(): any {
|
|
128
|
+
if (!this.groundingWarnings) return null
|
|
129
|
+
try {
|
|
130
|
+
return JSON.parse(this.groundingWarnings)
|
|
131
|
+
} catch {
|
|
132
|
+
return null
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
110
136
|
/**
|
|
111
137
|
* 메시지를 일으킨 사용자.
|
|
112
138
|
* - role='user' → 보낸 사용자
|
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 요약 접기 오케스트레이션 — **판정과 조립은 여기, DB 는 호출부**.
|
|
3
|
+
*
|
|
4
|
+
* 왜 분리하는가: 요약을 만드는 결정(무엇을·언제·무엇에 이어)은 규칙이고, 규칙은 테스트로 고정할 수
|
|
5
|
+
* 있어야 한다. 리졸버 안에 두면 DB 없이 검증할 수 없어 "배선했지만 한 번도 안 돌아본" 코드가 된다.
|
|
6
|
+
* 요약 생성기(LLM)와 이력·저장은 호출부가 주입한다.
|
|
7
|
+
*/
|
|
8
|
+
import type { HistoryRow } from './llm-history.js'
|
|
9
|
+
import {
|
|
10
|
+
splitByCap,
|
|
11
|
+
pendingForSummary,
|
|
12
|
+
shouldResummarize,
|
|
13
|
+
summaryUserContent,
|
|
14
|
+
summaryPatch,
|
|
15
|
+
type StoredSummary
|
|
16
|
+
} from './history-summary.js'
|
|
17
|
+
|
|
18
|
+
export interface FoldHistoryInput {
|
|
19
|
+
/** 시간 오름차순 이력 전체. */
|
|
20
|
+
rows: HistoryRow[]
|
|
21
|
+
/** 세션에 저장된 요약 상태. */
|
|
22
|
+
stored?: StoredSummary
|
|
23
|
+
/** 프롬프트 상한(이 밖으로 밀려난 것이 요약 대상). */
|
|
24
|
+
maxTurns: number
|
|
25
|
+
/** 새로 밀려난 것이 이 수 이상일 때만 요약을 만든다(매 턴 호출 방지). */
|
|
26
|
+
threshold: number
|
|
27
|
+
/** 요약 생성 — 실패하면 throw 하거나 빈 문자열을 돌려주면 된다(둘 다 "접지 않음" 으로 처리). */
|
|
28
|
+
summarize: (content: string) => Promise<string | undefined | null>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface FoldHistoryResult {
|
|
32
|
+
/** 세션에 저장할 값. null 이면 이번엔 접지 않는다(대상 없음·임계치 미만·생성 실패). */
|
|
33
|
+
patch: { lastSummary: string; summaryUpToMessageId?: string } | null
|
|
34
|
+
/** 접지 않은 이유 — 관측·테스트용(조용한 무동작을 남기지 않는다). */
|
|
35
|
+
skipped?: 'nothing-dropped' | 'below-threshold' | 'empty-summary' | 'failed'
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 접을지 판정하고, 접어야 하면 요약을 만들어 저장 값을 돌려준다.
|
|
40
|
+
*
|
|
41
|
+
* 실패를 삼키되 **왜 안 접었는지**는 남긴다 — 조용한 무동작은 나중에 "요약이 왜 안 생기지" 를
|
|
42
|
+
* 추적 불가능하게 만든다.
|
|
43
|
+
*/
|
|
44
|
+
export async function foldHistorySummary(input: FoldHistoryInput): Promise<FoldHistoryResult> {
|
|
45
|
+
const { dropped } = splitByCap(input.rows, input.maxTurns)
|
|
46
|
+
if (dropped.length === 0) return { patch: null, skipped: 'nothing-dropped' }
|
|
47
|
+
|
|
48
|
+
const pending = pendingForSummary(dropped, input.stored)
|
|
49
|
+
if (!shouldResummarize(pending, input.threshold)) return { patch: null, skipped: 'below-threshold' }
|
|
50
|
+
|
|
51
|
+
let text: string | undefined | null
|
|
52
|
+
try {
|
|
53
|
+
text = await input.summarize(summaryUserContent(input.stored?.text, pending))
|
|
54
|
+
} catch {
|
|
55
|
+
return { patch: null, skipped: 'failed' }
|
|
56
|
+
}
|
|
57
|
+
if (!text || !text.trim()) return { patch: null, skipped: 'empty-summary' }
|
|
58
|
+
|
|
59
|
+
return { patch: summaryPatch(text, pending) }
|
|
60
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 이력 요약 접기 규칙 검증.
|
|
3
|
+
*
|
|
4
|
+
* 무엇을 지키는가:
|
|
5
|
+
* ① **누적** — 이전 요약 뒤부터만 새로 접는다(전체 재요약=비용, 중복 접기=왜곡).
|
|
6
|
+
* ② **아껴 만든다** — 새로 밀려난 것이 임계치를 넘을 때만. 상한 넘긴 뒤 매 턴 부르면 호출이 두 배.
|
|
7
|
+
* ③ **덮은 범위를 함께 저장** — 본문과 upToMessageId 는 항상 같이 간다(따로 저장하면 어긋난다).
|
|
8
|
+
* ④ **모르면 다시 요약** — 기록된 id 를 이력에서 못 찾으면 전부 미반영으로 본다(맥락 상실 방지).
|
|
9
|
+
* ⑤ **지시는 사실 위주** — 결정·미결·식별자 보존을 요구하고 창작을 금지한다.
|
|
10
|
+
*/
|
|
11
|
+
import {
|
|
12
|
+
splitByCap,
|
|
13
|
+
pendingForSummary,
|
|
14
|
+
shouldResummarize,
|
|
15
|
+
summarySourceText,
|
|
16
|
+
summaryUserContent,
|
|
17
|
+
summaryInstruction,
|
|
18
|
+
summaryPatch
|
|
19
|
+
} from './history-summary'
|
|
20
|
+
|
|
21
|
+
const rows = (n: number, from = 0) =>
|
|
22
|
+
Array.from({ length: n }, (_, i) => ({
|
|
23
|
+
id: `m${from + i}`,
|
|
24
|
+
role: (from + i) % 2 ? 'assistant' : 'user',
|
|
25
|
+
content: `말 ${from + i}`
|
|
26
|
+
}))
|
|
27
|
+
|
|
28
|
+
describe('splitByCap', () => {
|
|
29
|
+
it('상한 이하면 아무것도 밀려나지 않는다', () => {
|
|
30
|
+
const r = splitByCap(rows(5), 10)
|
|
31
|
+
expect(r.dropped).toEqual([])
|
|
32
|
+
expect(r.kept).toHaveLength(5)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('상한을 넘으면 앞부분이 밀려나고 최근이 남는다', () => {
|
|
36
|
+
const r = splitByCap(rows(12), 4)
|
|
37
|
+
expect(r.dropped).toHaveLength(8)
|
|
38
|
+
expect(r.kept.map(x => x.id)).toEqual(['m8', 'm9', 'm10', 'm11'])
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
describe('pendingForSummary — 누적', () => {
|
|
43
|
+
const dropped = rows(8)
|
|
44
|
+
|
|
45
|
+
it('요약이 없으면 밀려난 전부가 대상', () => {
|
|
46
|
+
expect(pendingForSummary(dropped, undefined)).toHaveLength(8)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('요약이 덮은 지점 뒤부터만 대상 — 이미 접은 것을 또 접지 않는다', () => {
|
|
50
|
+
const p = pendingForSummary(dropped, { text: '앞부분 요약', upToMessageId: 'm4' })
|
|
51
|
+
expect(p.map(x => x.id)).toEqual(['m5', 'm6', 'm7'])
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('기록된 id 를 못 찾으면 전부 다시 — 빠뜨리는 쪽보다 안전하다', () => {
|
|
55
|
+
const p = pendingForSummary(dropped, { text: '요약', upToMessageId: 'deleted' })
|
|
56
|
+
expect(p).toHaveLength(8)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('본문 없는 기록은 요약이 없는 것으로 본다', () => {
|
|
60
|
+
expect(pendingForSummary(dropped, { text: '', upToMessageId: 'm4' })).toHaveLength(8)
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe('shouldResummarize — 아껴 만들기', () => {
|
|
65
|
+
it('임계치를 넘으면 만든다', () => {
|
|
66
|
+
expect(shouldResummarize(rows(10), 10)).toBe(true)
|
|
67
|
+
})
|
|
68
|
+
it('임계치 미만이면 만들지 않는다 — 매 턴 호출 방지', () => {
|
|
69
|
+
expect(shouldResummarize(rows(9), 10)).toBe(false)
|
|
70
|
+
})
|
|
71
|
+
it('밀려난 것이 없으면 만들지 않는다', () => {
|
|
72
|
+
expect(shouldResummarize([], 10)).toBe(false)
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
describe('요약 입력', () => {
|
|
77
|
+
it('발신자 표기를 유지한다 — 협의에서 누가 말했는지가 핵심 정보', () => {
|
|
78
|
+
const text = summarySourceText([
|
|
79
|
+
{ id: 'a', role: 'user', content: '도크 포화 확인 필요', senderName: '김철수' },
|
|
80
|
+
{ id: 'b', role: 'assistant', content: 'dock-1 점유 92%' },
|
|
81
|
+
{ id: 'c', role: 'system', content: '실행됨: o7 보류' }
|
|
82
|
+
])
|
|
83
|
+
expect(text).toContain('김철수: 도크 포화 확인 필요')
|
|
84
|
+
expect(text).toContain('AI: dock-1 점유 92%')
|
|
85
|
+
expect(text).toContain('SYSTEM: 실행됨: o7 보류')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('이전 요약을 앞에 얹어 누적 입력을 만든다', () => {
|
|
89
|
+
const content = summaryUserContent('앞에서 정한 것: 지게차 1대 추가', rows(2))
|
|
90
|
+
expect(content).toContain('SUMMARY SO FAR')
|
|
91
|
+
expect(content).toContain('지게차 1대 추가')
|
|
92
|
+
expect(content).toContain('NEW MESSAGES TO FOLD IN')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('이전 요약이 없으면 새 대화만 넣는다', () => {
|
|
96
|
+
expect(summaryUserContent(null, rows(2))).not.toContain('SUMMARY SO FAR')
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
describe('요약 지시', () => {
|
|
101
|
+
const instruction = summaryInstruction()
|
|
102
|
+
|
|
103
|
+
it('결정·미결을 남기라고 요구한다 — 그게 나중까지 필요한 것이다', () => {
|
|
104
|
+
expect(instruction).toMatch(/decided/i)
|
|
105
|
+
expect(instruction).toMatch(/still open/i)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('식별자를 그대로 보존하라고 요구한다 — 이름이 바뀌면 이후 접지가 깨진다', () => {
|
|
109
|
+
expect(instruction).toMatch(/identifiers/i)
|
|
110
|
+
expect(instruction).toMatch(/EXACTLY/)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('없는 사실을 더하지 말라고 못박는다', () => {
|
|
114
|
+
expect(instruction).toMatch(/Never add facts/i)
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
describe('summaryPatch — 본문과 범위는 함께', () => {
|
|
119
|
+
it('마지막으로 접은 메시지 id 를 함께 저장한다', () => {
|
|
120
|
+
const patch = summaryPatch('요약 본문', rows(3))
|
|
121
|
+
expect(patch).toEqual({ lastSummary: '요약 본문', summaryUpToMessageId: 'm2' })
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('본문은 상한(4000)으로 자른다 — 요약이 프롬프트를 다시 부풀리지 않게', () => {
|
|
125
|
+
expect(summaryPatch('가'.repeat(5000), rows(1)).lastSummary).toHaveLength(4000)
|
|
126
|
+
})
|
|
127
|
+
})
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 이력 요약 접기 — 긴 협의의 앞부분을 **요약으로 대체**한다.
|
|
3
|
+
*
|
|
4
|
+
* ── 왜 필요한가 ─────────────────────────────────────────────────────────────
|
|
5
|
+
* 모델은 기억이 없어 매 턴 이력을 다시 받는다. 그래서 상한(maxTurns)을 두었지만, 상한만으로는
|
|
6
|
+
* 넘친 앞부분이 **그냥 사라진다** — 며칠 이어지는 협의에서 앞에서 합의한 내용이 없던 일이 된다.
|
|
7
|
+
* 요약을 대신 실어 보내면 토큰은 상한 안에 머물면서 맥락은 남는다.
|
|
8
|
+
*
|
|
9
|
+
* ── 규율 ────────────────────────────────────────────────────────────────────
|
|
10
|
+
* ① **어디까지 요약했는지 기록한다**(upToMessageId). 그것이 없으면 매 턴 전체를 다시 요약하거나
|
|
11
|
+
* (비용) 이미 요약한 것을 또 이어 붙인다(중복).
|
|
12
|
+
* ② **누적 요약**이다: 이전 요약 + 새로 밀려난 메시지 → 새 요약. 원본 전체를 다시 읽지 않는다.
|
|
13
|
+
* ③ **아껴 만든다**: 새로 밀려난 것이 임계치를 넘을 때만 다시 만든다. 상한을 넘긴 뒤 매 턴
|
|
14
|
+
* 요약을 만들면 대화마다 LLM 호출이 두 번씩 붙는다.
|
|
15
|
+
* ④ **요약은 요약이라고 밝힌다**: 프롬프트에 "요약(원문 아님)" 으로 표기해 모델이 그것을 인용
|
|
16
|
+
* 가능한 원문처럼 다루지 않게 한다. 요약에 없는 사실은 사용자에게 다시 확인해야 한다.
|
|
17
|
+
*
|
|
18
|
+
* 이 파일은 순수 로직이다 — LLM 호출·DB 는 리졸버가 한다(테스트 가능성 유지).
|
|
19
|
+
*/
|
|
20
|
+
import type { HistoryRow } from './llm-history.js'
|
|
21
|
+
|
|
22
|
+
/** 세션에 저장된 요약 상태. */
|
|
23
|
+
export interface StoredSummary {
|
|
24
|
+
/** 요약 본문(사람이 읽어도 되는 몇 문장). 없으면 아직 요약이 없다. */
|
|
25
|
+
text?: string | null
|
|
26
|
+
/** 이 메시지까지 요약에 반영됐다. 이후 메시지는 아직 요약 밖. */
|
|
27
|
+
upToMessageId?: string | null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 상한 기준으로 이력을 둘로 나눈다 — 밀려나는 앞부분과 원문으로 남길 최근 부분. */
|
|
31
|
+
export function splitByCap(rows: HistoryRow[], maxTurns: number): { dropped: HistoryRow[]; kept: HistoryRow[] } {
|
|
32
|
+
if (!maxTurns || maxTurns <= 0 || rows.length <= maxTurns) return { dropped: [], kept: rows }
|
|
33
|
+
return { dropped: rows.slice(0, rows.length - maxTurns), kept: rows.slice(-maxTurns) }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 아직 요약에 반영되지 않은, 밀려난 메시지들.
|
|
38
|
+
*
|
|
39
|
+
* 저장된 요약이 어디까지 덮는지(upToMessageId)를 기준으로 그 **뒤부터** 센다. 기록된 id 가 이력에
|
|
40
|
+
* 없으면(삭제·편집) 덮은 범위를 알 수 없으므로 **전부 미반영으로 본다** — 빠뜨리는 쪽보다 다시
|
|
41
|
+
* 요약하는 쪽이 안전하다(맥락 상실은 답을 망친다).
|
|
42
|
+
*/
|
|
43
|
+
export function pendingForSummary(dropped: HistoryRow[], stored: StoredSummary | undefined): HistoryRow[] {
|
|
44
|
+
const upTo = stored?.upToMessageId
|
|
45
|
+
if (!upTo || !stored?.text) return dropped
|
|
46
|
+
const idx = dropped.findIndex(r => r.id === upTo)
|
|
47
|
+
return idx < 0 ? dropped : dropped.slice(idx + 1)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 요약을 다시 만들 때인가 — 새로 밀려난 것이 임계치 이상일 때만.
|
|
52
|
+
* (상한을 넘긴 뒤 매 턴 만들면 대화마다 LLM 호출이 두 번씩 붙는다.)
|
|
53
|
+
*/
|
|
54
|
+
export function shouldResummarize(pending: HistoryRow[], threshold = 10): boolean {
|
|
55
|
+
return pending.length >= Math.max(1, threshold)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 요약 대상 텍스트 — 발신자 표기를 유지해 누가 무엇을 말했는지 남긴다(협의의 핵심 정보). */
|
|
59
|
+
export function summarySourceText(pending: HistoryRow[]): string {
|
|
60
|
+
return pending
|
|
61
|
+
.map(r => {
|
|
62
|
+
const who = r.role === 'assistant' ? 'AI' : r.role === 'system' ? 'SYSTEM' : r.senderName || 'USER'
|
|
63
|
+
return `${who}: ${(r.content ?? '').trim()}`
|
|
64
|
+
})
|
|
65
|
+
.filter(line => line.length > 6)
|
|
66
|
+
.join('\n')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 요약 생성 지시 — **사실만, 짧게, 지어내지 말 것.**
|
|
71
|
+
*
|
|
72
|
+
* 협의에서 나중까지 필요한 것은 "무엇을 확인했고, 무엇을 정했고, 무엇이 남았는지" 다. 잡담·인사는
|
|
73
|
+
* 버려도 되지만 **결정과 미결**은 남아야 한다. 식별자는 원문 그대로 유지하라고 지시한다 — 요약이
|
|
74
|
+
* 이름을 바꾸면 이후 대화의 접지가 깨진다.
|
|
75
|
+
*/
|
|
76
|
+
export function summaryInstruction(locale?: string): string {
|
|
77
|
+
return [
|
|
78
|
+
'Summarize the earlier part of a shared operations deliberation so it can be carried into later turns.',
|
|
79
|
+
'Keep ONLY what stays relevant: what was checked and found, what was decided, what is still open, and any action that was executed.',
|
|
80
|
+
'Drop greetings, small talk, and repetition.',
|
|
81
|
+
'Preserve identifiers (node / order / instance ids) EXACTLY as written — renaming them breaks grounding in later turns.',
|
|
82
|
+
'Never add facts that are not in the text. If something is unclear, leave it out.',
|
|
83
|
+
'At most 8 short bullet points. No preamble, no closing remark.',
|
|
84
|
+
locale ? `Write the summary in the language of locale "${locale}".` : ''
|
|
85
|
+
]
|
|
86
|
+
.filter(Boolean)
|
|
87
|
+
.join(' ')
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** 이전 요약과 새로 밀려난 대화를 합쳐 넘길 본문 — 누적 요약의 입력. */
|
|
91
|
+
export function summaryUserContent(previous: string | null | undefined, pending: HistoryRow[]): string {
|
|
92
|
+
const head = previous?.trim() ? `SUMMARY SO FAR:\n${previous.trim()}\n\n` : ''
|
|
93
|
+
return `${head}NEW MESSAGES TO FOLD IN:\n${summarySourceText(pending)}`
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 요약 저장 값 — 본문과 "어디까지 덮었는지". 둘은 항상 함께 저장한다(따로 저장하면 어긋난다). */
|
|
97
|
+
export function summaryPatch(text: string, pending: HistoryRow[]): { lastSummary: string; summaryUpToMessageId?: string } {
|
|
98
|
+
const last = [...pending].reverse().find(r => r.id)?.id
|
|
99
|
+
return { lastSummary: text.trim().slice(0, 4000), summaryUpToMessageId: last }
|
|
100
|
+
}
|