@things-factory/ai-assistant 10.1.103 → 10.1.106

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.103",
3
+ "version": "10.1.106",
4
4
  "main": "dist-server/index.js",
5
5
  "things-factory": true,
6
6
  "author": "heartyoh",
@@ -34,11 +34,11 @@
34
34
  "@operato/shell": "^10.0.0",
35
35
  "@operato/styles": "^10.0.0",
36
36
  "@things-factory/ai-client-base": "^10.1.81",
37
- "@things-factory/apptool-base": "^10.1.79",
38
- "@things-factory/attachment-base": "^10.1.79",
39
- "@things-factory/auth-base": "^10.1.79",
37
+ "@things-factory/apptool-base": "^10.1.106",
38
+ "@things-factory/attachment-base": "^10.1.106",
39
+ "@things-factory/auth-base": "^10.1.106",
40
40
  "@things-factory/env": "^10.1.20",
41
- "@things-factory/shell": "^10.1.79",
41
+ "@things-factory/shell": "^10.1.106",
42
42
  "dompurify": "^3.0.0",
43
43
  "graphql-scalars": "^1.22.0",
44
44
  "graphql-tag": "^2.12.6",
@@ -51,5 +51,5 @@
51
51
  "copyfiles": "^2.4.1",
52
52
  "rimraf": "^5.0.0"
53
53
  },
54
- "gitHead": "db9d51a78d29b96c2c9a5c6a5beac70998aaaeb5"
54
+ "gitHead": "8d78c14708420773ec32d18b8dd44c300771c44d"
55
55
  }
@@ -110,6 +110,13 @@ describe('the card', () => {
110
110
  expect(rows[0].issues).toEqual([{ field: 'spentDate', code: 'not-changeable', blocking: false }, { code: 'no-change', blocking: true }])
111
111
  expect(rows[1].issues).toEqual([{ field: 'category', code: 'not-in-list', params: { values: ['교통비', '회의비'] }, blocking: true }])
112
112
  })
113
+
114
+ it('★ a field restated as it already is is not a change - not shown, not written (the model sent the name back with the note)', async () => {
115
+ const result: any = await proposeChanges(expense, { changes: [{ ref: 'l1', values: { merchant: '카카오T', amount: '12400', category: '회의비' } }] }, { host: {} }, run)
116
+ expect(result.rows[0]).toMatchObject({ before: { category: '교통비' }, values: { category: '회의비' }, issues: [] })
117
+ expect(Object.keys(result.rows[0].values)).toEqual(['category'])
118
+ expect(result.columns.map((one: any) => one.name)).toEqual(['category'])
119
+ })
113
120
  })
114
121
 
115
122
  describe('changing what the person pressed', () => {
@@ -311,3 +318,33 @@ describe('a record with lines', () => {
311
318
  })
312
319
  })
313
320
 
321
+ describe('checking the changes', () => {
322
+ const named: EntryTarget = {
323
+ ...expense,
324
+ update: {
325
+ ...expense.update!,
326
+ check: rows => rows.map(row => (row.values.merchant === '스타벅스' ? [{ code: 'name-taken', blocking: true }] : []))
327
+ }
328
+ }
329
+
330
+ it('★ the target checks a change when the card is made - and again when it is pressed', async () => {
331
+ declareEntryTarget(named)
332
+ const proposal: any = { ...(await proposeChanges(named, { changes: [{ ref: 'l1', values: { merchant: '스타벅스' } }, { ref: 'l2', values: { amount: 9000 } }] }, { host: {} }, run)), key: 'c1' }
333
+ expect(proposal.rows[0].issues).toEqual([{ code: 'name-taken', blocking: true }])
334
+ expect(proposal.rows[1].issues).toEqual([])
335
+ /* A card made before the clash: the check at pressing keeps it out. */
336
+ const before: any = { ...proposal, rows: proposal.rows.map((row: any) => ({ ...row, issues: [] })) }
337
+ const outcome = await applyEntryProposal('c1', { messages: [{ id: 'm1', proposals: [before] }], answered: false, passesDoor: async () => true, execute: run, state: {} })
338
+ expect(outcome.rows[0]).toEqual({ index: 0, ok: false, error: 'name-taken' })
339
+ expect(outcome.rows[1]).toMatchObject({ ok: true })
340
+ })
341
+ })
342
+
343
+ describe('a check that cannot run', () => {
344
+ it('★ keeps every change out - nothing is written unchecked', async () => {
345
+ const broken: EntryTarget = { ...expense, update: { ...expense.update!, check: () => { throw new Error('db down') } } }
346
+ const proposal: any = await proposeChanges(broken, { changes: [{ ref: 'l1', values: { amount: 1 } }] }, { host: {} }, run)
347
+ expect(proposal.rows[0].issues).toEqual([{ code: 'check-failed', blocking: true }])
348
+ })
349
+ })
350
+
@@ -11,7 +11,7 @@
11
11
  * Dependencies come in as arguments so the rules are tested without a database.
12
12
  */
13
13
  import { PROPOSAL_OUTCOME_PREFIX, PROPOSAL_VERDICTS } from '../chat-message/proposal-outcome.js'
14
- import { readRecords, stillAsProposed, type ChangeRow } from './entry-update.js'
14
+ import { readRecords, stillAsProposed, withChangeChecks, type ChangeRow } from './entry-update.js'
15
15
  import { changeToolName, zonedDateTimes, entryGroups, resolveReferences, entryDoorOf, entryTargetOf, entryToolName, rootFieldOf, isBlocked, recordEntryEvent, normalizeEntryRows, withCheckedIssues, type EntryRow, type EntryTarget } from './entry-target.js'
16
16
 
17
17
  export interface StoredProposalMessage {
@@ -185,7 +185,8 @@ async function applyChanges(key: string, proposal: any, target: EntryTarget, dep
185
185
  }
186
186
 
187
187
  const results: EntryRowResult[] = []
188
- for (const row of stillAsProposed(target, stored, now)) {
188
+ /* The target's checks of the changes again - another record may have taken the new name since the card was made. */
189
+ for (const row of await withChangeChecks(target, stillAsProposed(target, stored, now), now, { state: deps.state })) {
189
190
  if (row.issues.some(one => one.blocking)) {
190
191
  const issue = row.issues.find(one => one.blocking)!
191
192
  results.push({ index: row.index, ok: false, error: issue.field ? `${issue.field}: ${issue.code}` : issue.code })
@@ -179,6 +179,12 @@ export interface EntryUpdate {
179
179
  labelOf?(record: EntryRecord): string
180
180
  /** The fields that may change. Default: every declared field. */
181
181
  fields?: string[]
182
+ /**
183
+ * Read-only checks of the changes - a new name that another record already has. Asked when the card is made and
184
+ * again when it is pressed, like an entry's check; one issue list per row. Without it a clash is caught only by
185
+ * the database's own refusal.
186
+ */
187
+ check?(rows: { id: string; record?: EntryRecord; values: Record<string, unknown> }[], context: EntryContext): EntryIssue[][] | Promise<EntryIssue[][]>
182
188
  }
183
189
 
184
190
  /**
@@ -115,8 +115,12 @@ export function changeRows(target: EntryTarget, picked: { id: string; via: Chang
115
115
  if (record) before[name] = record[name] ?? null
116
116
  }
117
117
  if (!record) issues.push({ code: 'record-not-found', blocking: true })
118
- else if (Object.keys(values).length && Object.keys(values).every(name => sameFieldValue(fields.get(name)!, before[name], values[name])))
119
- issues.push({ code: 'no-change', blocking: true })
118
+ else {
119
+ /* A field the model restated as it already is does not change - the card shows it as no change, and it is not written. */
120
+ const same = Object.keys(values).filter(name => fields.has(name) && !issues.some(issue => issue.field === name) && sameFieldValue(fields.get(name)!, before[name], values[name]))
121
+ if (same.length && same.length === Object.keys(values).length) issues.push({ code: 'no-change', blocking: true })
122
+ else for (const name of same) delete values[name], delete before[name]
123
+ }
120
124
  if (!Object.keys(values).length && !issues.some(issue => issue.blocking)) issues.push({ code: 'nothing-to-change', blocking: true })
121
125
  return { index, id: one.id, via: one.via, ...(record && target.update?.labelOf ? { label: target.update.labelOf(record) } : {}), before, values, issues }
122
126
  })
@@ -148,7 +152,8 @@ export async function proposeChanges(target: EntryTarget, args: any, context: an
148
152
  } catch (error: any) {
149
153
  return { rejected: true, error: 'read-failed', message: `could not read the records: ${error?.message ?? error}` }
150
154
  }
151
- const rows = await resolveReferences(changeableFields(target), zonedDateTimes(changeableFields(target), changeRows(target, picked, records), context?.state?.domain?.timezone), run)
155
+ const changed = await resolveReferences(changeableFields(target), zonedDateTimes(changeableFields(target), changeRows(target, picked, records), context?.state?.domain?.timezone), run)
156
+ const rows = await withChangeChecks(target, changed, records, { state: context?.state })
152
157
  const blocked = rows.filter(blockedChange).length
153
158
  return {
154
159
  proposed: true,
@@ -209,3 +214,19 @@ export function stillAsProposed(target: EntryTarget, rows: ChangeRow[], now: Map
209
214
  return moved ? { ...row, now: record, issues: [...row.issues, { field: moved, code: 'changed-since', blocking: true }] } : { ...row, now: record }
210
215
  })
211
216
  }
217
+
218
+ /**
219
+ * The target's own checks of the changes, added to each row. A check that could not run keeps every row out - a
220
+ * change is not written unchecked (an entry's failed check refuses its card the same way).
221
+ */
222
+ export async function withChangeChecks<T extends ChangeRow>(target: EntryTarget, rows: T[], records: Map<string, EntryRecord>, context: { state: any }): Promise<T[]> {
223
+ if (!target.update?.check) return rows
224
+ let found: EntryIssue[][] | undefined
225
+ try {
226
+ found = await target.update.check(rows.map(row => ({ id: row.id, record: records.get(row.id), values: row.values })), context)
227
+ } catch {
228
+ return rows.map(row => ({ ...row, issues: [...row.issues, { code: 'check-failed', blocking: true }] }))
229
+ }
230
+ return rows.map((row, i) => (found?.[i]?.length ? { ...row, issues: [...row.issues, ...found[i]] } : row))
231
+ }
232
+
@@ -11,6 +11,7 @@ import {
11
11
  entryOutcomeNote,
12
12
  entryProposalHandler,
13
13
  entryReadyCount,
14
+ entryTitle,
14
15
  latestEntryKeys,
15
16
  type EntryApplyAnswer
16
17
  } from '../client/utils/entry-proposal'
@@ -21,6 +22,24 @@ const t = (key: string, options: any = {}) =>
21
22
  const card = (key: string, target = 'office.expense-line', rows: any[] = []) => ({ key, kind: 'entry', target, rows })
22
23
 
23
24
  describe('the entry card', () => {
25
+ it('★ is titled by its kind - a 수정안 is never titled as a 입력안', () => {
26
+ const named = (key: string, options: any = {}) => (key === 'label.office-entry-counterparty' ? 'counterparty' : t(key, options))
27
+ expect(entryTitle({ kind: 'entry', target: 'office.counterparty', labelKey: 'label.office-entry-counterparty', rows: [] }, named)).toBe('counterparty to enter')
28
+ expect(entryTitle({ kind: 'entry', op: 'update', target: 'office.counterparty', labelKey: 'label.office-entry-counterparty', rows: [] }, named)).toBe('counterparty to change')
29
+ })
30
+
31
+ it('★ every target label names the record only - the card adds 입력안 or 수정안', () => {
32
+ /* The framework's own declarers; the applications hold the same rule in their translations. */
33
+ const offending: string[] = []
34
+ for (const root of ['../../ops-master', '../../organization', '../../i18n-base'])
35
+ for (const lang of ['ko', 'ja', 'zh', 'ms']) {
36
+ const table: Record<string, string> = JSON.parse(readFileSync(join(__dirname, root, 'translations', `${lang}.json`), 'utf8'))
37
+ for (const [key, value] of Object.entries(table))
38
+ if (/^label\.[a-z0-9-]+-entry-/.test(key) && /입력안|수정안|入力案|录入方案|cadangan/.test(value)) offending.push(`${root}/${lang}: ${key}`)
39
+ }
40
+ expect(offending).toEqual([])
41
+ })
42
+
24
43
  it('★ counts only the rows that will go in', () => {
25
44
  const p = card('k', undefined, [
26
45
  { index: 0, values: {}, sources: [], issues: [] },
@@ -254,6 +254,8 @@
254
254
  "ai-assistant.workspace.contains-metrics": "{count} figures",
255
255
  "ai-assistant.workspace.contains-steps": "{count} progress",
256
256
  "ai-assistant.entry.change": "change {count} records",
257
+ "ai-assistant.entry.title-create": "{name} to enter",
258
+ "ai-assistant.entry.title-update": "{name} to change",
257
259
  "ai-assistant.entry.changed": "changed {count} records.",
258
260
  "ai-assistant.entry.changed-none": "no record was changed.",
259
261
  "ai-assistant.entry.changes": "{count} records",
@@ -275,5 +277,6 @@
275
277
  "ai-assistant.entry.issue.reference-unreadable": "\"{text}\" - could not be looked up",
276
278
  "ai-assistant.entry.issue.not-a-reference": "not a record it can point to",
277
279
  "ai-assistant.entry.groups": "{count} records",
278
- "ai-assistant.entry.groups-kept-out": "{count} records kept out whole"
280
+ "ai-assistant.entry.groups-kept-out": "{count} records kept out whole",
281
+ "ai-assistant.entry.issue.check-failed": "could not be checked - not changed"
279
282
  }
@@ -254,6 +254,8 @@
254
254
  "ai-assistant.workspace.contains-metrics": "指標 {count}",
255
255
  "ai-assistant.workspace.contains-steps": "経過 {count}",
256
256
  "ai-assistant.entry.change": "{count}件を修正",
257
+ "ai-assistant.entry.title-create": "{name}の入力案",
258
+ "ai-assistant.entry.title-update": "{name}の修正案",
257
259
  "ai-assistant.entry.changed": "{count}件を修正しました。",
258
260
  "ai-assistant.entry.changed-none": "修正された記録はありません。",
259
261
  "ai-assistant.entry.changes": "{count}件",
@@ -275,5 +277,6 @@
275
277
  "ai-assistant.entry.issue.reference-unreadable": "「{text}」— 検索できませんでした",
276
278
  "ai-assistant.entry.issue.not-a-reference": "参照先の記録が分かりません",
277
279
  "ai-assistant.entry.groups": "{count}件",
278
- "ai-assistant.entry.groups-kept-out": "{count}件は行が止まり全体を入力しません"
280
+ "ai-assistant.entry.groups-kept-out": "{count}件は行が止まり全体を入力しません",
281
+ "ai-assistant.entry.issue.check-failed": "検査できなかったため修正しません"
279
282
  }
@@ -254,6 +254,8 @@
254
254
  "ai-assistant.workspace.contains-metrics": "수치 {count}",
255
255
  "ai-assistant.workspace.contains-steps": "경과 {count}",
256
256
  "ai-assistant.entry.change": "{count}건 고치기",
257
+ "ai-assistant.entry.title-create": "{name} 입력안",
258
+ "ai-assistant.entry.title-update": "{name} 수정안",
257
259
  "ai-assistant.entry.changed": "{count}건을 고쳤습니다.",
258
260
  "ai-assistant.entry.changed-none": "고친 기록이 없습니다.",
259
261
  "ai-assistant.entry.changes": "{count}건",
@@ -275,5 +277,6 @@
275
277
  "ai-assistant.entry.issue.reference-unreadable": "\"{text}\" — 찾아보지 못했습니다",
276
278
  "ai-assistant.entry.issue.not-a-reference": "가리키는 기록을 알 수 없습니다",
277
279
  "ai-assistant.entry.groups": "{count}건",
278
- "ai-assistant.entry.groups-kept-out": "{count}건은 줄이 막혀 통째로 입력하지 않습니다"
280
+ "ai-assistant.entry.groups-kept-out": "{count}건은 줄이 막혀 통째로 입력하지 않습니다",
281
+ "ai-assistant.entry.issue.check-failed": "검사를 하지 못해 고치지 않습니다"
279
282
  }
@@ -254,6 +254,8 @@
254
254
  "ai-assistant.workspace.contains-metrics": "{count} angka",
255
255
  "ai-assistant.workspace.contains-steps": "{count} kemajuan",
256
256
  "ai-assistant.entry.change": "ubah {count} rekod",
257
+ "ai-assistant.entry.title-create": "cadangan kemasukan {name}",
258
+ "ai-assistant.entry.title-update": "cadangan perubahan {name}",
257
259
  "ai-assistant.entry.changed": "{count} rekod diubah.",
258
260
  "ai-assistant.entry.changed-none": "tiada rekod diubah.",
259
261
  "ai-assistant.entry.changes": "{count} rekod",
@@ -275,5 +277,6 @@
275
277
  "ai-assistant.entry.issue.reference-unreadable": "\"{text}\" - tidak dapat dicari",
276
278
  "ai-assistant.entry.issue.not-a-reference": "bukan rekod yang boleh dirujuk",
277
279
  "ai-assistant.entry.groups": "{count} rekod",
278
- "ai-assistant.entry.groups-kept-out": "{count} rekod ditahan sepenuhnya"
280
+ "ai-assistant.entry.groups-kept-out": "{count} rekod ditahan sepenuhnya",
281
+ "ai-assistant.entry.issue.check-failed": "tidak dapat disemak - tidak diubah"
279
282
  }
@@ -254,6 +254,8 @@
254
254
  "ai-assistant.workspace.contains-metrics": "指标 {count}",
255
255
  "ai-assistant.workspace.contains-steps": "进度 {count}",
256
256
  "ai-assistant.entry.change": "修改{count}条",
257
+ "ai-assistant.entry.title-create": "{name}录入方案",
258
+ "ai-assistant.entry.title-update": "{name}修改方案",
257
259
  "ai-assistant.entry.changed": "已修改{count}条。",
258
260
  "ai-assistant.entry.changed-none": "未修改任何记录。",
259
261
  "ai-assistant.entry.changes": "{count}条",
@@ -275,5 +277,6 @@
275
277
  "ai-assistant.entry.issue.reference-unreadable": "\"{text}\" — 无法查找",
276
278
  "ai-assistant.entry.issue.not-a-reference": "无法确定所指记录",
277
279
  "ai-assistant.entry.groups": "{count}条",
278
- "ai-assistant.entry.groups-kept-out": "{count}条因有行被拦而整体不录入"
280
+ "ai-assistant.entry.groups-kept-out": "{count}条因有行被拦而整体不录入",
281
+ "ai-assistant.entry.issue.check-failed": "无法检查,未修改"
279
282
  }