@things-factory/ai-assistant 10.1.28 → 10.1.31
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 +51 -7
- package/client/components/assistant-mode.test.ts +20 -1
- package/client/components/assistant-mode.ts +20 -0
- package/client/components/panel-aria.test.ts +20 -0
- package/client/utils/assistant-session-controller.ts +14 -1
- package/client/utils/assistant-session-transport.ts +2 -2
- package/dist-client/components/assistant-chat.d.ts +12 -0
- package/dist-client/components/assistant-chat.js +48 -6
- package/dist-client/components/assistant-chat.js.map +1 -1
- package/dist-client/components/assistant-mode.d.ts +22 -0
- package/dist-client/components/assistant-mode.js +16 -0
- package/dist-client/components/assistant-mode.js.map +1 -1
- package/dist-client/components/assistant-mode.test.js +17 -1
- package/dist-client/components/assistant-mode.test.js.map +1 -1
- package/dist-client/components/panel-aria.test.js +18 -0
- package/dist-client/components/panel-aria.test.js.map +1 -1
- 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.map +1 -1
- package/dist-client/utils/assistant-session-transport.js +2 -2
- package/dist-client/utils/assistant-session-transport.js.map +1 -1
- package/dist-server/door-declaration-report.d.ts +14 -0
- package/dist-server/door-declaration-report.js +54 -0
- package/dist-server/door-declaration-report.js.map +1 -0
- package/dist-server/service/assistant-chat-resolver.js +19 -0
- package/dist-server/service/assistant-chat-resolver.js.map +1 -1
- package/dist-server/service/assistant-mode/assistant-mode-resolver.d.ts +8 -0
- package/dist-server/service/assistant-mode/assistant-mode-resolver.js +8 -0
- package/dist-server/service/assistant-mode/assistant-mode-resolver.js.map +1 -1
- package/dist-server/service/assistant-mode/index.d.ts +1 -1
- package/dist-server/service/assistant-mode/index.js +1 -1
- package/dist-server/service/assistant-mode/index.js.map +1 -1
- package/dist-server/service/assistant-mode/resolve.d.ts +10 -0
- package/dist-server/service/assistant-mode/resolve.js +5 -0
- package/dist-server/service/assistant-mode/resolve.js.map +1 -1
- package/dist-server/service/assistant-mode/types.d.ts +21 -0
- package/dist-server/service/assistant-mode/types.js.map +1 -1
- package/dist-server/service/chat-message/carried-turns.js +14 -2
- package/dist-server/service/chat-message/carried-turns.js.map +1 -1
- package/dist-server/service/chat-message/door-access.d.ts +19 -7
- package/dist-server/service/chat-message/door-access.js +19 -11
- package/dist-server/service/chat-message/door-access.js.map +1 -1
- package/dist-server/service/chat-message/reader-gate.js +6 -4
- package/dist-server/service/chat-message/reader-gate.js.map +1 -1
- package/dist-server/service/chat-session/chat-session.d.ts +12 -0
- package/dist-server/service/chat-session/chat-session.js +12 -11
- package/dist-server/service/chat-session/chat-session.js.map +1 -1
- package/dist-server/service/chat-session/session-anchor.d.ts +10 -1
- package/dist-server/service/chat-session/session-anchor.js +12 -4
- package/dist-server/service/chat-session/session-anchor.js.map +1 -1
- package/dist-server/service/chat-session/session-inbox.d.ts +2 -0
- package/dist-server/service/chat-session/session-inbox.js.map +1 -1
- package/dist-server/service/chat-session/session-kind.d.ts +14 -0
- package/dist-server/service/chat-session/session-kind.js +30 -0
- package/dist-server/service/chat-session/session-kind.js.map +1 -0
- package/dist-server/service/chat-session/session-visibility.js +8 -1
- package/dist-server/service/chat-session/session-visibility.js.map +1 -1
- package/dist-server/service/chat-session-resolver.d.ts +2 -0
- package/dist-server/service/chat-session-resolver.js +4 -0
- package/dist-server/service/chat-session-resolver.js.map +1 -1
- package/dist-server/service/index.d.ts +1 -0
- package/dist-server/service/index.js +2 -0
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/service/testing/conversation-script.d.ts +7 -0
- package/dist-server/service/testing/conversation-script.js +1 -0
- package/dist-server/service/testing/conversation-script.js.map +1 -1
- package/dist-server/service/testing/mode-conformance.d.ts +10 -0
- package/dist-server/service/testing/mode-conformance.js +13 -0
- package/dist-server/service/testing/mode-conformance.js.map +1 -1
- package/dist-server/service/tool-access.js +20 -7
- package/dist-server/service/tool-access.js.map +1 -1
- package/dist-server/tool-registry-boot-report.js +15 -9
- package/dist-server/tool-registry-boot-report.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -5
- package/server/door-declaration-report.test.ts +53 -0
- package/server/door-declaration-report.ts +61 -0
- package/server/service/assistant-chat-resolver.ts +21 -0
- package/server/service/assistant-mode/assistant-mode-resolver.ts +6 -0
- package/server/service/assistant-mode/index.ts +1 -1
- package/server/service/assistant-mode/resolve.test.ts +42 -0
- package/server/service/assistant-mode/resolve.ts +9 -0
- package/server/service/assistant-mode/types.ts +18 -0
- package/server/service/chat-message/carried-turns.test.ts +13 -2
- package/server/service/chat-message/carried-turns.ts +16 -3
- package/server/service/chat-message/door-access.test.ts +72 -0
- package/server/service/chat-message/door-access.ts +43 -23
- package/server/service/chat-message/fold-history-window.test.ts +184 -0
- package/server/service/chat-message/reader-gate.ts +6 -4
- package/server/service/chat-session/chat-session.ts +15 -0
- package/server/service/chat-session/session-anchor.ts +12 -3
- package/server/service/chat-session/session-inbox.ts +2 -0
- package/server/service/chat-session/session-kind.test.ts +24 -0
- package/server/service/chat-session/session-kind.ts +32 -0
- package/server/service/chat-session/session-visibility.test.ts +16 -0
- package/server/service/chat-session/session-visibility.ts +9 -1
- package/server/service/chat-session-resolver.ts +4 -0
- package/server/service/index.ts +2 -0
- package/server/service/testing/conversation-scenario.test.ts +2 -1
- package/server/service/testing/conversation-script.test.ts +4 -2
- package/server/service/testing/conversation-script.ts +8 -0
- package/server/service/testing/mode-conformance.ts +15 -0
- package/server/service/tool-access.test.ts +67 -19
- package/server/service/tool-access.ts +24 -15
- package/server/tool-registry-boot-report.ts +14 -9
- package/translations/en.json +3 -2
- package/translations/ja.json +3 -2
- package/translations/ko.json +3 -2
- package/translations/ms.json +3 -2
- package/translations/zh.json +3 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/ai-assistant",
|
|
3
|
-
"version": "10.1.
|
|
3
|
+
"version": "10.1.31",
|
|
4
4
|
"main": "dist-server/index.js",
|
|
5
5
|
"things-factory": true,
|
|
6
6
|
"author": "heartyoh",
|
|
@@ -29,10 +29,10 @@
|
|
|
29
29
|
"@operato/graphql": "^10.0.0",
|
|
30
30
|
"@operato/i18n": "^10.0.0",
|
|
31
31
|
"@operato/styles": "^10.0.0",
|
|
32
|
-
"@things-factory/ai-client-base": "^10.1.
|
|
33
|
-
"@things-factory/auth-base": "^10.1.
|
|
32
|
+
"@things-factory/ai-client-base": "^10.1.31",
|
|
33
|
+
"@things-factory/auth-base": "^10.1.31",
|
|
34
34
|
"@things-factory/env": "^10.1.20",
|
|
35
|
-
"@things-factory/shell": "^10.1.
|
|
35
|
+
"@things-factory/shell": "^10.1.31",
|
|
36
36
|
"dompurify": "^3.0.0",
|
|
37
37
|
"graphql-scalars": "^1.22.0",
|
|
38
38
|
"graphql-tag": "^2.12.6",
|
|
@@ -44,5 +44,5 @@
|
|
|
44
44
|
"copyfiles": "^2.4.1",
|
|
45
45
|
"rimraf": "^5.0.0"
|
|
46
46
|
},
|
|
47
|
-
"gitHead": "
|
|
47
|
+
"gitHead": "5213e6f9913b40ea2618a2111d9884b792dbaa01"
|
|
48
48
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 도구가 적어 둔 문이 스키마에 있는가 — ADR-0072 결정 5 의 표로 묻는다.
|
|
3
|
+
*
|
|
4
|
+
* 오타 하나가 조용하다. ADR-0073 판정 1 은 모르는 문을 닫으므로, 이름을 틀리게 적은 도구로 만든 답은
|
|
5
|
+
* **아무도 못 읽는다** — 쓴 사람까지. 부팅이 그것을 말하지 않으면 화면에서 처음 본다.
|
|
6
|
+
*/
|
|
7
|
+
import { doorDeclarationWarnings, formatDoorDeclarationWarnings } from './door-declaration-report'
|
|
8
|
+
|
|
9
|
+
const table: Record<string, any> = {
|
|
10
|
+
'Query.jobOrders': { known: true, gate: { category: 'job-order', privilege: 'query', owner: false, super: false } },
|
|
11
|
+
'Query.twinExternalAttentions': { known: true, gate: null }
|
|
12
|
+
}
|
|
13
|
+
const lookup = (typeName: string, fieldName: string) => table[`${typeName}.${fieldName}`] ?? { known: false }
|
|
14
|
+
|
|
15
|
+
describe('declared doors, measured against the schema', () => {
|
|
16
|
+
it('★ 스키마에 없는 문은 말한다 — 오타 하나가 그 대화의 답을 통째로 가린다', () => {
|
|
17
|
+
const found = doorDeclarationWarnings([{ name: 'findJobOrders', doors: ['Query.jobOrder'] }], lookup)
|
|
18
|
+
|
|
19
|
+
expect(found).toEqual([{ code: 'tool-door-not-in-schema', tool: 'findJobOrders', door: 'Query.jobOrder' }])
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('★ 선언이 없는 문은 사실로 말한다 — 그 문이 열려 있으니 답도 열린다', () => {
|
|
23
|
+
const found = doorDeclarationWarnings(
|
|
24
|
+
[{ name: 'listExternalAttentions', doors: ['Query.twinExternalAttentions'] }],
|
|
25
|
+
lookup
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
expect(found.map(w => w.code)).toEqual(['tool-door-has-no-privilege'])
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('지키는 문에는 아무 말도 하지 않는다', () => {
|
|
32
|
+
expect(doorDeclarationWarnings([{ name: 'findJobOrders', doors: ['Query.jobOrders'] }], lookup)).toEqual([])
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('문을 안 적은 도구는 이 검사의 것이 아니다 — 등록기가 따로 말한다', () => {
|
|
36
|
+
expect(doorDeclarationWarnings([{ name: 'getOpenScene' }, { name: 'quiet', doors: [] }], lookup)).toEqual([])
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('★ 문 모양이 아닌 글자도 스키마에 없는 것으로 센다', () => {
|
|
40
|
+
const found = doorDeclarationWarnings([{ name: 'odd', doors: ['jobOrders'] }], lookup)
|
|
41
|
+
|
|
42
|
+
expect(found.map(w => w.code)).toEqual(['tool-door-not-in-schema'])
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('코드마다 한 줄로 모은다', () => {
|
|
46
|
+
const lines = formatDoorDeclarationWarnings([
|
|
47
|
+
{ code: 'tool-door-not-in-schema', tool: 'a', door: 'Query.x' },
|
|
48
|
+
{ code: 'tool-door-not-in-schema', tool: 'b', door: 'Query.y' }
|
|
49
|
+
])
|
|
50
|
+
|
|
51
|
+
expect(lines).toEqual(['[ai-assistant] tool-door-not-in-schema ×2: a → Query.x, b → Query.y'])
|
|
52
|
+
})
|
|
53
|
+
})
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 도구가 적어 둔 문이 **스키마에 있는 문인가** — 부팅에서 한 번 묻는다.
|
|
3
|
+
*
|
|
4
|
+
* ── 왜 등록기가 아니라 여기인가 ───────────────────────────────────────────
|
|
5
|
+
* 이 물음은 AUTH 의 필드→권한 표(`privilegeOfField`, ADR-0072 결정 5)가 있어야 답할 수 있고, 그 표는
|
|
6
|
+
* **스키마를 지은 뒤에** 찬다. 등록기는 모듈이 올라오는 동안 도니 그때는 표가 비어 있다. 부팅 보고는
|
|
7
|
+
* 모든 모듈이 등록을 마치고 스키마가 선 뒤에 도는 자리다.
|
|
8
|
+
*
|
|
9
|
+
* ── 무엇을 가르나 ─────────────────────────────────────────────────────────
|
|
10
|
+
* `tool-door-not-in-schema` 그런 루트 필드가 없다. 도구가 이름을 틀리게 적은 것이다 — 그 도구로
|
|
11
|
+
* 만든 답은 **아무도 못 읽는다**(ADR-0073 판정 1 은 모르는 문을 닫는다).
|
|
12
|
+
* 오타 하나가 그 대화의 답을 통째로 가린다.
|
|
13
|
+
* `tool-door-has-no-privilege` 필드는 있고 아무것도 선언하지 않았다. 그 문이 열려 있으므로 그 답도
|
|
14
|
+
* 도메인 안에서 열린다. 결함이 아니라 **사실**이고, 문의 차례다.
|
|
15
|
+
*
|
|
16
|
+
* 등록기의 같은 이름 경고와 묻는 것이 다르다. 등록기는 「도구가 문의 권한을 베껴 적었나」를 물었고,
|
|
17
|
+
* 그 베끼기는 표가 서면서 없어진다(ADR-0072 결정 5). 여기서는 **문에 직접** 묻는다.
|
|
18
|
+
*/
|
|
19
|
+
import { getAllToolSpecs } from '@things-factory/ai-client-base'
|
|
20
|
+
import { privilegeOfField } from '@things-factory/auth-base'
|
|
21
|
+
|
|
22
|
+
export interface DoorDeclarationWarning {
|
|
23
|
+
code: 'tool-door-not-in-schema' | 'tool-door-has-no-privilege'
|
|
24
|
+
tool: string
|
|
25
|
+
door: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 등록된 도구들이 적어 둔 문을 스키마에 대어 본다. 스키마가 선 뒤에만 뜻이 있다. */
|
|
29
|
+
export function doorDeclarationWarnings(
|
|
30
|
+
specs: ReadonlyArray<any> = getAllToolSpecs(),
|
|
31
|
+
lookup: (typeName: string, fieldName: string) => { known: false } | { known: true; gate: unknown } = privilegeOfField
|
|
32
|
+
): DoorDeclarationWarning[] {
|
|
33
|
+
const out: DoorDeclarationWarning[] = []
|
|
34
|
+
|
|
35
|
+
for (const spec of specs) {
|
|
36
|
+
for (const door of spec?.doors ?? []) {
|
|
37
|
+
const match = /^(Query|Mutation|Subscription)\.([A-Za-z_][A-Za-z0-9_]*)$/.exec(String(door))
|
|
38
|
+
if (!match) {
|
|
39
|
+
out.push({ code: 'tool-door-not-in-schema', tool: spec.name, door: String(door) })
|
|
40
|
+
continue
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const asked = lookup(match[1], match[2])
|
|
44
|
+
if (!asked.known) out.push({ code: 'tool-door-not-in-schema', tool: spec.name, door })
|
|
45
|
+
else if (asked.gate === null) out.push({ code: 'tool-door-has-no-privilege', tool: spec.name, door })
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return out
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 코드마다 한 줄 — 몇이고 어느 도구의 어느 문인가. */
|
|
53
|
+
export function formatDoorDeclarationWarnings(warnings: ReadonlyArray<DoorDeclarationWarning>): string[] {
|
|
54
|
+
const byCode = new Map<string, string[]>()
|
|
55
|
+
for (const warning of warnings) {
|
|
56
|
+
const found = byCode.get(warning.code) ?? []
|
|
57
|
+
found.push(`${warning.tool} → ${warning.door}`)
|
|
58
|
+
byCode.set(warning.code, found)
|
|
59
|
+
}
|
|
60
|
+
return [...byCode].map(([code, found]) => `[ai-assistant] ${code} ×${found.length}: ${found.join(', ')}`)
|
|
61
|
+
}
|
|
@@ -49,6 +49,7 @@ import { activityPatch, autoSessionName } from './chat-session/session-inbox.js'
|
|
|
49
49
|
import { toolAccessFor } from './tool-access.js'
|
|
50
50
|
import { ChatSessionParticipant } from './chat-session-participant/chat-session-participant.js'
|
|
51
51
|
import { doorsForAnswer } from './chat-message/message-doors.js'
|
|
52
|
+
import { judgeSessionKind } from './chat-session/session-kind.js'
|
|
52
53
|
import { readerGate } from './chat-message/reader-gate.js'
|
|
53
54
|
import { inviteMentionedUsers } from './chat-session-participant/mention-invite.js'
|
|
54
55
|
import { buildInviteNotification } from './chat-session-participant/invite-notification.js'
|
|
@@ -238,6 +239,24 @@ export class AssistantChatResolver {
|
|
|
238
239
|
})
|
|
239
240
|
)
|
|
240
241
|
}
|
|
242
|
+
/*
|
|
243
|
+
* 이 세션이 **이 대화의** 세션인가 — ADR-0072 결정 3. 다른 대화의 세션에 말을 붙이면 그 이력이
|
|
244
|
+
* 이 대화의 프롬프트로 들어가고, 화면은 줄이 사라져 새 대화처럼 보이므로 보낸 사람은 모른다.
|
|
245
|
+
*/
|
|
246
|
+
const verdict = judgeSessionKind((found as any).kind, input.mode.kind)
|
|
247
|
+
if (verdict.action === 'refuse') {
|
|
248
|
+
throw new Error(
|
|
249
|
+
t('ai-assistant.text.session-kind-mismatch', {
|
|
250
|
+
...verdict.params,
|
|
251
|
+
defaultValue: 'this conversation is {sessionKind}, and you are writing as {requested}. open the conversation you mean.'
|
|
252
|
+
})
|
|
253
|
+
)
|
|
254
|
+
}
|
|
255
|
+
if (verdict.action === 'stamp') {
|
|
256
|
+
/* 칸이 서기 전에 난 세션 — 요청한 대화로 한 번 찍는다. 다음부터는 위 규칙 하나만 남는다. */
|
|
257
|
+
await sessionRepo.update(found.id!, { kind: verdict.kind } as any)
|
|
258
|
+
;(found as any).kind = verdict.kind
|
|
259
|
+
}
|
|
241
260
|
session = found
|
|
242
261
|
}
|
|
243
262
|
|
|
@@ -389,6 +408,8 @@ export class AssistantChatResolver {
|
|
|
389
408
|
messages: conversation as any,
|
|
390
409
|
systemPrompt: settings.systemPrompt,
|
|
391
410
|
toolCategories: settings.toolCategories,
|
|
411
|
+
/* 도구가 「어느 대화에서 불렸나」를 안다 — 서버의 값이다(ADR-0061 결정 3). */
|
|
412
|
+
mode: { kind: input.mode.kind, ...(input.mode.target ? { target: input.mode.target } : {}) },
|
|
392
413
|
toolAccess: toolAccessFor(context),
|
|
393
414
|
/* The stop notice is a sentence a person reads — the server writes it in their language (ADR-0054). */
|
|
394
415
|
t,
|
|
@@ -44,6 +44,12 @@ class AssistantModeView {
|
|
|
44
44
|
@Field({ nullable: true, description: 'What the empty input says — what this conversation is for.' })
|
|
45
45
|
placeholder?: string
|
|
46
46
|
|
|
47
|
+
@Field(() => GraphQLJSON, { nullable: true, description: 'Title and one line for an empty conversation.' })
|
|
48
|
+
intro?: { title: string; subtitle: string }
|
|
49
|
+
|
|
50
|
+
@Field(() => GraphQLJSON, { nullable: true, description: 'Example sentences this conversation can actually answer.' })
|
|
51
|
+
examples?: Array<{ label: string; items: string[] }>
|
|
52
|
+
|
|
47
53
|
@Field({ nullable: true, description: 'The line under an empty conversation.' })
|
|
48
54
|
footerNotice?: string
|
|
49
55
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Modes — what each conversation is, declared by the app server that owns the screen.
|
|
3
3
|
*
|
|
4
|
-
* Design: ADR-0061 decision 7 (`operato-
|
|
4
|
+
* Design: ADR-0061 decision 7 (`operato-application/adr/ADR-0061.md`) and the AI standard's A2.
|
|
5
5
|
*/
|
|
6
6
|
export {
|
|
7
7
|
registerAssistantMode,
|
|
@@ -76,3 +76,45 @@ it('a mode that declares no wording gets empty lists, not undefined holes', () =
|
|
|
76
76
|
expect(view.catalogEntries).toEqual([])
|
|
77
77
|
expect(view.footerNotice).toBeUndefined()
|
|
78
78
|
})
|
|
79
|
+
|
|
80
|
+
/*
|
|
81
|
+
* 빈 대화가 무엇을 말하는가 — ADR-0061 결정 7.
|
|
82
|
+
*
|
|
83
|
+
* 자리표와 `/` 는 대화를 따라가는데 이 블록만 host 속성에 남아 있었다. 그래서 머리에는 「이 도형
|
|
84
|
+
* 고치기」가 서 있고 본문은 「만들 Figure 를 설명하세요」라고 적혀 있었다(2026-09-18, :3300).
|
|
85
|
+
*/
|
|
86
|
+
describe('the empty conversation says what this conversation is', () => {
|
|
87
|
+
it('★ 대화가 자기 제목과 한 줄을 갖고, 서버가 요청의 말로 옮겨 낸다', () => {
|
|
88
|
+
clearAssistantModes()
|
|
89
|
+
registerAssistantMode({
|
|
90
|
+
kind: 'figure.modeller',
|
|
91
|
+
systemPrompt: 'you edit the open figure',
|
|
92
|
+
introKeys: { title: 'figure.text.revise-title', subtitle: 'figure.text.revise-subtitle' }
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
expect(assistantModeView('figure.modeller', t).intro).toEqual({
|
|
96
|
+
title: 't(figure.text.revise-title)',
|
|
97
|
+
subtitle: 't(figure.text.revise-subtitle)'
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('★ 예시도 대화를 따라간다 — 이 대화가 못 하는 말을 예시로 두면 빈 화면이 거짓을 가르친다', () => {
|
|
102
|
+
clearAssistantModes()
|
|
103
|
+
registerAssistantMode({
|
|
104
|
+
kind: 'figure.modeller',
|
|
105
|
+
systemPrompt: 'you edit the open figure',
|
|
106
|
+
exampleKeys: [{ labelKey: 'figure.label.revise', itemKeys: ['figure.text.ex-revise', 'figure.text.ex-inspect'] }]
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
expect(assistantModeView('figure.modeller', t).examples).toEqual([
|
|
110
|
+
{ label: 't(figure.label.revise)', items: ['t(figure.text.ex-revise)', 't(figure.text.ex-inspect)'] }
|
|
111
|
+
])
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('선언이 없으면 안 낸다 — host 가 주던 글이 그대로 선다', () => {
|
|
115
|
+
const view = assistantModeView('twin.space', t)
|
|
116
|
+
|
|
117
|
+
expect(view.intro).toBeUndefined()
|
|
118
|
+
expect(view.examples).toBeUndefined()
|
|
119
|
+
})
|
|
120
|
+
})
|
|
@@ -71,6 +71,10 @@ export interface AssistantModeView {
|
|
|
71
71
|
catalogEntries: any[]
|
|
72
72
|
proposalChoices: any[]
|
|
73
73
|
placeholder?: string
|
|
74
|
+
/** 빈 대화의 제목과 한 줄 — 이 대화가 무엇을 하는 자리인지. */
|
|
75
|
+
intro?: { title: string; subtitle: string }
|
|
76
|
+
/** 빈 대화의 예시 — 이 대화에서 실제로 할 수 있는 말. */
|
|
77
|
+
examples?: Array<{ label: string; items: string[] }>
|
|
74
78
|
footerNotice?: string
|
|
75
79
|
stagedNotice?: string
|
|
76
80
|
}
|
|
@@ -90,6 +94,11 @@ export function assistantModeView(kind: string, t: Translate): AssistantModeView
|
|
|
90
94
|
catalogEntries: mode.catalogEntries ?? [],
|
|
91
95
|
proposalChoices: mode.proposalChoices ?? [],
|
|
92
96
|
placeholder: mode.placeholderKey ? t(mode.placeholderKey) : undefined,
|
|
97
|
+
intro: mode.introKeys ? { title: t(mode.introKeys.title), subtitle: t(mode.introKeys.subtitle) } : undefined,
|
|
98
|
+
examples: mode.exampleKeys?.map(group => ({
|
|
99
|
+
label: t(group.labelKey),
|
|
100
|
+
items: group.itemKeys.map(key => t(key))
|
|
101
|
+
})),
|
|
93
102
|
footerNotice: mode.footerNoticeKey ? t(mode.footerNoticeKey) : undefined,
|
|
94
103
|
stagedNotice: mode.stagedNoticeKey ? t(mode.stagedNoticeKey) : undefined
|
|
95
104
|
}
|
|
@@ -76,6 +76,24 @@ export interface AssistantModeSpec {
|
|
|
76
76
|
* "describe the figure you want" while the person was there to change the figure already open.
|
|
77
77
|
*/
|
|
78
78
|
placeholderKey?: string
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 빈 대화가 무엇을 말하는가 — 제목과 한 줄 (ADR-0061 결정 7).
|
|
82
|
+
*
|
|
83
|
+
* 자리표와 `/` 는 대화를 따라가는데 이 블록만 host 속성에 남아 있었다. 그래서 머리에는 「이 도형
|
|
84
|
+
* 고치기」가 서 있고 본문은 「만들 Figure 를 설명하세요」라고 적혀 있었다(2026-09-18, Figure 레인
|
|
85
|
+
* :3300). 한 대화가 화면에 무엇을 말하는가는 한 자리에서 나와야 한다.
|
|
86
|
+
*/
|
|
87
|
+
introKeys?: { title: string; subtitle: string }
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 빈 대화의 예시 — 「이 대화에서 할 수 있는 말」.
|
|
91
|
+
*
|
|
92
|
+
* 예시는 그 도구로 **실제로 할 수 있는 말**이어야 한다. 할 수 없는 것을 예시로 두면 빈 화면이 거짓을
|
|
93
|
+
* 가르친다. 대화마다 열리는 도구가 다르므로 예시도 대화를 따라간다.
|
|
94
|
+
*/
|
|
95
|
+
exampleKeys?: Array<{ labelKey: string; itemKeys: string[] }>
|
|
96
|
+
|
|
79
97
|
/** i18n key for the line under an empty conversation. */
|
|
80
98
|
footerNoticeKey?: string
|
|
81
99
|
/** i18n key for the line shown once the host reports a proposal staged. */
|
|
@@ -34,8 +34,19 @@ it("'ai' is the same as 'assistant' — the same rule applies to both spellings"
|
|
|
34
34
|
expect(planCarriedTurns([{ role: 'ai', content: '답' }])[0].role).toBe('system')
|
|
35
35
|
})
|
|
36
36
|
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
/*
|
|
38
|
+
* ADR-0070 결정 ③ — 이 문은 사람의 말과 어시스턴트의 답만 옮긴다.
|
|
39
|
+
*
|
|
40
|
+
* 2026-09-18 까지는 그 밖의 역할을 `system` 행으로 그대로 적었다. 모델은 `[system]` 을 사람의 말보다
|
|
41
|
+
* 무겁게 읽으므로, 클라이언트가 「이것은 시스템이 한 말」이라고 적을 수 있었다 — 역할은 서버가 쓴다는
|
|
42
|
+
* 결정이 그 자리에서 무너진다. 사람이 친 것이 아닌 맥락은 `appendSessionNote` 가 자리다.
|
|
43
|
+
*/
|
|
44
|
+
it('★ 클라이언트가 지어낸 역할은 거절한다 — `system` 은 서버가 쓰는 자리다', () => {
|
|
45
|
+
expect(() => planCarriedTurns([{ role: 'note', content: '메모' }])).toThrow(/appendSessionNote/)
|
|
46
|
+
expect(() => planCarriedTurns([{ role: 'system', content: '화면에서 승격함' }])).toThrow(/cannot be carried/)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('빈 내용은 조용히 빠진다 — 옮길 말이 없는 줄이다', () => {
|
|
39
50
|
expect(planCarriedTurns([{ role: 'user', content: ' ' }])).toEqual([])
|
|
40
51
|
})
|
|
41
52
|
|
|
@@ -46,7 +46,22 @@ export function planCarriedTurns(turns: CarriedTurn[]): PlannedRow[] {
|
|
|
46
46
|
const content = (turn?.content ?? '').trim().slice(0, CARRIED_CONTENT_LIMIT)
|
|
47
47
|
if (!content) continue
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
/*
|
|
50
|
+
* 이 문은 **사람의 말과 어시스턴트의 답**만 옮긴다(ADR-0070 결정 ③). 그 밖의 역할은 받지 않는다 —
|
|
51
|
+
* `system` 행은 서버가 쓰는 자리이고, 모델은 그 줄을 사람의 말보다 무겁게 읽는다. 클라이언트가
|
|
52
|
+
* 「이것은 시스템이 한 말」이라고 적을 수 있으면 역할을 서버가 쓴다는 결정이 무너진다.
|
|
53
|
+
*
|
|
54
|
+
* 사람이 친 것이 아닌 맥락은 `appendSessionNote` 가 자리다 — 그 문은 서버가 `system` 으로 적는다.
|
|
55
|
+
*/
|
|
56
|
+
const asked = turn?.role === 'ai' ? 'assistant' : turn?.role
|
|
57
|
+
if (asked !== 'assistant' && asked !== 'user') {
|
|
58
|
+
throw new Error(
|
|
59
|
+
`[ai-assistant/carried-turns] role '${String(turn?.role)}' cannot be carried. This door carries what ` +
|
|
60
|
+
"people said and what the assistant answered. Context that nobody typed goes through `appendSessionNote`, " +
|
|
61
|
+
'which writes the system row itself.'
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
const role = asked
|
|
50
65
|
|
|
51
66
|
if (role === 'user') {
|
|
52
67
|
planned.push({ role: 'user', content, addressee: 'room' })
|
|
@@ -65,8 +80,6 @@ export function planCarriedTurns(turns: CarriedTurn[]): PlannedRow[] {
|
|
|
65
80
|
}
|
|
66
81
|
continue
|
|
67
82
|
}
|
|
68
|
-
|
|
69
|
-
planned.push({ role: 'system', content })
|
|
70
83
|
}
|
|
71
84
|
|
|
72
85
|
return planned.slice(-CARRIED_TURN_LIMIT)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* ADR-0072 결정 5 — 문이 묻는 것을 AUTH 의 표에서 읽는다.
|
|
3
|
+
*
|
|
4
|
+
* 표가 주는 답은 셋이고 서로 다르다. 가운데 하나만 열림이다 — 나머지 둘을 열림으로 읽으면 못 볼 값이
|
|
5
|
+
* 나간다. 그리고 「없는 필드」를 열림으로 읽는 것이 가장 하기 쉬운 잘못이다: 도구가 문 이름을 틀리게
|
|
6
|
+
* 적었을 때 검사가 붙는 대신 조용히 빠진다.
|
|
7
|
+
*/
|
|
8
|
+
import { doorCheck, doorField, isKnownDoor, type FieldLookup } from './door-access'
|
|
9
|
+
|
|
10
|
+
const table: Record<string, any> = {
|
|
11
|
+
'Query.open': { known: true, gate: null },
|
|
12
|
+
'Query.guarded': { known: true, gate: { category: 'job-order', privilege: 'query', owner: false, super: false } }
|
|
13
|
+
}
|
|
14
|
+
const lookup: FieldLookup = (typeName, fieldName) => table[`${typeName}.${fieldName}`] ?? { known: false }
|
|
15
|
+
|
|
16
|
+
describe('door access', () => {
|
|
17
|
+
it('★ 선언이 없는 필드는 열려 있다 — 그 문으로 아무나 읽으므로 이 행도 읽는다', async () => {
|
|
18
|
+
const check = doorCheck({ lookup, permits: async () => false })
|
|
19
|
+
|
|
20
|
+
expect(await check('Query.open')).toBe(true)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('★ 없는 필드는 닫는다 — 도구가 이름을 틀리게 적은 것이지 열린 문이 아니다', async () => {
|
|
24
|
+
const check = doorCheck({ lookup, permits: async () => true })
|
|
25
|
+
|
|
26
|
+
expect(await check('Query.doesNotExist')).toBe(false)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('★ 선언이 있는 필드는 AUTH 에게 묻는다 — 여기서 규칙을 다시 쓰지 않는다', async () => {
|
|
30
|
+
const asked: any[] = []
|
|
31
|
+
const check = doorCheck({
|
|
32
|
+
lookup,
|
|
33
|
+
permits: async gate => {
|
|
34
|
+
asked.push(gate)
|
|
35
|
+
return false
|
|
36
|
+
}
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
expect(await check('Query.guarded')).toBe(false)
|
|
40
|
+
expect(asked).toEqual([{ category: 'job-order', privilege: 'query', owner: false, super: false }])
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('AUTH 가 통과시키면 지난다', async () => {
|
|
44
|
+
expect(await doorCheck({ lookup, permits: async () => true })('Query.guarded')).toBe(true)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('★ 문 모양이 아니면 아무도 못 지난다 — 표를 묻지도 않는다', async () => {
|
|
48
|
+
const asked: string[] = []
|
|
49
|
+
const watching: FieldLookup = (typeName, fieldName) => {
|
|
50
|
+
asked.push(`${typeName}.${fieldName}`)
|
|
51
|
+
return { known: false }
|
|
52
|
+
}
|
|
53
|
+
const check = doorCheck({ lookup: watching, permits: async () => true })
|
|
54
|
+
|
|
55
|
+
for (const bad of ['jobOrders', 'Query.', 'Query.a.b', '', 'unresolved:getStationScreen']) {
|
|
56
|
+
expect(await check(bad)).toBe(false)
|
|
57
|
+
}
|
|
58
|
+
expect(asked).toEqual([])
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('문 이름을 타입과 필드로 가른다', () => {
|
|
62
|
+
expect(doorField('Mutation.proposeFigure')).toEqual({ typeName: 'Mutation', fieldName: 'proposeFigure' })
|
|
63
|
+
expect(doorField('Subscription.chatMessageActivity')?.typeName).toBe('Subscription')
|
|
64
|
+
expect(doorField('Query.a.b')).toBeUndefined()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('스키마에 있는 문인지 따로 답한다 — 부팅 보고가 이것으로 틀린 선언을 말한다', () => {
|
|
68
|
+
expect(isKnownDoor('Query.open', lookup)).toBe(true)
|
|
69
|
+
expect(isKnownDoor('Query.guarded', lookup)).toBe(true)
|
|
70
|
+
expect(isKnownDoor('Query.doesNotExist', lookup)).toBe(false)
|
|
71
|
+
})
|
|
72
|
+
})
|
|
@@ -1,33 +1,49 @@
|
|
|
1
1
|
/*
|
|
2
2
|
* 문 → 그 문을 지키는 권한 — ADR-0073 판정 1 의 관문을 세우는 자리.
|
|
3
3
|
*
|
|
4
|
-
* ──
|
|
4
|
+
* ── 정본은 AUTH 의 표다 ───────────────────────────────────────────────────
|
|
5
5
|
* 행에 적힌 것은 문 이름(`Query.jobOrders`)이고, 사람이 가진 것은 권한(`job-order:query`)이다. 둘을
|
|
6
|
-
* 잇는
|
|
7
|
-
*
|
|
8
|
-
* 권한도 함께 적어 두었다.
|
|
6
|
+
* 잇는 것은 `privilegeOfField(typeName, fieldName)` 이다(ADR-0072 결정 5). 스키마를 지을 때 모든 루트
|
|
7
|
+
* 필드가 기록되므로, 이 표는 **그 문이 실제로 무엇을 묻는지**를 그대로 답한다.
|
|
9
8
|
*
|
|
10
|
-
*
|
|
9
|
+
* 그 전에는 도구 명세가 들고 있는 짝을 베껴 썼다. 베낀 값은 문이 선언을 바꾸면 조용히 어긋나고,
|
|
10
|
+
* 어긋나면 느슨한 쪽이 이긴다. 표가 서면서 그 사본을 걷었다.
|
|
11
11
|
*
|
|
12
|
-
* ──
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* ── 판정하지 않고 AUTH 에게 묻는다 ────────────────────────────────────────
|
|
13
|
+
* 표가 주는 gate 는 `checkPermission` 이 받는 넷(category · privilege · owner · super)과 같은 모양이고,
|
|
14
|
+
* 그대로 `checkPermission` 에 넘긴다. 여기서 「category 가 없으면 소유권만 본다」 같은 규칙을 다시 쓰면
|
|
15
|
+
* 그 순간 AUTH 와 이 파일이 두 벌이 된다. 문에서 통하는 것이 여기서도 통해야 한다.
|
|
16
|
+
*
|
|
17
|
+
* ── 셋 중 둘은 닫는다 ─────────────────────────────────────────────────────
|
|
18
|
+
* `{ known: false }` 그런 루트 필드가 없다. 도구가 문 이름을 틀리게 적은 것이다 —
|
|
19
|
+
* **열림으로 읽지 않는다.** 부팅 보고가 따로 말한다.
|
|
20
|
+
* `{ known: true, gate: null }` 필드는 있고 선언이 없다. 그 문은 열려 있으므로 이 행도 열린다.
|
|
21
|
+
* `{ known: true, gate }` 그 문이 묻는 것을 이 사람이 가졌는지 AUTH 에게 묻는다.
|
|
22
|
+
*
|
|
23
|
+
* 스키마를 짓기 전에는 표가 비어 있어 모든 문이 `known: false` 로 답한다 — 가려지는 쪽이다.
|
|
15
24
|
*/
|
|
16
|
-
import type {
|
|
25
|
+
import type { FieldGate } from '@things-factory/auth-base'
|
|
17
26
|
|
|
18
|
-
/**
|
|
19
|
-
export
|
|
20
|
-
for (const spec of specs) {
|
|
21
|
-
if (((spec as any).doors ?? []).includes(door)) return (spec as any).privilege
|
|
22
|
-
}
|
|
23
|
-
return undefined
|
|
24
|
-
}
|
|
27
|
+
/** `privilegeOfField` 와 같은 모양 — 이 파일은 표를 **읽기만** 하고 어디서 오는지는 모른다. */
|
|
28
|
+
export type FieldLookup = (typeName: string, fieldName: string) => { known: false } | { known: true; gate: FieldGate | null }
|
|
25
29
|
|
|
26
30
|
export interface DoorCheckDeps {
|
|
27
|
-
/**
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
|
|
31
|
+
/** AUTH 의 필드→권한 표. 운영에서는 `privilegeOfField` 가 들어온다(`reader-gate.ts`). */
|
|
32
|
+
lookup: FieldLookup
|
|
33
|
+
/** `checkPermission` 을 그대로 싸서 넘긴다 — 판정은 AUTH 가 한다. */
|
|
34
|
+
permits(gate: FieldGate): Promise<boolean>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** `Query.jobOrders` 를 타입과 필드로 가른다. 모양이 아니면 undefined — 아무도 못 지난다. */
|
|
38
|
+
export function doorField(door: string): { typeName: string; fieldName: string } | undefined {
|
|
39
|
+
const match = /^(Query|Mutation|Subscription)\.([A-Za-z_][A-Za-z0-9_]*)$/.exec(door ?? '')
|
|
40
|
+
return match ? { typeName: match[1], fieldName: match[2] } : undefined
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 이 문이 스키마에 있는가 — 없으면 선언이 틀린 것이고, 부팅 보고가 그것을 말한다. */
|
|
44
|
+
export function isKnownDoor(door: string, lookup: FieldLookup): boolean {
|
|
45
|
+
const field = doorField(door)
|
|
46
|
+
return !!field && lookup(field.typeName, field.fieldName).known
|
|
31
47
|
}
|
|
32
48
|
|
|
33
49
|
/**
|
|
@@ -37,9 +53,13 @@ export interface DoorCheckDeps {
|
|
|
37
53
|
*/
|
|
38
54
|
export function doorCheck(deps: DoorCheckDeps): (door: string) => Promise<boolean> {
|
|
39
55
|
return async (door: string) => {
|
|
40
|
-
const
|
|
41
|
-
if (!
|
|
56
|
+
const field = doorField(door)
|
|
57
|
+
if (!field) return false
|
|
58
|
+
|
|
59
|
+
const asked = deps.lookup(field.typeName, field.fieldName)
|
|
60
|
+
if (!asked.known) return false
|
|
61
|
+
if (asked.gate === null) return true
|
|
42
62
|
|
|
43
|
-
return await deps.
|
|
63
|
+
return await deps.permits(asked.gate)
|
|
44
64
|
}
|
|
45
65
|
}
|