@things-factory/ai-assistant 10.1.102 → 10.1.104

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.102",
3
+ "version": "10.1.104",
4
4
  "main": "dist-server/index.js",
5
5
  "things-factory": true,
6
6
  "author": "heartyoh",
@@ -51,5 +51,5 @@
51
51
  "copyfiles": "^2.4.1",
52
52
  "rimraf": "^5.0.0"
53
53
  },
54
- "gitHead": "8bba629dd321caa7c047d52f451d61d2678cc6e2"
54
+ "gitHead": "0d05c7573220c6da2dec2654a4b86d7bd8e3c4fb"
55
55
  }
@@ -311,3 +311,33 @@ describe('a record with lines', () => {
311
311
  })
312
312
  })
313
313
 
314
+ describe('checking the changes', () => {
315
+ const named: EntryTarget = {
316
+ ...expense,
317
+ update: {
318
+ ...expense.update!,
319
+ check: rows => rows.map(row => (row.values.merchant === '스타벅스' ? [{ code: 'name-taken', blocking: true }] : []))
320
+ }
321
+ }
322
+
323
+ it('★ the target checks a change when the card is made - and again when it is pressed', async () => {
324
+ declareEntryTarget(named)
325
+ const proposal: any = { ...(await proposeChanges(named, { changes: [{ ref: 'l1', values: { merchant: '스타벅스' } }, { ref: 'l2', values: { amount: 9000 } }] }, { host: {} }, run)), key: 'c1' }
326
+ expect(proposal.rows[0].issues).toEqual([{ code: 'name-taken', blocking: true }])
327
+ expect(proposal.rows[1].issues).toEqual([])
328
+ /* A card made before the clash: the check at pressing keeps it out. */
329
+ const before: any = { ...proposal, rows: proposal.rows.map((row: any) => ({ ...row, issues: [] })) }
330
+ const outcome = await applyEntryProposal('c1', { messages: [{ id: 'm1', proposals: [before] }], answered: false, passesDoor: async () => true, execute: run, state: {} })
331
+ expect(outcome.rows[0]).toEqual({ index: 0, ok: false, error: 'name-taken' })
332
+ expect(outcome.rows[1]).toMatchObject({ ok: true })
333
+ })
334
+ })
335
+
336
+ describe('a check that cannot run', () => {
337
+ it('★ keeps every change out - nothing is written unchecked', async () => {
338
+ const broken: EntryTarget = { ...expense, update: { ...expense.update!, check: () => { throw new Error('db down') } } }
339
+ const proposal: any = await proposeChanges(broken, { changes: [{ ref: 'l1', values: { amount: 1 } }] }, { host: {} }, run)
340
+ expect(proposal.rows[0].issues).toEqual([{ code: 'check-failed', blocking: true }])
341
+ })
342
+ })
343
+
@@ -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
  /**
@@ -252,6 +252,7 @@ export function entryCategoryOf(target: EntryTarget, run: (context: any) => Grap
252
252
  guidance: [
253
253
  `- When the person gives material to enter as records (files, photos, pasted text), read all of it first - call readChatAttachment for a file sent in an earlier turn - then call ${name} once with every record as a row.`,
254
254
  '- Never invent a value. Leave a field out when the material does not say it; the card marks it, and you ask the person for it.',
255
+ '- A row kept out because a name was not found, or matches several records, is settled in the conversation: say which name, list the candidates the card names, and ask; then propose again with the name the person chose. The card has no picker - never tell the person to choose on it.',
255
256
  '- Give each row its sources: which attachment and where in it, written in the person\'s language. A value the person gave or corrected in the conversation adds the source { person: true }. The server looks for each row\'s numbers and dates in the cited file and marks what it does not find.',
256
257
  '- When the material prints a total, always pass it as declaredTotal exactly as printed - do not add it up yourself. The server also reads a total line of a text file itself.',
257
258
  `- ${name} only proposes. Nothing is entered until the person presses the button on the card - never say the records were entered.`,
@@ -148,7 +148,8 @@ export async function proposeChanges(target: EntryTarget, args: any, context: an
148
148
  } catch (error: any) {
149
149
  return { rejected: true, error: 'read-failed', message: `could not read the records: ${error?.message ?? error}` }
150
150
  }
151
- const rows = await resolveReferences(changeableFields(target), zonedDateTimes(changeableFields(target), changeRows(target, picked, records), context?.state?.domain?.timezone), run)
151
+ const changed = await resolveReferences(changeableFields(target), zonedDateTimes(changeableFields(target), changeRows(target, picked, records), context?.state?.domain?.timezone), run)
152
+ const rows = await withChangeChecks(target, changed, records, { state: context?.state })
152
153
  const blocked = rows.filter(blockedChange).length
153
154
  return {
154
155
  proposed: true,
@@ -209,3 +210,19 @@ export function stillAsProposed(target: EntryTarget, rows: ChangeRow[], now: Map
209
210
  return moved ? { ...row, now: record, issues: [...row.issues, { field: moved, code: 'changed-since', blocking: true }] } : { ...row, now: record }
210
211
  })
211
212
  }
213
+
214
+ /**
215
+ * The target's own checks of the changes, added to each row. A check that could not run keeps every row out - a
216
+ * change is not written unchecked (an entry's failed check refuses its card the same way).
217
+ */
218
+ export async function withChangeChecks<T extends ChangeRow>(target: EntryTarget, rows: T[], records: Map<string, EntryRecord>, context: { state: any }): Promise<T[]> {
219
+ if (!target.update?.check) return rows
220
+ let found: EntryIssue[][] | undefined
221
+ try {
222
+ found = await target.update.check(rows.map(row => ({ id: row.id, record: records.get(row.id), values: row.values })), context)
223
+ } catch {
224
+ return rows.map(row => ({ ...row, issues: [...row.issues, { code: 'check-failed', blocking: true }] }))
225
+ }
226
+ return rows.map((row, i) => (found?.[i]?.length ? { ...row, issues: [...row.issues, ...found[i]] } : row))
227
+ }
228
+
@@ -275,5 +275,6 @@
275
275
  "ai-assistant.entry.issue.reference-unreadable": "\"{text}\" - could not be looked up",
276
276
  "ai-assistant.entry.issue.not-a-reference": "not a record it can point to",
277
277
  "ai-assistant.entry.groups": "{count} records",
278
- "ai-assistant.entry.groups-kept-out": "{count} records kept out whole"
278
+ "ai-assistant.entry.groups-kept-out": "{count} records kept out whole",
279
+ "ai-assistant.entry.issue.check-failed": "could not be checked - not changed"
279
280
  }
@@ -275,5 +275,6 @@
275
275
  "ai-assistant.entry.issue.reference-unreadable": "「{text}」— 検索できませんでした",
276
276
  "ai-assistant.entry.issue.not-a-reference": "参照先の記録が分かりません",
277
277
  "ai-assistant.entry.groups": "{count}件",
278
- "ai-assistant.entry.groups-kept-out": "{count}件は行が止まり全体を入力しません"
278
+ "ai-assistant.entry.groups-kept-out": "{count}件は行が止まり全体を入力しません",
279
+ "ai-assistant.entry.issue.check-failed": "検査できなかったため修正しません"
279
280
  }
@@ -275,5 +275,6 @@
275
275
  "ai-assistant.entry.issue.reference-unreadable": "\"{text}\" — 찾아보지 못했습니다",
276
276
  "ai-assistant.entry.issue.not-a-reference": "가리키는 기록을 알 수 없습니다",
277
277
  "ai-assistant.entry.groups": "{count}건",
278
- "ai-assistant.entry.groups-kept-out": "{count}건은 줄이 막혀 통째로 입력하지 않습니다"
278
+ "ai-assistant.entry.groups-kept-out": "{count}건은 줄이 막혀 통째로 입력하지 않습니다",
279
+ "ai-assistant.entry.issue.check-failed": "검사를 하지 못해 고치지 않습니다"
279
280
  }
@@ -275,5 +275,6 @@
275
275
  "ai-assistant.entry.issue.reference-unreadable": "\"{text}\" - tidak dapat dicari",
276
276
  "ai-assistant.entry.issue.not-a-reference": "bukan rekod yang boleh dirujuk",
277
277
  "ai-assistant.entry.groups": "{count} rekod",
278
- "ai-assistant.entry.groups-kept-out": "{count} rekod ditahan sepenuhnya"
278
+ "ai-assistant.entry.groups-kept-out": "{count} rekod ditahan sepenuhnya",
279
+ "ai-assistant.entry.issue.check-failed": "tidak dapat disemak - tidak diubah"
279
280
  }
@@ -275,5 +275,6 @@
275
275
  "ai-assistant.entry.issue.reference-unreadable": "\"{text}\" — 无法查找",
276
276
  "ai-assistant.entry.issue.not-a-reference": "无法确定所指记录",
277
277
  "ai-assistant.entry.groups": "{count}条",
278
- "ai-assistant.entry.groups-kept-out": "{count}条因有行被拦而整体不录入"
278
+ "ai-assistant.entry.groups-kept-out": "{count}条因有行被拦而整体不录入",
279
+ "ai-assistant.entry.issue.check-failed": "无法检查,未修改"
279
280
  }