@things-factory/board-ai 10.0.0 → 10.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/components/board-ai-chat.ts +107 -2
- package/client/components/chat-echo-dedup.ts +3 -0
- package/client/components/chat-input-builder.ts +6 -0
- package/dist-client/client/components/board-ai-chat.d.ts +17 -0
- package/dist-client/client/components/board-ai-chat.js +102 -2
- package/dist-client/client/components/board-ai-chat.js.map +1 -1
- package/dist-client/client/components/chat-echo-dedup.d.ts +2 -0
- package/dist-client/client/components/chat-echo-dedup.js +1 -0
- package/dist-client/client/components/chat-echo-dedup.js.map +1 -1
- package/dist-client/client/components/chat-input-builder.d.ts +5 -0
- package/dist-client/client/components/chat-input-builder.js +1 -0
- package/dist-client/client/components/chat-input-builder.js.map +1 -1
- package/dist-client/server/service/agentic-loop.d.ts +18 -0
- package/dist-client/server/service/agentic-loop.js +10 -1
- package/dist-client/server/service/agentic-loop.js.map +1 -1
- package/dist-client/server/service/assistant.js +14 -2
- package/dist-client/server/service/assistant.js.map +1 -1
- package/dist-client/server/service/grounding.d.ts +17 -0
- package/dist-client/server/service/grounding.js +42 -0
- package/dist-client/server/service/grounding.js.map +1 -0
- package/dist-client/server/service/types.d.ts +16 -0
- package/dist-client/server/service/types.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-server/service/agentic-loop.d.ts +18 -0
- package/dist-server/service/agentic-loop.js +10 -1
- package/dist-server/service/agentic-loop.js.map +1 -1
- package/dist-server/service/assistant.js +14 -2
- package/dist-server/service/assistant.js.map +1 -1
- package/dist-server/service/board-ai-resolver.d.ts +2 -0
- package/dist-server/service/board-ai-resolver.js +22 -1
- package/dist-server/service/board-ai-resolver.js.map +1 -1
- package/dist-server/service/chat-message/chat-message.d.ts +12 -0
- package/dist-server/service/chat-message/chat-message.js +23 -0
- package/dist-server/service/chat-message/chat-message.js.map +1 -1
- package/dist-server/service/chat-session/session-inbox.d.ts +26 -0
- package/dist-server/service/chat-session/session-inbox.js +41 -0
- package/dist-server/service/chat-session/session-inbox.js.map +1 -1
- package/dist-server/service/chat-session-participant/chat-session-participant.d.ts +11 -0
- package/dist-server/service/chat-session-participant/chat-session-participant.js +17 -1
- package/dist-server/service/chat-session-participant/chat-session-participant.js.map +1 -1
- package/dist-server/service/chat-session-resolver.d.ts +44 -1
- package/dist-server/service/chat-session-resolver.js +306 -6
- package/dist-server/service/chat-session-resolver.js.map +1 -1
- package/dist-server/service/grounding.d.ts +17 -0
- package/dist-server/service/grounding.js +46 -0
- package/dist-server/service/grounding.js.map +1 -0
- package/dist-server/service/types.d.ts +16 -0
- package/dist-server/service/types.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -5
- package/server/service/agentic-loop.test.ts +63 -0
- package/server/service/agentic-loop.ts +28 -1
- package/server/service/assistant.ts +15 -2
- package/server/service/board-ai-resolver.ts +23 -1
- package/server/service/chat-message/chat-message.ts +26 -0
- package/server/service/chat-session/session-inbox.test.ts +69 -1
- package/server/service/chat-session/session-inbox.ts +45 -0
- package/server/service/chat-session-participant/chat-session-participant.ts +14 -0
- package/server/service/chat-session-resolver.ts +297 -5
- package/server/service/grounding.test.ts +55 -0
- package/server/service/grounding.ts +53 -0
- package/server/service/types.ts +16 -0
- package/translations/en.json +3 -1
- package/translations/ja.json +3 -1
- package/translations/ko.json +2 -0
- package/translations/ms.json +3 -1
- package/translations/zh.json +3 -1
|
@@ -14,7 +14,7 @@ import { In } from 'typeorm'
|
|
|
14
14
|
|
|
15
15
|
import { ChatSession } from './chat-session/chat-session.js'
|
|
16
16
|
import { anchorColumns, anchorWhere } from './chat-session/session-anchor.js'
|
|
17
|
-
import { buildInbox, activityPatch } from './chat-session/session-inbox.js'
|
|
17
|
+
import { buildInbox, applyHidden, activityPatch, searchInbox, INBOX_SCAN_CAP } from './chat-session/session-inbox.js'
|
|
18
18
|
import { publishSessionActivity } from './chat-session/session-activity-publish.js'
|
|
19
19
|
import { ChatMessage } from './chat-message/chat-message.js'
|
|
20
20
|
import { ChatSessionParticipant } from './chat-session-participant/chat-session-participant.js'
|
|
@@ -56,6 +56,15 @@ class ChatSessionInboxEntry {
|
|
|
56
56
|
|
|
57
57
|
@Field({ description: 'Whether this viewer is a participant of the session (non-participants get no unread state).' })
|
|
58
58
|
participant!: boolean
|
|
59
|
+
|
|
60
|
+
/* 삭제는 되돌릴 수 없으므로 **만든 사람**만 한다 — 클라이언트가 버튼을 보일지 판단하는 근거.
|
|
61
|
+
* (권한 판정은 서버가 다시 한다. 이 값은 UI 힌트일 뿐이다.) */
|
|
62
|
+
@Field({ description: 'Whether this viewer created the session — only the creator may delete it.' })
|
|
63
|
+
mine!: boolean
|
|
64
|
+
|
|
65
|
+
/* 숨김은 나에게만 적용되고 되돌릴 수 있다 — 필터를 켠 사용자가 무엇이 숨겨진 것인지 알아야 한다. */
|
|
66
|
+
@Field({ nullable: true, description: 'Whether this viewer hid the session from their own list (only returned when showHidden is on).' })
|
|
67
|
+
hidden?: boolean
|
|
59
68
|
}
|
|
60
69
|
|
|
61
70
|
@Resolver()
|
|
@@ -118,23 +127,190 @@ export class ChatSessionResolver {
|
|
|
118
127
|
*/
|
|
119
128
|
@Query(() => [ChatSessionInboxEntry], {
|
|
120
129
|
description:
|
|
121
|
-
"List sessions anchored to a target with this viewer's unread state,
|
|
130
|
+
"List sessions anchored to a target with this viewer's unread state, most recent activity first. Supports free-text search over name and last preview, a mine-only filter, hidden inclusion, and paging. Uses the denormalized last-activity timestamp compared against the viewer's read point — no per-session aggregation."
|
|
122
131
|
})
|
|
123
132
|
@Directive('@privilege(category: "board-ai", privilege: "query")')
|
|
124
133
|
async chatSessionInbox(
|
|
134
|
+
@Arg('anchorType') anchorType: string,
|
|
135
|
+
@Arg('anchorId') anchorId: string,
|
|
136
|
+
@Ctx() context: ResolverContext,
|
|
137
|
+
/* 숨긴 대화 보기 — 숨김이 한 번 넣으면 못 꺼내는 함정이 되지 않도록 필터를 준다. */
|
|
138
|
+
@Arg('showHidden', { nullable: true }) showHidden?: boolean,
|
|
139
|
+
/* 대량 목록 대응 — 검색·소유 필터·페이지. 목록 UI 는 처음부터 이걸 갖고 있어야 한다. */
|
|
140
|
+
@Arg('search', { nullable: true }) search?: string,
|
|
141
|
+
@Arg('mineOnly', { nullable: true }) mineOnly?: boolean,
|
|
142
|
+
@Arg('limit', () => Int, { nullable: true }) limit?: number,
|
|
143
|
+
@Arg('offset', () => Int, { nullable: true }) offset?: number
|
|
144
|
+
): Promise<ChatSessionInboxEntry[]> {
|
|
145
|
+
const filtered = await this._inboxFiltered(anchorType, anchorId, context, { showHidden, search, mineOnly })
|
|
146
|
+
const from = Math.max(0, offset ?? 0)
|
|
147
|
+
const size = Math.min(Math.max(1, limit ?? 50), 200)
|
|
148
|
+
return filtered.slice(from, from + size) as any
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* 필터를 통과한 **전체 개수** — "더 보기" 가 남았는지, 검색 결과가 몇 건인지.
|
|
153
|
+
*
|
|
154
|
+
* 목록과 같은 요청에 담아 가져가므로 왕복은 늘지 않는다. 개수를 모르면 사용자는 목록의 끝이
|
|
155
|
+
* 페이지의 끝인지 자료의 끝인지 구별할 수 없다.
|
|
156
|
+
*/
|
|
157
|
+
@Query(() => Int, {
|
|
158
|
+
description:
|
|
159
|
+
'Total number of sessions matching the same filters as chatSessionInbox (before paging). Request it alongside the list so the UI can tell "end of page" from "end of data".'
|
|
160
|
+
})
|
|
161
|
+
@Directive('@privilege(category: "board-ai", privilege: "query")')
|
|
162
|
+
async chatSessionInboxTotal(
|
|
163
|
+
@Arg('anchorType') anchorType: string,
|
|
164
|
+
@Arg('anchorId') anchorId: string,
|
|
165
|
+
@Ctx() context: ResolverContext,
|
|
166
|
+
@Arg('showHidden', { nullable: true }) showHidden?: boolean,
|
|
167
|
+
@Arg('search', { nullable: true }) search?: string,
|
|
168
|
+
@Arg('mineOnly', { nullable: true }) mineOnly?: boolean
|
|
169
|
+
): Promise<number> {
|
|
170
|
+
return (await this._inboxFiltered(anchorType, anchorId, context, { showHidden, search, mineOnly })).length
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** 필터 적용(페이지 전) — 목록·개수가 **같은 규칙**을 쓰도록 한 곳에 둔다(어긋나면 "더 보기" 가 거짓말이 된다). */
|
|
174
|
+
private async _inboxFiltered(
|
|
175
|
+
anchorType: string,
|
|
176
|
+
anchorId: string,
|
|
177
|
+
context: ResolverContext,
|
|
178
|
+
opts: { showHidden?: boolean; search?: string; mineOnly?: boolean }
|
|
179
|
+
): Promise<ChatSessionInboxEntry[]> {
|
|
180
|
+
const all = await this._inboxEntries(anchorType, anchorId, context)
|
|
181
|
+
let entries = opts.showHidden ? all : all.filter(e => !e.hidden)
|
|
182
|
+
if (opts.mineOnly) entries = entries.filter(e => (e as any).mine)
|
|
183
|
+
return searchInbox(entries as any, opts.search) as any
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* 내가 숨긴 대화 수 — 필터에 "몇 개가 접혀 있는지" 를 보여주기 위한 값.
|
|
188
|
+
*
|
|
189
|
+
* 숨긴 것이 몇 개인지 모르면 필터를 눌러 볼 이유가 생기지 않는다(숨긴 사실 자체를 잊는다).
|
|
190
|
+
* 목록 조회와 **같은 요청**에 담아 가져갈 수 있으므로 왕복은 늘지 않는다.
|
|
191
|
+
*/
|
|
192
|
+
@Query(() => Int, {
|
|
193
|
+
description:
|
|
194
|
+
"Number of sessions on this anchor that the caller has hidden and that have had no activity since. Meant to be requested alongside chatSessionInbox in the same operation so the filter can say how many are folded away."
|
|
195
|
+
})
|
|
196
|
+
@Directive('@privilege(category: "board-ai", privilege: "query")')
|
|
197
|
+
async hiddenSessionCount(
|
|
125
198
|
@Arg('anchorType') anchorType: string,
|
|
126
199
|
@Arg('anchorId') anchorId: string,
|
|
127
200
|
@Ctx() context: ResolverContext
|
|
201
|
+
): Promise<number> {
|
|
202
|
+
const all = await this._inboxEntries(anchorType, anchorId, context)
|
|
203
|
+
return all.filter(e => e.hidden).length
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** 목록 조립 — 숨김 표시까지 붙인 **전체**. 목록·개수 두 진입점이 같은 규칙을 쓰도록 한 곳에 둔다. */
|
|
207
|
+
private async _inboxEntries(
|
|
208
|
+
anchorType: string,
|
|
209
|
+
anchorId: string,
|
|
210
|
+
context: ResolverContext
|
|
128
211
|
): Promise<ChatSessionInboxEntry[]> {
|
|
129
212
|
const { domain, user } = context.state
|
|
213
|
+
/* 조회 상한 — 한 앵커의 대화가 아무리 늘어도 한 번에 이만큼만 훑는다. 최근 활동순으로 자른다
|
|
214
|
+
* (오래된 대화가 잘리는 것이 최근 대화가 잘리는 것보다 낫다). 정렬·검색·페이지는 순수 로직이 한다. */
|
|
130
215
|
const sessions = await getRepository(ChatSession).find({
|
|
131
|
-
where: anchorWhere(domain.id, anchorType, anchorId)
|
|
216
|
+
where: anchorWhere(domain.id, anchorType, anchorId),
|
|
217
|
+
order: { lastMessageAt: 'DESC' },
|
|
218
|
+
take: INBOX_SCAN_CAP
|
|
132
219
|
})
|
|
133
220
|
if (sessions.length === 0) return []
|
|
134
|
-
const
|
|
221
|
+
const rows = await getRepository(ChatSessionParticipant).find({
|
|
135
222
|
where: { domain: { id: domain.id } as any, user: { id: user.id } as any, session: { id: In(sessions.map(s => s.id!)) } as any }
|
|
136
223
|
})
|
|
137
|
-
|
|
224
|
+
/* 'viewer' 는 대화에 낀 사람이 아니다 — 숨김을 기록하려고 만든 행이므로 읽음·안 읽음 판정에서 뺀다.
|
|
225
|
+
* (참여자가 아닌 사람에게 남의 협의 배지를 띄우지 않는다.) */
|
|
226
|
+
const participations = rows.filter(r => r.role !== 'viewer')
|
|
227
|
+
const hiddenAt = new Map(rows.map(r => [(r as any).sessionId as string, r.hiddenAt]))
|
|
228
|
+
/* 내가 만든 대화인지 표시 — 삭제 버튼을 보일지 결정하는 UI 힌트(권한은 서버가 다시 판정한다). */
|
|
229
|
+
const createdByMe = new Set(sessions.filter(s => (s as any).creatorId === user?.id).map(s => s.id))
|
|
230
|
+
const entries = buildInbox(sessions as any, participations as any).map(entry => ({
|
|
231
|
+
...entry,
|
|
232
|
+
mine: createdByMe.has(entry.id)
|
|
233
|
+
}))
|
|
234
|
+
/* showHidden=true 로 불러 **전체**를 얻는다 — 숨김 여부 판정을 두 곳에 복제하지 않기 위함. */
|
|
235
|
+
return applyHidden(entries as any, hiddenAt, true) as any
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* 세션 삭제 — **만든 사람만.**
|
|
240
|
+
*
|
|
241
|
+
* 협의는 여럿의 기록이고 삭제는 화면에서 되돌릴 수 없다. 그래서 권한을 좁힌다(이름 변경은 되돌릴 수
|
|
242
|
+
* 있어서 누구나 할 수 있는 것과 대비). 권한이 없는 사람에게는 `hideAISession`(내 목록에서만 숨김)이
|
|
243
|
+
* 있다 — 남의 기록을 지우지 않고 자기 목록을 정리하는 길.
|
|
244
|
+
*
|
|
245
|
+
* 소프트 삭제(deletedAt)다 — 메시지·패치는 세션을 통해서만 도달하므로 함께 사라지고, 잘못 지운 경우
|
|
246
|
+
* 데이터로는 남아 있다. 다른 참여자 목록에서도 사라져야 하므로 meta 활동으로 방송한다.
|
|
247
|
+
*/
|
|
248
|
+
@Mutation(() => Boolean, {
|
|
249
|
+
description:
|
|
250
|
+
'Delete a chat session — creator only, since a shared deliberation cannot be un-deleted from the UI. Participants who are not the creator can hide it from their own list instead (hideAISession). Soft delete; broadcast as meta activity so every list drops it.'
|
|
251
|
+
})
|
|
252
|
+
@Directive('@privilege(category: "board-ai", privilege: "mutation")')
|
|
253
|
+
async deleteAISession(@Arg('sessionId') sessionId: string, @Ctx() context: ResolverContext): Promise<boolean> {
|
|
254
|
+
const { domain, user } = context.state
|
|
255
|
+
const repo = getRepository(ChatSession)
|
|
256
|
+
const session = await repo.findOneBy({ id: sessionId, domain: { id: domain.id } as any })
|
|
257
|
+
if (!session) return false
|
|
258
|
+
if (!user?.id || (session as any).creatorId !== user.id) {
|
|
259
|
+
/* 조용한 무동작이 아니라 명시 실패 — 지워진 줄 알고 넘어가면 안 된다. */
|
|
260
|
+
throw new Error('Only the user who created this session may delete it. Hide it from your own list instead.')
|
|
261
|
+
}
|
|
262
|
+
await repo.softDelete(sessionId)
|
|
263
|
+
publishSessionActivity({
|
|
264
|
+
domainId: domain.id,
|
|
265
|
+
sessionId,
|
|
266
|
+
kind: 'meta',
|
|
267
|
+
anchorType: (session as any).anchorType,
|
|
268
|
+
anchorId: (session as any).anchorId,
|
|
269
|
+
actorId: user?.id
|
|
270
|
+
})
|
|
271
|
+
return true
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* 내 목록에서 숨기기·되돌리기 — **나에게만** 적용된다(대화·다른 참여자 목록은 그대로).
|
|
276
|
+
*
|
|
277
|
+
* 참여자가 아니어도 숨길 수 있어야 한다 — 목록에는 같은 공간의 남의 협의도 보이고, 그것을 정리하는
|
|
278
|
+
* 것은 남에게 아무 영향이 없다. 그 경우 'viewer' 역할로 기록만 남긴다(참여자 명부·배지에는 들지 않는다).
|
|
279
|
+
*/
|
|
280
|
+
@Mutation(() => Boolean, {
|
|
281
|
+
description:
|
|
282
|
+
'Hide (or unhide) a session from the calling user\'s own list. Per-user and reversible — the session and other participants are unaffected. Activity newer than the hide makes it reappear; the inbox filter (showHidden) reveals hidden ones.'
|
|
283
|
+
})
|
|
284
|
+
@Directive('@privilege(category: "board-ai", privilege: "mutation")')
|
|
285
|
+
async hideAISession(
|
|
286
|
+
@Arg('sessionId') sessionId: string,
|
|
287
|
+
@Ctx() context: ResolverContext,
|
|
288
|
+
@Arg('hidden', { nullable: true }) hidden?: boolean
|
|
289
|
+
): Promise<boolean> {
|
|
290
|
+
const { domain, user } = context.state
|
|
291
|
+
if (!user?.id) return false
|
|
292
|
+
const session = await getRepository(ChatSession).findOneBy({ id: sessionId, domain: { id: domain.id } as any })
|
|
293
|
+
if (!session) return false
|
|
294
|
+
const repo = getRepository(ChatSessionParticipant)
|
|
295
|
+
const existing = await repo.findOne({
|
|
296
|
+
where: { domain: { id: domain.id } as any, session: { id: sessionId } as any, user: { id: user.id } as any }
|
|
297
|
+
})
|
|
298
|
+
const hiddenAt = hidden === false ? null : new Date()
|
|
299
|
+
if (existing) {
|
|
300
|
+
await repo.update(existing.id!, { hiddenAt, updater: user } as any)
|
|
301
|
+
} else {
|
|
302
|
+
if (hidden === false) return true // 숨긴 적이 없으면 되돌릴 것도 없다
|
|
303
|
+
await repo.save({
|
|
304
|
+
domain: { id: domain.id } as any,
|
|
305
|
+
session: { id: sessionId } as any,
|
|
306
|
+
user: { id: user.id } as any,
|
|
307
|
+
role: 'viewer', // 대화에 낀 것이 아니라 숨김만 기록한다
|
|
308
|
+
hiddenAt,
|
|
309
|
+
creator: user,
|
|
310
|
+
updater: user
|
|
311
|
+
} as any)
|
|
312
|
+
}
|
|
313
|
+
return true
|
|
138
314
|
}
|
|
139
315
|
|
|
140
316
|
/**
|
|
@@ -407,6 +583,122 @@ export class ChatSessionResolver {
|
|
|
407
583
|
return true
|
|
408
584
|
}
|
|
409
585
|
|
|
586
|
+
@Directive('@transaction')
|
|
587
|
+
@Mutation(() => Boolean, {
|
|
588
|
+
description:
|
|
589
|
+
'Append a system note to a session — context that did not come from a participant typing (e.g. a conversation carried over from another surface, an external state change). The AI sees it as part of the shared history.'
|
|
590
|
+
})
|
|
591
|
+
@Directive('@privilege(category: "board-ai", privilege: "mutation")')
|
|
592
|
+
async appendSessionNote(
|
|
593
|
+
@Arg('sessionId') sessionId: string,
|
|
594
|
+
@Arg('content') content: string,
|
|
595
|
+
@Ctx() context: ResolverContext
|
|
596
|
+
): Promise<boolean> {
|
|
597
|
+
const { domain, user, tx } = context.state
|
|
598
|
+
const session = await getRepository(ChatSession, tx).findOneBy({ id: sessionId, domain: { id: domain.id } })
|
|
599
|
+
if (!session) throw new Error(`ChatSession ${sessionId} not found`)
|
|
600
|
+
/* 프롬프트가 한 기록으로 폭증하지 않도록 자른다 — 맥락 전달이 목적이고 전문 보존이 아니다. */
|
|
601
|
+
const body = (content ?? '').trim().slice(0, 4000)
|
|
602
|
+
if (!body) return false
|
|
603
|
+
|
|
604
|
+
const previousLast = await getRepository(ChatMessage, tx).findOne({
|
|
605
|
+
where: { session: { id: session.id } as any },
|
|
606
|
+
order: { createdAt: 'DESC' }
|
|
607
|
+
})
|
|
608
|
+
const sysMsg = await getRepository(ChatMessage, tx).save({
|
|
609
|
+
session: { id: session.id } as any,
|
|
610
|
+
role: 'system',
|
|
611
|
+
content: body,
|
|
612
|
+
creator: user,
|
|
613
|
+
updater: user,
|
|
614
|
+
parentMessage: previousLast ? ({ id: previousLast.id } as any) : undefined
|
|
615
|
+
} as any)
|
|
616
|
+
|
|
617
|
+
/* 활동 갱신 + 방송 — 다른 참여자 목록·안 읽음과 열려 있는 화면의 transcript 일관성. best-effort. */
|
|
618
|
+
try {
|
|
619
|
+
await getRepository(ChatSession, tx).update(session.id!, activityPatch(body) as any)
|
|
620
|
+
} catch {
|
|
621
|
+
/* 기록 자체를 막지 않는다 */
|
|
622
|
+
}
|
|
623
|
+
publishSessionActivity({
|
|
624
|
+
domainId: domain.id,
|
|
625
|
+
sessionId: session.id!,
|
|
626
|
+
kind: 'message',
|
|
627
|
+
anchorType: (session as any).anchorType,
|
|
628
|
+
anchorId: (session as any).anchorId,
|
|
629
|
+
preview: body,
|
|
630
|
+
actorId: user?.id
|
|
631
|
+
})
|
|
632
|
+
publishChatMessage({ domainId: domain.id, sessionId: session.id, message: sysMsg })
|
|
633
|
+
return true
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
@Directive('@transaction')
|
|
637
|
+
@Mutation(() => Boolean, {
|
|
638
|
+
description:
|
|
639
|
+
'Carry a conversation from another surface into this session as its own turns (user / assistant), preserving order. Use when a lightweight exchange is promoted into a persistent discussion — the participants should read it as the conversation continuing, not as a machine dump.'
|
|
640
|
+
})
|
|
641
|
+
@Directive('@privilege(category: "board-ai", privilege: "mutation")')
|
|
642
|
+
async appendSessionTurns(
|
|
643
|
+
@Arg('sessionId') sessionId: string,
|
|
644
|
+
@Arg('turns', () => GraphQLJSON) turns: { role?: string; content?: string }[],
|
|
645
|
+
@Ctx() context: ResolverContext
|
|
646
|
+
): Promise<boolean> {
|
|
647
|
+
const { domain, user, tx } = context.state
|
|
648
|
+
const session = await getRepository(ChatSession, tx).findOneBy({ id: sessionId, domain: { id: domain.id } })
|
|
649
|
+
if (!session) throw new Error(`ChatSession ${sessionId} not found`)
|
|
650
|
+
|
|
651
|
+
/* 옮겨온 대화는 **그 대화의 발언 그대로** 들어간다 — 한 덩어리 기록으로 뭉치면 사람이 읽을 수 없고
|
|
652
|
+
* (화면 하나를 잡아먹는 이탤릭 덩어리), 마크다운도 죽는다. 대신 개수·길이는 제한한다: 맥락 전달이
|
|
653
|
+
* 목적이고 전문 보존이 아니다. */
|
|
654
|
+
const rows = (Array.isArray(turns) ? turns : [])
|
|
655
|
+
.map(t => ({
|
|
656
|
+
role: t?.role === 'ai' || t?.role === 'assistant' ? 'assistant' : t?.role === 'user' ? 'user' : 'system',
|
|
657
|
+
content: (t?.content ?? '').trim().slice(0, 4000)
|
|
658
|
+
}))
|
|
659
|
+
.filter(t => t.content)
|
|
660
|
+
.slice(-40)
|
|
661
|
+
if (!rows.length) return false
|
|
662
|
+
|
|
663
|
+
let previous = await getRepository(ChatMessage, tx).findOne({
|
|
664
|
+
where: { session: { id: session.id } as any },
|
|
665
|
+
order: { createdAt: 'DESC' }
|
|
666
|
+
})
|
|
667
|
+
const saved: ChatMessage[] = []
|
|
668
|
+
for (const row of rows) {
|
|
669
|
+
previous = await getRepository(ChatMessage, tx).save({
|
|
670
|
+
session: { id: session.id } as any,
|
|
671
|
+
role: row.role,
|
|
672
|
+
content: row.content,
|
|
673
|
+
creator: user,
|
|
674
|
+
updater: user,
|
|
675
|
+
parentMessage: previous ? ({ id: previous.id } as any) : undefined
|
|
676
|
+
} as any)
|
|
677
|
+
saved.push(previous!)
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/* 활동은 마지막 발언 기준 한 번만 — 옮긴 줄마다 목록을 흔들 이유가 없다. */
|
|
681
|
+
const last = rows[rows.length - 1]
|
|
682
|
+
try {
|
|
683
|
+
await getRepository(ChatSession, tx).update(session.id!, activityPatch(last.content) as any)
|
|
684
|
+
} catch {
|
|
685
|
+
/* best-effort — 옮기기 자체를 막지 않는다 */
|
|
686
|
+
}
|
|
687
|
+
publishSessionActivity({
|
|
688
|
+
domainId: domain.id,
|
|
689
|
+
sessionId: session.id!,
|
|
690
|
+
kind: 'message',
|
|
691
|
+
anchorType: (session as any).anchorType,
|
|
692
|
+
anchorId: (session as any).anchorId,
|
|
693
|
+
preview: last.content,
|
|
694
|
+
actorId: user?.id
|
|
695
|
+
})
|
|
696
|
+
for (const message of saved) {
|
|
697
|
+
publishChatMessage({ domainId: domain.id, sessionId: session.id, message })
|
|
698
|
+
}
|
|
699
|
+
return true
|
|
700
|
+
}
|
|
701
|
+
|
|
410
702
|
@Directive('@transaction')
|
|
411
703
|
@Mutation(() => PatchEntry, {
|
|
412
704
|
description: 'Record a patch from user direct edit. Adds a system message so AI sees the change next turn.'
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 근거집합의 정의 검증 — 무엇을 "모델이 받은 것" 으로 볼 것인가.
|
|
3
|
+
*
|
|
4
|
+
* 판정 규칙(식별자 대조·소음 제거)은 ai-client-base 가 소유하고 그쪽에서 검증한다.
|
|
5
|
+
* 여기서 지키는 것은 **하나도 빠뜨리지 않는 것**이다 — 근거를 빠뜨리면 정상 답에 경고가 붙고,
|
|
6
|
+
* 그 오탐이 반복되면 사용자는 경고를 무시한다(가드가 죽는다).
|
|
7
|
+
*/
|
|
8
|
+
import { buildGroundedCorpus, checkReplyGrounding } from './grounding'
|
|
9
|
+
|
|
10
|
+
describe('buildGroundedCorpus', () => {
|
|
11
|
+
it('네 갈래를 모두 담는다 — 프롬프트·보드 문맥·이력·도구 결과', () => {
|
|
12
|
+
const corpus = buildGroundedCorpus({
|
|
13
|
+
systemPrompt: 'types: dock-door',
|
|
14
|
+
boardContext: '{"components":[{"id":"gauge-1"}]}',
|
|
15
|
+
messages: ['aisle-2 상태 알려줘'],
|
|
16
|
+
toolResults: ['{"nodes":[{"id":"rack-9"}]}']
|
|
17
|
+
})
|
|
18
|
+
for (const id of ['dock-door', 'gauge-1', 'aisle-2', 'rack-9']) {
|
|
19
|
+
expect(corpus).toContain(id)
|
|
20
|
+
}
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('빈 갈래는 건너뛴다 — 근거 없는 대화(즉석 질문)도 성립한다', () => {
|
|
24
|
+
expect(buildGroundedCorpus({})).toBe('')
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
describe('checkReplyGrounding', () => {
|
|
29
|
+
const sources = {
|
|
30
|
+
systemPrompt: 'creatable types: dock-door, twin-node',
|
|
31
|
+
boardContext: '{"components":[{"id":"dock-1","name":"입고 도크"}]}',
|
|
32
|
+
messages: ['지금 상태 어때?'],
|
|
33
|
+
toolResults: ['{"instances":[{"instanceId":"hatiolab-wms"}],"nodes":[{"id":"dock-1"}]}']
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
it('도구가 돌려준 대상으로 답하면 경고가 없다', () => {
|
|
37
|
+
expect(checkReplyGrounding('hatiolab-wms 의 dock-1 은 여유롭습니다.', sources)).toEqual([])
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('프롬프트의 타입 이름을 말하는 것은 근거 있는 것이다 — 오탐 방지', () => {
|
|
41
|
+
expect(checkReplyGrounding('dock-door 컴포넌트를 추가했습니다.', sources)).toEqual([])
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('근거 밖의 대상을 지목하면 경고한다', () => {
|
|
45
|
+
expect(checkReplyGrounding('dock-5 에 적체가 있습니다.', sources)).toEqual(['dock-5'])
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('도구를 부르지 않았으면 도구 결과가 근거에 없다 — 그때 언급한 대상은 경고 대상', () => {
|
|
49
|
+
expect(checkReplyGrounding('rack-3 이 포화입니다.', { ...sources, toolResults: [] })).toEqual(['rack-3'])
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('빈 답은 검사하지 않는다', () => {
|
|
53
|
+
expect(checkReplyGrounding('', sources)).toEqual([])
|
|
54
|
+
})
|
|
55
|
+
})
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 접지 검사 — 이 대화면의 답이 **모델이 실제로 받은 것** 안에서 말했는지.
|
|
3
|
+
*
|
|
4
|
+
* ── 왜 board-ai 에 필요한가 ─────────────────────────────────────────────────
|
|
5
|
+
* 협의 도크(operato-twin)는 board-ai 위에 서 있고, 여기서 사람들이 운영 상태를 묻는다.
|
|
6
|
+
* 헤더 경로(twin-ai)에는 접지 가드가 있었지만 도크 경로에는 없었다 — 같은 질문에 대해
|
|
7
|
+
* 한쪽은 검증되고 한쪽은 검증되지 않는 상태였다. 판정 규칙은 공용 모듈이 소유하고
|
|
8
|
+
* (`@things-factory/ai-client-base` 의 grounding), 이 파일은 **무엇을 근거로 볼 것인가**만 정한다.
|
|
9
|
+
*
|
|
10
|
+
* ── 근거집합의 정의 ─────────────────────────────────────────────────────────
|
|
11
|
+
* 모델이 이 턴에 받은 전부다. 하나라도 빼면 정상 답이 경고를 받는다(오탐):
|
|
12
|
+
* · systemPrompt — 생성 가능한 컴포넌트 타입 목록 등. 여기 있는 이름을 말하는 건 정상이다.
|
|
13
|
+
* · boardContext — 현재 보드 요약(컴포넌트 id·이름). 보드를 두고 말하는 근거.
|
|
14
|
+
* · messages — 사용자·이력 텍스트. 사용자가 말한 식별자를 되받는 건 정상이다.
|
|
15
|
+
* · toolResults — 도구가 돌려준 원문(압축 전). 운영 상태 답의 유일한 사실 근거.
|
|
16
|
+
*
|
|
17
|
+
* ── 사정거리 ────────────────────────────────────────────────────────────────
|
|
18
|
+
* 식별자만 본다. "지게차들이 바쁘게 움직인다" 처럼 **식별자 없이 지어낸 서술**은 잡지 못한다 —
|
|
19
|
+
* 그건 도구를 부르지 않고 답한 경우이고, 도구 호출을 강제하는 별도 레버의 몫이다.
|
|
20
|
+
*/
|
|
21
|
+
import { groundingWarnings } from '@things-factory/ai-client-base'
|
|
22
|
+
|
|
23
|
+
export interface GroundingSources {
|
|
24
|
+
/** 시스템 프롬프트(타입 목록·규칙). */
|
|
25
|
+
systemPrompt?: string
|
|
26
|
+
/** 보드 상태 요약 등 대화 앞에 붙인 문맥. */
|
|
27
|
+
boardContext?: string
|
|
28
|
+
/** 사용자·어시스턴트 이력 텍스트. */
|
|
29
|
+
messages?: string[]
|
|
30
|
+
/** tool_result 로 회신된 원문(시간순). */
|
|
31
|
+
toolResults?: string[]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 근거집합을 한 덩어리 문자열로. (id 토큰 대조만 하므로 구분자는 개행으로 충분하다.) */
|
|
35
|
+
export function buildGroundedCorpus(sources: GroundingSources): string {
|
|
36
|
+
return [
|
|
37
|
+
sources.systemPrompt ?? '',
|
|
38
|
+
sources.boardContext ?? '',
|
|
39
|
+
...(sources.messages ?? []),
|
|
40
|
+
...(sources.toolResults ?? [])
|
|
41
|
+
]
|
|
42
|
+
.filter(Boolean)
|
|
43
|
+
.join('\n')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 답이 근거 밖의 식별자를 지목했으면 그 목록. 비면 접지 정상.
|
|
48
|
+
* 답을 고치거나 막지 않는다 — 사용자에게 표시할 경고만 낸다.
|
|
49
|
+
*/
|
|
50
|
+
export function checkReplyGrounding(reply: string, sources: GroundingSources): string[] {
|
|
51
|
+
if (!reply) return []
|
|
52
|
+
return groundingWarnings(reply, buildGroundedCorpus(sources))
|
|
53
|
+
}
|
package/server/service/types.ts
CHANGED
|
@@ -27,6 +27,15 @@ export interface ChatOptions {
|
|
|
27
27
|
* 되돌릴 수 있는 편집이 한 대화에 섞이지 않게 한다.
|
|
28
28
|
*/
|
|
29
29
|
boardTools?: boolean
|
|
30
|
+
/**
|
|
31
|
+
* **첫 턴에 도구 호출을 강제**한다. 라이브 상태를 다루는 대화면(운영 협의)에서 켠다.
|
|
32
|
+
*
|
|
33
|
+
* 접지 가드는 답이 지어낸 **식별자**를 잡지만, 식별자 없이 지어낸 **상황 서술**은 잡지 못한다
|
|
34
|
+
* ("지게차들이 바쁘게 움직이고 있다" — 실제로 나온 답이다). 그건 도구를 아예 부르지 않고 답한
|
|
35
|
+
* 경우여서 사후 검사로 막을 수 없다. 첫 턴에 무엇이든 조회하게 만들면 근거 없이 상태를 단언할
|
|
36
|
+
* 길이 없어진다. 켜고 끄는 판단은 대화면이 한다 — 서버가 질문 내용을 눈치로 분류하지 않는다.
|
|
37
|
+
*/
|
|
38
|
+
requireGroundingTools?: boolean
|
|
30
39
|
/**
|
|
31
40
|
* 호스트(대화면)가 실어 보내는 **문맥** — 도구가 ctx.state.host 로 읽는다.
|
|
32
41
|
*
|
|
@@ -133,6 +142,13 @@ export interface ChatResponse {
|
|
|
133
142
|
followUp?: string
|
|
134
143
|
/** AI 가 응답 만드는 과정에서 호출한 도구들 (시간순). UI fold-able 박스용. */
|
|
135
144
|
toolUsages?: ToolUsage[]
|
|
145
|
+
/**
|
|
146
|
+
* 접지 경고 — 답이 언급했으나 모델이 받은 근거(프롬프트·보드 문맥·이력·도구 결과)에 없는 식별자.
|
|
147
|
+
*
|
|
148
|
+
* 있으면 그 대상은 **만들어 낸 것일 수 있다**. 답을 막거나 고치지 않고 사용자에게 표시한다 —
|
|
149
|
+
* 판정 규칙·사정거리는 ./grounding 참조. 비면(undefined) 접지 정상.
|
|
150
|
+
*/
|
|
151
|
+
groundingWarnings?: string[]
|
|
136
152
|
}
|
|
137
153
|
|
|
138
154
|
export interface BoardEditPatch {
|
package/translations/en.json
CHANGED
|
@@ -45,5 +45,7 @@
|
|
|
45
45
|
"board-ai.text.copied": "Copied",
|
|
46
46
|
"board-ai.text.mention-not-found": "#{token} not found",
|
|
47
47
|
"board-ai.text.session-tab-label": "Session {n}",
|
|
48
|
-
"board-ai.text.tools-used": "Tools used"
|
|
48
|
+
"board-ai.text.tools-used": "Tools used",
|
|
49
|
+
"board-ai.text.ungrounded-mention": "Mentions an unverified target — not present in the actual data:",
|
|
50
|
+
"board-ai.text.toggle-system-note": "click to expand"
|
|
49
51
|
}
|
package/translations/ja.json
CHANGED
|
@@ -44,5 +44,7 @@
|
|
|
44
44
|
"board-ai.text.copied": "コピーしました",
|
|
45
45
|
"board-ai.text.mention-not-found": "#{token} が見つかりません",
|
|
46
46
|
"board-ai.text.session-tab-label": "セッション {n}",
|
|
47
|
-
"board-ai.text.tools-used": "使用したツール"
|
|
47
|
+
"board-ai.text.tools-used": "使用したツール",
|
|
48
|
+
"board-ai.text.ungrounded-mention": "確認できない対象に言及しています — 実データにありません:",
|
|
49
|
+
"board-ai.text.toggle-system-note": "クリックで展開"
|
|
48
50
|
}
|
package/translations/ko.json
CHANGED
|
@@ -23,6 +23,8 @@
|
|
|
23
23
|
"board-ai.text.failed-to-load-history": "이력 로드 실패",
|
|
24
24
|
"board-ai.text.empty-response": "응답이 비어있습니다.",
|
|
25
25
|
"board-ai.text.tools-used": "도구 사용",
|
|
26
|
+
"board-ai.text.ungrounded-mention": "확인되지 않은 대상을 언급했습니다 — 실제 데이터에 없습니다:",
|
|
27
|
+
"board-ai.text.toggle-system-note": "눌러서 펼치기",
|
|
26
28
|
|
|
27
29
|
"board-ai.example.create-monitoring-dashboard": "가동률·온도·압력 3개 게이지와 24시간 라인 차트로 모니터링 대시보드",
|
|
28
30
|
"board-ai.example.create-welcome-screen": "큰 'OPERATO' 제목과 시계, 부제로 환영 화면",
|
package/translations/ms.json
CHANGED
|
@@ -44,5 +44,7 @@
|
|
|
44
44
|
"board-ai.text.copied": "Disalin",
|
|
45
45
|
"board-ai.text.mention-not-found": "#{token} tidak dijumpai",
|
|
46
46
|
"board-ai.text.session-tab-label": "Sesi {n}",
|
|
47
|
-
"board-ai.text.tools-used": "Alat yang digunakan"
|
|
47
|
+
"board-ai.text.tools-used": "Alat yang digunakan",
|
|
48
|
+
"board-ai.text.ungrounded-mention": "Menyebut sasaran yang tidak disahkan — tiada dalam data sebenar:",
|
|
49
|
+
"board-ai.text.toggle-system-note": "klik untuk kembangkan"
|
|
48
50
|
}
|
package/translations/zh.json
CHANGED
|
@@ -44,5 +44,7 @@
|
|
|
44
44
|
"board-ai.text.copied": "已复制",
|
|
45
45
|
"board-ai.text.mention-not-found": "未找到 #{token}",
|
|
46
46
|
"board-ai.text.session-tab-label": "会话 {n}",
|
|
47
|
-
"board-ai.text.tools-used": "使用的工具"
|
|
47
|
+
"board-ai.text.tools-used": "使用的工具",
|
|
48
|
+
"board-ai.text.ungrounded-mention": "提到了未经确认的对象 — 实际数据中不存在:",
|
|
49
|
+
"board-ai.text.toggle-system-note": "点击展开"
|
|
48
50
|
}
|