@things-factory/ai-assistant 10.1.26 → 10.1.28
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 +653 -59
- package/client/components/assistant-mode.test.ts +86 -55
- package/client/components/assistant-mode.ts +124 -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 +34 -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 +23 -0
- package/client/utils/assistant-session-transport.ts +11 -3
- package/dist-client/components/assistant-chat.d.ts +128 -13
- package/dist-client/components/assistant-chat.js +613 -67
- package/dist-client/components/assistant-chat.js.map +1 -1
- package/dist-client/components/assistant-mode.d.ts +80 -68
- package/dist-client/components/assistant-mode.js +63 -19
- package/dist-client/components/assistant-mode.js.map +1 -1
- package/dist-client/components/assistant-mode.test.js +74 -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 +31 -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 +10 -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 +11 -3
- package/dist-client/utils/assistant-session-transport.js.map +1 -1
- 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 +166 -44
- package/dist-server/service/assistant-chat-resolver.js.map +1 -1
- package/dist-server/service/assistant-mode/assistant-mode-resolver.d.ts +25 -0
- package/dist-server/service/assistant-mode/assistant-mode-resolver.js +140 -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 +33 -0
- package/dist-server/service/assistant-mode/resolve.js +60 -0
- package/dist-server/service/assistant-mode/resolve.js.map +1 -0
- package/dist-server/service/assistant-mode/types.d.ts +99 -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 +80 -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 +15 -0
- package/dist-server/service/chat-message/door-access.js +26 -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 +37 -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 +24 -0
- package/dist-server/service/chat-session/chat-session.js +26 -2
- package/dist-server/service/chat-session/chat-session.js.map +1 -1
- package/dist-server/service/chat-session/session-inbox.d.ts +4 -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-visibility.d.ts +15 -0
- package/dist-server/service/chat-session/session-visibility.js +28 -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 +15 -0
- package/dist-server/service/chat-session-resolver.js +267 -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 +8 -1
- package/dist-server/service/index.js +19 -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 +40 -0
- package/dist-server/service/testing/mode-conformance.js +90 -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 +25 -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/explicit-graphql-types.test.ts +56 -0
- package/server/service/assistant-capability-resolver.ts +66 -0
- package/server/service/assistant-chat-resolver.ts +184 -42
- package/server/service/assistant-mode/assistant-mode-resolver.ts +102 -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 +78 -0
- package/server/service/assistant-mode/resolve.ts +96 -0
- package/server/service/assistant-mode/subject-defaults.test.ts +86 -0
- package/server/service/assistant-mode/types.ts +103 -0
- package/server/service/chat-message/carried-turns.test.ts +69 -0
- package/server/service/chat-message/carried-turns.ts +99 -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.ts +45 -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 +42 -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 +31 -1
- package/server/service/chat-session/session-inbox.test.ts +18 -0
- package/server/service/chat-session/session-inbox.ts +9 -2
- package/server/service/chat-session/session-visibility.test.ts +36 -0
- package/server/service/chat-session/session-visibility.ts +26 -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 +272 -15
- package/server/service/grounding-sources.test.ts +55 -0
- package/server/service/grounding-sources.ts +46 -0
- package/server/service/index.ts +19 -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 +107 -0
- package/server/tool-registry-boot-report.ts +25 -2
- package/test/assistant-chat-call-sites.test.ts +82 -0
- package/translations/en.json +44 -2
- package/translations/ja.json +44 -2
- package/translations/ko.json +44 -2
- package/translations/ms.json +44 -2
- package/translations/zh.json +44 -2
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* What this guards: a client cannot make the assistant say something (ADR-0070 A15 decision ③).
|
|
3
|
+
*
|
|
4
|
+
* `appendSessionTurns` used to store whatever role it was handed. A carried conversation could
|
|
5
|
+
* therefore put words in the assistant's mouth — every participant reads them as the assistant's,
|
|
6
|
+
* and the model reads them back as its own earlier turn and builds on them.
|
|
7
|
+
*/
|
|
8
|
+
import { CARRIED_TURN_LIMIT, carriedOriginalWhere, planCarriedTurns, quoteCarriedAnswer } from './carried-turns'
|
|
9
|
+
|
|
10
|
+
it("★ a person's carried words are theirs, and they were said to the room", () => {
|
|
11
|
+
const rows = planCarriedTurns([{ role: 'user', content: '3 라인이 멈춘 것 같아요' }])
|
|
12
|
+
|
|
13
|
+
expect(rows).toEqual([{ role: 'user', content: '3 라인이 멈춘 것 같아요', addressee: 'room' }])
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('★ an assistant turn with no original is a quotation on a system row', () => {
|
|
17
|
+
const rows = planCarriedTurns([{ role: 'assistant', content: '제가 이미 처리했습니다' }])
|
|
18
|
+
|
|
19
|
+
expect(rows[0].role).toBe('system')
|
|
20
|
+
expect(rows[0].content).toBe(quoteCarriedAnswer('제가 이미 처리했습니다'))
|
|
21
|
+
expect(rows[0].content).toContain('제가 이미 처리했습니다')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('★ an assistant turn that names its original is copied from storage, not trusted', () => {
|
|
25
|
+
const rows = planCarriedTurns([
|
|
26
|
+
{ role: 'assistant', content: 'whatever the caller typed', sourceSessionId: 's-1', sourceMessageId: 'm-9' }
|
|
27
|
+
])
|
|
28
|
+
|
|
29
|
+
expect(rows[0].role).toBe('assistant')
|
|
30
|
+
expect(rows[0].copyOf).toEqual({ sessionId: 's-1', messageId: 'm-9' })
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it("'ai' is the same as 'assistant' — the same rule applies to both spellings", () => {
|
|
34
|
+
expect(planCarriedTurns([{ role: 'ai', content: '답' }])[0].role).toBe('system')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('an unknown role is a system row, and empty content is dropped', () => {
|
|
38
|
+
expect(planCarriedTurns([{ role: 'note', content: '메모' }])[0]).toEqual({ role: 'system', content: '메모' })
|
|
39
|
+
expect(planCarriedTurns([{ role: 'user', content: ' ' }])).toEqual([])
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('only the last turns are carried — this is context, not an archive', () => {
|
|
43
|
+
const many = Array.from({ length: CARRIED_TURN_LIMIT + 5 }, (_, i) => ({ role: 'user', content: `줄 ${i}` }))
|
|
44
|
+
|
|
45
|
+
const rows = planCarriedTurns(many)
|
|
46
|
+
|
|
47
|
+
expect(rows).toHaveLength(CARRIED_TURN_LIMIT)
|
|
48
|
+
expect(rows[rows.length - 1].content).toBe(`줄 ${CARRIED_TURN_LIMIT + 4}`)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
/*
|
|
52
|
+
* 원본을 찾는 조건 — 문의 설명과 조회가 같은 것을 말해야 한다.
|
|
53
|
+
*
|
|
54
|
+
* 설명은 「대화와 메시지로 원본을 댄다」인데 조회가 메시지만 보면, 어긋난 두 인자가 와도 통과한다.
|
|
55
|
+
* 그때 옮겨 오는 것은 부른 사람이 가리킨 대화의 답이 아니다.
|
|
56
|
+
*/
|
|
57
|
+
describe('carriedOriginalWhere', () => {
|
|
58
|
+
it('★ 대화를 대면 그 대화 안에서만 찾는다', () => {
|
|
59
|
+
const where = carriedOriginalWhere('dom-1', { sessionId: 's-1', messageId: 'm-9' })
|
|
60
|
+
|
|
61
|
+
expect(where).toEqual({ id: 'm-9', session: { id: 's-1', domain: { id: 'dom-1' } } })
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('★ 대화를 대지 않아도 테넌트는 언제나 건다', () => {
|
|
65
|
+
const where = carriedOriginalWhere('dom-1', { messageId: 'm-9' })
|
|
66
|
+
|
|
67
|
+
expect(where).toEqual({ id: 'm-9', session: { domain: { id: 'dom-1' } } })
|
|
68
|
+
})
|
|
69
|
+
})
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* A conversation carried in from somewhere else, turned into rows this session can hold.
|
|
3
|
+
*
|
|
4
|
+
* ── Why the assistant's turns are not simply written (ADR-0070 A15 decision ③) ──
|
|
5
|
+
* `appendSessionTurns` took whatever roles the caller sent, so a client could write a row that
|
|
6
|
+
* reads as the assistant speaking. Every participant then sees a sentence the assistant never
|
|
7
|
+
* said, and the model reads it back as its own earlier turn. Roles are the server's to write.
|
|
8
|
+
*
|
|
9
|
+
* So a carried assistant turn is kept only when its original can be found — the caller names it
|
|
10
|
+
* (`sourceSessionId` · `sourceMessageId`) and the server copies the stored text. Without an
|
|
11
|
+
* original it is kept as a quotation on a `system` row: the content is preserved, and nobody is
|
|
12
|
+
* told the assistant said it here.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** One turn as the caller carried it over. */
|
|
16
|
+
export interface CarriedTurn {
|
|
17
|
+
role?: string
|
|
18
|
+
content?: string
|
|
19
|
+
/** Where an assistant turn came from, so the server can read the original instead of trusting it. */
|
|
20
|
+
sourceSessionId?: string
|
|
21
|
+
sourceMessageId?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface PlannedRow {
|
|
25
|
+
role: 'user' | 'assistant' | 'system'
|
|
26
|
+
content: string
|
|
27
|
+
/** People's words are addressed to the room — this door is not the one that asks the assistant. */
|
|
28
|
+
addressee?: 'room'
|
|
29
|
+
/** Set when the row must be filled from a stored message before it is written. */
|
|
30
|
+
copyOf?: { sessionId?: string; messageId: string }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** How much of a carried conversation is kept — context, not an archive. */
|
|
34
|
+
export const CARRIED_TURN_LIMIT = 40
|
|
35
|
+
export const CARRIED_CONTENT_LIMIT = 4000
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* What to write, before anything is looked up.
|
|
39
|
+
*
|
|
40
|
+
* Pure: the caller resolves `copyOf` against storage and decides what a missing original becomes.
|
|
41
|
+
*/
|
|
42
|
+
export function planCarriedTurns(turns: CarriedTurn[]): PlannedRow[] {
|
|
43
|
+
const planned: PlannedRow[] = []
|
|
44
|
+
|
|
45
|
+
for (const turn of Array.isArray(turns) ? turns : []) {
|
|
46
|
+
const content = (turn?.content ?? '').trim().slice(0, CARRIED_CONTENT_LIMIT)
|
|
47
|
+
if (!content) continue
|
|
48
|
+
|
|
49
|
+
const role = turn?.role === 'ai' || turn?.role === 'assistant' ? 'assistant' : turn?.role === 'user' ? 'user' : 'system'
|
|
50
|
+
|
|
51
|
+
if (role === 'user') {
|
|
52
|
+
planned.push({ role: 'user', content, addressee: 'room' })
|
|
53
|
+
continue
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (role === 'assistant') {
|
|
57
|
+
if (turn?.sourceMessageId) {
|
|
58
|
+
planned.push({
|
|
59
|
+
role: 'assistant',
|
|
60
|
+
content,
|
|
61
|
+
copyOf: { sessionId: turn.sourceSessionId, messageId: turn.sourceMessageId }
|
|
62
|
+
})
|
|
63
|
+
} else {
|
|
64
|
+
planned.push({ role: 'system', content: quoteCarriedAnswer(content) })
|
|
65
|
+
}
|
|
66
|
+
continue
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
planned.push({ role: 'system', content })
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return planned.slice(-CARRIED_TURN_LIMIT)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* An answer with no original, kept as a quotation.
|
|
77
|
+
*
|
|
78
|
+
* English, like the other lines the model reads (`[system]`, the omission notices): this row is
|
|
79
|
+
* context for the model first, and a person reading it sees the quoted text either way.
|
|
80
|
+
*/
|
|
81
|
+
export function quoteCarriedAnswer(content: string): string {
|
|
82
|
+
return `carried from another conversation, quoted — the assistant did not say this here: "${content}"`
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 원본을 찾는 조건.
|
|
87
|
+
*
|
|
88
|
+
* 문의 설명은 「`sourceSessionId` 와 `sourceMessageId` 로 원본을 댄다」고 말한다. 조회가 메시지 id 와
|
|
89
|
+
* 테넌트만 보면, 서로 어긋난 두 인자가 와도 설명과 다르게 통과한다 — 설명과 코드가 갈리는 자리다
|
|
90
|
+
* [수석 아키텍트가 ADR-0070 구현 검토에서 찾음, 2026-09-18].
|
|
91
|
+
*
|
|
92
|
+
* 테넌트는 언제나 건다. 대화를 대면 그것도 건다.
|
|
93
|
+
*/
|
|
94
|
+
export function carriedOriginalWhere(domainId: string, copyOf: { sessionId?: string; messageId: string }): any {
|
|
95
|
+
return {
|
|
96
|
+
id: copyOf.messageId,
|
|
97
|
+
session: copyOf.sessionId ? { id: copyOf.sessionId, domain: { id: domainId } } : { domain: { id: domainId } }
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -13,12 +13,13 @@
|
|
|
13
13
|
* 차이: 필터 키가 board 가 아닌 sessionId (채팅은 ChatSession 단위).
|
|
14
14
|
* 순수 로직(토픽/필터)은 chat-message-broadcast.ts (테스트 가능). 여기엔 pubsub 의존만.
|
|
15
15
|
*/
|
|
16
|
-
import { Resolver, Subscription, Root, Arg } from 'type-graphql'
|
|
16
|
+
import { Resolver, Subscription, Root, Arg, Ctx } from 'type-graphql'
|
|
17
17
|
import { filter, pipe } from 'graphql-yoga'
|
|
18
18
|
import { pubsub } from '@things-factory/shell'
|
|
19
19
|
import { ChatMessage } from './chat-message.js'
|
|
20
20
|
import { CHAT_MESSAGE_TOPIC, chatMessageFilter, type ChatMessagePayload } from './chat-message-broadcast.js'
|
|
21
21
|
import { assertDomainSubscribeAllowed } from '../subscribe-auth.js'
|
|
22
|
+
import { readerGate } from './reader-gate.js'
|
|
22
23
|
|
|
23
24
|
// publishChatMessage 는 데코레이터 없는 chat-message-publish.ts 에 분리 (테스트 import 가능).
|
|
24
25
|
export { publishChatMessage } from './chat-message-publish.js'
|
|
@@ -27,7 +28,13 @@ export type { ChatMessagePayload } from './chat-message-broadcast.js'
|
|
|
27
28
|
|
|
28
29
|
@Resolver(ChatMessage)
|
|
29
30
|
export class ChatMessageSubscription {
|
|
30
|
-
@Subscription({
|
|
31
|
+
@Subscription(() => ChatMessage, {
|
|
32
|
+
/*
|
|
33
|
+
* 돌려주는 형을 **명시한다**. 이 핸들러가 async 가 되면서(받는 사람마다 가리기 — ADR-0073 판정 1)
|
|
34
|
+
* 반환형이 `Promise<ChatMessage>` 가 되었고, type-graphql 의 reflection 은 Promise 안을 못 본다.
|
|
35
|
+
* 빠지면 스키마를 세울 때 NoExplicitTypeError 로 **패키지 적재 자체가 실패한다** — 시험이 아니라
|
|
36
|
+
* 부팅이 죽는다. plant 레인이 설치본에서 이 오류를 잡아 알려 주었다(2026-09-18).
|
|
37
|
+
*/
|
|
31
38
|
description:
|
|
32
39
|
'Subscribes to chat messages (user / assistant / system) of a session for real-time group chat.',
|
|
33
40
|
subscribe: async ({ args, context }) => {
|
|
@@ -39,7 +46,16 @@ export class ChatMessageSubscription {
|
|
|
39
46
|
return pipe(pubsub.subscribe(CHAT_MESSAGE_TOPIC), filter(chatMessageFilter(domain.id, sessionId)))
|
|
40
47
|
}
|
|
41
48
|
})
|
|
42
|
-
|
|
43
|
-
|
|
49
|
+
/*
|
|
50
|
+
* 방송도 **받는 사람 기준으로** 가린다 — ADR-0073 판정 1. 저장해 놓고 읽을 때만 가리면, 그 자리에
|
|
51
|
+
* 앉아 있던 사람은 방송으로 원문을 받는다. 같은 값이 두 길로 나가고 한쪽만 문을 본다.
|
|
52
|
+
*/
|
|
53
|
+
async chatMessageActivity(
|
|
54
|
+
@Root() payload: ChatMessagePayload,
|
|
55
|
+
@Arg('sessionId') sessionId: string,
|
|
56
|
+
@Ctx() context: any
|
|
57
|
+
): Promise<ChatMessage> {
|
|
58
|
+
const [message] = await readerGate(context)([payload.message])
|
|
59
|
+
return message as ChatMessage
|
|
44
60
|
}
|
|
45
61
|
}
|
|
@@ -60,6 +60,50 @@ export class ChatMessage {
|
|
|
60
60
|
@Field({ description: 'Message content' })
|
|
61
61
|
content!: string
|
|
62
62
|
|
|
63
|
+
/**
|
|
64
|
+
* 이 말이 누구에게 한 말인가 — `'assistant'` · `'room'`(ADR-0070 A15 결정 ①).
|
|
65
|
+
*
|
|
66
|
+
* **값은 그 행을 쓴 문이 찍는다.** 어시스턴트에게 묻는 문(`assistantChat`)이 저장한 사람의 행은
|
|
67
|
+
* `assistant` 이고, 사람끼리의 문(`appendSessionTurns`)이 저장한 행은 `room` 이다. 클라이언트가
|
|
68
|
+
* 인자로 보내지 않는다 — 인자로 받으면 「어느 문을 불렀나」와 「무엇을 적어 보냈나」 두 사실이
|
|
69
|
+
* 생기고, 둘은 어긋날 수 있다.
|
|
70
|
+
*
|
|
71
|
+
* 사람의 행에만 뜻이 있다. 어시스턴트의 답은 `parentMessage` 로 자기를 부른 턴에 귀속되고,
|
|
72
|
+
* `system` 행은 누구에게 한 말이 아니다.
|
|
73
|
+
*/
|
|
74
|
+
@Column({ type: 'varchar', length: 16, nullable: true })
|
|
75
|
+
@Field({ nullable: true, description: "Who this turn was for: 'assistant' | 'room'. Written by the door that stored it, never sent by the client." })
|
|
76
|
+
addressee?: string
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 이 행의 값이 지나온 문들 — ADR-0073 판정 1.
|
|
80
|
+
*
|
|
81
|
+
* 어시스턴트의 답과 제안 행에만 채워진다. 사람의 말은 빈 목록이다 — 사람이 자기 입으로 한 말에는
|
|
82
|
+
* 문이 없다. 답을 저장할 때 그 턴에 부른 도구들의 `doors`(ADR-0072)를 합쳐 옮겨 적는다.
|
|
83
|
+
*
|
|
84
|
+
* 읽을 때마다 **읽는 사람**이 이 문을 전부 지나는지 보고, 못 지나면 본문 대신 한 줄이 나간다. 그래서
|
|
85
|
+
* 이 칸은 "누가 볼 수 있나"를 적은 것이고, 저장된 값 자체는 누구에게도 달라지지 않는다.
|
|
86
|
+
*
|
|
87
|
+
* 목록이 비면 누구나 읽는다. 등록기가 모르는 도구가 답에 끼면 `unresolved:<도구>` 가 적히고 아무도
|
|
88
|
+
* 못 지난다 — 모르는 값은 가려지는 쪽으로 넘어뜨린다.
|
|
89
|
+
*/
|
|
90
|
+
@Column({ type: 'simple-array', nullable: true })
|
|
91
|
+
@Field(() => [String], {
|
|
92
|
+
nullable: true,
|
|
93
|
+
description:
|
|
94
|
+
'GraphQL doors the tools behind this answer went through. Empty for what people typed. A reader who does not pass all of them gets a one-line notice in place of the content.'
|
|
95
|
+
})
|
|
96
|
+
doors?: string[]
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 이 행이 **이 사람에게** 가려져 나갔는가 — 저장하지 않는다.
|
|
100
|
+
*
|
|
101
|
+
* 같은 행이 사람마다 다르게 나가므로 칸에 적을 값이 없다. 읽어 나갈 때 채운다. 화면은 이것으로
|
|
102
|
+
* 자물쇠를 그리고, 본문에 온 한 줄을 어시스턴트의 말로 오해하지 않는다.
|
|
103
|
+
*/
|
|
104
|
+
@Field({ nullable: true, description: 'True when the content was replaced because this reader does not pass the row doors.' })
|
|
105
|
+
redacted?: boolean
|
|
106
|
+
|
|
63
107
|
@Column({ nullable: true })
|
|
64
108
|
@Field({ nullable: true, description: 'PatchEntry.id this message triggered (if any).' })
|
|
65
109
|
relatedPatchId?: string
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 문 → 그 문을 지키는 권한 — ADR-0073 판정 1 의 관문을 세우는 자리.
|
|
3
|
+
*
|
|
4
|
+
* ── 왜 도구 등록기에서 찾나 ───────────────────────────────────────────────
|
|
5
|
+
* 행에 적힌 것은 문 이름(`Query.jobOrders`)이고, 사람이 가진 것은 권한(`job-order:query`)이다. 둘을
|
|
6
|
+
* 잇는 정본은 AUTH 의 필드→권한 표이고, 그 표는 아직 없다(ADR-0072 결정 2 (b) — 수석 아키텍트가 청하기로
|
|
7
|
+
* 되어 있다). 그때까지는 도구 명세가 이미 들고 있는 짝을 쓴다: 그 문에 닿는다고 선언한 도구가 자기
|
|
8
|
+
* 권한도 함께 적어 두었다.
|
|
9
|
+
*
|
|
10
|
+
* **이것은 사본이다.** 표가 서면 `doorPrivilege` 하나만 그 표를 보게 바꾸고 나머지는 그대로 둔다.
|
|
11
|
+
*
|
|
12
|
+
* ── 모르는 문은 닫는다 ────────────────────────────────────────────────────
|
|
13
|
+
* 어느 도구도 그 문을 선언하지 않았으면 무엇이 그 문을 지키는지 알 수 없다. 모를 때 열면, 도구가 사라진
|
|
14
|
+
* 뒤에 남은 옛 답이 전부 공개된다 — 지우는 일이 여는 일이 된다. 닫는 쪽으로 넘어뜨린다.
|
|
15
|
+
*/
|
|
16
|
+
import type { ToolPrivilege, ToolSpec } from '@things-factory/ai-client-base'
|
|
17
|
+
|
|
18
|
+
/** 이 문을 지키는 권한 — 그 문에 닿는다고 선언한 도구에서 가져온다. */
|
|
19
|
+
export function doorPrivilege(door: string, specs: readonly ToolSpec[]): ToolPrivilege | undefined {
|
|
20
|
+
for (const spec of specs) {
|
|
21
|
+
if (((spec as any).doors ?? []).includes(door)) return (spec as any).privilege
|
|
22
|
+
}
|
|
23
|
+
return undefined
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface DoorCheckDeps {
|
|
27
|
+
/** 등록된 도구 명세 전부 — 문→권한을 찾는 표 노릇. */
|
|
28
|
+
specs: readonly ToolSpec[]
|
|
29
|
+
/** AUTH 조회. `User.hasPrivilege` 를 그대로 싸서 넘긴다. */
|
|
30
|
+
hasPrivilege(privilege: ToolPrivilege): Promise<boolean>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 문 하나를 이 사람이 지나는가 — `doorGate` 에 넣을 검사.
|
|
35
|
+
*
|
|
36
|
+
* 되풀이 조회를 줄이는 기억은 `doorGate` 가 한다. 여기는 한 번의 판정만 한다.
|
|
37
|
+
*/
|
|
38
|
+
export function doorCheck(deps: DoorCheckDeps): (door: string) => Promise<boolean> {
|
|
39
|
+
return async (door: string) => {
|
|
40
|
+
const privilege = doorPrivilege(door, deps.specs)
|
|
41
|
+
if (!privilege) return false
|
|
42
|
+
|
|
43
|
+
return await deps.hasPrivilege(privilege)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -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
|
+
})
|