@things-factory/ai-assistant 10.1.39 → 10.1.40

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/ai-assistant",
3
- "version": "10.1.39",
3
+ "version": "10.1.40",
4
4
  "main": "dist-server/index.js",
5
5
  "things-factory": true,
6
6
  "author": "heartyoh",
@@ -30,9 +30,9 @@
30
30
  "@operato/i18n": "^10.0.0",
31
31
  "@operato/styles": "^10.0.0",
32
32
  "@things-factory/ai-client-base": "^10.1.39",
33
- "@things-factory/auth-base": "^10.1.39",
33
+ "@things-factory/auth-base": "^10.1.40",
34
34
  "@things-factory/env": "^10.1.20",
35
- "@things-factory/shell": "^10.1.39",
35
+ "@things-factory/shell": "^10.1.40",
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": "fe5257ad8995fe7e79275e287f36e95b5fd32d1c"
47
+ "gitHead": "51c5d804c14ad8fcc6d4707e6ef80c9deede2766"
48
48
  }
@@ -80,7 +80,7 @@ export class ChatSession {
80
80
  @Field({
81
81
  nullable: true,
82
82
  description:
83
- "What this conversation is anchored to: 'board' | 'figure' (authoring a Figure) | 'space' | 'instance' | 'domain' | 'none'. Absent on legacy rows — treated as 'board' when boardId is present."
83
+ "What this conversation is anchored to: 'board' | 'figure' (authoring a Figure) | 'application' | 'space' | 'instance' | 'domain' | 'subject' (one subject in a 360, '<axis>:<itemId>') | 'none'. Absent on legacy rows — treated as 'board' when boardId is present."
84
84
  })
85
85
  anchorType?: string
86
86
 
@@ -50,11 +50,15 @@
50
50
  * space 공간 운영 대화 — 사이트와 그 공간에서 도는 트윈들
51
51
  * instance 트윈 하나에 대한 대화
52
52
  * domain 테넌트 전체가 주제인 대화 — 공장 전체 문답처럼 더 좁게 가리킬 것이 없을 때
53
+ * subject one subject in a 360 — `<axis>:<itemId>`: the 360 frame's axis word (ADR-0067, the shared
54
+ * frame; no new words are made here) and that row's uuid. Anchoring to `domain` instead
55
+ * would put every subject's conversations in one bucket and lose the per-subject list and
56
+ * history boundary (ruled 2026-09-24).
53
57
  * none 앵커로 찾을 수 없는 대화. 「아직 아무것도 고르지 않았다」는 화면 상태다
54
58
  */
55
59
  // Figure-specific history requested explicitly: reuse the existing varchar
56
60
  // anchor columns, without writing Figure ids into the legacy boardId column.
57
- export const SESSION_ANCHOR_TYPES = ['board', 'figure', 'application', 'space', 'instance', 'domain', 'none'] as const
61
+ export const SESSION_ANCHOR_TYPES = ['board', 'figure', 'application', 'space', 'instance', 'domain', 'subject', 'none'] as const
58
62
 
59
63
  export type SessionAnchorType = (typeof SESSION_ANCHOR_TYPES)[number]
60
64
 
@@ -76,6 +80,25 @@ function assertAnchorType(anchorType: string | undefined, where: string): Sessio
76
80
  return type
77
81
  }
78
82
 
83
+ /**
84
+ * A `subject` anchor id is `<axis>:<itemId>`, the item being that row's uuid (ruled 2026-09-24).
85
+ *
86
+ * Checked because the shape is the whole contract: a screen that passes what it shows — an EPC like
87
+ * `urn:epc:id:sgtin:…`, or an order's name — would store a pointer to nothing stable. A name changes
88
+ * and the conversation is orphaned; an EPC splits on its own colons. Refused here, on write and on
89
+ * read, rather than stored and never found.
90
+ */
91
+ const SUBJECT_ANCHOR_ID = /^[a-z][a-z0-9-]*:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
92
+
93
+ function assertSubjectAnchorId(type: SessionAnchorType, id: string | undefined, where: string): void {
94
+ if (type !== 'subject' || !id || SUBJECT_ANCHOR_ID.test(id)) return
95
+ throw new Error(
96
+ `[ai-assistant/session-anchor] ${where}: '${id}' is not a subject anchor. ` +
97
+ "A subject anchor is '<axis>:<itemId>' — the 360 frame's axis word and the row's uuid, " +
98
+ "for example 'items:6f1c…'. Resolve what the screen shows (an EPC, a name) to the row's uuid first."
99
+ )
100
+ }
101
+
79
102
  /** 앵커 → 저장할 컬럼 값. 보드 앵커는 boardId 에도 같은 값을 넣어 기존 조회가 그대로 동작하게 한다. */
80
103
  export function anchorColumns(
81
104
  anchorType: string | undefined,
@@ -91,6 +114,7 @@ export function anchorColumns(
91
114
  "Use 'none' for a conversation that is deliberately not addressable by anchor."
92
115
  )
93
116
  }
117
+ assertSubjectAnchorId(type, id, 'anchorColumns')
94
118
  return { anchorType: type, anchorId: id, boardId: type === 'board' ? id : undefined }
95
119
  }
96
120
 
@@ -112,6 +136,7 @@ export function anchorColumns(
112
136
  */
113
137
  export function anchorWhere(domainId: string, anchorType: string, anchorId: string, kind?: string): any {
114
138
  const type = assertAnchorType(anchorType, 'anchorWhere')
139
+ assertSubjectAnchorId(type, anchorId, 'anchorWhere')
115
140
  const where = type === 'board' ? { boardId: anchorId, domain: { id: domainId } } : { anchorType: type, anchorId, domain: { id: domainId } }
116
141
  return kind ? { ...where, kind } : where
117
142
  }
@@ -12,6 +12,9 @@
12
12
  * ③ 공간·인스턴스 앵커는 boardId 를 오염시키지 않는다(보드 조회에 섞이면 이력 경계가 무너진다).
13
13
  */
14
14
  import { anchorColumns, anchorWhere, isSessionAnchorType, SESSION_ANCHOR_TYPES } from './chat-session/session-anchor'
15
+ import { initialVisibility } from './chat-session/session-visibility'
16
+
17
+ const SUBJECT_ID = 'items:6f1c2a90-3b4d-4e5f-8a6b-7c8d9e0f1a2b'
15
18
 
16
19
  describe('앵커 저장 값 (anchorColumns)', () => {
17
20
  it('board 앵커는 anchorId 와 boardId 에 같은 값을 쓴다 — 기존 조회 하위호환', () => {
@@ -134,7 +137,9 @@ describe('앵커 조회 조건 (anchorWhere)', () => {
134
137
 
135
138
  it('모든 조회에 domain 조건이 포함된다 — 빠지면 남의 테넌트 대화가 보인다', () => {
136
139
  for (const type of SESSION_ANCHOR_TYPES) {
137
- expect(anchorWhere('dom-9', type, 'X').domain).toEqual({ id: 'dom-9' })
140
+ /* A subject anchor has a shape of its own (below); every other type takes any id. */
141
+ const id = type === 'subject' ? SUBJECT_ID : 'X'
142
+ expect(anchorWhere('dom-9', type, id).domain).toEqual({ id: 'dom-9' })
138
143
  }
139
144
  })
140
145
 
@@ -142,3 +147,52 @@ describe('앵커 조회 조건 (anchorWhere)', () => {
142
147
  expect(anchorWhere('dom-1', 'space', 'busan').boardId).toBeUndefined()
143
148
  })
144
149
  })
150
+
151
+ /*
152
+ * The subject anchor — one subject in a 360 (ruled 2026-09-24).
153
+ *
154
+ * `<axis>:<itemId>`: the 360 frame's axis word and that row's uuid. Tested on both sides the ruling
155
+ * split: without an id it throws, and with one it is stored as given and finds its own list again.
156
+ */
157
+ describe('the subject anchor', () => {
158
+ it('★ without an id it throws — a pointer to nothing would never be found again', () => {
159
+ expect(() => anchorColumns('subject', '')).toThrow(/was given no id/)
160
+ expect(() => anchorColumns('subject', undefined)).toThrow(/was given no id/)
161
+ })
162
+
163
+ it('★ `<axis>:<itemId>` is stored as given, and the lookup for that subject finds it', () => {
164
+ const stored = anchorColumns('subject', SUBJECT_ID)
165
+ expect(stored).toEqual({ anchorType: 'subject', anchorId: SUBJECT_ID, boardId: undefined })
166
+
167
+ const where = anchorWhere('dom-1', 'subject', SUBJECT_ID)
168
+ expect(where).toEqual({ anchorType: stored.anchorType, anchorId: stored.anchorId, domain: { id: 'dom-1' } })
169
+ })
170
+
171
+ it("another subject, another axis or another domain is another list", () => {
172
+ const other = 'items:0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d'
173
+ const sameRowOtherAxis = SUBJECT_ID.replace('items:', 'locations:')
174
+
175
+ expect(anchorWhere('dom-1', 'subject', other)).not.toEqual(anchorWhere('dom-1', 'subject', SUBJECT_ID))
176
+ expect(anchorWhere('dom-1', 'subject', sameRowOtherAxis)).not.toEqual(anchorWhere('dom-1', 'subject', SUBJECT_ID))
177
+ expect(anchorWhere('dom-2', 'subject', SUBJECT_ID)).not.toEqual(anchorWhere('dom-1', 'subject', SUBJECT_ID))
178
+ })
179
+
180
+ it.each([
181
+ ['an EPC, as the screen shows a pallet', 'items:urn:epc:id:sgtin:0614141.107346.2017'],
182
+ ['a name, as the screen shows an order', 'orders:SO-A887417'],
183
+ ['a uuid with no axis', '6f1c2a90-3b4d-4e5f-8a6b-7c8d9e0f1a2b'],
184
+ ['an axis with no item', 'items:']
185
+ ])('★ refuses %s — on write and on read', (_what, id) => {
186
+ expect(() => anchorColumns('subject', id)).toThrow(/is not a subject anchor/)
187
+ expect(() => anchorWhere('dom-1', 'subject', id)).toThrow(/is not a subject anchor/)
188
+ })
189
+
190
+ it('the shape rule is the subject anchor\'s alone — other anchors keep taking the ids they take', () => {
191
+ expect(anchorColumns('space', 'busan').anchorId).toBe('busan')
192
+ expect(anchorColumns('application', 'operato-figure').anchorId).toBe('operato-figure')
193
+ })
194
+
195
+ it('★ a subject conversation is born open — an anchored conversation is about a shared thing (ADR-0073 decision 2)', () => {
196
+ expect(initialVisibility({ anchorType: 'subject' })).toBe('open')
197
+ })
198
+ })
@@ -1,7 +1,7 @@
1
1
  import assert from 'node:assert/strict'
2
2
  import { readFileSync } from 'node:fs'
3
3
  import { join } from 'node:path'
4
- import { assistantAutoAskKey, assistantModelFingerprint, assistantRecentSessions } from '../client/utils/assistant-request-context.ts'
4
+ import { assistantAutoAskKey, assistantDraftDecision, assistantModelFingerprint, assistantRecentSessions } from '../client/utils/assistant-request-context.ts'
5
5
 
6
6
  test('model signature ignores object key order but detects nested edits and array order', () => {
7
7
  assert.equal(assistantModelFingerprint({ b: 2, a: { y: 3, x: 1 } }), assistantModelFingerprint({ a: { x: 1, y: 3 }, b: 2 }))
@@ -28,3 +28,43 @@ test('send captures current context before optimistic input and attaches request
28
28
  assert.match(send, /detail: \{ actions: out.actions, sessionId: out.sessionId, requestBase \}/)
29
29
  assert.match(send, /requestContext\.hostContext/)
30
30
  })
31
+
32
+ /*
33
+ * The draft — a sentence placed in the input for the person to edit and send (twin's "new twin → AI").
34
+ */
35
+ test('★ a draft fills an empty input once per request, and a re-render does not fill it again', () => {
36
+ const first = assistantDraftDecision({ draft: 'make a twin of line 3', draftRequestId: 'r1', typed: '' })
37
+ assert.deepEqual(first, { fill: true, consumed: assistantAutoAskKey('make a twin of line 3', 'r1') })
38
+
39
+ const again = assistantDraftDecision({ draft: 'make a twin of line 3', draftRequestId: 'r1', drafted: first.consumed, typed: '' })
40
+ assert.equal(again.fill, false)
41
+
42
+ const next = assistantDraftDecision({ draft: 'make a twin of line 3', draftRequestId: 'r2', drafted: first.consumed, typed: '' })
43
+ assert.equal(next.fill, true, 'the same sentence under a new request is a new draft')
44
+ })
45
+
46
+ test('★ a draft never overwrites what the person is typing — and does not surface later', () => {
47
+ const out = assistantDraftDecision({ draft: 'make a twin', draftRequestId: 'r1', typed: 'how many forklifts' })
48
+ assert.equal(out.fill, false)
49
+ assert.ok(out.consumed, 'skipped, and remembered as consumed')
50
+ })
51
+
52
+ test('★ when autoAsk comes for the same request, autoAsk wins', () => {
53
+ const out = assistantDraftDecision({ draft: 'edit me', draftRequestId: 'r1', typed: '', autoAsk: 'send me', autoAskRequestId: 'r1' })
54
+ assert.equal(out.fill, false)
55
+
56
+ const otherRequest = assistantDraftDecision({ draft: 'edit me', draftRequestId: 'r2', typed: '', autoAsk: 'send me', autoAskRequestId: 'r1' })
57
+ assert.equal(otherRequest.fill, true)
58
+ })
59
+
60
+ test('no draft, nothing to do', () => {
61
+ assert.deepEqual(assistantDraftDecision({ typed: '' }), { fill: false })
62
+ })
63
+
64
+ test('the component places the draft and never sends it', () => {
65
+ const chat = readFileSync(join(__dirname, '../client/components/assistant-chat.ts'), 'utf8')
66
+ const updated = chat.slice(chat.indexOf(' updated(changed'), chat.indexOf('if (changed.has(\'subject\'))'))
67
+ const draftPart = updated.slice(updated.indexOf('assistantDraftDecision('))
68
+ assert.match(draftPart, /this\.input = this\.draft!/)
69
+ assert.doesNotMatch(draftPart, /this\.send\(\)/)
70
+ })