@things-factory/ai-assistant 10.1.98 → 10.1.100
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/entry-review.ts +27 -8
- package/client/components/workspace-view.ts +6 -1
- package/client/utils/entry-proposal.ts +2 -1
- package/dist-client/components/entry-review.d.ts +1 -1
- package/dist-client/components/entry-review.js +27 -7
- package/dist-client/components/entry-review.js.map +1 -1
- package/dist-client/components/workspace-view.d.ts +1 -1
- package/dist-client/components/workspace-view.js +7 -2
- package/dist-client/components/workspace-view.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-client/utils/entry-proposal.d.ts +2 -0
- package/dist-client/utils/entry-proposal.js.map +1 -1
- package/dist-server/service/data-entry/entry-apply.js +2 -0
- package/dist-server/service/data-entry/entry-apply.js.map +1 -1
- package/dist-server/service/data-entry/entry-target.d.ts +54 -4
- package/dist-server/service/data-entry/entry-target.js +115 -10
- package/dist-server/service/data-entry/entry-target.js.map +1 -1
- package/dist-server/service/data-entry/entry-tools.d.ts +2 -1
- package/dist-server/service/data-entry/entry-tools.js +27 -10
- package/dist-server/service/data-entry/entry-tools.js.map +1 -1
- package/dist-server/service/data-entry/entry-update.d.ts +1 -0
- package/dist-server/service/data-entry/entry-update.js +4 -4
- package/dist-server/service/data-entry/entry-update.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/server/service/data-entry/data-entry-update.test.ts +61 -1
- package/server/service/data-entry/data-entry.test.ts +36 -4
- package/server/service/data-entry/entry-apply.ts +3 -1
- package/server/service/data-entry/entry-target.ts +139 -14
- package/server/service/data-entry/entry-tools.ts +28 -10
- package/server/service/data-entry/entry-update.ts +5 -4
- package/translations/en.json +7 -1
- package/translations/ja.json +7 -1
- package/translations/ko.json +7 -1
- package/translations/ms.json +7 -1
- package/translations/zh.json +7 -1
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.100",
|
|
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": "
|
|
54
|
+
"gitHead": "e3c2444ded42290fdc77b189f5befa419f575f9b"
|
|
55
55
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* and what is asked again before a record is changed.
|
|
4
4
|
*/
|
|
5
5
|
import { clearEntryTargets, declareEntryTarget, changeToolName, findToolName, type EntryTarget } from './entry-target'
|
|
6
|
-
import { entryCategoryOf } from './entry-tools'
|
|
6
|
+
import { entryCategoryOf, proposeEntries } from './entry-tools'
|
|
7
7
|
import { changeRows, findRecords, proposeChanges, resolveChangeRefs, stillAsProposed } from './entry-update'
|
|
8
8
|
import { applyEntryProposal, type EntryApplyDeps } from './entry-apply'
|
|
9
9
|
|
|
@@ -205,3 +205,63 @@ describe('the tool schemas', () => {
|
|
|
205
205
|
for (const spec of entryCategoryOf(expense, () => run).specs as any[]) expect(typesOf(spec.schema)).toEqual([])
|
|
206
206
|
})
|
|
207
207
|
})
|
|
208
|
+
|
|
209
|
+
describe('a field that points at another record', () => {
|
|
210
|
+
const loans: Record<string, any> = { a: { id: 'loan-a', name: '기업은행 운전자금' }, b: { id: 'loan-b', name: '중진공 시설자금' }, c: { id: 'loan-c', name: '기업은행 시설자금' } }
|
|
211
|
+
const lookup = async (document: string, variables: any) => {
|
|
212
|
+
calls.push({ document, variables })
|
|
213
|
+
if (document.includes('loans(')) return { data: { loans: Object.values(loans).filter(one => one.name.includes(variables.text)) } }
|
|
214
|
+
return run(document, variables)
|
|
215
|
+
}
|
|
216
|
+
const repayment: EntryTarget = {
|
|
217
|
+
key: 'office.loan-repayment',
|
|
218
|
+
labelKey: 'x',
|
|
219
|
+
description: 'one repayment.',
|
|
220
|
+
mutation: 'mutation ($r: NewLoanRepayment!) { createLoanRepayment(loanRepayment: $r) { id } }',
|
|
221
|
+
variables: row => ({ r: { ...row.values, loan: { id: (row.values.loan as any).id } } }),
|
|
222
|
+
fields: [
|
|
223
|
+
{ name: 'loan', type: 'reference', required: true, reference: { query: 'query ($text: String) { loans(text: $text) { id name } }', variables: text => ({ text }), recordsOf: (data, text) => data.loans.filter((one: any) => one.name.includes(text)).map((one: any) => ({ id: one.id, label: one.name })) } },
|
|
224
|
+
{ name: 'dueDate', type: 'date', required: true },
|
|
225
|
+
{ name: 'principal', type: 'number' }
|
|
226
|
+
]
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
it('★ one match keeps its id and name; none or several keep the row out, several with their names - the server never picks', async () => {
|
|
230
|
+
const result: any = await proposeEntries(
|
|
231
|
+
repayment,
|
|
232
|
+
{ rows: [{ values: { loan: '중진공', dueDate: '2026-10-25', principal: 1 }, sources: [{ where: 'x' }] }, { values: { loan: '기업은행', dueDate: '2026-10-25' }, sources: [{ where: 'x' }] }, { values: { loan: '신한', dueDate: '2026-10-25' }, sources: [{ where: 'x' }] }] },
|
|
233
|
+
{ state: {} },
|
|
234
|
+
async () => new Map(),
|
|
235
|
+
lookup
|
|
236
|
+
)
|
|
237
|
+
expect(result.rows[0].values.loan).toEqual({ id: 'loan-b', label: '중진공 시설자금' })
|
|
238
|
+
expect(result.rows[0].issues).toEqual([])
|
|
239
|
+
expect(result.rows[1].issues).toEqual([{ field: 'loan', code: 'reference-ambiguous', params: { text: '기업은행', candidates: '기업은행 운전자금, 기업은행 시설자금' }, blocking: true }])
|
|
240
|
+
expect(result.rows[2].issues).toEqual([{ field: 'loan', code: 'reference-not-found', params: { text: '신한' }, blocking: true }])
|
|
241
|
+
expect(result.counts).toMatchObject({ ready: 1, blocked: 2 })
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it("★ the tool carries the reference query's door too - reading the names is the person's privilege", () => {
|
|
245
|
+
expect((entryCategoryOf(repayment, () => lookup).specs[0] as any).doors).toEqual(['Mutation.createLoanRepayment', 'Query.loans'])
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
it('a declaration whose reference has no query is refused at boot', () => {
|
|
249
|
+
expect(() => declareEntryTarget({ ...repayment, fields: [{ name: 'loan', type: 'reference' }] as any })).toThrow(/reference field 'loan'/)
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it('the entering passes the resolved id, never the name', async () => {
|
|
253
|
+
declareEntryTarget(repayment)
|
|
254
|
+
const proposed: any = await proposeEntries(repayment, { rows: [{ values: { loan: '중진공', dueDate: '2026-10-25' }, sources: [{ where: 'x' }] }] }, { state: {} }, async () => new Map(), lookup)
|
|
255
|
+
const sent: any[] = []
|
|
256
|
+
const outcome = await applyEntryProposal('r1', {
|
|
257
|
+
messages: [{ id: 'm1', proposals: [{ ...proposed, key: 'r1' }] }],
|
|
258
|
+
answered: false,
|
|
259
|
+
passesDoor: async () => true,
|
|
260
|
+
execute: async (document, variables) => (document.includes('createLoanRepayment') ? (sent.push(variables), { data: { createLoanRepayment: { id: 'x' } } }) : lookup(document, variables)),
|
|
261
|
+
state: {}
|
|
262
|
+
})
|
|
263
|
+
expect(outcome).toMatchObject({ verdict: 'accepted', applied: 1 })
|
|
264
|
+
expect(sent[0].r.loan).toEqual({ id: 'loan-b' })
|
|
265
|
+
})
|
|
266
|
+
})
|
|
267
|
+
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Data entry from material (design/ai/data-entry.md) - the rules a row passes, what the tool hands back,
|
|
3
3
|
* and what the server asks again before it writes.
|
|
4
4
|
*/
|
|
5
|
-
import { clearEntryTargets, declareEntryTarget, entryDoorOf, entryToolName, entryTotals, lineHas, normalizeEntryRows, verifyRowsAgainstSources, writtenForms, type EntryTarget } from './entry-target'
|
|
5
|
+
import { clearEntryTargets, declareEntryTarget, entryDoorOf, entryToolName, entryTotals, lineHas, normalizeEntryRows, printedTotalOf, verifyRowsAgainstSources, writtenForms, type EntryTarget } from './entry-target'
|
|
6
6
|
import { entryCategoryOf, proposeEntries } from './entry-tools'
|
|
7
7
|
import { applyEntryProposal, entryProposalAnswered, findEntryProposal, type EntryApplyDeps } from './entry-apply'
|
|
8
8
|
import { modeToolCategories } from '../assistant-mode/resolve'
|
|
@@ -237,6 +237,7 @@ describe('checking the rows against the material', () => {
|
|
|
237
237
|
texts
|
|
238
238
|
)
|
|
239
239
|
expect(row.issues).toEqual([{ code: 'source-scattered', blocking: false }])
|
|
240
|
+
expect(row.sources).toEqual([{ attachmentId: 'a1', where: 'row 2' }, { person: true }])
|
|
240
241
|
})
|
|
241
242
|
|
|
242
243
|
it('a photo cannot be read here - the row says it was not checked', () => {
|
|
@@ -251,10 +252,12 @@ describe('checking the rows against the material', () => {
|
|
|
251
252
|
{ values: { amount: 500 }, sources: [{ where: 'x' }] }
|
|
252
253
|
])
|
|
253
254
|
expect(entryTotals(rows, expense.fields, { field: 'amount', value: '21,400', attachmentId: 'a1' }, texts)).toEqual([
|
|
254
|
-
{ field: 'amount', all: 21900, ready: 21400, declared: 21400, declaredFound: true, difference: -500 }
|
|
255
|
+
{ field: 'amount', all: 21900, ready: 21400, declared: 21400, declaredFound: true, difference: -500, declaredBy: 'model' }
|
|
255
256
|
])
|
|
256
257
|
expect(entryTotals(rows, expense.fields, { value: 30000, attachmentId: 'a1' }, texts)[0]).toMatchObject({ declared: 30000, declaredFound: false, difference: 8100 })
|
|
257
|
-
|
|
258
|
+
/* No total from the model: the server reads the file's own total line. */
|
|
259
|
+
expect(entryTotals(rows, expense.fields, undefined, texts)).toEqual([{ field: 'amount', all: 21900, ready: 21400, declared: 21400, declaredFound: true, declaredBy: 'file', difference: -500 }])
|
|
260
|
+
expect(entryTotals(rows, expense.fields, undefined, new Map())).toEqual([{ field: 'amount', all: 21900, ready: 21400 }])
|
|
258
261
|
})
|
|
259
262
|
|
|
260
263
|
it('the tool reads the cited files and puts the check and the totals on the card', async () => {
|
|
@@ -274,7 +277,7 @@ describe('checking the rows against the material', () => {
|
|
|
274
277
|
expect(asked[0]).toEqual(['a1', 'a1', 'a1'])
|
|
275
278
|
expect(result.counts).toMatchObject({ verified: 1, unmatched: 1 })
|
|
276
279
|
expect(result.rows[0].evidence).toEqual({ attachmentId: 'a1', line: 2 })
|
|
277
|
-
expect(result.totals).toEqual([{ field: 'amount', all: 21900, ready: 21900, declared: 21400, declaredFound: true, difference: -500 }])
|
|
280
|
+
expect(result.totals).toEqual([{ field: 'amount', all: 21900, ready: 21900, declared: 21400, declaredFound: true, difference: -500, declaredBy: 'model' }])
|
|
278
281
|
expect(result.message).toMatch(/differs from the rows by -500/)
|
|
279
282
|
})
|
|
280
283
|
})
|
|
@@ -309,3 +312,32 @@ describe('telling the application', () => {
|
|
|
309
312
|
expect(result.proposed).toBe(true)
|
|
310
313
|
})
|
|
311
314
|
})
|
|
315
|
+
|
|
316
|
+
describe('the card speaks the person\'s words', () => {
|
|
317
|
+
it('an enum field hands its value key prefix to the card - PERSONAL_CARD shows as the app\'s own word for it', async () => {
|
|
318
|
+
const target: EntryTarget = { ...expense, fields: [...expense.fields, { name: 'paidBy', type: 'enum', values: ['PERSONAL_CARD'], valueKeyPrefix: 'text.expense-payment-' }] }
|
|
319
|
+
const result: any = await proposeEntries(target, { rows: [{ values: { spentOn: '2026-09-12', amount: 1, paidBy: 'PERSONAL_CARD' }, sources: [{ where: 'x' }] }] }, { state: {} }, async () => new Map())
|
|
320
|
+
expect(result.columns.find((one: any) => one.name === 'paidBy')).toMatchObject({ valueKeyPrefix: 'text.expense-payment-' })
|
|
321
|
+
})
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
describe('what the screen says for the model', () => {
|
|
325
|
+
it("★ the model's \"the person's message\" becomes the person flag - the screen words it, the model's English never shows", () => {
|
|
326
|
+
const { rows } = normalizeEntryRows(expense, [{ values: { spentOn: '2026-09-12', amount: 1 }, sources: [{ where: "the person's message" }, { person: true }, { attachmentId: 'a1', where: '3번째 줄' }] }])
|
|
327
|
+
expect(rows[0].sources).toEqual([{ person: true }, { person: true }, { attachmentId: 'a1', where: '3번째 줄' }])
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
it('reads a printed total line - the last amount on a line named 합계 or total', () => {
|
|
331
|
+
expect(printedTotalOf('a,1\n합계,,"50,900"')).toEqual({ value: 50900, line: 2 })
|
|
332
|
+
expect(printedTotalOf('Total amount: 1,234.50 KRW')).toEqual({ value: 1234.5, line: 1 })
|
|
333
|
+
expect(printedTotalOf('nothing like it here, 100')).toBeUndefined()
|
|
334
|
+
})
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
describe('the model names the total its own way', () => {
|
|
338
|
+
it("★ with one totalled field, the model's total counts whatever field name it wrote - 이용금액 is amount", () => {
|
|
339
|
+
const { rows } = normalizeEntryRows(expense, [{ values: { spentOn: '2026-09-12', amount: 100 }, sources: [{ where: 'x' }] }])
|
|
340
|
+
expect(entryTotals(rows, expense.fields, { field: '이용금액', value: '100' }, new Map())).toEqual([{ field: 'amount', all: 100, ready: 100, declared: 100, difference: 0, declaredBy: 'model' }])
|
|
341
|
+
})
|
|
342
|
+
})
|
|
343
|
+
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { PROPOSAL_OUTCOME_PREFIX, PROPOSAL_VERDICTS } from '../chat-message/proposal-outcome.js'
|
|
14
14
|
import { readRecords, stillAsProposed, type ChangeRow } from './entry-update.js'
|
|
15
|
-
import { changeToolName, entryDoorOf, entryTargetOf, entryToolName, rootFieldOf, isBlocked, recordEntryEvent, normalizeEntryRows, withCheckedIssues, type EntryRow, type EntryTarget } from './entry-target.js'
|
|
15
|
+
import { changeToolName, resolveReferences, entryDoorOf, entryTargetOf, entryToolName, rootFieldOf, isBlocked, recordEntryEvent, normalizeEntryRows, withCheckedIssues, type EntryRow, type EntryTarget } from './entry-target.js'
|
|
16
16
|
|
|
17
17
|
export interface StoredProposalMessage {
|
|
18
18
|
id: string
|
|
@@ -85,6 +85,8 @@ export async function applyEntryProposal(key: string, deps: EntryApplyDeps, targ
|
|
|
85
85
|
|
|
86
86
|
/* Read again from the stored values - the same rules, and today's records for the target's checks. */
|
|
87
87
|
let { rows } = normalizeEntryRows(target, (proposal.rows ?? []).map((row: EntryRow) => ({ values: row.values, sources: row.sources })))
|
|
88
|
+
/* A reference that was not settled on the card is asked again - never entered as a name. */
|
|
89
|
+
rows = await resolveReferences(target.fields, rows, deps.execute)
|
|
88
90
|
if (target.check) {
|
|
89
91
|
try {
|
|
90
92
|
rows = withCheckedIssues(rows, await target.check(rows, { state: deps.state }))
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { parse } from 'graphql'
|
|
16
16
|
|
|
17
|
-
export type EntryFieldType = 'string' | 'text' | 'number' | 'integer' | 'date' | 'boolean' | 'enum'
|
|
17
|
+
export type EntryFieldType = 'string' | 'text' | 'number' | 'integer' | 'date' | 'boolean' | 'enum' | 'reference'
|
|
18
18
|
|
|
19
19
|
export interface EntryField {
|
|
20
20
|
name: string
|
|
@@ -28,13 +28,37 @@ export interface EntryField {
|
|
|
28
28
|
labelKey?: string
|
|
29
29
|
/** A number field the card sums - and compares with the total the material prints (§ totals). */
|
|
30
30
|
total?: boolean
|
|
31
|
+
/** An enum's values on the card: `<prefix><value in lowercase>` - `text.expense-payment-` shows PERSONAL_CARD as 개인카드. */
|
|
32
|
+
valueKeyPrefix?: string
|
|
33
|
+
/**
|
|
34
|
+
* A `reference` field points at another record - a loan, an item, a location. The model knows it by a name or
|
|
35
|
+
* a code; the server finds it through the application's query, as the person, and keeps its id. One match is
|
|
36
|
+
* taken; none or several keep the row out and say so - the server never picks among several.
|
|
37
|
+
*/
|
|
38
|
+
reference?: EntryReference
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface EntryReference {
|
|
42
|
+
query: string
|
|
43
|
+
/** The query's variables for what the model wrote - "기업은행", "A-100". */
|
|
44
|
+
variables(text: string): Record<string, unknown>
|
|
45
|
+
/** The records the answer holds that the text names - the application filters, the server does not guess. */
|
|
46
|
+
recordsOf(data: any, text: string): { id: string; label?: string }[]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A resolved reference, as a row keeps it. */
|
|
50
|
+
export interface ReferenceValue {
|
|
51
|
+
id: string
|
|
52
|
+
label?: string
|
|
31
53
|
}
|
|
32
54
|
|
|
33
55
|
/** Where a row came from - which file, and where in it. Shown on the card beside the row. */
|
|
34
56
|
export interface EntrySource {
|
|
35
57
|
attachmentId?: string
|
|
36
|
-
/** "row 3", "the receipt photo"
|
|
58
|
+
/** "row 3", "the receipt photo" - the model's words, in the person's language. */
|
|
37
59
|
where?: string
|
|
60
|
+
/** The value came from what the person said in the conversation - shown in the screen's own words, never the model's. */
|
|
61
|
+
person?: boolean
|
|
38
62
|
}
|
|
39
63
|
|
|
40
64
|
export interface EntryIssue {
|
|
@@ -133,9 +157,9 @@ export type EntryRecord = { id: string } & Record<string, unknown>
|
|
|
133
157
|
*/
|
|
134
158
|
export interface EntryUpdate {
|
|
135
159
|
/** Reads the records by id, as the person - one they may not see is not found. */
|
|
136
|
-
read: { query: string; variables(ids: string[]): Record<string, unknown>; recordsOf(data: any): EntryRecord[] }
|
|
160
|
+
read: { query: string; variables(ids: string[]): Record<string, unknown>; recordsOf(data: any, ids: string[]): EntryRecord[] }
|
|
137
161
|
/** Finds candidates from what the person said - "last week's 12,400 taxi". */
|
|
138
|
-
find?: { query: string; variables(criteria: Record<string, unknown>): Record<string, unknown>; recordsOf(data: any): EntryRecord[]; criteria: EntryField[] }
|
|
162
|
+
find?: { query: string; variables(criteria: Record<string, unknown>): Record<string, unknown>; recordsOf(data: any, criteria: Record<string, unknown>): EntryRecord[]; criteria: EntryField[] }
|
|
139
163
|
/** The application's own mutation that changes one record. */
|
|
140
164
|
mutation: string
|
|
141
165
|
variables(change: { id: string; record: EntryRecord; values: Record<string, unknown> }): Record<string, unknown>
|
|
@@ -175,6 +199,9 @@ export async function recordEntryEvent(target: EntryTarget, event: EntryEvent, c
|
|
|
175
199
|
|
|
176
200
|
export const DEFAULT_MAX_ROWS = 200
|
|
177
201
|
|
|
202
|
+
/* The model's way of saying a value came from the person - kept only to read older rows and loose words. */
|
|
203
|
+
const PERSON = /person|message|user|사용자|말/i
|
|
204
|
+
|
|
178
205
|
const targets = new Map<string, EntryTarget>()
|
|
179
206
|
|
|
180
207
|
const KEY = /^[a-z][a-z0-9-]*\.[a-z][a-z0-9-]*$/
|
|
@@ -206,6 +233,13 @@ export function declareEntryTarget(target: EntryTarget): void {
|
|
|
206
233
|
if (!target.fields?.length) throw new Error(`[ai-assistant] entry target '${target.key}' declares no fields.`)
|
|
207
234
|
const names = new Set<string>()
|
|
208
235
|
for (const field of target.fields) {
|
|
236
|
+
if (field.type === 'reference') {
|
|
237
|
+
try {
|
|
238
|
+
rootFieldOf(field.reference?.query ?? '', 'query')
|
|
239
|
+
} catch (error: any) {
|
|
240
|
+
throw new Error(`[ai-assistant] entry target '${target.key}': reference field '${field.name}' ${error?.message ?? 'has no query'}.`)
|
|
241
|
+
}
|
|
242
|
+
}
|
|
209
243
|
if (names.has(field.name)) throw new Error(`[ai-assistant] entry target '${target.key}' declares '${field.name}' twice.`)
|
|
210
244
|
names.add(field.name)
|
|
211
245
|
if (field.type === 'enum' && !field.values?.length)
|
|
@@ -304,6 +338,11 @@ export function readFieldValue(field: EntryField, value: unknown): { read?: unkn
|
|
|
304
338
|
}
|
|
305
339
|
case 'enum':
|
|
306
340
|
return field.values!.includes(String(value).trim()) ? { read: String(value).trim() } : { code: 'not-in-list' }
|
|
341
|
+
case 'reference':
|
|
342
|
+
/* Resolved already ({ id }), or a name to look up ({ text }) - resolveReferences settles the second. */
|
|
343
|
+
if (value && typeof value === 'object' && typeof (value as any).id === 'string') return { read: { id: (value as any).id, ...((value as any).label ? { label: String((value as any).label) } : {}) } }
|
|
344
|
+
if (value && typeof value === 'object' && typeof (value as any).text === 'string') return { read: { text: (value as any).text } }
|
|
345
|
+
return typeof value === 'string' || typeof value === 'number' ? { read: { text: String(value).trim() } } : { code: 'not-a-reference' }
|
|
307
346
|
default:
|
|
308
347
|
return { read: String(value).trim() }
|
|
309
348
|
}
|
|
@@ -313,6 +352,10 @@ export function readFieldValue(field: EntryField, value: unknown): { read?: unkn
|
|
|
313
352
|
export function sameFieldValue(field: EntryField, a: unknown, b: unknown): boolean {
|
|
314
353
|
if (empty(a) && empty(b)) return true
|
|
315
354
|
if (empty(a) || empty(b)) return false
|
|
355
|
+
if (field.type === 'reference') {
|
|
356
|
+
const idOf = (v: any) => (v && typeof v === 'object' ? v.id ?? v.text : String(v))
|
|
357
|
+
return idOf(a) === idOf(b)
|
|
358
|
+
}
|
|
316
359
|
const x = readFieldValue(field, a)
|
|
317
360
|
const y = readFieldValue(field, b)
|
|
318
361
|
return x.read !== undefined && y.read !== undefined ? x.read === y.read : String(a) === String(b)
|
|
@@ -355,11 +398,15 @@ export function normalizeEntryRows(target: EntryTarget, input: unknown): { rows:
|
|
|
355
398
|
|
|
356
399
|
const sources: EntrySource[] = Array.isArray(raw?.sources)
|
|
357
400
|
? raw.sources
|
|
358
|
-
.filter((one: any) => one && (one.attachmentId || one.where))
|
|
359
|
-
.map((one: any) =>
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
401
|
+
.filter((one: any) => one && (one.attachmentId || one.where || one.person === true))
|
|
402
|
+
.map((one: any): EntrySource => {
|
|
403
|
+
/* "the person's message" written by the model is the person flag - the screen says it in its own words. */
|
|
404
|
+
if (one.person === true || (!one.attachmentId && typeof one.where === 'string' && PERSON.test(one.where))) return { person: true }
|
|
405
|
+
return {
|
|
406
|
+
...(typeof one.attachmentId === 'string' ? { attachmentId: one.attachmentId } : {}),
|
|
407
|
+
...(typeof one.where === 'string' ? { where: one.where.slice(0, 120) } : {})
|
|
408
|
+
}
|
|
409
|
+
})
|
|
363
410
|
: []
|
|
364
411
|
if (!sources.length) issues.push({ code: 'no-source', blocking: false })
|
|
365
412
|
|
|
@@ -388,7 +435,6 @@ export function isBlocked(row: EntryRow): boolean {
|
|
|
388
435
|
* Names and descriptions are not looked for: the model tidies them, and a tidied name is not a wrong one.
|
|
389
436
|
*/
|
|
390
437
|
|
|
391
|
-
const PERSON = /person|message|user|사용자|말/i
|
|
392
438
|
|
|
393
439
|
/** The ways a number or a date is written in a file. */
|
|
394
440
|
export function writtenForms(type: EntryFieldType, value: unknown): string[] {
|
|
@@ -460,7 +506,7 @@ export function verifyRowsAgainstSources(rows: EntryRow[], fields: EntryField[],
|
|
|
460
506
|
if (at >= 0) return { ...row, evidence: { attachmentId: id, line: at + 1 } }
|
|
461
507
|
}
|
|
462
508
|
|
|
463
|
-
const saidByPerson = row.sources.some(source =>
|
|
509
|
+
const saidByPerson = row.sources.some(source => source.person)
|
|
464
510
|
const issues = [...row.issues]
|
|
465
511
|
for (const one of forms) {
|
|
466
512
|
const anywhere = readable.some(id => texts.get(id)!.split(/\r?\n/).some(line => lineHas(line, one.forms)))
|
|
@@ -471,6 +517,20 @@ export function verifyRowsAgainstSources(rows: EntryRow[], fields: EntryField[],
|
|
|
471
517
|
})
|
|
472
518
|
}
|
|
473
519
|
|
|
520
|
+
/**
|
|
521
|
+
* The total a file prints - a line named 합계 · 총계 · 총액 · total · sum, and the last amount on it. The server
|
|
522
|
+
* reads it itself, so the card can compare even when the model did not say it (2026-10-01: once it did, once not).
|
|
523
|
+
*/
|
|
524
|
+
export function printedTotalOf(text: string): { value: number; line: number } | undefined {
|
|
525
|
+
const lines = text.split(/\r?\n/)
|
|
526
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
527
|
+
if (!/(합\s*계|총\s*계|총\s*액|total|sum)/i.test(lines[i])) continue
|
|
528
|
+
const amounts = [...lines[i].matchAll(/-?\d{1,3}(?:,\d{3})+(?:\.\d+)?|-?\d+(?:\.\d+)?/g)].map(match => Number(match[0].replace(/,/g, '')))
|
|
529
|
+
if (amounts.length) return { value: amounts[amounts.length - 1], line: i + 1 }
|
|
530
|
+
}
|
|
531
|
+
return undefined
|
|
532
|
+
}
|
|
533
|
+
|
|
474
534
|
export interface EntryTotal {
|
|
475
535
|
field: string
|
|
476
536
|
/** The sum over every row, and over the rows that will be entered. */
|
|
@@ -481,6 +541,8 @@ export interface EntryTotal {
|
|
|
481
541
|
declaredFound?: boolean
|
|
482
542
|
/** declared - all. */
|
|
483
543
|
difference?: number
|
|
544
|
+
/** Where the declared total came from - the model's reading, or the server's own from a file's total line. */
|
|
545
|
+
declaredBy?: 'model' | 'file'
|
|
484
546
|
}
|
|
485
547
|
|
|
486
548
|
/** The sums of the fields declared `total`, and how they stand against the total the material prints. */
|
|
@@ -490,13 +552,28 @@ export function entryTotals(
|
|
|
490
552
|
declared: { field?: string; value?: unknown; attachmentId?: string } | undefined,
|
|
491
553
|
texts: Map<string, string>
|
|
492
554
|
): EntryTotal[] {
|
|
493
|
-
|
|
494
|
-
|
|
555
|
+
const totalled = fields.filter(field => field.total && (field.type === 'number' || field.type === 'integer'))
|
|
556
|
+
/* With one totalled field and no total from the model, the server looks for the file's own total line. */
|
|
557
|
+
const printed = !declared && totalled.length === 1 ? [...texts.values()].map(printedTotalOf).find(Boolean) : undefined
|
|
558
|
+
return totalled
|
|
495
559
|
.map(field => {
|
|
496
560
|
const sum = (list: EntryRow[]) => Math.round(list.reduce((acc, row) => acc + (typeof row.values[field.name] === 'number' ? (row.values[field.name] as number) : 0), 0) * 100) / 100
|
|
497
561
|
const total: EntryTotal = { field: field.name, all: sum(rows), ready: sum(rows.filter(row => !isBlocked(row))) }
|
|
498
|
-
|
|
562
|
+
if (printed) {
|
|
563
|
+
total.declared = printed.value
|
|
564
|
+
total.declaredFound = true
|
|
565
|
+
total.declaredBy = 'file'
|
|
566
|
+
total.difference = Math.round((printed.value - total.all) * 100) / 100
|
|
567
|
+
return total
|
|
568
|
+
}
|
|
569
|
+
/*
|
|
570
|
+
* With one totalled field the model's total is that field's, whatever it named it - on office's second card it
|
|
571
|
+
* wrote the file's column name (이용금액) for amount, and the total was dropped (2026-10-01).
|
|
572
|
+
*/
|
|
573
|
+
const mine = declared && (totalled.length === 1 || (declared.field ?? field.name) === field.name)
|
|
574
|
+
const value = mine ? toNumber(declared!.value) : undefined
|
|
499
575
|
if (value !== undefined) {
|
|
576
|
+
total.declaredBy = 'model'
|
|
500
577
|
total.declared = value
|
|
501
578
|
total.difference = Math.round((value - total.all) * 100) / 100
|
|
502
579
|
const text = declared!.attachmentId ? texts.get(declared!.attachmentId) : undefined
|
|
@@ -506,3 +583,51 @@ export function entryTotals(
|
|
|
506
583
|
})
|
|
507
584
|
}
|
|
508
585
|
|
|
586
|
+
/** The doors of the queries a target's reference fields reach - the person must pass them too. */
|
|
587
|
+
export function referenceDoorsOf(fields: EntryField[]): string[] {
|
|
588
|
+
return [...new Set(fields.filter(field => field.type === 'reference' && field.reference).map(field => rootFieldOf(field.reference!.query, 'query')))]
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Settles each reference a row names by text, through the application's query as the person. One match keeps its
|
|
593
|
+
* id and name; none or several keep the row out - several with their names, so the person can say which.
|
|
594
|
+
*/
|
|
595
|
+
export async function resolveReferences<T extends { values: Record<string, unknown>; issues: EntryIssue[] }>(
|
|
596
|
+
fields: EntryField[],
|
|
597
|
+
rows: T[],
|
|
598
|
+
run: (document: string, variables: Record<string, unknown>) => Promise<{ data?: any; errors?: readonly { message: string }[] }>
|
|
599
|
+
): Promise<T[]> {
|
|
600
|
+
const references = fields.filter(field => field.type === 'reference' && field.reference)
|
|
601
|
+
if (!references.length) return rows
|
|
602
|
+
const asked = new Map<string, { id: string; label?: string }[] | Error>()
|
|
603
|
+
for (const row of rows) {
|
|
604
|
+
for (const field of references) {
|
|
605
|
+
const value: any = row.values[field.name]
|
|
606
|
+
if (!value || typeof value !== 'object' || typeof value.text !== 'string') continue
|
|
607
|
+
const key = `${field.name}\u0000${value.text}`
|
|
608
|
+
if (!asked.has(key)) {
|
|
609
|
+
try {
|
|
610
|
+
const answer = await run(field.reference!.query, field.reference!.variables(value.text))
|
|
611
|
+
asked.set(key, answer.errors?.length ? new Error(answer.errors[0].message) : field.reference!.recordsOf(answer.data, value.text) ?? [])
|
|
612
|
+
} catch (error: any) {
|
|
613
|
+
asked.set(key, error instanceof Error ? error : new Error(String(error)))
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
const found = asked.get(key)!
|
|
617
|
+
if (found instanceof Error) {
|
|
618
|
+
row.issues.push({ field: field.name, code: 'reference-unreadable', params: { text: value.text }, blocking: true })
|
|
619
|
+
} else if (found.length === 1) {
|
|
620
|
+
row.values[field.name] = { id: found[0].id, ...(found[0].label ? { label: found[0].label } : {}) }
|
|
621
|
+
} else {
|
|
622
|
+
row.issues.push({
|
|
623
|
+
field: field.name,
|
|
624
|
+
code: found.length ? 'reference-ambiguous' : 'reference-not-found',
|
|
625
|
+
params: { text: value.text, ...(found.length ? { candidates: found.slice(0, 5).map(one => one.label || one.id).join(', ') } : {}) },
|
|
626
|
+
blocking: true
|
|
627
|
+
})
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
return rows
|
|
632
|
+
}
|
|
633
|
+
|
|
@@ -22,6 +22,8 @@ import {
|
|
|
22
22
|
entryToolName,
|
|
23
23
|
isBlocked,
|
|
24
24
|
normalizeEntryRows,
|
|
25
|
+
referenceDoorsOf,
|
|
26
|
+
resolveReferences,
|
|
25
27
|
recordEntryEvent,
|
|
26
28
|
withCheckedIssues,
|
|
27
29
|
type EntryField,
|
|
@@ -35,6 +37,7 @@ function fieldSchema(field: EntryField): any {
|
|
|
35
37
|
* whole request with it: "12,400" or "₩12,400" is the model's to write as 12400 (2026-10-01, office).
|
|
36
38
|
*/
|
|
37
39
|
const type = field.type === 'number' ? 'number' : field.type === 'integer' ? 'integer' : field.type === 'boolean' ? 'boolean' : 'string'
|
|
40
|
+
if (field.type === 'reference') return { type: 'string', description: [field.description, field.required ? 'required' : 'optional', 'the name or code as the material or the person gives it - the server finds the record'].filter(Boolean).join('. ') }
|
|
38
41
|
const said = [
|
|
39
42
|
field.description,
|
|
40
43
|
field.required ? 'required' : 'optional',
|
|
@@ -48,14 +51,20 @@ function fieldSchema(field: EntryField): any {
|
|
|
48
51
|
|
|
49
52
|
/** What the card needs besides the rows - the columns, in the target's order. */
|
|
50
53
|
function columnsOf(target: EntryTarget) {
|
|
51
|
-
return target.fields.map(field => ({
|
|
54
|
+
return target.fields.map(field => ({
|
|
55
|
+
name: field.name,
|
|
56
|
+
type: field.type,
|
|
57
|
+
required: !!field.required,
|
|
58
|
+
...(field.labelKey ? { labelKey: field.labelKey } : {}),
|
|
59
|
+
...(field.valueKeyPrefix ? { valueKeyPrefix: field.valueKeyPrefix } : {})
|
|
60
|
+
}))
|
|
52
61
|
}
|
|
53
62
|
|
|
54
63
|
export type SourceTextLoader = (attachmentIds: string[], context: any) => Promise<Map<string, string>>
|
|
55
64
|
|
|
56
65
|
const readSources: SourceTextLoader = async (ids, context) => (await import('./entry-sources.js')).loadSourceTexts(ids, context)
|
|
57
66
|
|
|
58
|
-
export async function proposeEntries(target: EntryTarget, args: any, context: any, loadTexts: SourceTextLoader = readSources) {
|
|
67
|
+
export async function proposeEntries(target: EntryTarget, args: any, context: any, loadTexts: SourceTextLoader = readSources, run?: GraphqlRunner) {
|
|
59
68
|
const tool = entryToolName(target.key)
|
|
60
69
|
const told = (event: Omit<Parameters<typeof recordEntryEvent>[1], 'phase' | 'tool'>) => recordEntryEvent(target, { phase: 'propose', tool, ...event }, { state: context?.state })
|
|
61
70
|
const { rows: normalized, cut } = normalizeEntryRows(target, args?.rows)
|
|
@@ -68,7 +77,9 @@ export async function proposeEntries(target: EntryTarget, args: any, context: an
|
|
|
68
77
|
const declared = args?.declaredTotal && typeof args.declaredTotal === 'object' ? args.declaredTotal : undefined
|
|
69
78
|
const cited = [...normalized.flatMap(row => row.sources.map(source => source.attachmentId)), declared?.attachmentId].filter((id): id is string => typeof id === 'string' && !!id)
|
|
70
79
|
const texts = cited.length ? await loadTexts(cited, context).catch(() => new Map<string, string>()) : new Map<string, string>()
|
|
71
|
-
|
|
80
|
+
/* A name the model wrote for another record is settled to its id by the application's query, as the person. */
|
|
81
|
+
const resolved = await resolveReferences(target.fields, normalized, run ?? runAsCaller(context))
|
|
82
|
+
const read = verifyRowsAgainstSources(resolved, target.fields, texts)
|
|
72
83
|
|
|
73
84
|
let rows: EntryRow[] = read
|
|
74
85
|
let checkFailed: string | undefined
|
|
@@ -132,7 +143,7 @@ function changeSpecs(target: EntryTarget, run: (context: any) => GraphqlRunner):
|
|
|
132
143
|
kind: 'read',
|
|
133
144
|
name: changeToolName(target.key),
|
|
134
145
|
/* The person must be able to read the records and change them by hand. */
|
|
135
|
-
doors: [rootFieldOf(update.mutation, 'mutation'), readDoor],
|
|
146
|
+
doors: [...new Set([rootFieldOf(update.mutation, 'mutation'), readDoor, ...referenceDoorsOf(changeableFields(target))])],
|
|
136
147
|
description:
|
|
137
148
|
`Propose changes to existing records: ${target.description} ` +
|
|
138
149
|
'Name each record by ref: "current" (the one on screen), "selected" (every row selected on screen) or an id a find tool returned. ' +
|
|
@@ -177,7 +188,7 @@ export function entryCategoryOf(target: EntryTarget, run: (context: any) => Grap
|
|
|
177
188
|
kind: 'read',
|
|
178
189
|
name,
|
|
179
190
|
/* The person must be able to enter these records themselves - the tool is not a second way in. */
|
|
180
|
-
doors: [entryDoorOf(target.mutation)],
|
|
191
|
+
doors: [entryDoorOf(target.mutation), ...referenceDoorsOf(target.fields)],
|
|
181
192
|
description:
|
|
182
193
|
`Propose records to enter: ${target.description} ` +
|
|
183
194
|
'Pass every record found in the material as one row. The rows are shown to the person as a card; nothing is written until they press it.',
|
|
@@ -196,8 +207,15 @@ export function entryCategoryOf(target: EntryTarget, run: (context: any) => Grap
|
|
|
196
207
|
},
|
|
197
208
|
sources: {
|
|
198
209
|
type: 'array',
|
|
199
|
-
description: 'Where this row came from: the attachment id and where in it
|
|
200
|
-
items: {
|
|
210
|
+
description: 'Where this row came from: the attachment id and where in it, and { person: true } for a value the person said.',
|
|
211
|
+
items: {
|
|
212
|
+
type: 'object',
|
|
213
|
+
properties: {
|
|
214
|
+
attachmentId: { type: 'string' },
|
|
215
|
+
where: { type: 'string', description: 'Where in the attachment, in the person\'s language ("3번째 줄", "영수증 사진").' },
|
|
216
|
+
person: { type: 'boolean', description: 'true when the value came from what the person said in the conversation.' }
|
|
217
|
+
}
|
|
218
|
+
}
|
|
201
219
|
}
|
|
202
220
|
},
|
|
203
221
|
required: ['values', 'sources']
|
|
@@ -217,7 +235,7 @@ export function entryCategoryOf(target: EntryTarget, run: (context: any) => Grap
|
|
|
217
235
|
},
|
|
218
236
|
required: ['rows']
|
|
219
237
|
},
|
|
220
|
-
builder: (args: any, context: any) => proposeEntries(target, args, context)
|
|
238
|
+
builder: (args: any, context: any) => proposeEntries(target, args, context, readSources, run(context))
|
|
221
239
|
} as ToolSpec
|
|
222
240
|
|
|
223
241
|
return {
|
|
@@ -226,8 +244,8 @@ export function entryCategoryOf(target: EntryTarget, run: (context: any) => Grap
|
|
|
226
244
|
guidance: [
|
|
227
245
|
`- 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.`,
|
|
228
246
|
'- 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.',
|
|
229
|
-
'- Give each row its sources: which attachment and where in it. A value the person gave or corrected in the conversation adds the source
|
|
230
|
-
'- When the material prints a total, pass it as declaredTotal exactly as printed - do not add it up yourself.',
|
|
247
|
+
'- 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.',
|
|
248
|
+
'- 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.',
|
|
231
249
|
`- ${name} only proposes. Nothing is entered until the person presses the button on the card - never say the records were entered.`,
|
|
232
250
|
`- When the person corrects or drops rows, call ${name} again with the whole corrected set. The newest card replaces the earlier ones; an earlier card can no longer be entered.`,
|
|
233
251
|
...(target.update
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
changeToolName,
|
|
14
14
|
findToolName,
|
|
15
15
|
readFieldValue,
|
|
16
|
+
resolveReferences,
|
|
16
17
|
sameFieldValue,
|
|
17
18
|
type EntryField,
|
|
18
19
|
type EntryIssue,
|
|
@@ -85,7 +86,7 @@ export async function readRecords(target: EntryTarget, ids: string[], run: Graph
|
|
|
85
86
|
const update = target.update!
|
|
86
87
|
const answer = await run(update.read.query, update.read.variables([...new Set(ids)]))
|
|
87
88
|
if (answer.errors?.length) throw new Error(answer.errors.map(one => one.message).join('; '))
|
|
88
|
-
return new Map((update.read.recordsOf(answer.data) ?? []).filter(record => record && typeof record.id === 'string').map(record => [record.id, record]))
|
|
89
|
+
return new Map((update.read.recordsOf(answer.data, [...new Set(ids)]) ?? []).filter(record => record && typeof record.id === 'string').map(record => [record.id, record]))
|
|
89
90
|
}
|
|
90
91
|
|
|
91
92
|
/** The change rows against what the records hold now. */
|
|
@@ -125,7 +126,7 @@ function changedColumns(target: EntryTarget, rows: ChangeRow[]) {
|
|
|
125
126
|
const used = new Set(rows.flatMap(row => Object.keys(row.values)))
|
|
126
127
|
return changeableFields(target)
|
|
127
128
|
.filter(field => used.has(field.name))
|
|
128
|
-
.map(field => ({ name: field.name, type: field.type, ...(field.labelKey ? { labelKey: field.labelKey } : {}) }))
|
|
129
|
+
.map(field => ({ name: field.name, type: field.type, ...(field.labelKey ? { labelKey: field.labelKey } : {}), ...(field.valueKeyPrefix ? { valueKeyPrefix: field.valueKeyPrefix } : {}) }))
|
|
129
130
|
}
|
|
130
131
|
|
|
131
132
|
export async function proposeChanges(target: EntryTarget, args: any, context: any, run: GraphqlRunner) {
|
|
@@ -146,7 +147,7 @@ export async function proposeChanges(target: EntryTarget, args: any, context: an
|
|
|
146
147
|
} catch (error: any) {
|
|
147
148
|
return { rejected: true, error: 'read-failed', message: `could not read the records: ${error?.message ?? error}` }
|
|
148
149
|
}
|
|
149
|
-
const rows = changeRows(target, picked, records)
|
|
150
|
+
const rows = await resolveReferences(changeableFields(target), changeRows(target, picked, records), run)
|
|
150
151
|
const blocked = rows.filter(blockedChange).length
|
|
151
152
|
return {
|
|
152
153
|
proposed: true,
|
|
@@ -181,7 +182,7 @@ export async function findRecords(target: EntryTarget, args: any, run: GraphqlRu
|
|
|
181
182
|
}
|
|
182
183
|
const answer = await run(find.query, find.variables(criteria))
|
|
183
184
|
if (answer.errors?.length) return { rejected: true, error: 'find-failed', message: answer.errors.map(one => one.message).join('; ') }
|
|
184
|
-
const found = (find.recordsOf(answer.data) ?? []).filter(record => record && typeof record.id === 'string')
|
|
185
|
+
const found = (find.recordsOf(answer.data, criteria) ?? []).filter(record => record && typeof record.id === 'string')
|
|
185
186
|
return {
|
|
186
187
|
total: found.length,
|
|
187
188
|
candidates: found.slice(0, MAX_CANDIDATES).map(record => ({ id: record.id, ...(target.update?.labelOf ? { label: target.update.labelOf(record) } : {}), record })),
|
package/translations/en.json
CHANGED
|
@@ -267,5 +267,11 @@
|
|
|
267
267
|
"ai-assistant.entry.issue.changed-since": "changed since the card was made",
|
|
268
268
|
"ai-assistant.entry.issue.no-change": "already that value",
|
|
269
269
|
"ai-assistant.entry.issue.nothing-to-change": "nothing to change",
|
|
270
|
-
"ai-assistant.entry.issue.not-changeable": "this field cannot be changed - left out"
|
|
270
|
+
"ai-assistant.entry.issue.not-changeable": "this field cannot be changed - left out",
|
|
271
|
+
"ai-assistant.entry.source-person": "said in the conversation",
|
|
272
|
+
"ai-assistant.entry.total-declared-file": "the file's total line {sum}",
|
|
273
|
+
"ai-assistant.entry.issue.reference-not-found": "\"{text}\" - not found",
|
|
274
|
+
"ai-assistant.entry.issue.reference-ambiguous": "\"{text}\" - matches several: {candidates}",
|
|
275
|
+
"ai-assistant.entry.issue.reference-unreadable": "\"{text}\" - could not be looked up",
|
|
276
|
+
"ai-assistant.entry.issue.not-a-reference": "not a record it can point to"
|
|
271
277
|
}
|
package/translations/ja.json
CHANGED
|
@@ -267,5 +267,11 @@
|
|
|
267
267
|
"ai-assistant.entry.issue.changed-since": "その間に変更されました",
|
|
268
268
|
"ai-assistant.entry.issue.no-change": "すでにその値です",
|
|
269
269
|
"ai-assistant.entry.issue.nothing-to-change": "修正する項目がありません",
|
|
270
|
-
"ai-assistant.entry.issue.not-changeable": "修正できない項目のため除外しました"
|
|
270
|
+
"ai-assistant.entry.issue.not-changeable": "修正できない項目のため除外しました",
|
|
271
|
+
"ai-assistant.entry.source-person": "会話で伝えた値",
|
|
272
|
+
"ai-assistant.entry.total-declared-file": "資料の合計行 {sum}",
|
|
273
|
+
"ai-assistant.entry.issue.reference-not-found": "「{text}」— 見つかりません",
|
|
274
|
+
"ai-assistant.entry.issue.reference-ambiguous": "「{text}」— 複数に一致: {candidates}",
|
|
275
|
+
"ai-assistant.entry.issue.reference-unreadable": "「{text}」— 検索できませんでした",
|
|
276
|
+
"ai-assistant.entry.issue.not-a-reference": "参照先の記録が分かりません"
|
|
271
277
|
}
|
package/translations/ko.json
CHANGED
|
@@ -267,5 +267,11 @@
|
|
|
267
267
|
"ai-assistant.entry.issue.changed-since": "그사이 바뀌었습니다",
|
|
268
268
|
"ai-assistant.entry.issue.no-change": "이미 그 값입니다",
|
|
269
269
|
"ai-assistant.entry.issue.nothing-to-change": "고칠 칸이 없습니다",
|
|
270
|
-
"ai-assistant.entry.issue.not-changeable": "고칠 수 없는 칸이라 뺐습니다"
|
|
270
|
+
"ai-assistant.entry.issue.not-changeable": "고칠 수 없는 칸이라 뺐습니다",
|
|
271
|
+
"ai-assistant.entry.source-person": "대화에서 말한 값",
|
|
272
|
+
"ai-assistant.entry.total-declared-file": "자료의 합계 줄 {sum}",
|
|
273
|
+
"ai-assistant.entry.issue.reference-not-found": "\"{text}\" — 찾지 못했습니다",
|
|
274
|
+
"ai-assistant.entry.issue.reference-ambiguous": "\"{text}\" — 여럿과 맞습니다: {candidates}",
|
|
275
|
+
"ai-assistant.entry.issue.reference-unreadable": "\"{text}\" — 찾아보지 못했습니다",
|
|
276
|
+
"ai-assistant.entry.issue.not-a-reference": "가리키는 기록을 알 수 없습니다"
|
|
271
277
|
}
|