@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
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 답에 실린 문 — ADR-0073 판정 1.
|
|
3
|
+
*
|
|
4
|
+
* ── 무엇을 막는가 ─────────────────────────────────────────────────────────
|
|
5
|
+
* 대화방은 도메인 안에서 공개다(ADR-0073 판정 2). 그런데 대화 안에는 어시스턴트가 도구 결과로 근거를
|
|
6
|
+
* 대고 쓴 답이 들어 있고, 그 근거는 **부른 사람의 문**을 지나 얻은 값이다. 도구는 문을 지켰는데
|
|
7
|
+
* 어시스턴트의 문장이 그 값을 이미 평문으로 적어 놓았다. 나중에 읽는 사람은 자기가 못 여는 문 너머의
|
|
8
|
+
* 값을 어시스턴트의 말로 읽는다 — 도구 권한을 다 지켜도 **글로 새는 길**이다.
|
|
9
|
+
*
|
|
10
|
+
* 그래서 답 행에 그 답을 만든 도구들이 지난 문 목록을 적어 두고(사람의 말은 `[]`), 읽는 사람이 그 문을
|
|
11
|
+
* 전부 지나는지 볼 때마다 확인한다. 못 지나면 본문 대신 한 줄이 나간다 — **행이 있다는 사실은 보이고
|
|
12
|
+
* 값만 안 보인다.** 방에 무슨 일이 있었는지는 알 수 있고, 값은 자기 권한만큼만 읽는다.
|
|
13
|
+
*
|
|
14
|
+
* ── 시점은 뜻이 없다 ──────────────────────────────────────────────────────
|
|
15
|
+
* 언제 들어왔는지로 가르지 않는다. 오전에 있었든 방금 들어왔든, 볼 수 있는 것은 본다. 권한이 나중에
|
|
16
|
+
* 붙으면 그때부터 보이고, 떨어지면 그때부터 안 보인다. 저장된 행은 그대로 있고 **보는 일만** 달라진다.
|
|
17
|
+
*
|
|
18
|
+
* ── 모델이 읽는 이력도 같다 ───────────────────────────────────────────────
|
|
19
|
+
* 부른 사람이 못 여는 문의 답은 모델에게도 가리고 보낸다. 가리지 않으면 모델이 그 값을 다음 답에 옮겨
|
|
20
|
+
* 적어서, 문을 한 번도 안 지난 사람이 값을 갖게 된다. 가린 자리를 지우지 않고 한 줄로 남기는 이유도
|
|
21
|
+
* 같다 — 모델이 "그런 말 한 적 없다"고 답하면 그것은 거짓말이다.
|
|
22
|
+
*
|
|
23
|
+
* 이 파일은 저장소·엔티티를 모르는 순수 판정이다.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** 문 이름으로 보이지 않는 값 — 등록되지 않은 도구가 답에 끼었을 때. */
|
|
27
|
+
const UNRESOLVED = 'unresolved:'
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 이 답이 지난 문들 — 부른 도구들의 문을 합친다(중복 없이, 정렬).
|
|
31
|
+
*
|
|
32
|
+
* 등록기에 없는 도구가 답에 끼면 그 도구가 무엇을 읽었는지 알 길이 없다. 그때 `[]`(누구나 읽는 답)로
|
|
33
|
+
* 적으면 모르는 값이 공개된다. 아무도 못 지나는 이름을 적어 **가려지는 쪽**으로 넘어뜨리고, 이름에
|
|
34
|
+
* 원인을 실어 둔다.
|
|
35
|
+
*/
|
|
36
|
+
export function doorsForAnswer(toolNames: readonly string[], doorsOf: (name: string) => string[] | undefined): string[] {
|
|
37
|
+
const doors = new Set<string>()
|
|
38
|
+
|
|
39
|
+
for (const name of toolNames) {
|
|
40
|
+
const declared = doorsOf(name)
|
|
41
|
+
if (declared === undefined) doors.add(`${UNRESOLVED}${name}`)
|
|
42
|
+
else for (const door of declared) doors.add(door)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return [...doors].sort()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 한 요청 안에서 (사람, 문) 별로 한 번만 묻는 관문.
|
|
50
|
+
*
|
|
51
|
+
* 한 화면이 백 줄을 읽고 그 줄들이 같은 문 서넛을 가리킨다. 줄마다 물으면 권한 조회가 백 번 간다.
|
|
52
|
+
* 같은 요청 안에서 답이 바뀔 일은 없으므로 문 하나에 한 번 묻고 그 답을 쓴다. 관문은 요청마다 새로
|
|
53
|
+
* 만든다 — 프로세스가 들고 있으면 권한이 바뀌어도 낡은 답을 준다.
|
|
54
|
+
*/
|
|
55
|
+
export function doorGate(check: (door: string) => Promise<boolean>): (door: string) => Promise<boolean> {
|
|
56
|
+
const asked = new Map<string, Promise<boolean>>()
|
|
57
|
+
|
|
58
|
+
return (door: string) => {
|
|
59
|
+
/* 등록되지 않은 도구의 자리는 물을 것도 없다 — 지나는 사람이 없다. */
|
|
60
|
+
if (door.startsWith(UNRESOLVED)) return Promise.resolve(false)
|
|
61
|
+
|
|
62
|
+
let answer = asked.get(door)
|
|
63
|
+
if (!answer) {
|
|
64
|
+
answer = check(door)
|
|
65
|
+
asked.set(door, answer)
|
|
66
|
+
}
|
|
67
|
+
return answer
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 이 사람이 이 행을 열 수 있나 — 적힌 문을 **전부** 지나야 한다(AND). */
|
|
72
|
+
export async function passesRow(doors: readonly string[] | null | undefined, gate: (door: string) => Promise<boolean>): Promise<boolean> {
|
|
73
|
+
for (const door of doors ?? []) {
|
|
74
|
+
if (!(await gate(door))) return false
|
|
75
|
+
}
|
|
76
|
+
return true
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 문 판정에 필요한 만큼만 — 엔티티를 이 자리로 끌고 오지 않는다. */
|
|
80
|
+
export interface DoorReadableRow {
|
|
81
|
+
doors?: string[] | null
|
|
82
|
+
content?: string | null
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 가린 행에서 값이 실려 있는 칸들 — 본문만 지우고 두면 그리로 새어 나간다. */
|
|
86
|
+
const VALUE_CARRYING = ['extra', 'proposals', 'grounding', 'patchEntryId', 'importSessionId'] as const
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 못 지나는 행의 값을 가린다 — 행은 남기고 본문을 한 줄로 바꾼다.
|
|
90
|
+
*
|
|
91
|
+
* `notice` 는 부르는 쪽이 번역해 넘긴다(서버 번역, 화면 말은 서버가 만든다 — ADR-0061 결정 7).
|
|
92
|
+
*/
|
|
93
|
+
export async function redactByDoors<T extends DoorReadableRow>(
|
|
94
|
+
rows: readonly T[],
|
|
95
|
+
gate: (door: string) => Promise<boolean>,
|
|
96
|
+
notice: (doors: string[]) => string
|
|
97
|
+
): Promise<T[]> {
|
|
98
|
+
const out: T[] = []
|
|
99
|
+
|
|
100
|
+
for (const row of rows) {
|
|
101
|
+
const doors = row.doors ?? []
|
|
102
|
+
if (doors.length === 0 || (await passesRow(doors, gate))) {
|
|
103
|
+
out.push(row)
|
|
104
|
+
continue
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const hidden: any = { ...row, content: notice(doors) }
|
|
108
|
+
for (const field of VALUE_CARRYING) {
|
|
109
|
+
if (field in hidden) hidden[field] = null
|
|
110
|
+
}
|
|
111
|
+
/* 가려졌다는 것 자체는 사실이다. 화면이 자물쇠를 그릴 수 있게 남긴다. */
|
|
112
|
+
hidden.redacted = true
|
|
113
|
+
out.push(hidden)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return out
|
|
117
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* What this guards: the line the server writes is the line the panel reads.
|
|
3
|
+
*
|
|
4
|
+
* Two files hold the shape — one writes it into the conversation, one reads it back for the card.
|
|
5
|
+
* They are tested together here, so a change to either breaks a test instead of quietly leaving
|
|
6
|
+
* cards in "sending" forever.
|
|
7
|
+
*
|
|
8
|
+
* The test lives on the server side on purpose: the client build compiles everything under
|
|
9
|
+
* `client/`, so a client test importing the server would pull the server into the browser build's
|
|
10
|
+
* root and move every emitted path (`dist-client/client/…`). The server build excludes tests, so
|
|
11
|
+
* reaching the other way costs nothing.
|
|
12
|
+
*/
|
|
13
|
+
import { formatProposalOutcomeLine, PROPOSAL_VERDICTS } from './proposal-outcome'
|
|
14
|
+
import { parseProposalOutcomeLine } from '../../../client/components/proposal-outcome-line'
|
|
15
|
+
|
|
16
|
+
const key = 'proposeSceneEdit|{"kind":"scene-edit"}'
|
|
17
|
+
|
|
18
|
+
it('★ every verdict survives the round trip', () => {
|
|
19
|
+
for (const verdict of PROPOSAL_VERDICTS) {
|
|
20
|
+
const line = formatProposalOutcomeLine(key, { verdict })
|
|
21
|
+
expect(parseProposalOutcomeLine(line)).toEqual({ key, verdict, errorCode: undefined, applied: undefined, total: undefined })
|
|
22
|
+
}
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('★ the reason code comes back exactly as the host gave it', () => {
|
|
26
|
+
const line = formatProposalOutcomeLine(key, { verdict: 'refused', errorCode: 'unknown-tag' })
|
|
27
|
+
|
|
28
|
+
expect(parseProposalOutcomeLine(line)?.errorCode).toBe('unknown-tag')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('★ counts come back as "how many of how many"', () => {
|
|
32
|
+
const line = formatProposalOutcomeLine(key, { verdict: 'accepted', applied: 2, missed: 1 })
|
|
33
|
+
|
|
34
|
+
expect(parseProposalOutcomeLine(line)).toMatchObject({ verdict: 'accepted', applied: 2, total: 3 })
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('★ another system row is not read as an outcome — no guessing', () => {
|
|
38
|
+
expect(parseProposalOutcomeLine('User directly edited the board: moved EQ-1')).toBeUndefined()
|
|
39
|
+
expect(parseProposalOutcomeLine('proposal something without a verdict')).toBeUndefined()
|
|
40
|
+
expect(parseProposalOutcomeLine('')).toBeUndefined()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('a key containing colons and spaces still parses', () => {
|
|
44
|
+
const awkward = 'proposeAction|stop|EQ-1|{"note":"a: b"}'
|
|
45
|
+
const line = formatProposalOutcomeLine(awkward, { verdict: 'accepted' })
|
|
46
|
+
|
|
47
|
+
expect(parseProposalOutcomeLine(line)?.key).toBe(awkward)
|
|
48
|
+
})
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* What the host did with a proposal, written into the conversation (ADR-0070 A12-a).
|
|
3
|
+
*
|
|
4
|
+
* The card used to change to "executed" the moment it was pressed, which is the one thing it knew
|
|
5
|
+
* and the one thing that is not the answer. The host's answer comes back through a server door and
|
|
6
|
+
* is stored as a `system` row, so three readers get it from the same place: the card that is open,
|
|
7
|
+
* the card after a refresh, and the model on its next turn — a model that is not told its proposal
|
|
8
|
+
* was refused goes on as though it had been applied.
|
|
9
|
+
*
|
|
10
|
+
* The line is the row's content. It is parsed back by the panel, so the shape is fixed here and the
|
|
11
|
+
* panel's parser is tested against this formatter.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** ADR-0042's verdicts, plus the one for a person who backed out at the confirm step. */
|
|
15
|
+
export type ProposalVerdict = 'accepted' | 'refused' | 'unavailable' | 'declined'
|
|
16
|
+
|
|
17
|
+
export interface ProposalOutcome {
|
|
18
|
+
verdict: ProposalVerdict
|
|
19
|
+
/** The host's own reason code, carried through untouched — no mapping table (ADR-0042). */
|
|
20
|
+
errorCode?: string
|
|
21
|
+
/** For a proposal made of several edits: how many went in, and how many did not. */
|
|
22
|
+
applied?: number
|
|
23
|
+
missed?: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const PROPOSAL_OUTCOME_PREFIX = 'proposal '
|
|
27
|
+
|
|
28
|
+
export const PROPOSAL_VERDICTS: ProposalVerdict[] = ['accepted', 'refused', 'unavailable', 'declined']
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* `proposal <key>: <verdict> (<errorCode>) 2/3`
|
|
32
|
+
*
|
|
33
|
+
* English, like the other lines the model reads. The panel turns it into the reader's language.
|
|
34
|
+
*/
|
|
35
|
+
export function formatProposalOutcomeLine(key: string, outcome: ProposalOutcome): string {
|
|
36
|
+
const reason = outcome.errorCode ? ` (${outcome.errorCode})` : ''
|
|
37
|
+
const counted =
|
|
38
|
+
typeof outcome.applied === 'number' || typeof outcome.missed === 'number'
|
|
39
|
+
? ` ${outcome.applied ?? 0}/${(outcome.applied ?? 0) + (outcome.missed ?? 0)}`
|
|
40
|
+
: ''
|
|
41
|
+
return `${PROPOSAL_OUTCOME_PREFIX}${key}: ${outcome.verdict}${reason}${counted}`
|
|
42
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 읽는 사람의 관문 — ADR-0073 판정 1·2 를 요청 하나에 세운다.
|
|
3
|
+
*
|
|
4
|
+
* 리졸버는 이것을 한 번 만들고 행 목록을 통과시킨다. 문 판정은 (사람, 문) 별로 한 번만 일어나고,
|
|
5
|
+
* 세션이 private 인지도 여기서 함께 답한다. 리졸버마다 같은 판정을 다시 적으면 한 곳이 빠진 날
|
|
6
|
+
* 그 문으로 샌다.
|
|
7
|
+
*/
|
|
8
|
+
import { getAllToolSpecs } from '@things-factory/ai-client-base'
|
|
9
|
+
import { User } from '@things-factory/auth-base'
|
|
10
|
+
|
|
11
|
+
import { doorCheck } from './door-access.js'
|
|
12
|
+
import { doorGate, redactByDoors, type DoorReadableRow } from './message-doors.js'
|
|
13
|
+
import { requestTranslator } from '../request-translator.js'
|
|
14
|
+
|
|
15
|
+
/** 이 요청에서 행을 가려 내보내는 함수. 같은 요청 안에서 계속 쓴다. */
|
|
16
|
+
export function readerGate(context: any): <T extends DoorReadableRow>(rows: readonly T[]) => Promise<T[]> {
|
|
17
|
+
const { domain, user } = context.state
|
|
18
|
+
const t = requestTranslator(context)
|
|
19
|
+
|
|
20
|
+
const gate = doorGate(
|
|
21
|
+
doorCheck({
|
|
22
|
+
specs: getAllToolSpecs(),
|
|
23
|
+
hasPrivilege: privilege => User.hasPrivilege(privilege.privilege, privilege.category, domain, user)
|
|
24
|
+
})
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
/*
|
|
28
|
+
* 문 이름(`Query.productionReports`)은 이 문장에 넣지 않는다.
|
|
29
|
+
*
|
|
30
|
+
* 현장에서 읽는 사람에게 GraphQL 이름은 아무 말도 아니다. 문 이름을 사람의 말로 바꿀 표는 AUTH 의
|
|
31
|
+
* 필드→권한 표와 같은 자리에서 나와야 하고, 그것은 아직 없다(ADR-0072 결정 2 (b)). 없는 표를 흉내
|
|
32
|
+
* 내느니 문장은 사실만 말하고, 목록은 `ChatMessage.doors` 로 따로 나간다 — 관리자에게 무엇을
|
|
33
|
+
* 청할지 알아야 하는 화면은 거기서 읽는다. 문구는 UX 레인이 정한다.
|
|
34
|
+
*/
|
|
35
|
+
return rows =>
|
|
36
|
+
redactByDoors(rows, gate, () =>
|
|
37
|
+
t('ai-assistant.text.answer-needs-permission', {
|
|
38
|
+
/* 구독에는 요청 번역기가 없다 — `request-translator.ts` 참조. */
|
|
39
|
+
defaultValue: 'you do not have permission to see this answer. ask an administrator for access.'
|
|
40
|
+
})
|
|
41
|
+
)
|
|
42
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* What this guards: the people's door keeps what was said, and refuses what was not.
|
|
3
|
+
*
|
|
4
|
+
* An empty turn stored as a message is a blank line in a shared conversation and a blank line in
|
|
5
|
+
* what the model reads — both mean "someone said something" when nobody did.
|
|
6
|
+
*/
|
|
7
|
+
import { ROOM_MESSAGE_LIMIT, roomMessageBody } from './room-message'
|
|
8
|
+
|
|
9
|
+
it('keeps what was said, without the whitespace around it', () => {
|
|
10
|
+
expect(roomMessageBody(' 3 라인이 멈춘 것 같아요 ')).toBe('3 라인이 멈춘 것 같아요')
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('★ an empty turn is nothing to store', () => {
|
|
14
|
+
expect(roomMessageBody(' ')).toBeUndefined()
|
|
15
|
+
expect(roomMessageBody('')).toBeUndefined()
|
|
16
|
+
expect(roomMessageBody(undefined)).toBeUndefined()
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('a very long turn is capped — this is a conversation, not an archive', () => {
|
|
20
|
+
expect(roomMessageBody('가'.repeat(ROOM_MESSAGE_LIMIT + 100))?.length).toBe(ROOM_MESSAGE_LIMIT)
|
|
21
|
+
})
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* A person saying something to the room (ADR-0070 A15).
|
|
3
|
+
*
|
|
4
|
+
* The addressee column has two values and, until this door, a screen could only produce one of
|
|
5
|
+
* them: every turn a person typed went through `assistantChat`, which means every turn was for the
|
|
6
|
+
* assistant. `room` existed only on turns carried in from somewhere else. So the half of the design
|
|
7
|
+
* that says "people also talk to each other here, and the assistant reads it as context" had no way
|
|
8
|
+
* to happen.
|
|
9
|
+
*
|
|
10
|
+
* The rule the door follows is the same one: the addressee comes from **which door was called**,
|
|
11
|
+
* never from an argument. This one is the people's door — it stores the turn and does not call the
|
|
12
|
+
* model. Nobody is answered, and that silence is the point (A15 decision ⑦).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** How much of one turn is kept — the same cap the carried turns use. */
|
|
16
|
+
export const ROOM_MESSAGE_LIMIT = 4000
|
|
17
|
+
|
|
18
|
+
/** Trimmed and capped, or nothing when there is no message. */
|
|
19
|
+
export function roomMessageBody(content: string | undefined | null): string | undefined {
|
|
20
|
+
const body = (content ?? '').trim().slice(0, ROOM_MESSAGE_LIMIT)
|
|
21
|
+
return body || undefined
|
|
22
|
+
}
|
|
@@ -43,7 +43,7 @@ const DATABASE_TYPE = ORMCONFIG.type
|
|
|
43
43
|
// 앵커 조회용 — chatSessionsByAnchor 가 [domain, anchorType, anchorId] 로 질의.
|
|
44
44
|
@Index('ix_chat_session_2', (session: ChatSession) => [session.domain, session.anchorType, session.anchorId])
|
|
45
45
|
@ObjectType({
|
|
46
|
-
description: 'AI
|
|
46
|
+
description: 'One AI conversation, anchored to what it is about. An anchor may have several — threads, one per person, one per context.'
|
|
47
47
|
})
|
|
48
48
|
export class ChatSession {
|
|
49
49
|
@PrimaryGeneratedColumn('uuid')
|
|
@@ -125,11 +125,41 @@ export class ChatSession {
|
|
|
125
125
|
@Field({ nullable: true, description: 'Timestamp of the last message in this session (denormalized for list ordering and unread comparison — avoids per-session aggregation across the supported DB drivers).' })
|
|
126
126
|
lastMessageAt?: Date
|
|
127
127
|
|
|
128
|
+
/**
|
|
129
|
+
* 이 대화를 도메인 안 누구나 읽는가 — ADR-0073 판정 2.
|
|
130
|
+
*
|
|
131
|
+
* `open` 도메인 안이면 누구나 들어와 읽고 말한다. 초대 절차는 없다.
|
|
132
|
+
* `private` 참여자만 읽는다.
|
|
133
|
+
*
|
|
134
|
+
* 앵커가 있는 세션(space · board · station)은 공유된 무엇을 놓고 하는 이야기이므로 `open` 으로
|
|
135
|
+
* 시작한다. 앵커가 없는 세션과 **옮겨 적어 만든 세션**은 `private` 으로 시작한다 — 가벼운 질문을
|
|
136
|
+
* 승격하면서 앞선 대화가 새 세션에 옮겨 적히는데(`appendSessionTurns`), 그것이 공개로 태어나면
|
|
137
|
+
* 사적으로 오간 원문이 소급해서 공개된다.
|
|
138
|
+
*
|
|
139
|
+
* owner 가 양방향으로 바꿀 수 있다. 공개로 바꾸기 전에 화면이 한 줄을 먼저 보인다.
|
|
140
|
+
*
|
|
141
|
+
* open 이어도 답 행의 문 판정(ADR-0073 판정 1)은 그대로다 — 방에 들어온 것과 값을 읽는 것은 다르다.
|
|
142
|
+
*/
|
|
143
|
+
@Column({ type: 'varchar', length: 16, default: 'open' })
|
|
144
|
+
@Field({ description: "Who may read this conversation: 'open' (anyone in the domain) or 'private' (participants only)." })
|
|
145
|
+
visibility!: string
|
|
146
|
+
|
|
128
147
|
/** 목록에 보여줄 마지막 메시지 미리보기 — 목록에서 메시지 테이블을 다시 읽지 않기 위해 짧게 저장. */
|
|
129
148
|
@Column({ nullable: true, length: 200 })
|
|
130
149
|
@Field({ nullable: true, description: 'Short preview of the last message, stored so the session list does not need to query messages.' })
|
|
131
150
|
lastMessagePreview?: string
|
|
132
151
|
|
|
152
|
+
/**
|
|
153
|
+
* 그 미리보기가 지나온 문 — ADR-0073 판정 1.
|
|
154
|
+
*
|
|
155
|
+
* 목록은 답을 **다시 읽는 두 번째 길**이다. 메시지 화면에서만 가리면 같은 문장이 목록에 그대로 뜬다.
|
|
156
|
+
* 문이 둘이고 한쪽이 느슨하면 느슨한 쪽이 이긴다(ADR-0061 결정 1). 미리보기를 저장할 때 그 행의 문을
|
|
157
|
+
* 함께 옮겨 적고, 목록도 같은 관문을 지난다.
|
|
158
|
+
*/
|
|
159
|
+
@Column({ type: 'simple-array', nullable: true })
|
|
160
|
+
@Field(() => [String], { nullable: true, description: 'Doors of the message the preview came from — the session list passes the same gate as the conversation.' })
|
|
161
|
+
lastMessageDoors?: string[]
|
|
162
|
+
|
|
133
163
|
@Column({
|
|
134
164
|
nullable: true,
|
|
135
165
|
type:
|
|
@@ -183,3 +183,21 @@ describe('searchInbox — 대량 목록의 풀텍스트 필터', () => {
|
|
|
183
183
|
expect(searchInbox(rows, 'zzz')).toEqual([])
|
|
184
184
|
})
|
|
185
185
|
})
|
|
186
|
+
|
|
187
|
+
/*
|
|
188
|
+
* ADR-0073 판정 1 — 목록 미리보기도 같은 관문을 지나야 한다.
|
|
189
|
+
*
|
|
190
|
+
* 대화 화면에서만 가리면 같은 문장이 목록에 그대로 뜬다. 문이 둘이고 한쪽이 느슨하면 느슨한 쪽이 이긴다.
|
|
191
|
+
*/
|
|
192
|
+
describe('preview carries its doors', () => {
|
|
193
|
+
it('★ 미리보기를 적을 때 그 답이 지나온 문도 함께 적는다', () => {
|
|
194
|
+
const patch = activityPatch('오늘 양품 1,204', new Date('2026-09-18T00:00:00Z'), ['Query.productionReports'])
|
|
195
|
+
|
|
196
|
+
expect(patch.lastMessageDoors).toEqual(['Query.productionReports'])
|
|
197
|
+
expect(patch.lastMessagePreview).toBe('오늘 양품 1,204')
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('사람의 말에는 문이 없다 — 빈 목록이면 누구나 본다', () => {
|
|
201
|
+
expect(activityPatch('이것 좀 봐줘').lastMessageDoors).toEqual([])
|
|
202
|
+
})
|
|
203
|
+
})
|
|
@@ -24,6 +24,8 @@ export interface InboxSessionRow {
|
|
|
24
24
|
boardId?: string
|
|
25
25
|
lastMessageAt?: Date | string | null
|
|
26
26
|
lastMessagePreview?: string | null
|
|
27
|
+
/** 그 미리보기가 지나온 문 — 목록도 같은 관문을 지난다. */
|
|
28
|
+
lastMessageDoors?: string[] | null
|
|
27
29
|
createdAt?: Date | string | null
|
|
28
30
|
}
|
|
29
31
|
|
|
@@ -137,9 +139,14 @@ export function countUnreadSessions(entries: InboxEntry[]): number {
|
|
|
137
139
|
* 메시지 저장 시 세션에 반영할 활동 값. 미리보기는 짧게 자르고 개행을 없앤다
|
|
138
140
|
* (목록 한 줄에 들어가야 하고, 컬럼 길이 제한이 있다).
|
|
139
141
|
*/
|
|
140
|
-
export function activityPatch(
|
|
142
|
+
export function activityPatch(
|
|
143
|
+
content: string,
|
|
144
|
+
at: Date = new Date(),
|
|
145
|
+
doors: string[] = []
|
|
146
|
+
): { lastMessageAt: Date; lastMessagePreview: string; lastMessageDoors: string[] } {
|
|
141
147
|
const flat = (content ?? '').replace(/\s+/g, ' ').trim()
|
|
142
|
-
|
|
148
|
+
/* 미리보기가 어느 문을 지나온 값인지 함께 적는다 — 목록도 같은 관문을 지난다(ADR-0073 판정 1). */
|
|
149
|
+
return { lastMessageAt: at, lastMessagePreview: flat.slice(0, 200), lastMessageDoors: doors }
|
|
143
150
|
}
|
|
144
151
|
|
|
145
152
|
/**
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* ADR-0073 판정 2 — 새 대화가 공개로 태어나는가, 비공개는 누가 읽는가.
|
|
3
|
+
*
|
|
4
|
+
* 옮겨 적어 만든 세션이 공개로 태어나면 사적으로 오간 원문이 소급해서 공개된다. 그 한 줄을 여기서 문다.
|
|
5
|
+
*/
|
|
6
|
+
import { initialVisibility, mayReadSession } from './session-visibility'
|
|
7
|
+
|
|
8
|
+
describe('session visibility', () => {
|
|
9
|
+
it('앵커가 있는 대화는 공개로 시작한다 — 공유된 대상을 놓고 하는 이야기다', () => {
|
|
10
|
+
expect(initialVisibility({ anchorType: 'space' })).toBe('open')
|
|
11
|
+
expect(initialVisibility({ anchorType: 'board' })).toBe('open')
|
|
12
|
+
expect(initialVisibility({ anchorType: 'station' })).toBe('open')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('★ 옮겨 적어 만든 대화는 비공개로 시작한다 — 원문이 소급 공개되지 않는다', () => {
|
|
16
|
+
expect(initialVisibility({ anchorType: 'board', copyOf: 'session-1' })).toBe('private')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('앵커 없는 대화는 비공개로 시작한다', () => {
|
|
20
|
+
expect(initialVisibility({})).toBe('private')
|
|
21
|
+
expect(initialVisibility({ anchorType: null })).toBe('private')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('★ 비공개 대화는 참여자만 읽는다', () => {
|
|
25
|
+
expect(mayReadSession('private', false)).toBe(false)
|
|
26
|
+
expect(mayReadSession('private', true)).toBe(true)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('공개 대화는 참여자가 아니어도 읽는다 — 초대 절차가 없다', () => {
|
|
30
|
+
expect(mayReadSession('open', false)).toBe(true)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('값이 없는 옛 행은 공개로 읽는다 — 오늘까지 도메인만 보고 있었다', () => {
|
|
34
|
+
expect(mayReadSession(undefined, false)).toBe(true)
|
|
35
|
+
})
|
|
36
|
+
})
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 새 대화가 공개로 태어나는가 — ADR-0073 판정 2.
|
|
3
|
+
*
|
|
4
|
+
* 판정을 세션을 만드는 자리마다 적으면 한 곳이 빠진 날 그 세션만 다른 규칙으로 산다. 한 줄로 모은다.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type SessionVisibility = 'open' | 'private'
|
|
8
|
+
|
|
9
|
+
export const SESSION_VISIBILITIES: readonly SessionVisibility[] = ['open', 'private']
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 이렇게 만들어진 세션은 무엇으로 시작하나.
|
|
13
|
+
*
|
|
14
|
+
* 앵커가 있으면 공유된 대상을 놓고 하는 이야기다 — 공개. 앵커가 없거나 앞선 대화를 옮겨 적어 만든
|
|
15
|
+
* 것이면 비공개. `copyOf` 가 있다는 것은 다른 대화의 원문이 이 안에 들어왔다는 뜻이고, 그 원문은
|
|
16
|
+
* 자기가 있던 자리의 규칙으로 쓰였다.
|
|
17
|
+
*/
|
|
18
|
+
export function initialVisibility(session: { anchorType?: string | null; copyOf?: string | null }): SessionVisibility {
|
|
19
|
+
if (session.copyOf) return 'private'
|
|
20
|
+
return session.anchorType ? 'open' : 'private'
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** 이 사람이 이 대화를 읽을 수 있나. open 이면 도메인 안 누구나, private 이면 참여자만. */
|
|
24
|
+
export function mayReadSession(visibility: string | null | undefined, isParticipant: boolean): boolean {
|
|
25
|
+
return (visibility ?? 'open') === 'open' || isParticipant
|
|
26
|
+
}
|
|
@@ -2,9 +2,13 @@
|
|
|
2
2
|
* This is a consistency check, not authorization; tenant/privilege checks stay in the resolver.
|
|
3
3
|
* Older Board sessions have only boardId, so preserve that supported read shape.
|
|
4
4
|
*/
|
|
5
|
+
const STATION_MISMATCH = 'this conversation belongs to another station screen. open the conversation of the screen you are on.'
|
|
6
|
+
|
|
5
7
|
export function assertStationSession(
|
|
6
8
|
session: {anchorType?:string|null;anchorId?:string|null;boardId?:string|null}|undefined,
|
|
7
|
-
host: {surface?:string;boardId?:unknown}|undefined
|
|
9
|
+
host: {surface?:string;boardId?:unknown}|undefined,
|
|
10
|
+
/* The reader's sentence comes from the request (ADR-0054); without a translator it falls back to English. */
|
|
11
|
+
t?: (key: string, params?: Record<string, any>) => string
|
|
8
12
|
) {
|
|
9
13
|
if(host?.surface!=='station-screen-modeller')return
|
|
10
14
|
const boardId=host.boardId
|
|
@@ -13,5 +17,9 @@ export function assertStationSession(
|
|
|
13
17
|
(session.anchorType==='board'||legacy)&&
|
|
14
18
|
(session.anchorId||session.boardId)===boardId&&
|
|
15
19
|
(!session.boardId||session.boardId===boardId)
|
|
16
|
-
if(!valid)throw new Error(
|
|
20
|
+
if(!valid)throw new Error(
|
|
21
|
+
t
|
|
22
|
+
? t('ai-assistant.text.other-station-history', { defaultValue: STATION_MISMATCH })
|
|
23
|
+
: STATION_MISMATCH
|
|
24
|
+
)
|
|
17
25
|
}
|