@things-factory/ai-assistant 10.1.27 → 10.1.29
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/assistant-chat.ts +701 -63
- package/client/components/assistant-mode.test.ts +105 -55
- package/client/components/assistant-mode.ts +144 -82
- package/client/components/chat-echo-dedup.test.ts +23 -0
- package/client/components/chat-echo-dedup.ts +29 -3
- package/client/components/chat-input-builder.test.ts +20 -20
- package/client/components/chat-input-builder.ts +16 -14
- package/client/components/chat-proposal-survival.test.ts +73 -0
- package/client/components/mention-popup.ts +31 -8
- package/client/components/panel-aria.test.ts +54 -0
- package/client/components/proposal-outcome-line.ts +33 -0
- package/client/utils/assistant-session-controller.test.ts +75 -0
- package/client/utils/assistant-session-controller.ts +37 -1
- package/client/utils/assistant-session-transport.ts +13 -5
- package/dist-client/components/assistant-chat.d.ts +140 -13
- package/dist-client/components/assistant-chat.js +659 -71
- package/dist-client/components/assistant-chat.js.map +1 -1
- package/dist-client/components/assistant-mode.d.ts +102 -68
- package/dist-client/components/assistant-mode.js +79 -19
- package/dist-client/components/assistant-mode.js.map +1 -1
- package/dist-client/components/assistant-mode.test.js +90 -53
- package/dist-client/components/assistant-mode.test.js.map +1 -1
- package/dist-client/components/chat-echo-dedup.d.ts +2 -0
- package/dist-client/components/chat-echo-dedup.js +27 -4
- package/dist-client/components/chat-echo-dedup.js.map +1 -1
- package/dist-client/components/chat-echo-dedup.test.js +20 -0
- package/dist-client/components/chat-echo-dedup.test.js.map +1 -1
- package/dist-client/components/chat-input-builder.d.ts +12 -7
- package/dist-client/components/chat-input-builder.js +8 -10
- package/dist-client/components/chat-input-builder.js.map +1 -1
- package/dist-client/components/chat-input-builder.test.js +17 -18
- package/dist-client/components/chat-input-builder.test.js.map +1 -1
- package/dist-client/components/chat-proposal-survival.test.d.ts +1 -0
- package/dist-client/components/chat-proposal-survival.test.js +61 -0
- package/dist-client/components/chat-proposal-survival.test.js.map +1 -0
- package/dist-client/components/mention-popup.js +28 -8
- package/dist-client/components/mention-popup.js.map +1 -1
- package/dist-client/components/panel-aria.test.d.ts +1 -0
- package/dist-client/components/panel-aria.test.js +49 -0
- package/dist-client/components/panel-aria.test.js.map +1 -0
- package/dist-client/components/proposal-outcome-line.d.ts +9 -0
- package/dist-client/components/proposal-outcome-line.js +15 -0
- package/dist-client/components/proposal-outcome-line.js.map +1 -0
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-client/utils/assistant-session-controller.d.ts +20 -0
- package/dist-client/utils/assistant-session-controller.js +16 -0
- package/dist-client/utils/assistant-session-controller.js.map +1 -1
- package/dist-client/utils/assistant-session-controller.test.d.ts +1 -0
- package/dist-client/utils/assistant-session-controller.test.js +56 -0
- package/dist-client/utils/assistant-session-controller.test.js.map +1 -0
- package/dist-client/utils/assistant-session-transport.js +13 -5
- package/dist-client/utils/assistant-session-transport.js.map +1 -1
- package/dist-server/door-declaration-report.d.ts +14 -0
- package/dist-server/door-declaration-report.js +54 -0
- package/dist-server/door-declaration-report.js.map +1 -0
- package/dist-server/service/assistant-capability-resolver.d.ts +17 -0
- package/dist-server/service/assistant-capability-resolver.js +88 -0
- package/dist-server/service/assistant-capability-resolver.js.map +1 -0
- package/dist-server/service/assistant-chat-resolver.d.ts +6 -6
- package/dist-server/service/assistant-chat-resolver.js +185 -44
- package/dist-server/service/assistant-chat-resolver.js.map +1 -1
- package/dist-server/service/assistant-mode/assistant-mode-resolver.d.ts +33 -0
- package/dist-server/service/assistant-mode/assistant-mode-resolver.js +148 -0
- package/dist-server/service/assistant-mode/assistant-mode-resolver.js.map +1 -0
- package/dist-server/service/assistant-mode/index.d.ts +9 -0
- package/dist-server/service/assistant-mode/index.js +19 -0
- package/dist-server/service/assistant-mode/index.js.map +1 -0
- package/dist-server/service/assistant-mode/registry.d.ts +18 -0
- package/dist-server/service/assistant-mode/registry.js +136 -0
- package/dist-server/service/assistant-mode/registry.js.map +1 -0
- package/dist-server/service/assistant-mode/resolve.d.ts +43 -0
- package/dist-server/service/assistant-mode/resolve.js +65 -0
- package/dist-server/service/assistant-mode/resolve.js.map +1 -0
- package/dist-server/service/assistant-mode/types.d.ts +120 -0
- package/dist-server/service/assistant-mode/types.js +12 -0
- package/dist-server/service/assistant-mode/types.js.map +1 -0
- package/dist-server/service/chat-message/carried-turns.d.ts +48 -0
- package/dist-server/service/chat-message/carried-turns.js +92 -0
- package/dist-server/service/chat-message/carried-turns.js.map +1 -0
- package/dist-server/service/chat-message/chat-message-subscription.d.ts +1 -1
- package/dist-server/service/chat-message/chat-message-subscription.js +24 -6
- package/dist-server/service/chat-message/chat-message-subscription.js.map +1 -1
- package/dist-server/service/chat-message/chat-message.d.ts +32 -0
- package/dist-server/service/chat-message/chat-message.js +17 -0
- package/dist-server/service/chat-message/chat-message.js.map +1 -1
- package/dist-server/service/chat-message/door-access.d.ts +27 -0
- package/dist-server/service/chat-message/door-access.js +34 -0
- package/dist-server/service/chat-message/door-access.js.map +1 -0
- package/dist-server/service/chat-message/fold-session-history.d.ts +30 -0
- package/dist-server/service/chat-message/fold-session-history.js +133 -0
- package/dist-server/service/chat-message/fold-session-history.js.map +1 -0
- package/dist-server/service/chat-message/llm-history.d.ts +7 -0
- package/dist-server/service/chat-message/llm-history.js +23 -11
- package/dist-server/service/chat-message/llm-history.js.map +1 -1
- package/dist-server/service/chat-message/message-doors.d.ts +29 -0
- package/dist-server/service/chat-message/message-doors.js +107 -0
- package/dist-server/service/chat-message/message-doors.js.map +1 -0
- package/dist-server/service/chat-message/proposal-outcome.d.ts +18 -0
- package/dist-server/service/chat-message/proposal-outcome.js +31 -0
- package/dist-server/service/chat-message/proposal-outcome.js.map +1 -0
- package/dist-server/service/chat-message/reader-gate.d.ts +3 -0
- package/dist-server/service/chat-message/reader-gate.js +39 -0
- package/dist-server/service/chat-message/reader-gate.js.map +1 -0
- package/dist-server/service/chat-message/room-message.d.ts +4 -0
- package/dist-server/service/chat-message/room-message.js +25 -0
- package/dist-server/service/chat-message/room-message.js.map +1 -0
- package/dist-server/service/chat-session/chat-session.d.ts +36 -0
- package/dist-server/service/chat-session/chat-session.js +27 -2
- package/dist-server/service/chat-session/chat-session.js.map +1 -1
- package/dist-server/service/chat-session/session-anchor.d.ts +10 -1
- package/dist-server/service/chat-session/session-anchor.js +12 -4
- package/dist-server/service/chat-session/session-anchor.js.map +1 -1
- package/dist-server/service/chat-session/session-inbox.d.ts +6 -1
- package/dist-server/service/chat-session/session-inbox.js +3 -2
- package/dist-server/service/chat-session/session-inbox.js.map +1 -1
- package/dist-server/service/chat-session/session-kind.d.ts +14 -0
- package/dist-server/service/chat-session/session-kind.js +30 -0
- package/dist-server/service/chat-session/session-kind.js.map +1 -0
- package/dist-server/service/chat-session/session-visibility.d.ts +15 -0
- package/dist-server/service/chat-session/session-visibility.js +35 -0
- package/dist-server/service/chat-session/session-visibility.js.map +1 -0
- package/dist-server/service/chat-session/station-session-guard.d.ts +1 -5
- package/dist-server/service/chat-session/station-session-guard.js +7 -2
- package/dist-server/service/chat-session/station-session-guard.js.map +1 -1
- package/dist-server/service/chat-session-participant/mention-invite.d.ts +29 -0
- package/dist-server/service/chat-session-participant/mention-invite.js +103 -0
- package/dist-server/service/chat-session-participant/mention-invite.js.map +1 -0
- package/dist-server/service/chat-session-resolver.d.ts +17 -0
- package/dist-server/service/chat-session-resolver.js +271 -15
- package/dist-server/service/chat-session-resolver.js.map +1 -1
- package/dist-server/service/grounding-sources.d.ts +14 -0
- package/dist-server/service/grounding-sources.js +26 -0
- package/dist-server/service/grounding-sources.js.map +1 -0
- package/dist-server/service/index.d.ts +9 -1
- package/dist-server/service/index.js +21 -0
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/service/request-translator.d.ts +2 -0
- package/dist-server/service/request-translator.js +20 -0
- package/dist-server/service/request-translator.js.map +1 -0
- package/dist-server/service/testing/conversation-script.d.ts +123 -0
- package/dist-server/service/testing/conversation-script.js +202 -0
- package/dist-server/service/testing/conversation-script.js.map +1 -0
- package/dist-server/service/testing/mode-conformance.d.ts +50 -0
- package/dist-server/service/testing/mode-conformance.js +103 -0
- package/dist-server/service/testing/mode-conformance.js.map +1 -0
- package/dist-server/tool-registry-boot-report.d.ts +3 -0
- package/dist-server/tool-registry-boot-report.js +32 -2
- package/dist-server/tool-registry-boot-report.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -5
- package/server/door-declaration-report.test.ts +53 -0
- package/server/door-declaration-report.ts +61 -0
- package/server/explicit-graphql-types.test.ts +56 -0
- package/server/service/assistant-capability-resolver.ts +66 -0
- package/server/service/assistant-chat-resolver.ts +205 -42
- package/server/service/assistant-mode/assistant-mode-resolver.ts +108 -0
- package/server/service/assistant-mode/index.ts +16 -0
- package/server/service/assistant-mode/registry.test.ts +105 -0
- package/server/service/assistant-mode/registry.ts +147 -0
- package/server/service/assistant-mode/resolve.test.ts +120 -0
- package/server/service/assistant-mode/resolve.ts +105 -0
- package/server/service/assistant-mode/subject-defaults.test.ts +86 -0
- package/server/service/assistant-mode/types.ts +121 -0
- package/server/service/chat-message/carried-turns.test.ts +80 -0
- package/server/service/chat-message/carried-turns.ts +112 -0
- package/server/service/chat-message/chat-message-subscription.ts +20 -4
- package/server/service/chat-message/chat-message.ts +44 -0
- package/server/service/chat-message/door-access.test.ts +72 -0
- package/server/service/chat-message/door-access.ts +65 -0
- package/server/service/chat-message/fold-history-window.test.ts +184 -0
- package/server/service/chat-message/fold-session-history.test.ts +98 -0
- package/server/service/chat-message/fold-session-history.ts +115 -0
- package/server/service/chat-message/llm-history.test.ts +58 -7
- package/server/service/chat-message/llm-history.ts +30 -11
- package/server/service/chat-message/message-doors.test.ts +87 -0
- package/server/service/chat-message/message-doors.ts +117 -0
- package/server/service/chat-message/proposal-outcome.test.ts +48 -0
- package/server/service/chat-message/proposal-outcome.ts +42 -0
- package/server/service/chat-message/reader-gate.ts +44 -0
- package/server/service/chat-message/room-message.test.ts +21 -0
- package/server/service/chat-message/room-message.ts +22 -0
- package/server/service/chat-session/chat-session.ts +46 -1
- package/server/service/chat-session/session-anchor.ts +12 -3
- package/server/service/chat-session/session-inbox.test.ts +18 -0
- package/server/service/chat-session/session-inbox.ts +11 -2
- package/server/service/chat-session/session-kind.test.ts +24 -0
- package/server/service/chat-session/session-kind.ts +32 -0
- package/server/service/chat-session/session-visibility.test.ts +52 -0
- package/server/service/chat-session/session-visibility.ts +34 -0
- package/server/service/chat-session/station-session-guard.ts +10 -2
- package/server/service/chat-session-participant/mention-invite.test.ts +125 -0
- package/server/service/chat-session-participant/mention-invite.ts +121 -0
- package/server/service/chat-session-resolver.ts +276 -15
- package/server/service/grounding-sources.test.ts +55 -0
- package/server/service/grounding-sources.ts +46 -0
- package/server/service/index.ts +21 -0
- package/server/service/request-translator.test.ts +45 -0
- package/server/service/request-translator.ts +33 -0
- package/server/service/station-session-guard.test.ts +12 -5
- package/server/service/testing/conversation-scenario.test.ts +163 -0
- package/server/service/testing/conversation-script.test.ts +131 -0
- package/server/service/testing/conversation-script.ts +288 -0
- package/server/service/testing/mode-conformance.ts +122 -0
- package/server/tool-registry-boot-report.ts +31 -2
- package/test/assistant-chat-call-sites.test.ts +82 -0
- package/translations/en.json +46 -3
- package/translations/ja.json +46 -3
- package/translations/ko.json +46 -3
- package/translations/ms.json +46 -3
- package/translations/zh.json +46 -3
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* ★ 요약 접기는 **필요한 만큼만 읽는다** — 그러면서 앞부분을 잃지 않는다.
|
|
3
|
+
*
|
|
4
|
+
* ── 무엇을 겪었나 (2026-08-20) ──────────────────────────────────────────────
|
|
5
|
+
* `_foldHistorySummary` 는 **메시지를 보낼 때마다** 그 세션의 대화를 처음부터 끝까지 읽었다
|
|
6
|
+
* (`creator` 조인까지). 정작 쓰는 것은 두 조각뿐이다: 마지막 `maxTurns`개와, 요약 워터마크
|
|
7
|
+
* 뒤부터의 미반영분. 대화가 2,000개면 매 메시지마다 2,000행을 읽어 수십 행을 쓴 셈이다.
|
|
8
|
+
*
|
|
9
|
+
* 같은 종류의 결함을 트윈 쪽에서 먼저 잡았다(`timeRange` — min/max 두 수를 구하려고 저널 27만
|
|
10
|
+
* 행을 4초마다 읽었다). 사람 리뷰가 그것을 못 잡은 이유는 **코드가 맞았기 때문**이다. 여기도 같다:
|
|
11
|
+
* 답은 맞고 비용만 틀렸다. 그래서 이 시험은 답과 함께 **어디까지 읽어야 하는가**를 못 박는다.
|
|
12
|
+
*
|
|
13
|
+
* ── 지키는 것 ───────────────────────────────────────────────────────────────
|
|
14
|
+
* ① 창으로 읽어 왔으면 밀려난 것이 곧 미반영분이다 — 워터마크로 또 자르지 않는다.
|
|
15
|
+
* ② 전체를 읽어 왔으면 워터마크로 자른다 — 이미 요약에 담은 것을 두 번 담지 않는다.
|
|
16
|
+
* ③ 두 경로가 **같은 답**을 낸다. 읽는 범위를 바꾼 것이 요약 내용을 바꾸면 안 된다.
|
|
17
|
+
* ④ 워터마크 행이 사라졌으면 전부 미반영으로 본다 — 빠뜨리는 쪽보다 다시 요약하는 쪽이 안전하다.
|
|
18
|
+
*
|
|
19
|
+
* ── 왜 여기로 왔나 (2026-09-18) ────────────────────────────────────────────
|
|
20
|
+
* 요약 접기를 `board-ai` 에서 이 꾸러미로 옮기면서(`4257583d1b`) 이 시험만 board-ai 에 남았다. 읽는
|
|
21
|
+
* 파일이 사라졌으니 board-ai 의 `npm test` 가 그날부터 실패했다 — 옮긴 사람이 시험을 두고 온 것이다.
|
|
22
|
+
* 여기 오면서 `dist-server` 를 거치지 않고 소스를 바로 읽는다.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { foldHistorySummary } from './fold-history'
|
|
26
|
+
import { pendingForSummary, splitByCap } from './history-summary'
|
|
27
|
+
|
|
28
|
+
const MAX_TURNS = 5
|
|
29
|
+
const THRESHOLD = 3
|
|
30
|
+
|
|
31
|
+
/** 메시지 n개 — id 는 `m1`.. 로 두어 어디까지 담겼는지 눈으로 읽을 수 있게 한다. */
|
|
32
|
+
const rows = (n: number, from = 1) =>
|
|
33
|
+
Array.from({ length: n }, (_, i) => ({
|
|
34
|
+
id: `m${from + i}`,
|
|
35
|
+
role: (from + i) % 2 ? 'user' : 'assistant',
|
|
36
|
+
content: `말 ${from + i}`
|
|
37
|
+
}))
|
|
38
|
+
|
|
39
|
+
/** 요약기를 가로채 **무엇이 요약 대상이 됐는지** 본다. 이것이 이 시험의 핵심 관측점이다. */
|
|
40
|
+
function capturing() {
|
|
41
|
+
const seen: string[] = []
|
|
42
|
+
return {
|
|
43
|
+
seen,
|
|
44
|
+
summarize: async (content: string) => {
|
|
45
|
+
seen.push(content)
|
|
46
|
+
return '요약'
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
it('창으로 읽어 왔으면 밀려난 것이 곧 미반영분이다 — 워터마크로 또 자르지 않는다', async () => {
|
|
52
|
+
/* m1..m9 가 창(요약 뒤부터)이다. 마지막 5개는 원문 유지, 앞 4개가 새로 밀려났다. */
|
|
53
|
+
const cap = capturing()
|
|
54
|
+
const r = await foldHistorySummary({
|
|
55
|
+
rows: rows(9),
|
|
56
|
+
rowsStartAfterSummary: true,
|
|
57
|
+
stored: { text: '앞선 요약', upToMessageId: 'm0-요약에-담긴-것' },
|
|
58
|
+
maxTurns: MAX_TURNS,
|
|
59
|
+
threshold: THRESHOLD,
|
|
60
|
+
summarize: cap.summarize
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
expect(r.patch).toBeTruthy() // '4개가 밀려났고 임계치 3 이상이므로 접는다'
|
|
64
|
+
expect(r.patch!.summaryUpToMessageId).toBe('m4') // '밀려난 마지막까지 담았다고 기록한다'
|
|
65
|
+
expect(cap.seen[0]).toMatch(/말 1/) // '창의 첫 메시지가 요약에 들어간다'
|
|
66
|
+
expect(cap.seen[0]).toMatch(/말 4/)
|
|
67
|
+
expect(cap.seen[0]).not.toMatch(/말 5/) // '유지분은 원문으로 남으므로 요약에 넣지 않는다'
|
|
68
|
+
expect(cap.seen[0]).toMatch(/앞선 요약/) // '앞선 요약에 이어 붙인다 — 앞부분을 잃지 않는다'
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('전체를 읽어 왔으면 워터마크로 자른다 — 이미 담은 것을 두 번 담지 않는다', async () => {
|
|
72
|
+
/* m1..m14 전체. m5 까지 요약에 담겼다 → 미반영분은 m6..m9(마지막 5개는 유지분). */
|
|
73
|
+
const cap = capturing()
|
|
74
|
+
const r = await foldHistorySummary({
|
|
75
|
+
rows: rows(14),
|
|
76
|
+
stored: { text: '앞선 요약', upToMessageId: 'm5' },
|
|
77
|
+
maxTurns: MAX_TURNS,
|
|
78
|
+
threshold: THRESHOLD,
|
|
79
|
+
summarize: cap.summarize
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
expect(r.patch).toBeTruthy()
|
|
83
|
+
expect(r.patch!.summaryUpToMessageId).toBe('m9')
|
|
84
|
+
/* m5 는 이미 요약에 담겼다 — 두 번 담지 않는다. */
|
|
85
|
+
expect(cap.seen[0]).not.toMatch(/말 5(?!\d)/)
|
|
86
|
+
expect(cap.seen[0]).toMatch(/말 6/)
|
|
87
|
+
expect(cap.seen[0]).toMatch(/말 9/)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('읽는 범위를 바꿔도 요약 대상은 같다 — 성능을 바꾼 것이 내용을 바꾸지 않는다', async () => {
|
|
91
|
+
/*
|
|
92
|
+
* 같은 상황을 두 방식으로 읽는다. 전체는 m1..m14 이고 m5 까지 요약됨. 창은 그 뒤인 m6..m14.
|
|
93
|
+
* 둘의 요약 대상이 다르면, 이 최적화가 사실을 옮긴 것이다.
|
|
94
|
+
*/
|
|
95
|
+
const whole = capturing()
|
|
96
|
+
const windowed = capturing()
|
|
97
|
+
|
|
98
|
+
const a = await foldHistorySummary({
|
|
99
|
+
rows: rows(14),
|
|
100
|
+
stored: { text: '앞선 요약', upToMessageId: 'm5' },
|
|
101
|
+
maxTurns: MAX_TURNS,
|
|
102
|
+
threshold: THRESHOLD,
|
|
103
|
+
summarize: whole.summarize
|
|
104
|
+
})
|
|
105
|
+
const b = await foldHistorySummary({
|
|
106
|
+
rows: rows(9, 6),
|
|
107
|
+
rowsStartAfterSummary: true,
|
|
108
|
+
stored: { text: '앞선 요약', upToMessageId: 'm5' },
|
|
109
|
+
maxTurns: MAX_TURNS,
|
|
110
|
+
threshold: THRESHOLD,
|
|
111
|
+
summarize: windowed.summarize
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
expect(whole.seen[0]).toBe(windowed.seen[0]) // '요약에 들어가는 글이 글자까지 같다'
|
|
115
|
+
expect(a.patch).toEqual(b.patch) // '기록하는 워터마크도 같다'
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('워터마크 행이 사라졌으면 전부 미반영으로 본다 — 빠뜨리지 않는다', () => {
|
|
119
|
+
/* 삭제·편집으로 그 id 가 이력에 없을 수 있다. 그때 덮은 범위를 알 수 없으므로 전부 다시 본다. */
|
|
120
|
+
const { dropped } = splitByCap(rows(14), MAX_TURNS)
|
|
121
|
+
const pending = pendingForSummary(dropped, { text: '앞선 요약', upToMessageId: '지워진-id' })
|
|
122
|
+
|
|
123
|
+
expect(pending.length).toBe(dropped.length) // '전부 미반영'
|
|
124
|
+
expect(pending[0].id).toBe('m1')
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('창이 유지분보다 짧으면 접지 않는다 — 새로 밀려난 것이 없다', async () => {
|
|
128
|
+
const cap = capturing()
|
|
129
|
+
const r = await foldHistorySummary({
|
|
130
|
+
rows: rows(MAX_TURNS),
|
|
131
|
+
rowsStartAfterSummary: true,
|
|
132
|
+
stored: { text: '앞선 요약', upToMessageId: 'm0' },
|
|
133
|
+
maxTurns: MAX_TURNS,
|
|
134
|
+
threshold: THRESHOLD,
|
|
135
|
+
summarize: cap.summarize
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
expect(r.patch).toBe(null)
|
|
139
|
+
expect(r.skipped).toBe('nothing-dropped')
|
|
140
|
+
expect(cap.seen.length).toBe(0) // 'LLM 을 부르지 않는다'
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('밀려난 것이 임계치 미만이면 접지 않는다 — 매 턴 요약하지 않는다', async () => {
|
|
144
|
+
const cap = capturing()
|
|
145
|
+
const r = await foldHistorySummary({
|
|
146
|
+
rows: rows(MAX_TURNS + THRESHOLD - 1),
|
|
147
|
+
rowsStartAfterSummary: true,
|
|
148
|
+
stored: { text: '앞선 요약', upToMessageId: 'm0' },
|
|
149
|
+
maxTurns: MAX_TURNS,
|
|
150
|
+
threshold: THRESHOLD,
|
|
151
|
+
summarize: cap.summarize
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
expect(r.skipped).toBe('below-threshold')
|
|
155
|
+
expect(cap.seen.length).toBe(0)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
/*
|
|
159
|
+
* ★ 위 ① 을 **가르는** 자리.
|
|
160
|
+
*
|
|
161
|
+
* 앞의 「창으로 읽어 왔으면…」 시험은 워터마크 id 가 창에 없는 값이라(`m0-요약에-담긴-것`), 두 갈래가
|
|
162
|
+
* 같은 답을 낸다 — 워터마크가 안 보이면 전부 미반영으로 보는 규칙 ④ 때문이다. 그래서 창 갈래를 통째로
|
|
163
|
+
* 지워도 그 시험은 초록이었다(2026-09-18, 옮겨 온 뒤 결함을 심어 보고 알았다).
|
|
164
|
+
*
|
|
165
|
+
* 둘이 갈리는 것은 **워터마크 id 가 창 안에 보일 때**다. 그때 맞는 답은 창 쪽이다 — 창은 이미 요약
|
|
166
|
+
* 뒤부터이므로, 그 안에 같은 id 가 보여도 그것은 아직 요약에 안 담긴 말이다.
|
|
167
|
+
*/
|
|
168
|
+
it('★ 워터마크 id 가 창 안에 보여도 창은 워터마크로 자르지 않는다 — 여기서 두 갈래가 갈린다', async () => {
|
|
169
|
+
const cap = capturing()
|
|
170
|
+
|
|
171
|
+
const r = await foldHistorySummary({
|
|
172
|
+
rows: rows(9),
|
|
173
|
+
rowsStartAfterSummary: true,
|
|
174
|
+
stored: { text: '앞선 요약', upToMessageId: 'm2' },
|
|
175
|
+
maxTurns: MAX_TURNS,
|
|
176
|
+
threshold: THRESHOLD,
|
|
177
|
+
summarize: cap.summarize
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
expect(r.patch).toBeTruthy()
|
|
181
|
+
/* 창 갈래: m1..m4 가 전부 미반영이다. 전체 갈래였다면 m3..m4 만 담고 m1 · m2 를 잃는다. */
|
|
182
|
+
expect(cap.seen[0]).toMatch(/말 1/)
|
|
183
|
+
expect(cap.seen[0]).toMatch(/말 2/)
|
|
184
|
+
})
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* What this guards: a long conversation on the neutral door gets its earlier turns summarised.
|
|
3
|
+
*
|
|
4
|
+
* Every piece existed — the cap, the "N left out" notice, `ChatSession.lastSummary`, and the pure
|
|
5
|
+
* rules for when to summarise again. Only the board product called them, so twin, plant and figure
|
|
6
|
+
* conversations announced the omission every turn and never folded anything. A twin deliberation
|
|
7
|
+
* runs for days, and the first hour is exactly what falls off the front.
|
|
8
|
+
*/
|
|
9
|
+
import { foldSessionHistory, rowsAfterWatermark } from './fold-session-history'
|
|
10
|
+
|
|
11
|
+
const rows = (count: number, from = 1) =>
|
|
12
|
+
Array.from({ length: count }, (_, i) => ({
|
|
13
|
+
id: `m${from + i}`,
|
|
14
|
+
role: i % 2 ? 'assistant' : 'user',
|
|
15
|
+
content: `말 ${from + i}`,
|
|
16
|
+
createdAt: new Date(2026, 8, 18, 9, 0, from + i),
|
|
17
|
+
creator: { id: 'u1', name: 'alice' }
|
|
18
|
+
}))
|
|
19
|
+
|
|
20
|
+
function repos(session: any, messages: any[]) {
|
|
21
|
+
const updates: any[] = []
|
|
22
|
+
return {
|
|
23
|
+
updates,
|
|
24
|
+
repos: {
|
|
25
|
+
sessions: {
|
|
26
|
+
findOneBy: async () => session,
|
|
27
|
+
update: async (id: string, patch: any) => updates.push({ id, patch })
|
|
28
|
+
},
|
|
29
|
+
messages: {
|
|
30
|
+
findOne: async ({ where }: any) => messages.find(m => m.id === where.id),
|
|
31
|
+
find: async () => messages
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
it('★ a conversation past the cap is summarised, and the summary is stored', async () => {
|
|
38
|
+
const summarize = jest.fn().mockResolvedValue('앞부분 요약')
|
|
39
|
+
const { repos: r, updates } = repos({ id: 's1' }, rows(60))
|
|
40
|
+
|
|
41
|
+
await foldSessionHistory({
|
|
42
|
+
sessionId: 's1',
|
|
43
|
+
domainId: 'dom-1',
|
|
44
|
+
client: { chat: summarize } as any,
|
|
45
|
+
maxTurns: 40,
|
|
46
|
+
repos: r
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
expect(summarize).toHaveBeenCalled()
|
|
50
|
+
expect(updates).toHaveLength(1)
|
|
51
|
+
expect(updates[0].patch.lastSummary).toBe('앞부분 요약')
|
|
52
|
+
expect(updates[0].patch.summaryUpToMessageId).toBeDefined()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('★ a short conversation is left alone — nothing has fallen off yet', async () => {
|
|
56
|
+
const summarize = jest.fn()
|
|
57
|
+
const { repos: r, updates } = repos({ id: 's1' }, rows(5))
|
|
58
|
+
|
|
59
|
+
await foldSessionHistory({ sessionId: 's1', domainId: 'dom-1', client: { chat: summarize } as any, maxTurns: 40, repos: r })
|
|
60
|
+
|
|
61
|
+
expect(summarize).not.toHaveBeenCalled()
|
|
62
|
+
expect(updates).toEqual([])
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('★ a failed summary does not throw — the next turn says "left out" instead', async () => {
|
|
66
|
+
const summarize = jest.fn().mockRejectedValue(new Error('provider down'))
|
|
67
|
+
const { repos: r, updates } = repos({ id: 's1' }, rows(60))
|
|
68
|
+
|
|
69
|
+
await expect(
|
|
70
|
+
foldSessionHistory({ sessionId: 's1', domainId: 'dom-1', client: { chat: summarize } as any, maxTurns: 40, repos: r })
|
|
71
|
+
).resolves.toBeUndefined()
|
|
72
|
+
expect(updates).toEqual([])
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('a session that is not this tenant\'s is not touched', async () => {
|
|
76
|
+
const summarize = jest.fn()
|
|
77
|
+
const { repos: r, updates } = repos(undefined, rows(60))
|
|
78
|
+
|
|
79
|
+
await foldSessionHistory({ sessionId: 's1', domainId: 'dom-1', client: { chat: summarize } as any, maxTurns: 40, repos: r })
|
|
80
|
+
|
|
81
|
+
expect(summarize).not.toHaveBeenCalled()
|
|
82
|
+
expect(updates).toEqual([])
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
describe('rowsAfterWatermark', () => {
|
|
86
|
+
it('reads the whole conversation when nothing is summarised yet', () => {
|
|
87
|
+
expect(rowsAfterWatermark('s1', undefined)).toEqual({ session: { id: 's1' } })
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('★ continues within one timestamp by id — messages in the same millisecond are not skipped', () => {
|
|
91
|
+
const at = new Date(2026, 8, 18, 9, 0, 1)
|
|
92
|
+
|
|
93
|
+
const where = rowsAfterWatermark('s1', { id: 'm7', createdAt: at })
|
|
94
|
+
|
|
95
|
+
expect(Array.isArray(where)).toBe(true)
|
|
96
|
+
expect(where[1]).toMatchObject({ session: { id: 's1' }, createdAt: at })
|
|
97
|
+
})
|
|
98
|
+
})
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Folding a long conversation's earlier turns into a summary — for every surface, not only boards.
|
|
3
|
+
*
|
|
4
|
+
* ── The gap this closes (2026-09-18) ───────────────────────────────────────────
|
|
5
|
+
* The pieces were all here: a cap on how many turns reach the model, a notice when turns are left
|
|
6
|
+
* out, `ChatSession.lastSummary` to carry what was dropped, and pure rules for when to re-summarise
|
|
7
|
+
* (`history-summary`, `fold-history`). Only the board product ever called them. The neutral door —
|
|
8
|
+
* the one the twin, plant and figure conversations run through — never did, so those sessions kept
|
|
9
|
+
* announcing "the first N messages were left out" and never summarised them.
|
|
10
|
+
*
|
|
11
|
+
* That is worst exactly where it was designed to help: a twin deliberation runs for days, and what
|
|
12
|
+
* the room agreed on in the first hour is what falls off the front.
|
|
13
|
+
*
|
|
14
|
+
* ── What is here and what is not ───────────────────────────────────────────────
|
|
15
|
+
* The rules stay pure and tested elsewhere. This is the reading and the writing: find what the
|
|
16
|
+
* stored summary does not cover yet, ask the model for a new one, store it. It is called after the
|
|
17
|
+
* reply has gone out, and it never throws — a conversation must not fail because a summary did not
|
|
18
|
+
* get written, and the next turn says "N left out" honestly if it did not.
|
|
19
|
+
*/
|
|
20
|
+
import { MoreThan } from 'typeorm'
|
|
21
|
+
|
|
22
|
+
import type { AIClient } from '@things-factory/ai-client-base'
|
|
23
|
+
|
|
24
|
+
import { foldHistorySummary } from './fold-history.js'
|
|
25
|
+
import { summaryInstruction } from './history-summary.js'
|
|
26
|
+
|
|
27
|
+
/*
|
|
28
|
+
* 엔티티는 **늦게 가져온다.** 위에서 import 하면 이 파일을 읽는 순간 TypeGraphQL 데코레이터가 돌고,
|
|
29
|
+
* 데코레이터 메타데이터가 없는 자리(시험 러너)에서 모듈 적재가 깨진다. 규칙 자체는 데이터베이스가
|
|
30
|
+
* 없어도 검사할 수 있어야 한다.
|
|
31
|
+
*/
|
|
32
|
+
async function defaultRepos() {
|
|
33
|
+
const { getRepository } = await import('@things-factory/shell')
|
|
34
|
+
const { ChatSession } = await import('../chat-session/chat-session.js')
|
|
35
|
+
const { ChatMessage } = await import('./chat-message.js')
|
|
36
|
+
return { sessions: getRepository(ChatSession as any), messages: getRepository(ChatMessage as any) }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** How many newly-dropped turns are worth another summarisation call. */
|
|
40
|
+
export const DEFAULT_SUMMARY_THRESHOLD = 10
|
|
41
|
+
|
|
42
|
+
export interface FoldSessionHistoryInput {
|
|
43
|
+
sessionId: string
|
|
44
|
+
domainId: string
|
|
45
|
+
/** The same client that answered — a summary is written by whatever this deployment has. */
|
|
46
|
+
client: Pick<AIClient, 'chat'>
|
|
47
|
+
/** The cap the door uses when it builds the history, so both sides agree on what fell off. */
|
|
48
|
+
maxTurns: number
|
|
49
|
+
threshold?: number
|
|
50
|
+
locale?: string
|
|
51
|
+
/** Test seam: the two repositories this reads and writes. */
|
|
52
|
+
repos?: { sessions: any; messages: any }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Where the rows after the summary's watermark are.
|
|
57
|
+
*
|
|
58
|
+
* Kept separate so the window can be read without a database: the array is an OR, and the second
|
|
59
|
+
* branch continues within one timestamp by id — the same axis the ordering uses, or a burst of
|
|
60
|
+
* messages in the same millisecond would be summarised twice or not at all.
|
|
61
|
+
*/
|
|
62
|
+
export function rowsAfterWatermark(sessionId: string, mark: { id?: string; createdAt?: Date } | undefined): any {
|
|
63
|
+
const session = { id: sessionId } as any
|
|
64
|
+
if (!mark?.createdAt) return { session }
|
|
65
|
+
return [
|
|
66
|
+
{ session, createdAt: MoreThan(mark.createdAt) },
|
|
67
|
+
{ session, createdAt: mark.createdAt, id: MoreThan(mark.id) }
|
|
68
|
+
]
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function foldSessionHistory(input: FoldSessionHistoryInput): Promise<void> {
|
|
72
|
+
try {
|
|
73
|
+
const { sessions, messages } = input.repos ?? (await defaultRepos())
|
|
74
|
+
const session = await sessions.findOneBy({ id: input.sessionId, domain: { id: input.domainId } as any })
|
|
75
|
+
if (!session) return
|
|
76
|
+
|
|
77
|
+
const stored = { text: (session as any).lastSummary, upToMessageId: (session as any).summaryUpToMessageId }
|
|
78
|
+
const order = { createdAt: 'ASC', id: 'ASC' } as any
|
|
79
|
+
const relations = { creator: true } as any
|
|
80
|
+
|
|
81
|
+
/* 요약이 덮은 지점을 알면 그 뒤만 읽는다 — 매 턴 전체 대화를 읽지 않는다. */
|
|
82
|
+
let mark: { id?: string; createdAt?: Date } | undefined
|
|
83
|
+
if (stored.text && stored.upToMessageId) {
|
|
84
|
+
mark = await messages.findOne({ where: { session: { id: input.sessionId } as any, id: stored.upToMessageId } })
|
|
85
|
+
}
|
|
86
|
+
/* 워터마크 행이 사라졌으면 덮은 범위를 모른다 — 전부 다시 본다. */
|
|
87
|
+
const startsAfterSummary = Boolean(stored.text && mark?.createdAt)
|
|
88
|
+
const rows = await messages.find({ where: rowsAfterWatermark(input.sessionId, startsAfterSummary ? mark : undefined), relations, order })
|
|
89
|
+
|
|
90
|
+
const { patch } = await foldHistorySummary({
|
|
91
|
+
rows: rows.map((row: any) => ({
|
|
92
|
+
id: row.id,
|
|
93
|
+
role: row.role,
|
|
94
|
+
content: row.content ?? '',
|
|
95
|
+
senderId: row.creator?.id,
|
|
96
|
+
senderName: row.creator?.name || row.creator?.username
|
|
97
|
+
})),
|
|
98
|
+
rowsStartAfterSummary: startsAfterSummary,
|
|
99
|
+
stored,
|
|
100
|
+
maxTurns: input.maxTurns,
|
|
101
|
+
threshold: input.threshold ?? DEFAULT_SUMMARY_THRESHOLD,
|
|
102
|
+
summarize: content =>
|
|
103
|
+
input.client.chat!([{ role: 'user', content }], {
|
|
104
|
+
systemPrompt: summaryInstruction(input.locale),
|
|
105
|
+
maxTokens: 700,
|
|
106
|
+
/* 요약은 창작이 아니다 — 흔들림을 줄인다. */
|
|
107
|
+
temperature: 0
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
if (patch) await sessions.update(input.sessionId, patch as any)
|
|
112
|
+
} catch {
|
|
113
|
+
/* 요약 실패는 대화를 막지 않는다 — 다음 턴이 「N 개 생략」으로 정직하게 알린다. */
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -48,7 +48,7 @@ describe('발신자 표기', () => {
|
|
|
48
48
|
|
|
49
49
|
it('이름이 없으면 빈 대괄호를 남기지 않고 참여자로 표기한다', () => {
|
|
50
50
|
const out = buildLlmHistory([u('1', 'a', 'U1', '김철수'), u('2', 'b', 'U2', undefined)])
|
|
51
|
-
expect(out[1].content).toBe('[
|
|
51
|
+
expect(out[1].content).toBe('[participant] b')
|
|
52
52
|
})
|
|
53
53
|
|
|
54
54
|
it('assistant 발언에는 표기하지 않는다 — role 로 이미 구별된다', () => {
|
|
@@ -75,7 +75,7 @@ describe('발신자 표기', () => {
|
|
|
75
75
|
describe('system 기록', () => {
|
|
76
76
|
it('시스템 기록은 표기를 붙인 user 메시지로 전달된다 — 사장되지 않는다', () => {
|
|
77
77
|
const out = buildLlmHistory([sys('1', 'User directly edited the board: 3 op(s)')])
|
|
78
|
-
expect(out).toEqual([{ role: 'user', content: '[
|
|
78
|
+
expect(out).toEqual([{ role: 'user', content: '[system] User directly edited the board: 3 op(s)' }])
|
|
79
79
|
})
|
|
80
80
|
|
|
81
81
|
it('시스템 기록은 발신자 수 계산에 넣지 않는다 — 사람 한 명이면 표기 없음 유지', () => {
|
|
@@ -145,8 +145,9 @@ describe('이력 상한 — 협의가 길어질 때', () => {
|
|
|
145
145
|
|
|
146
146
|
it('생략은 **반드시 알린다** — 조용히 버리면 모델이 남은 첫 줄을 대화의 시작으로 오해한다', () => {
|
|
147
147
|
const out = buildLlmHistory(rows(10), { maxTurns: 4 })
|
|
148
|
-
|
|
149
|
-
expect(out[0].content).toContain('
|
|
148
|
+
/* The notice is read by the model, so it is English. */
|
|
149
|
+
expect(out[0].content).toContain('6 message(s)')
|
|
150
|
+
expect(out[0].content).toContain('left out')
|
|
150
151
|
})
|
|
151
152
|
|
|
152
153
|
it('상한에 걸리지 않으면 알림을 붙이지 않는다 — 없는 생략을 말하지 않는다', () => {
|
|
@@ -172,18 +173,18 @@ describe('이력 요약 접기 — 프롬프트 조립', () => {
|
|
|
172
173
|
summary: { text: '앞에서 정한 것: 지게차 1대 추가', upToMessageId: 'm5' }
|
|
173
174
|
})
|
|
174
175
|
expect(out[0].content).toContain('지게차 1대 추가')
|
|
175
|
-
expect(out[0].content).not.toContain('
|
|
176
|
+
expect(out[0].content).not.toContain('left out')
|
|
176
177
|
})
|
|
177
178
|
|
|
178
179
|
it('요약임을 밝힌다 — 모델이 원문처럼 인용하지 않게', () => {
|
|
179
180
|
const out = buildLlmHistory(rows(10), { maxTurns: 4, summary: { text: '요약본', upToMessageId: 'm5' } })
|
|
180
|
-
expect(out[0].content).toContain('
|
|
181
|
+
expect(out[0].content).toContain('not the original')
|
|
181
182
|
})
|
|
182
183
|
|
|
183
184
|
it('요약이 밀려난 구간을 다 덮지 못하면 남은 개수를 밝힌다 — 최신이 아님을 숨기지 않는다', () => {
|
|
184
185
|
/* 밀려난 것은 m0..m5(6개), 요약은 m3 까지 → m4·m5 두 개가 미반영. */
|
|
185
186
|
const out = buildLlmHistory(rows(10), { maxTurns: 4, summary: { text: '요약본', upToMessageId: 'm3' } })
|
|
186
|
-
expect(out[0].content).toContain('2
|
|
187
|
+
expect(out[0].content).toContain('2 message(s) after it are not in the summary yet')
|
|
187
188
|
})
|
|
188
189
|
|
|
189
190
|
it('상한에 걸리지 않으면 요약을 싣지 않는다 — 필요 없는 문맥을 넣지 않는다', () => {
|
|
@@ -192,3 +193,53 @@ describe('이력 요약 접기 — 프롬프트 조립', () => {
|
|
|
192
193
|
expect(out[0].content).toBe('말 0')
|
|
193
194
|
})
|
|
194
195
|
})
|
|
196
|
+
|
|
197
|
+
/*
|
|
198
|
+
* 누구에게 한 말인가 — ADR-0070 A15 결정 ②.
|
|
199
|
+
*
|
|
200
|
+
* 여럿이 있는 방에서는 옆 사람에게 한 말과 어시스턴트를 부른 말이 같은 모양으로 저장된다. 대상을
|
|
201
|
+
* 적지 않으면 모델은 그 둘을 가르지 못하고, 방에 한 말을 자기에게 온 지시로 읽는다.
|
|
202
|
+
*/
|
|
203
|
+
describe('대상 표기', () => {
|
|
204
|
+
const rows = [
|
|
205
|
+
{ id: 'm1', role: 'user', content: '3 라인이 멈춘 것 같아요', senderId: 'u1', senderName: 'alice', addressee: 'room' },
|
|
206
|
+
{ id: 'm2', role: 'user', content: '아까부터 그랬어', senderId: 'u2', senderName: 'bob', addressee: 'room' },
|
|
207
|
+
{ id: 'm3', role: 'user', content: '왜 멈췄는지 확인해줘', senderId: 'u1', senderName: 'alice', addressee: 'assistant' }
|
|
208
|
+
]
|
|
209
|
+
|
|
210
|
+
it('★ 사람의 말마다 발신자와 대상을 함께 적는다', () => {
|
|
211
|
+
const out = buildLlmHistory(rows as any)
|
|
212
|
+
|
|
213
|
+
expect(out.map(m => m.content)).toEqual([
|
|
214
|
+
'[alice → room] 3 라인이 멈춘 것 같아요',
|
|
215
|
+
'[bob → room] 아까부터 그랬어',
|
|
216
|
+
'[alice → assistant] 왜 멈췄는지 확인해줘'
|
|
217
|
+
])
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('★ 혼자 하는 대화에서도 적는다 — 한 모양이어야 모델이 빈 자리에서 추론하지 않는다', () => {
|
|
221
|
+
const out = buildLlmHistory([rows[2]] as any)
|
|
222
|
+
|
|
223
|
+
expect(out[0].content).toBe('[alice → assistant] 왜 멈췄는지 확인해줘')
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it('★ 대상을 모르는 옛 행은 대상을 지어내지 않는다', () => {
|
|
227
|
+
const out = buildLlmHistory([
|
|
228
|
+
{ id: 'm1', role: 'user', content: '예전 말', senderId: 'u1', senderName: 'alice' },
|
|
229
|
+
{ id: 'm2', role: 'user', content: '예전 말 둘', senderId: 'u2', senderName: 'bob' }
|
|
230
|
+
] as any)
|
|
231
|
+
|
|
232
|
+
expect(out.map(m => m.content)).toEqual(['[alice] 예전 말', '[bob] 예전 말 둘'])
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('어시스턴트의 답과 시스템 행은 대상을 적지 않는다', () => {
|
|
236
|
+
const out = buildLlmHistory([
|
|
237
|
+
{ id: 'm1', role: 'user', content: '물었다', senderId: 'u1', senderName: 'alice', addressee: 'assistant' },
|
|
238
|
+
{ id: 'm2', role: 'assistant', content: '답했다' },
|
|
239
|
+
{ id: 'm3', role: 'system', content: 'proposal k: refused (no-target)' }
|
|
240
|
+
] as any)
|
|
241
|
+
|
|
242
|
+
expect(out[1]).toEqual({ role: 'assistant', content: '답했다' })
|
|
243
|
+
expect(out[2].content).toBe('[system] proposal k: refused (no-target)')
|
|
244
|
+
})
|
|
245
|
+
})
|
|
@@ -47,6 +47,13 @@ export interface HistoryRow {
|
|
|
47
47
|
senderId?: string
|
|
48
48
|
/** 프롬프트에 노출할 표시 이름. 이메일 같은 개인정보는 넣지 않는다. */
|
|
49
49
|
senderName?: string
|
|
50
|
+
/**
|
|
51
|
+
* 누구에게 한 말인가 — `'assistant'` · `'room'`(ADR-0070 A15). 문이 저장한 값 그대로.
|
|
52
|
+
*
|
|
53
|
+
* 없는 행(이 칸이 생기기 전에 저장된 것)은 **모른다.** 모르는 것을 `room` 으로 적으면 그것이
|
|
54
|
+
* 지어낸 사실이다 — 그때는 대상 없이 발신자만 적는다.
|
|
55
|
+
*/
|
|
56
|
+
addressee?: string
|
|
50
57
|
}
|
|
51
58
|
|
|
52
59
|
export interface BuildLlmHistoryOptions {
|
|
@@ -76,12 +83,17 @@ export interface BuildLlmHistoryOptions {
|
|
|
76
83
|
summary?: StoredSummary
|
|
77
84
|
}
|
|
78
85
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
86
|
+
/*
|
|
87
|
+
* These labels and notices are read by the model, so they are English. The reply the model writes is in the
|
|
88
|
+
* language the person used; that is the model's doing, not this file's. They were Korean, which made every
|
|
89
|
+
* conversation in this house speak Korean to the model whatever the reader's language was.
|
|
90
|
+
*/
|
|
91
|
+
/** Marks a record no person wrote (a direct canvas edit, for one). */
|
|
92
|
+
const SYSTEM_LABEL = 'system'
|
|
93
|
+
/** Marks a participant whose name we do not have — better than empty brackets. */
|
|
94
|
+
const UNKNOWN_LABEL = 'participant'
|
|
95
|
+
/** Marks the notice about messages the turn limit dropped, so the model does not read the first kept line as the start. */
|
|
96
|
+
const OMITTED_LABEL = 'system'
|
|
85
97
|
|
|
86
98
|
/**
|
|
87
99
|
* 저장된 이력 → LLM 대화.
|
|
@@ -108,18 +120,16 @@ export function buildLlmHistory(rows: HistoryRow[], options: BuildLlmHistoryOpti
|
|
|
108
120
|
/* 요약이 있으면 그것을 싣는다. **요약임을 밝힌다** — 모델이 원문처럼 인용하지 않게.
|
|
109
121
|
* 요약이 밀려난 구간을 다 덮지 못하면 남은 개수도 함께 알린다(최신이 아님을 숨기지 않는다). */
|
|
110
122
|
const uncovered = pendingForSummary(dropped, options.summary).length
|
|
111
|
-
const tail = uncovered
|
|
112
|
-
? ` 그 뒤 ${uncovered}개 메시지는 요약에 아직 반영되지 않았습니다.`
|
|
113
|
-
: ''
|
|
123
|
+
const tail = uncovered ? ` ${uncovered} message(s) after it are not in the summary yet.` : ''
|
|
114
124
|
out.push({
|
|
115
125
|
role: 'user',
|
|
116
|
-
content: `[${OMITTED_LABEL}]
|
|
126
|
+
content: `[${OMITTED_LABEL}] The first ${dropped.length} message(s) of this conversation were replaced by the summary below because of the length limit. It is a summary, not the original — ask the person about anything it does not say.${tail}\n${summaryText}`
|
|
117
127
|
})
|
|
118
128
|
} else {
|
|
119
129
|
/* 요약이 아직 없으면 생략 사실만 알린다 — 개수만 밝히고 내용은 지어내지 않는다. */
|
|
120
130
|
out.push({
|
|
121
131
|
role: 'user',
|
|
122
|
-
content: `[${OMITTED_LABEL}]
|
|
132
|
+
content: `[${OMITTED_LABEL}] The first ${omitted} message(s) of this conversation were left out because of the length limit. Ask the person again if you need them.`
|
|
123
133
|
})
|
|
124
134
|
}
|
|
125
135
|
}
|
|
@@ -134,6 +144,15 @@ export function buildLlmHistory(rows: HistoryRow[], options: BuildLlmHistoryOpti
|
|
|
134
144
|
out.push({ role: 'user', content: `[${SYSTEM_LABEL}] ${content}` })
|
|
135
145
|
continue
|
|
136
146
|
}
|
|
147
|
+
/*
|
|
148
|
+
* 사람의 말은 **누구에게 한 말인지까지** 적는다(ADR-0070 A15 결정 ②). 대상이 있을 때만 적으면
|
|
149
|
+
* 모델은 빈 자리에서 추론한다 — 옆 사람이 방에 한 말을 자기에게 온 지시로 읽는 것이 그 결과다.
|
|
150
|
+
*/
|
|
151
|
+
const addressee = (row.addressee ?? '').trim()
|
|
152
|
+
if (addressee) {
|
|
153
|
+
out.push({ role: 'user', content: `[${speaker(row)} → ${addressee}] ${content}` })
|
|
154
|
+
continue
|
|
155
|
+
}
|
|
137
156
|
out.push({ role: 'user', content: label ? `[${speaker(row)}] ${content}` : content })
|
|
138
157
|
}
|
|
139
158
|
return out
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* ADR-0073 판정 1 — 답이 지난 문을 읽는 사람이 지나는가.
|
|
3
|
+
*
|
|
4
|
+
* 여기서 무는 것 넷: 문을 합치는가 · 모르는 도구가 가려지는 쪽으로 넘어지는가 · 한 요청에 한 번만
|
|
5
|
+
* 묻는가 · 가린 행에서 값 실린 칸이 함께 지워지는가.
|
|
6
|
+
*/
|
|
7
|
+
import { doorGate, doorsForAnswer, passesRow, redactByDoors } from './message-doors'
|
|
8
|
+
|
|
9
|
+
const notice = (doors: string[]) => `needs ${doors.join(' + ')}`
|
|
10
|
+
|
|
11
|
+
describe('message doors', () => {
|
|
12
|
+
it('★ 여러 도구를 부른 답은 그 문을 모두 지난 것으로 적힌다', () => {
|
|
13
|
+
const doorsOf = (name: string) =>
|
|
14
|
+
({ getPlantOverview: ['Query.jobOrders', 'Query.productionReports'], findJobOrders: ['Query.jobOrders'] } as any)[name]
|
|
15
|
+
|
|
16
|
+
expect(doorsForAnswer(['getPlantOverview', 'findJobOrders'], doorsOf)).toEqual([
|
|
17
|
+
'Query.jobOrders',
|
|
18
|
+
'Query.productionReports'
|
|
19
|
+
])
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('사람의 말과 문 없는 도구는 빈 목록이다 — 누구나 읽는다', () => {
|
|
23
|
+
expect(doorsForAnswer([], () => undefined)).toEqual([])
|
|
24
|
+
expect(doorsForAnswer(['getOpenScene'], () => [])).toEqual([])
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('★ 등록기가 모르는 도구는 아무도 못 지나는 자리로 적힌다', async () => {
|
|
28
|
+
const doors = doorsForAnswer(['whoIsThis'], () => undefined)
|
|
29
|
+
expect(doors).toEqual(['unresolved:whoIsThis'])
|
|
30
|
+
|
|
31
|
+
/* 통과 검사에 무엇을 넣어도 열리지 않는다. */
|
|
32
|
+
expect(await passesRow(doors, doorGate(async () => true))).toBe(false)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('★ 같은 문은 한 요청에 한 번만 묻는다', async () => {
|
|
36
|
+
const asked: string[] = []
|
|
37
|
+
const gate = doorGate(async door => {
|
|
38
|
+
asked.push(door)
|
|
39
|
+
return true
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
const rows = Array.from({ length: 50 }, () => ({ doors: ['Query.jobOrders', 'Query.productionReports'] }))
|
|
43
|
+
await redactByDoors(rows, gate, notice)
|
|
44
|
+
|
|
45
|
+
expect(asked).toEqual(['Query.jobOrders', 'Query.productionReports'])
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('★ 문 하나라도 못 지나면 가린다 — 전부 지나야 한다(AND)', async () => {
|
|
49
|
+
const gate = doorGate(async door => door === 'Query.jobOrders')
|
|
50
|
+
|
|
51
|
+
const [row] = await redactByDoors(
|
|
52
|
+
[{ doors: ['Query.jobOrders', 'Query.productionReports'], content: '오늘 양품 1,204' }],
|
|
53
|
+
gate,
|
|
54
|
+
notice
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
expect(row.content).toBe('needs Query.jobOrders + Query.productionReports')
|
|
58
|
+
expect((row as any).redacted).toBe(true)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('★ 가린 행에서는 값이 실린 칸도 함께 지워진다 — 본문만 지우면 그리로 샌다', async () => {
|
|
62
|
+
const [row] = await redactByDoors(
|
|
63
|
+
[
|
|
64
|
+
{
|
|
65
|
+
doors: ['Query.jobOrders'],
|
|
66
|
+
content: '오늘 양품 1,204',
|
|
67
|
+
extra: { rows: [{ good: 1204 }] },
|
|
68
|
+
proposals: [{ key: 'a', args: { jobOrder: 'JO-1' } }],
|
|
69
|
+
patchEntryId: 'patch-1'
|
|
70
|
+
}
|
|
71
|
+
],
|
|
72
|
+
doorGate(async () => false),
|
|
73
|
+
notice
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
expect(row.extra).toBeNull()
|
|
77
|
+
expect(row.proposals).toBeNull()
|
|
78
|
+
expect(row.patchEntryId).toBeNull()
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('지나는 사람에게는 행이 손대지 않은 채로 간다', async () => {
|
|
82
|
+
const rows = [{ doors: ['Query.jobOrders'], content: '오늘 양품 1,204', extra: { good: 1204 } }]
|
|
83
|
+
const [row] = await redactByDoors(rows, doorGate(async () => true), notice)
|
|
84
|
+
|
|
85
|
+
expect(row).toBe(rows[0])
|
|
86
|
+
})
|
|
87
|
+
})
|