@things-factory/ai-assistant 10.1.212 → 10.1.214
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 +115 -2
- package/client/components/assistant-dock.ts +106 -3
- package/client/components/assistant-selection-bar.test.ts +42 -0
- package/client/components/assistant-selection-bar.ts +129 -0
- package/client/components/workspace-view.ts +56 -3
- package/client/index.ts +5 -0
- package/client/utils/assistant-ask.test.ts +38 -0
- package/client/utils/assistant-ask.ts +35 -1
- package/client/utils/assistant-proposals.ts +23 -0
- package/client/utils/assistant-selection.test.ts +54 -0
- package/client/utils/assistant-selection.ts +74 -0
- package/client/utils/assistant-session-controller.ts +2 -0
- package/client/utils/assistant-session-transport.ts +1 -1
- package/client/utils/invite-proposal-register.ts +31 -0
- package/client/utils/invite-proposal.test.ts +40 -0
- package/client/utils/invite-proposal.ts +54 -0
- package/dist-client/components/assistant-chat.d.ts +18 -0
- package/dist-client/components/assistant-chat.js +117 -2
- package/dist-client/components/assistant-chat.js.map +1 -1
- package/dist-client/components/assistant-dock.d.ts +11 -0
- package/dist-client/components/assistant-dock.js +97 -4
- package/dist-client/components/assistant-dock.js.map +1 -1
- package/dist-client/components/assistant-selection-bar.d.ts +44 -0
- package/dist-client/components/assistant-selection-bar.js +149 -0
- package/dist-client/components/assistant-selection-bar.js.map +1 -0
- package/dist-client/components/assistant-selection-bar.test.d.ts +1 -0
- package/dist-client/components/assistant-selection-bar.test.js +38 -0
- package/dist-client/components/assistant-selection-bar.test.js.map +1 -0
- package/dist-client/components/workspace-view.d.ts +2 -0
- package/dist-client/components/workspace-view.js +58 -3
- package/dist-client/components/workspace-view.js.map +1 -1
- package/dist-client/index.d.ts +3 -0
- package/dist-client/index.js +5 -0
- package/dist-client/index.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-client/utils/assistant-ask.d.ts +22 -1
- package/dist-client/utils/assistant-ask.js +19 -0
- package/dist-client/utils/assistant-ask.js.map +1 -1
- package/dist-client/utils/assistant-ask.test.d.ts +1 -0
- package/dist-client/utils/assistant-ask.test.js +40 -0
- package/dist-client/utils/assistant-ask.test.js.map +1 -0
- package/dist-client/utils/assistant-proposals.d.ts +14 -0
- package/dist-client/utils/assistant-proposals.js +7 -0
- package/dist-client/utils/assistant-proposals.js.map +1 -1
- package/dist-client/utils/assistant-selection.d.ts +43 -0
- package/dist-client/utils/assistant-selection.js +59 -0
- package/dist-client/utils/assistant-selection.js.map +1 -0
- package/dist-client/utils/assistant-selection.test.d.ts +1 -0
- package/dist-client/utils/assistant-selection.test.js +46 -0
- package/dist-client/utils/assistant-selection.test.js.map +1 -0
- package/dist-client/utils/assistant-session-controller.d.ts +2 -0
- package/dist-client/utils/assistant-session-controller.js.map +1 -1
- package/dist-client/utils/assistant-session-transport.js +1 -1
- package/dist-client/utils/assistant-session-transport.js.map +1 -1
- package/dist-client/utils/invite-proposal-register.d.ts +1 -0
- package/dist-client/utils/invite-proposal-register.js +24 -0
- package/dist-client/utils/invite-proposal-register.js.map +1 -0
- package/dist-client/utils/invite-proposal.d.ts +17 -0
- package/dist-client/utils/invite-proposal.js +37 -0
- package/dist-client/utils/invite-proposal.js.map +1 -0
- package/dist-client/utils/invite-proposal.test.d.ts +1 -0
- package/dist-client/utils/invite-proposal.test.js +35 -0
- package/dist-client/utils/invite-proposal.test.js.map +1 -0
- package/dist-server/service/assistant-chat-resolver.js +7 -3
- package/dist-server/service/assistant-chat-resolver.js.map +1 -1
- package/dist-server/service/assistant-mode/index.d.ts +2 -1
- package/dist-server/service/assistant-mode/index.js +6 -1
- package/dist-server/service/assistant-mode/index.js.map +1 -1
- package/dist-server/service/assistant-mode/mode-categories.d.ts +29 -1
- package/dist-server/service/assistant-mode/mode-categories.js +57 -5
- package/dist-server/service/assistant-mode/mode-categories.js.map +1 -1
- package/dist-server/service/assistant-mode/screen-rules.d.ts +6 -0
- package/dist-server/service/assistant-mode/screen-rules.js +21 -6
- package/dist-server/service/assistant-mode/screen-rules.js.map +1 -1
- package/dist-server/service/data-read/read-target.d.ts +24 -3
- package/dist-server/service/data-read/read-target.js +42 -10
- package/dist-server/service/data-read/read-target.js.map +1 -1
- package/dist-server/service/data-read/read-tools.d.ts +18 -1
- package/dist-server/service/data-read/read-tools.js +19 -5
- package/dist-server/service/data-read/read-tools.js.map +1 -1
- package/dist-server/service/index.d.ts +5 -1
- package/dist-server/service/index.js +10 -1
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/service/people/index.d.ts +4 -0
- package/dist-server/service/people/index.js +22 -0
- package/dist-server/service/people/index.js.map +1 -0
- package/dist-server/service/people/invite-tools.d.ts +34 -0
- package/dist-server/service/people/invite-tools.js +144 -0
- package/dist-server/service/people/invite-tools.js.map +1 -0
- package/dist-server/service/people/people-keys.d.ts +8 -0
- package/dist-server/service/people/people-keys.js +16 -0
- package/dist-server/service/people/people-keys.js.map +1 -0
- package/dist-server/service/people/people-read.d.ts +4 -0
- package/dist-server/service/people/people-read.js +57 -0
- package/dist-server/service/people/people-read.js.map +1 -0
- package/dist-server/service/testing/mode-conformance.js +3 -0
- package/dist-server/service/testing/mode-conformance.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +6 -6
- package/server/service/assistant-chat-resolver.ts +8 -4
- package/server/service/assistant-mode/conversation-category.test.ts +35 -0
- package/server/service/assistant-mode/index.ts +3 -1
- package/server/service/assistant-mode/mode-categories.ts +72 -5
- package/server/service/assistant-mode/mode-front.test.ts +32 -4
- package/server/service/assistant-mode/resolve.test.ts +1 -1
- package/server/service/assistant-mode/screen-rules.test.ts +23 -1
- package/server/service/assistant-mode/screen-rules.ts +22 -5
- package/server/service/data-entry/data-entry.test.ts +2 -2
- package/server/service/data-read/detail-lists.test.ts +61 -0
- package/server/service/data-read/read-target.ts +54 -9
- package/server/service/data-read/read-tools.ts +32 -6
- package/server/service/index.ts +7 -1
- package/server/service/people/index.ts +13 -0
- package/server/service/people/invite-tools.ts +159 -0
- package/server/service/people/invite.test.ts +159 -0
- package/server/service/people/people-keys.ts +16 -0
- package/server/service/people/people-read.test.ts +82 -0
- package/server/service/people/people-read.ts +56 -0
- package/server/service/testing/mode-conformance.test.ts +14 -1
- package/server/service/testing/mode-conformance.ts +2 -0
- package/test/assistant-dock.test.ts +73 -0
- package/translations/en.json +25 -1
- package/translations/es.json +25 -1
- package/translations/ja.json +25 -1
- package/translations/ko.json +25 -1
- package/translations/ms.json +25 -1
- package/translations/zh.json +25 -1
|
@@ -236,8 +236,21 @@ function argSchema(arg: DetailArg): any {
|
|
|
236
236
|
return { type, ...(said ? { description: said } : {}) }
|
|
237
237
|
}
|
|
238
238
|
|
|
239
|
-
/**
|
|
240
|
-
|
|
239
|
+
/**
|
|
240
|
+
* What running a detail read as the person gave - the root field's value, or why there is none. Shared by the tool,
|
|
241
|
+
* which hands the value to the model, and by every other reader of a detail read (a dashboard card, ADR-0160
|
|
242
|
+
* decision 2), which has its own limits: the arguments, the door and the query are one, the size rule is the tool's.
|
|
243
|
+
*/
|
|
244
|
+
export interface DetailRun {
|
|
245
|
+
/** The root field's value - absent when `error` says why. */
|
|
246
|
+
value?: unknown
|
|
247
|
+
error?: 'argument-unreadable' | 'query-failed' | 'not-found'
|
|
248
|
+
refused?: { name: string; code: string }[]
|
|
249
|
+
message?: string
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Runs the query as the person with the arguments given - read by their declared types, a missing required one refused. */
|
|
253
|
+
export async function runDetail(target: DetailRead, args: any, run: GraphqlRunner): Promise<DetailRun> {
|
|
241
254
|
const variables: Record<string, unknown> = {}
|
|
242
255
|
const refused: { name: string; code: string }[] = []
|
|
243
256
|
for (const arg of target.args ?? []) {
|
|
@@ -250,17 +263,30 @@ export async function queryDetail(target: DetailRead, args: any, run: GraphqlRun
|
|
|
250
263
|
if (read === undefined) refused.push({ name: arg.name, code: code ?? 'unreadable' })
|
|
251
264
|
else variables[arg.name] = read
|
|
252
265
|
}
|
|
253
|
-
if (refused.length) return {
|
|
266
|
+
if (refused.length) return { error: 'argument-unreadable', refused }
|
|
254
267
|
let answer
|
|
255
268
|
try {
|
|
256
269
|
answer = await run(target.query, variables)
|
|
257
270
|
} catch (error: any) {
|
|
258
|
-
return {
|
|
271
|
+
return { error: 'query-failed', message: String(error?.message ?? error) }
|
|
259
272
|
}
|
|
260
|
-
if (answer.errors?.length) return {
|
|
273
|
+
if (answer.errors?.length) return { error: 'query-failed', message: answer.errors.map(one => one.message).join('; ') }
|
|
261
274
|
const root = rootFieldOf(target.query, 'query').replace(/^Query\./, '')
|
|
262
275
|
const value = answer.data?.[root]
|
|
263
|
-
if (value === null || value === undefined) return {
|
|
276
|
+
if (value === null || value === undefined) return { error: 'not-found' }
|
|
277
|
+
return { value }
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Runs the query as the person with the arguments the model set; the answer as the query returned it. */
|
|
281
|
+
export async function queryDetail(target: DetailRead, args: any, run: GraphqlRunner) {
|
|
282
|
+
const ran = await runDetail(target, args, run)
|
|
283
|
+
if (ran.error) {
|
|
284
|
+
if (ran.error === 'argument-unreadable')
|
|
285
|
+
return { rejected: true, error: 'argument-unreadable', refused: ran.refused, message: `these arguments are missing or do not fit their type: ${ran.refused.map(one => `${one.name} (${one.code})`).join(', ')}. Fix them and call again.` }
|
|
286
|
+
if (ran.error === 'query-failed') return { rejected: true, error: 'query-failed', message: `it could not be read: ${ran.message}` }
|
|
287
|
+
return { target: target.key, found: false, message: 'nothing was found for these arguments - say so; do not fill it in.' }
|
|
288
|
+
}
|
|
289
|
+
const value = ran.value
|
|
264
290
|
const size = JSON.stringify(value).length
|
|
265
291
|
if (size > MAX_DETAIL_CHARS) return { rejected: true, error: 'answer-too-large', size, message: `the answer runs to ${size} characters, more than ${MAX_DETAIL_CHARS} - narrow the arguments, or read the list it is made of.` }
|
|
266
292
|
return { target: target.key, answer: value, message: 'answer from these fields; every number you say comes from them. Amounts are in the currency beside them - never add two currencies into one number.' }
|
package/server/service/index.ts
CHANGED
|
@@ -14,6 +14,8 @@ import './chat-session/session-purge.js'
|
|
|
14
14
|
import './chat-session/attachment-route.js'
|
|
15
15
|
import { EntryProposalResolver } from './data-entry/entry-resolver.js'
|
|
16
16
|
import './workspace/workspace-tools.js'
|
|
17
|
+
/* Who works here, and the card that brings one of them into a conversation - every conversation carries both. */
|
|
18
|
+
import './people/index.js'
|
|
17
19
|
import { entities as UnattendedRunEntities } from './unattended-run/index.js'
|
|
18
20
|
|
|
19
21
|
export * from './types.js'
|
|
@@ -35,8 +37,9 @@ export { registerEntryTarget, entryCategoryOf, runAsCaller } from './data-entry/
|
|
|
35
37
|
export { proposeChanges, MAX_CHANGES } from './data-entry/entry-update.js'
|
|
36
38
|
/* Reading an application's lists - an app registers what it lets read; a mode names it (design/ai/read-targets.md). */
|
|
37
39
|
export * from './data-read/read-target.js'
|
|
38
|
-
export { registerReadTarget, readCategoryOf, registerDetailRead, detailCategoryOf } from './data-read/read-tools.js'
|
|
40
|
+
export { registerReadTarget, readCategoryOf, registerDetailRead, detailCategoryOf, runDetail, type DetailRun } from './data-read/read-tools.js'
|
|
39
41
|
export * from './data-read/list-read.js'
|
|
42
|
+
export * from './people/index.js'
|
|
40
43
|
/* The AI 작업판 - large results on a page of their own (design/ai/workspace.md). */
|
|
41
44
|
export * from './workspace/workspace-blocks.js'
|
|
42
45
|
/* 무엇이 근거인가 — 도구 결과 밖의 셋(프롬프트 · 대화 · 화면 스냅숏). */
|
|
@@ -65,6 +68,9 @@ export * from './chat-session-participant/mention-invite.js'
|
|
|
65
68
|
export * from './chat-session-participant/presence.js'
|
|
66
69
|
export * from './chat-message/index.js'
|
|
67
70
|
export * from './chat-session-participant/index.js'
|
|
71
|
+
/* Reading the stored proposals of a conversation and whether one is answered - a package that applies its own card reads them as entry does. */
|
|
72
|
+
export { PROPOSAL_OUTCOME_PREFIX } from './chat-message/proposal-outcome.js'
|
|
73
|
+
export { assertMayWriteSession } from './chat-session/session-write-gate.js'
|
|
68
74
|
/* A mode run with no person in it - a scheduled draft (operato-application/design/ai/unattended-run.md). */
|
|
69
75
|
export * from './unattended-run/index.js'
|
|
70
76
|
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The people tools every conversation carries - the people list (people-read.ts) and the invite card (invite-tools.ts).
|
|
3
|
+
* Registered once, at boot, as the AI 작업판 is.
|
|
4
|
+
*/
|
|
5
|
+
import { registerReadTarget } from '../data-read/read-tools.js'
|
|
6
|
+
import { peopleReadTarget } from './people-read.js'
|
|
7
|
+
import './invite-tools.js'
|
|
8
|
+
|
|
9
|
+
registerReadTarget(peopleReadTarget())
|
|
10
|
+
|
|
11
|
+
export * from './people-keys.js'
|
|
12
|
+
export { peopleReadTarget, PEOPLE_SELECTION } from './people-read.js'
|
|
13
|
+
export { proposeInvite, findPeopleNamed, inviteCategory, MAX_INVITE_CANDIDATES, type InviteCandidate } from './invite-tools.js'
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* proposeInvite - the model offers a card that brings someone into this conversation (ADR-0073 decision 3).
|
|
3
|
+
*
|
|
4
|
+
* The card is a proposal: the model seats nobody. What an assistant writes must not seat people (mention-invite.ts,
|
|
5
|
+
* rule ①), so the seat is given when a person presses the card - the dock calls `inviteToAISession` with that
|
|
6
|
+
* person's request, and the door asks `maySeat` with their seat. This tool only finds who is meant and says whether
|
|
7
|
+
* this person could press it here.
|
|
8
|
+
*
|
|
9
|
+
* Who is meant is the server's to settle, never the model's memory: the name is looked up in the organization's own
|
|
10
|
+
* list as the person (the read people-read.ts opens), so a user id the model made up is never on a card. One person
|
|
11
|
+
* by that name - the card invites them. Several - the card lists each and the person picks one; the model does not ask.
|
|
12
|
+
*/
|
|
13
|
+
import { registerToolCategory, type ToolCategory, type ToolSpec } from '@things-factory/ai-client-base'
|
|
14
|
+
import { getRepository } from '@things-factory/shell'
|
|
15
|
+
|
|
16
|
+
import { runAsCaller } from '../data-entry/entry-tools.js'
|
|
17
|
+
import type { GraphqlRunner } from '../data-entry/entry-update.js'
|
|
18
|
+
import { ChatSession } from '../chat-session/chat-session.js'
|
|
19
|
+
import { maySeat } from '../chat-session/session-visibility.js'
|
|
20
|
+
import { ChatSessionParticipant } from '../chat-session-participant/chat-session-participant.js'
|
|
21
|
+
import { isRoomRole } from '../chat-session-participant/participant-lookup-roles.js'
|
|
22
|
+
import { PEOPLE_SELECTION } from './people-read.js'
|
|
23
|
+
import { INVITE_CATEGORY } from './people-keys.js'
|
|
24
|
+
|
|
25
|
+
/** At most this many people on one card - a name that matches more is too broad to pick from. */
|
|
26
|
+
export const MAX_INVITE_CANDIDATES = 8
|
|
27
|
+
|
|
28
|
+
export interface InviteCandidate {
|
|
29
|
+
userId: string
|
|
30
|
+
name: string
|
|
31
|
+
department?: string
|
|
32
|
+
position?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const PEOPLE_QUERY =
|
|
36
|
+
'query ($filters: [Filter!], $pagination: Pagination, $search: String) ' +
|
|
37
|
+
`{ employees(filters: $filters, pagination: $pagination, search: $search) { items { ${PEOPLE_SELECTION} } total } }`
|
|
38
|
+
|
|
39
|
+
const ACTIVE = { name: 'active', operator: 'eq', value: true }
|
|
40
|
+
|
|
41
|
+
const candidateOf = (row: any): InviteCandidate | undefined =>
|
|
42
|
+
row?.user?.id
|
|
43
|
+
? {
|
|
44
|
+
userId: String(row.user.id),
|
|
45
|
+
name: String(row.name ?? ''),
|
|
46
|
+
...(row.department?.name ? { department: String(row.department.name) } : {}),
|
|
47
|
+
...(row.jobPosition ? { position: String(row.jobPosition) } : {})
|
|
48
|
+
}
|
|
49
|
+
: undefined
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The people a name means, as the person may see them: the exact name first, then the name as a search term. Only
|
|
53
|
+
* people with an account - an invite seats a user.
|
|
54
|
+
*/
|
|
55
|
+
export async function findPeopleNamed(run: GraphqlRunner, name: string): Promise<{ people: InviteCandidate[]; error?: string }> {
|
|
56
|
+
const ask = async (variables: Record<string, unknown>) => {
|
|
57
|
+
const answer = await run(PEOPLE_QUERY, { pagination: { page: 1, limit: MAX_INVITE_CANDIDATES + 1 }, ...variables })
|
|
58
|
+
if (answer.errors?.length) return { error: answer.errors.map(one => one.message).join('; ') }
|
|
59
|
+
return { rows: (answer.data?.employees?.items ?? []) as any[] }
|
|
60
|
+
}
|
|
61
|
+
const exact = await ask({ filters: [ACTIVE, { name: 'name', operator: 'eq', value: name }] })
|
|
62
|
+
if ('error' in exact) return { people: [], error: exact.error }
|
|
63
|
+
const rows = exact.rows!.length ? exact.rows! : ((await ask({ filters: [ACTIVE], search: name })) as any).rows ?? []
|
|
64
|
+
return { people: rows.map(candidateOf).filter(Boolean) as InviteCandidate[] }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Why this person cannot bring anyone into this room, in their words - or nothing when they can. */
|
|
68
|
+
function seatRefusal(visibility: string | null | undefined, t: (key: string, options: any) => string): string | undefined {
|
|
69
|
+
if (visibility === 'private')
|
|
70
|
+
return t('ai-assistant.text.invite-private-refused', { defaultValue: 'a private conversation is yours alone. make it a group to bring people in.' })
|
|
71
|
+
return t('ai-assistant.text.invite-owner-only', { defaultValue: 'only the person who started this group conversation can bring people in.' })
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function proposeInvite(args: any, context: any, run: GraphqlRunner = runAsCaller(context)) {
|
|
75
|
+
const t = (key: string, options: any) => (typeof context?.t === 'function' ? context.t(key, options) : String(options?.defaultValue ?? key))
|
|
76
|
+
const { domain, user } = context?.state ?? {}
|
|
77
|
+
const name = typeof args?.name === 'string' ? args.name.trim() : ''
|
|
78
|
+
const wanted = typeof args?.userId === 'string' ? args.userId.trim() : ''
|
|
79
|
+
if (!name) return { rejected: true, error: 'no-name', message: 'say whom to invite - the name the person used.' }
|
|
80
|
+
|
|
81
|
+
const conversationId = context?.conversation?.id
|
|
82
|
+
const session: any = conversationId && domain?.id ? await getRepository(ChatSession).findOne({ where: { id: conversationId, domain: { id: domain.id } } as any }) : undefined
|
|
83
|
+
if (!session) return { rejected: true, error: 'no-conversation', message: 'this conversation is not stored yet, so nobody can be brought into it.' }
|
|
84
|
+
|
|
85
|
+
/* Asked before anyone is looked up: the card must be one this person can press here. */
|
|
86
|
+
const seats = await getRepository(ChatSessionParticipant).find({ where: { domain: { id: domain.id }, session: { id: session.id } } as any })
|
|
87
|
+
const mine = seats.find((seat: any) => seat.userId === user?.id)?.role
|
|
88
|
+
if (!maySeat(session.visibility, mine)) {
|
|
89
|
+
/* Who reads the conversation is the person's to change, in the conversation list - the model has no tool for it (2026-10-24: it offered 「그룹 대화로 바꿔줘」). */
|
|
90
|
+
return {
|
|
91
|
+
rejected: true,
|
|
92
|
+
error: 'may-not-invite',
|
|
93
|
+
message: `${seatRefusal(session.visibility, t)} Say so in one line - do not offer a card. Who reads this conversation is changed by the person who started it, from the conversation list (⋯); you cannot change it, so never offer to or suggest asking you.`
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const found = await findPeopleNamed(run, name)
|
|
98
|
+
if (found.error) return { rejected: true, error: 'people-unreadable', message: `the people list could not be read: ${found.error}` }
|
|
99
|
+
const here = new Set(seats.filter((seat: any) => isRoomRole(seat.role)).map((seat: any) => seat.userId))
|
|
100
|
+
const already = found.people.filter(one => here.has(one.userId))
|
|
101
|
+
let people = found.people.filter(one => !here.has(one.userId) && one.userId !== user?.id)
|
|
102
|
+
if (wanted) people = people.filter(one => one.userId === wanted)
|
|
103
|
+
|
|
104
|
+
if (!people.length) {
|
|
105
|
+
return already.length
|
|
106
|
+
? { rejected: true, error: 'already-here', message: `${already.map(one => one.name).join(', ')} is already in this conversation.` }
|
|
107
|
+
: { rejected: true, error: 'not-found', message: `no one named "${name}" with an account is in the people list this person may see.` }
|
|
108
|
+
}
|
|
109
|
+
if (people.length > MAX_INVITE_CANDIDATES) {
|
|
110
|
+
return { rejected: true, error: 'too-many', message: `more than ${MAX_INVITE_CANDIDATES} people match "${name}" - ask for the full name or the department.` }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const one = people.length === 1 ? people[0] : undefined
|
|
114
|
+
return {
|
|
115
|
+
proposed: true,
|
|
116
|
+
kind: 'invite',
|
|
117
|
+
icon: 'person_add',
|
|
118
|
+
label: one
|
|
119
|
+
? t('ai-assistant.people.invite-one', { name: one.name, defaultValue: 'Invite {name} to this conversation' })
|
|
120
|
+
: t('ai-assistant.people.invite-pick', { name, count: people.length, defaultValue: '{count} people named {name} - pick whom to invite' }),
|
|
121
|
+
...(one && (one.department || one.position) ? { reason: [one.department, one.position].filter(Boolean).join(' · ') } : {}),
|
|
122
|
+
people,
|
|
123
|
+
message: one
|
|
124
|
+
? `the card invites ${one.name}${one.department ? ` (${one.department})` : ''}. The person presses it; they are told when they join. Say that in one line.`
|
|
125
|
+
: `${people.length} people match "${name}"; the card lists each with department and position and the person picks one. Do not ask which - the card does.`
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const spec = {
|
|
130
|
+
kind: 'read',
|
|
131
|
+
name: 'proposeInvite',
|
|
132
|
+
/* It reads people through the organization's list - the same door that list has, so it is offered where that list is. */
|
|
133
|
+
doors: ['Query.employees'],
|
|
134
|
+
description:
|
|
135
|
+
'Offer a card that brings someone into this conversation. Give the name the person used; the server finds them in ' +
|
|
136
|
+
"the organization's people list and puts them on the card (several people by that name: the card lists each, the " +
|
|
137
|
+
'person picks). The person presses the card - nobody joins until then. One call per person.',
|
|
138
|
+
schema: {
|
|
139
|
+
type: 'object',
|
|
140
|
+
properties: {
|
|
141
|
+
name: { type: 'string', description: 'The name of the person to invite, as the person said it.' },
|
|
142
|
+
userId: { type: 'string', description: 'The user id from the people list, when it was read already and the person meant that one.' }
|
|
143
|
+
},
|
|
144
|
+
required: ['name']
|
|
145
|
+
},
|
|
146
|
+
builder: (args: any, context: any) => proposeInvite(args, context)
|
|
147
|
+
} as ToolSpec
|
|
148
|
+
|
|
149
|
+
export const inviteCategory: ToolCategory = {
|
|
150
|
+
name: INVITE_CATEGORY,
|
|
151
|
+
description: 'bring someone into this conversation - a card the person presses',
|
|
152
|
+
guidance: [
|
|
153
|
+
'- When the person asks to invite, add or bring someone into this conversation, call proposeInvite with that name in the same turn. Never send them to a screen to do it.',
|
|
154
|
+
'- To answer who someone is or who works where, read the people list (queryOrganizationEmployee).'
|
|
155
|
+
].join('\n'),
|
|
156
|
+
specs: [spec]
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
registerToolCategory(inviteCategory)
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The invite card (people/invite-tools.ts) on sqlite - the room's rules are the stored ones, the people list is a
|
|
3
|
+
* stand-in for the organization's query as the person.
|
|
4
|
+
*
|
|
5
|
+
* Asked 「정연희 초대해 줘」 the model said it had no tool to look people up and sent the person to a screen
|
|
6
|
+
* (2026-10-24). Now the name is found in the people list and the card carries whom it means; the card itself seats
|
|
7
|
+
* nobody - a person pressing it does, through the invite door.
|
|
8
|
+
*/
|
|
9
|
+
import type { DataSource } from 'typeorm'
|
|
10
|
+
|
|
11
|
+
const { DataSource: TypeOrmDataSource } = require('typeorm')
|
|
12
|
+
const shell = require('@things-factory/shell')
|
|
13
|
+
const authBase = require('@things-factory/auth-base')
|
|
14
|
+
|
|
15
|
+
function loadCompiled(modulePath: string): any {
|
|
16
|
+
const { existsSync } = require('fs')
|
|
17
|
+
const { join } = require('path')
|
|
18
|
+
const resolved = join(__dirname, '..', '..', '..', 'dist-server', 'service', `${modulePath}.js`)
|
|
19
|
+
if (!existsSync(resolved)) throw new Error(`ai-assistant has no build output: ${resolved}\nBuild first:\n yarn workspace @things-factory/ai-assistant build:server`)
|
|
20
|
+
return require(resolved)
|
|
21
|
+
}
|
|
22
|
+
const { ChatSession } = loadCompiled('chat-session/chat-session')
|
|
23
|
+
const { ChatMessage } = loadCompiled('chat-message/chat-message')
|
|
24
|
+
const { ChatSessionParticipant } = loadCompiled('chat-session-participant/chat-session-participant')
|
|
25
|
+
const { proposeInvite, MAX_INVITE_CANDIDATES } = loadCompiled('people/invite-tools')
|
|
26
|
+
|
|
27
|
+
let db: DataSource
|
|
28
|
+
let domain: any
|
|
29
|
+
let owner: any
|
|
30
|
+
let member: any
|
|
31
|
+
let session: any
|
|
32
|
+
let asked: Array<{ document: string; variables: any }>
|
|
33
|
+
|
|
34
|
+
beforeAll(async () => {
|
|
35
|
+
db = new TypeOrmDataSource({
|
|
36
|
+
type: 'sqlite',
|
|
37
|
+
database: ':memory:',
|
|
38
|
+
synchronize: true,
|
|
39
|
+
dropSchema: true,
|
|
40
|
+
namingStrategy: new shell.NamingStrategy(),
|
|
41
|
+
entities: [...shell.entities, ...authBase.entities, ChatSession, ChatMessage, ChatSessionParticipant],
|
|
42
|
+
logging: false
|
|
43
|
+
})
|
|
44
|
+
await db.initialize()
|
|
45
|
+
shell.addDataSource('default', db)
|
|
46
|
+
domain = await db.getRepository(shell.Domain).save({ name: 'acme', subdomain: 'acme' })
|
|
47
|
+
owner = await db.getRepository(authBase.User).save({ name: 'owner', username: 'owner', email: 'owner@example.com', status: 'activated' })
|
|
48
|
+
member = await db.getRepository(authBase.User).save({ name: 'member', username: 'member', email: 'member@example.com', status: 'activated' })
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
afterAll(async () => {
|
|
52
|
+
shell.removeDataSource('default')
|
|
53
|
+
await db?.destroy()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
beforeEach(async () => {
|
|
57
|
+
asked = []
|
|
58
|
+
session = await db.getRepository(ChatSession).save({ domain, anchorType: 'domain', anchorId: domain.id, name: 'pricing', visibility: 'group', creator: owner })
|
|
59
|
+
await db.getRepository(ChatSessionParticipant).save({ domain, session: { id: session.id }, user: { id: owner.id }, role: 'owner' })
|
|
60
|
+
await db.getRepository(ChatSessionParticipant).save({ domain, session: { id: session.id }, user: { id: member.id }, role: 'member' })
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
const person = (name: string, userId: string | null, department = 'Sales', position = 'Manager') => ({
|
|
64
|
+
id: `e-${name}-${userId}`,
|
|
65
|
+
name,
|
|
66
|
+
jobPosition: position,
|
|
67
|
+
department: { id: `d-${department}`, name: department },
|
|
68
|
+
user: userId ? { id: userId } : null
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
/** The people list as the organization's query answers it: by exact name, then by search. */
|
|
72
|
+
function peopleList(rows: any[]) {
|
|
73
|
+
return async (document: string, variables: any) => {
|
|
74
|
+
asked.push({ document, variables })
|
|
75
|
+
const exact = (variables.filters ?? []).find((one: any) => one.name === 'name')
|
|
76
|
+
const items = exact ? rows.filter(row => row.name === exact.value) : rows.filter(row => row.name.includes(variables.search ?? ''))
|
|
77
|
+
return { data: { employees: { items, total: items.length } } }
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const as = (user: any, conversation: any = session) => ({ state: { domain, user }, ...(conversation ? { conversation: { id: conversation.id } } : {}) })
|
|
82
|
+
const seats = async () => (await db.getRepository(ChatSessionParticipant).find({ where: { session: { id: session.id } } as any })).map((row: any) => [row.userId, row.role]).sort()
|
|
83
|
+
|
|
84
|
+
describe('the invite card', () => {
|
|
85
|
+
it('★ one person by that name - the card invites them, with where they work', async () => {
|
|
86
|
+
const card = await proposeInvite({ name: '정연희' }, as(owner), peopleList([person('정연희', 'u-jung'), person('김민수', 'u-kim')]))
|
|
87
|
+
expect(card).toMatchObject({ proposed: true, kind: 'invite', people: [{ userId: 'u-jung', name: '정연희', department: 'Sales', position: 'Manager' }], reason: 'Sales · Manager' })
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('★ the card seats nobody - only a person pressing it does (the invite door)', async () => {
|
|
91
|
+
const before = await seats()
|
|
92
|
+
await proposeInvite({ name: '정연희' }, as(owner), peopleList([person('정연희', 'u-jung')]))
|
|
93
|
+
expect(await seats()).toEqual(before)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('★ two people of one name - the card lists both and the person picks', async () => {
|
|
97
|
+
const card = await proposeInvite({ name: '정연희' }, as(owner), peopleList([person('정연희', 'u-1', 'Sales'), person('정연희', 'u-2', 'Finance')]))
|
|
98
|
+
expect(card.people.map((one: any) => one.userId)).toEqual(['u-1', 'u-2'])
|
|
99
|
+
expect(card.reason).toBeUndefined()
|
|
100
|
+
expect(card.message).toMatch(/do not ask which/i)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('a user id read before narrows the card to that one', async () => {
|
|
104
|
+
const card = await proposeInvite({ name: '정연희', userId: 'u-2' }, as(owner), peopleList([person('정연희', 'u-1'), person('정연희', 'u-2')]))
|
|
105
|
+
expect(card.people.map((one: any) => one.userId)).toEqual(['u-2'])
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('★ a user id the model made up is never on a card', async () => {
|
|
109
|
+
const card = await proposeInvite({ name: '정연희', userId: 'made-up' }, as(owner), peopleList([person('정연희', 'u-1')]))
|
|
110
|
+
expect(card).toMatchObject({ rejected: true, error: 'not-found' })
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('no exact name - the name is searched', async () => {
|
|
114
|
+
const card = await proposeInvite({ name: '연희' }, as(owner), peopleList([person('정연희', 'u-1')]))
|
|
115
|
+
expect(card.people.map((one: any) => one.userId)).toEqual(['u-1'])
|
|
116
|
+
expect(asked[1].variables.search).toBe('연희')
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('people who left are never asked for, and no contact or bank field is read', async () => {
|
|
120
|
+
await proposeInvite({ name: '정연희' }, as(owner), peopleList([person('정연희', 'u-1')]))
|
|
121
|
+
expect(asked[0].variables.filters).toContainEqual({ name: 'active', operator: 'eq', value: true })
|
|
122
|
+
expect(asked[0].document).not.toMatch(/phone|email|address|bank|emergency|contact|photo/i)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('someone without an account cannot be invited; someone already here is said', async () => {
|
|
126
|
+
expect(await proposeInvite({ name: '정연희' }, as(owner), peopleList([person('정연희', null)]))).toMatchObject({ rejected: true, error: 'not-found' })
|
|
127
|
+
expect(await proposeInvite({ name: 'member' }, as(owner), peopleList([person('member', member.id)]))).toMatchObject({ rejected: true, error: 'already-here' })
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('a name that matches too many is narrowed first', async () => {
|
|
131
|
+
const many = Array.from({ length: MAX_INVITE_CANDIDATES + 1 }, (_, i) => person('정연희', `u-${i}`))
|
|
132
|
+
expect(await proposeInvite({ name: '정연희' }, as(owner), peopleList(many))).toMatchObject({ rejected: true, error: 'too-many' })
|
|
133
|
+
})
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
describe('the card is offered only where the person could press it', () => {
|
|
137
|
+
it('★ a member of a group cannot bring anyone in - no card, and nobody is looked up', async () => {
|
|
138
|
+
const card = await proposeInvite({ name: '정연희' }, as(member), peopleList([person('정연희', 'u-1')]))
|
|
139
|
+
expect(card).toMatchObject({ rejected: true, error: 'may-not-invite' })
|
|
140
|
+
expect(card.message).toMatch(/you cannot change it, so never offer/)
|
|
141
|
+
expect(asked).toEqual([])
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('★ a private conversation takes nobody', async () => {
|
|
145
|
+
await db.getRepository(ChatSession).update(session.id, { visibility: 'private' })
|
|
146
|
+
expect(await proposeInvite({ name: '정연희' }, as(owner), peopleList([person('정연희', 'u-1')]))).toMatchObject({ rejected: true, error: 'may-not-invite' })
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('in an open conversation anyone may point a colleague at it', async () => {
|
|
150
|
+
await db.getRepository(ChatSession).update(session.id, { visibility: 'open' })
|
|
151
|
+
expect(await proposeInvite({ name: '정연희' }, as(member), peopleList([person('정연희', 'u-1')]))).toMatchObject({ proposed: true })
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('a turn without a stored conversation, or one of another domain, has no room to invite into', async () => {
|
|
155
|
+
expect(await proposeInvite({ name: '정연희' }, as(owner, null), peopleList([]))).toMatchObject({ rejected: true, error: 'no-conversation' })
|
|
156
|
+
const other = await db.getRepository(shell.Domain).save({ name: 'other', subdomain: 'other' })
|
|
157
|
+
expect(await proposeInvite({ name: '정연희' }, { state: { domain: other, user: owner }, conversation: { id: session.id } }, peopleList([]))).toMatchObject({ rejected: true, error: 'no-conversation' })
|
|
158
|
+
})
|
|
159
|
+
})
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The names of the people tools - every conversation carries them (mode-categories.ts), so they live apart from the
|
|
3
|
+
* tools themselves and the categories can be named without loading them.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** The organization's people as a read target - its `employees` list, read as the person (people-read.ts). */
|
|
7
|
+
export const PEOPLE_READ_KEY = 'organization.employee'
|
|
8
|
+
|
|
9
|
+
/** The category of that read - `data-read:<key>`, as every read target's. */
|
|
10
|
+
export const PEOPLE_READ_CATEGORY = `data-read:${PEOPLE_READ_KEY}`
|
|
11
|
+
|
|
12
|
+
/** The invite card - bring someone into this conversation (invite-tools.ts). */
|
|
13
|
+
export const INVITE_CATEGORY = 'chat-invite'
|
|
14
|
+
|
|
15
|
+
/** Both, in the order a turn puts them in front: find the person, then the card. */
|
|
16
|
+
export const PEOPLE_CATEGORIES = [PEOPLE_READ_CATEGORY, INVITE_CATEGORY]
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The people list every conversation reads (people-read.ts) - the organization's own query, behind its own door, and
|
|
3
|
+
* nothing private in what it reads.
|
|
4
|
+
*/
|
|
5
|
+
import { peopleReadTarget, PEOPLE_SELECTION } from './people-read'
|
|
6
|
+
import { PEOPLE_CATEGORIES, PEOPLE_READ_CATEGORY, INVITE_CATEGORY } from './people-keys'
|
|
7
|
+
import { modeFrontCategories, modeToolCategories, unattendedToolCategories } from '../assistant-mode/mode-categories'
|
|
8
|
+
import { rootFieldOf } from '../data-entry/entry-target'
|
|
9
|
+
|
|
10
|
+
describe('the people list', () => {
|
|
11
|
+
const target = peopleReadTarget()
|
|
12
|
+
|
|
13
|
+
it("★ reads the organization's own list - its door decides who may read people", () => {
|
|
14
|
+
expect(rootFieldOf(target.query, 'query')).toBe('Query.employees')
|
|
15
|
+
expect(target.key).toBe('organization.employee')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('★ the invite card reads people through the same door - offered only where the list is', () => {
|
|
19
|
+
/* The tool module holds entities - read as built, as the database suites do. */
|
|
20
|
+
const { inviteCategory } = require(require('path').join(__dirname, '..', '..', '..', 'dist-server', 'service', 'people', 'invite-tools.js'))
|
|
21
|
+
expect(inviteCategory.specs.map((spec: any) => spec.doors)).toEqual([[rootFieldOf(target.query, 'query')]])
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('★ reads no contact, address, bank or emergency field', () => {
|
|
25
|
+
expect(target.query).not.toMatch(/phone|email|address|bank|emergency|contact|photo|profile|note/i)
|
|
26
|
+
expect(PEOPLE_SELECTION.split(/[\s{}]+/).filter(Boolean).sort()).toEqual(['alias', 'department', 'id', 'id', 'id', 'jobPosition', 'jobResponsibility', 'name', 'name', 'user'].sort())
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('people who left are out of reach - a fixed filter the model cannot lift', () => {
|
|
30
|
+
expect(target.fixedFilters).toEqual([{ name: 'active', operator: 'eq', value: true }])
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('★ a department, a position, a responsibility each has its own condition - never the name search', () => {
|
|
34
|
+
const by = (name: string) => target.criteria.find(one => one.name === name)!
|
|
35
|
+
expect(by('position').filter('프로')).toEqual({ name: 'jobPosition', operator: 'i_like', value: '프로' })
|
|
36
|
+
expect(by('responsibility').filter('회계')).toEqual({ name: 'jobResponsibility', operator: 'i_like', value: '회계' })
|
|
37
|
+
expect(by('department').filter('영업')).toEqual({ name: 'departmentName', operator: 'i_like', value: '영업' })
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it("★ every part-of condition is on a field the organization's list lets LIKE reach - one outside is dropped without a word", () => {
|
|
41
|
+
const source = require('fs').readFileSync(require('path').join(__dirname, '..', '..', '..', '..', 'organization', 'server', 'service', 'employee', 'employee-query.ts'), 'utf8') as string
|
|
42
|
+
const searchables = JSON.parse(source.match(/searchables: (\[[^\]]*\])/)![1].replace(/'/g, '"')) as string[]
|
|
43
|
+
const liked = target.criteria.map(one => [one.filter('x')].flat()).flat().filter((f: any) => /like/.test(f.operator)).map((f: any) => f.name)
|
|
44
|
+
expect(liked.length).toBeGreaterThan(0)
|
|
45
|
+
expect(liked.filter(name => !searchables.includes(name))).toEqual([])
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it("the organization's list takes a department by its name", () => {
|
|
49
|
+
const source = require('fs').readFileSync(require('path').join(__dirname, '..', '..', '..', '..', 'organization', 'server', 'service', 'employee', 'employee-query.ts'), 'utf8') as string
|
|
50
|
+
expect(source).toMatch(/filtersMap: \{ departmentName: \{ columnName: 'name', relationColumn: 'department' \} \}/)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('carries the user id an invite takes, and finds by name', () => {
|
|
54
|
+
expect(target.columns.find(one => one.name === 'userId')?.path).toBe('user.id')
|
|
55
|
+
expect(target.search).toBeTruthy()
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe('every conversation carries the people tools', () => {
|
|
60
|
+
const mode = { toolCategories: ['office-reads'], readTargets: ['office.contract'] }
|
|
61
|
+
|
|
62
|
+
it('★ offered in every mode, and in front of every turn', () => {
|
|
63
|
+
for (const category of PEOPLE_CATEGORIES) {
|
|
64
|
+
expect(modeToolCategories(mode)).toContain(category)
|
|
65
|
+
expect(modeToolCategories({})).toContain(category)
|
|
66
|
+
expect(modeFrontCategories(mode)).toContain(category)
|
|
67
|
+
expect(modeFrontCategories({}, { page: 'anything' })).toContain(category)
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('not in an unattended run - nobody is there to press an invite', () => {
|
|
72
|
+
expect(unattendedToolCategories(mode)).not.toContain(INVITE_CATEGORY)
|
|
73
|
+
expect(unattendedToolCategories(mode)).not.toContain(PEOPLE_READ_CATEGORY)
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
describe('the invite card knows which conversation it is in', () => {
|
|
78
|
+
it("★ the conversation door hands the stored session to the tools - the server's value, never the client's", () => {
|
|
79
|
+
const source = require('fs').readFileSync(require('path').join(__dirname, '..', 'assistant-chat-resolver.ts'), 'utf8') as string
|
|
80
|
+
expect(source).toMatch(/\.\.\.\(session\?\.id \? \{ conversation: \{ id: session\.id \} \} : \{\}\)/)
|
|
81
|
+
})
|
|
82
|
+
})
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Who works here - the organization's `employees` list opened to every conversation as a read target
|
|
3
|
+
* (operato-application/design/ai/read-targets.md), the way an application opens its own lists.
|
|
4
|
+
*
|
|
5
|
+
* Asked to bring 정연희 into a group conversation, the model answered that it had no tool to look up people and
|
|
6
|
+
* sent the person to search a screen (2026-10-24). The list existed; no conversation could read it.
|
|
7
|
+
*
|
|
8
|
+
* Nothing new is built: the read runs the organization module's own query as the person, so its door
|
|
9
|
+
* (`employee` query, or the domain owner) decides who may read people, and an application without the organization
|
|
10
|
+
* module has no such door - the tool is not offered there. The selection is the projection: name, department,
|
|
11
|
+
* position and the user an invite takes. No phone, e-mail, address, bank account or emergency contact is read.
|
|
12
|
+
*/
|
|
13
|
+
import { listReadTarget } from '../data-read/list-read.js'
|
|
14
|
+
import type { ReadTarget } from '../data-read/read-target.js'
|
|
15
|
+
import { PEOPLE_READ_KEY } from './people-keys.js'
|
|
16
|
+
|
|
17
|
+
/** The fields read of each person - the answer may carry nothing else. */
|
|
18
|
+
export const PEOPLE_SELECTION = 'id name alias jobPosition jobResponsibility department { id name } user { id }'
|
|
19
|
+
|
|
20
|
+
export function peopleReadTarget(): ReadTarget {
|
|
21
|
+
const target = listReadTarget({
|
|
22
|
+
key: PEOPLE_READ_KEY,
|
|
23
|
+
title: 'ai-assistant.people.title',
|
|
24
|
+
name: 'people of the organization',
|
|
25
|
+
listQuery: 'employees',
|
|
26
|
+
/* People who left are not offered - an invite, a mention or an answer about who does what is about who is here. */
|
|
27
|
+
fixedFilters: [{ name: 'active', operator: 'eq', value: true }],
|
|
28
|
+
selection: PEOPLE_SELECTION,
|
|
29
|
+
columns: [
|
|
30
|
+
{ name: 'name', type: 'string', labelKey: 'ai-assistant.people.name' },
|
|
31
|
+
{ name: 'department', type: 'string', path: 'department.name', labelKey: 'ai-assistant.people.department' },
|
|
32
|
+
{ name: 'position', type: 'string', path: 'jobPosition', labelKey: 'ai-assistant.people.position' },
|
|
33
|
+
{ name: 'responsibility', type: 'string', path: 'jobResponsibility', labelKey: 'ai-assistant.people.responsibility' },
|
|
34
|
+
{ name: 'userId', type: 'string', path: 'user.id', labelKey: 'ai-assistant.people.user' }
|
|
35
|
+
],
|
|
36
|
+
search: 'name, employee number and alias'
|
|
37
|
+
})
|
|
38
|
+
/*
|
|
39
|
+
* Who holds a position, who is in a team - asked 「프로 직책인 사람은 누구야?」 the model put the position into the search
|
|
40
|
+
* term, which matches names, numbers and aliases only, and answered that nobody held it (2026-10-24).
|
|
41
|
+
*/
|
|
42
|
+
const contains = (name: string) => (value: any) => ({ name, operator: 'i_like', value })
|
|
43
|
+
return {
|
|
44
|
+
...target,
|
|
45
|
+
criteria: [
|
|
46
|
+
...target.criteria,
|
|
47
|
+
{ name: 'department', type: 'string', labelKey: 'ai-assistant.people.department', description: "part of the department's name", filter: contains('departmentName') },
|
|
48
|
+
{ name: 'position', type: 'string', labelKey: 'ai-assistant.people.position', description: 'part of the position (직책)', filter: contains('jobPosition') },
|
|
49
|
+
{ name: 'responsibility', type: 'string', labelKey: 'ai-assistant.people.responsibility', description: 'part of what they are responsible for (담당)', filter: contains('jobResponsibility') }
|
|
50
|
+
],
|
|
51
|
+
description:
|
|
52
|
+
'the people who work here, as the organization lists them - name, department, position, responsibility, and the user id ' +
|
|
53
|
+
'an invite into a conversation takes (empty when the person has no account). Read only who the asker may see. ' +
|
|
54
|
+
'The text condition finds by name, employee number or alias only - a department, position or responsibility has its own condition.'
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import { clearAssistantModes, registerAssistantMode } from '../assistant-mode/registry'
|
|
14
14
|
import { clearToolRegistry, registerToolCategory } from '@things-factory/ai-client-base'
|
|
15
15
|
|
|
16
|
-
import { runRegisteredMode } from './mode-conformance'
|
|
16
|
+
import { modeCarriesSharedRules, runRegisteredMode } from './mode-conformance'
|
|
17
17
|
|
|
18
18
|
const client = {
|
|
19
19
|
chatWithTools: async () => ({ text: '한 턴', toolCalls: [], stopReason: 'end_turn' })
|
|
@@ -46,3 +46,16 @@ it('an app that wants to see the other case names it — the door the caller can
|
|
|
46
46
|
|
|
47
47
|
expect(result.offeredTools).toEqual(['getOpenScene'])
|
|
48
48
|
})
|
|
49
|
+
|
|
50
|
+
describe('an app prompt that copies a shared rule', () => {
|
|
51
|
+
afterEach(() => clearAssistantModes())
|
|
52
|
+
|
|
53
|
+
it('★ reading the picked records in its own words is a copy of the selection rule', () => {
|
|
54
|
+
registerAssistantMode({ kind: 'office.copy', systemPrompt: 'explicit-page-selection is what the person picked.' } as any)
|
|
55
|
+
registerAssistantMode({ kind: 'office.keys', systemPrompt: 'read the [keys] line.' } as any)
|
|
56
|
+
registerAssistantMode({ kind: 'office.clean', systemPrompt: 'you answer about the books.' } as any)
|
|
57
|
+
expect(modeCarriesSharedRules('office.copy')).toContain('selection-rule-copy')
|
|
58
|
+
expect(modeCarriesSharedRules('office.keys')).toContain('selection-rule-copy')
|
|
59
|
+
expect(modeCarriesSharedRules('office.clean')).toEqual([])
|
|
60
|
+
})
|
|
61
|
+
})
|
|
@@ -147,6 +147,8 @@ export function modeCarriesSharedRules(kind: string): string[] {
|
|
|
147
147
|
if (/^\s*Next:/im.test(prompt) || /end the reply with one last line/i.test(prompt)) found.push('next-request-rule-copy')
|
|
148
148
|
if (/finish what was asked in this turn|never ask whether to propose/i.test(prompt)) found.push('finish-rule-copy')
|
|
149
149
|
if (/\[writer → addressee\]/.test(prompt)) found.push('speaker-rule-copy')
|
|
150
|
+
/* The picked records' note is the framework's form (SELECTION_RULE) - an app reading its own form of it is a copy. */
|
|
151
|
+
if (/explicit-page-selection|\[keys\]/i.test(prompt)) found.push('selection-rule-copy')
|
|
150
152
|
return found
|
|
151
153
|
}
|
|
152
154
|
|