dsh-scout 0.8.0
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/LICENSE +21 -0
- package/README.en.md +90 -0
- package/README.md +181 -0
- package/cordis.patch.yml +3 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +624 -0
- package/dist/model.d.ts +122 -0
- package/dist/model.js +564 -0
- package/docs/dsh-scout-product-contract.md +108 -0
- package/docs/fixtures/dsh-scout/snapmaker-hr-head.json +92 -0
- package/package.json +63 -0
- package/scripts/profile-smoke.mjs +101 -0
- package/tests/model.test.mjs +1356 -0
|
@@ -0,0 +1,1356 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { readFileSync } from 'node:fs'
|
|
4
|
+
import {
|
|
5
|
+
addClaim,
|
|
6
|
+
addSource,
|
|
7
|
+
appendEvent,
|
|
8
|
+
createCase,
|
|
9
|
+
decideCase,
|
|
10
|
+
exportCaseFiles,
|
|
11
|
+
generateInterviewQuestions,
|
|
12
|
+
importCaseFromFiles,
|
|
13
|
+
inferEvidenceLevel,
|
|
14
|
+
inferSourceType,
|
|
15
|
+
isTrustedAuthorityUrl,
|
|
16
|
+
renderComparison,
|
|
17
|
+
renderReport,
|
|
18
|
+
verifyClaim,
|
|
19
|
+
verifyIdentity,
|
|
20
|
+
} from '../dist/model.js'
|
|
21
|
+
import { apply, inject, name } from '../dist/index.js'
|
|
22
|
+
|
|
23
|
+
test('starts conservatively until company identity is verified', () => {
|
|
24
|
+
const scoutCase = createCase({
|
|
25
|
+
caseId: 'demo',
|
|
26
|
+
companyName: 'Example Co',
|
|
27
|
+
roleTitle: 'HR Head',
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
assert.equal(scoutCase.decision, 'VERIFY')
|
|
31
|
+
assert.equal(scoutCase.subject.identityStatus, 'needs_verification')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('rejects claims that reference an unknown source', () => {
|
|
35
|
+
const scoutCase = createCase({
|
|
36
|
+
caseId: 'demo',
|
|
37
|
+
companyName: 'Example Co',
|
|
38
|
+
roleTitle: 'HR Head',
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
assert.throws(() => addClaim(scoutCase, {
|
|
42
|
+
claimId: 'claim-1',
|
|
43
|
+
text: 'The role has budget authority.',
|
|
44
|
+
status: 'reported',
|
|
45
|
+
evidenceLevel: 'E1',
|
|
46
|
+
dimension: 'mandate',
|
|
47
|
+
impact: 'blocking',
|
|
48
|
+
sourceIds: ['missing'],
|
|
49
|
+
confidenceNote: 'Only a reported statement is available.',
|
|
50
|
+
nextAction: 'Ask the hiring manager.',
|
|
51
|
+
}), /Unknown source/)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('renders a sourced claim and preserves a VERIFY decision', () => {
|
|
55
|
+
let scoutCase = createCase({
|
|
56
|
+
caseId: 'demo',
|
|
57
|
+
companyName: 'Example Co',
|
|
58
|
+
roleTitle: 'HR Head',
|
|
59
|
+
})
|
|
60
|
+
scoutCase = addSource(scoutCase, {
|
|
61
|
+
sourceId: 'job-posting',
|
|
62
|
+
type: 'job_posting',
|
|
63
|
+
title: 'Example job posting',
|
|
64
|
+
url: 'https://example.com/job',
|
|
65
|
+
capturedAt: '2026-08-14T00:00:00.000Z',
|
|
66
|
+
evidenceLevel: 'E1',
|
|
67
|
+
status: 'captured',
|
|
68
|
+
})
|
|
69
|
+
scoutCase = addClaim(scoutCase, {
|
|
70
|
+
claimId: 'claim-1',
|
|
71
|
+
text: 'The role may build the HR function from zero.',
|
|
72
|
+
status: 'reported',
|
|
73
|
+
evidenceLevel: 'E1',
|
|
74
|
+
dimension: 'mandate',
|
|
75
|
+
impact: 'blocking',
|
|
76
|
+
sourceIds: ['job-posting'],
|
|
77
|
+
confidenceNote: 'The job posting is a company-controlled source.',
|
|
78
|
+
nextAction: 'Confirm the mandate and budget in the interview.',
|
|
79
|
+
})
|
|
80
|
+
scoutCase = decideCase(scoutCase)
|
|
81
|
+
const report = renderReport(scoutCase)
|
|
82
|
+
|
|
83
|
+
assert.equal(scoutCase.decision, 'VERIFY')
|
|
84
|
+
assert.match(report, /Example Co — HR Head/)
|
|
85
|
+
assert.match(report, /job-posting/)
|
|
86
|
+
assert.match(report, /Confirm the mandate/)
|
|
87
|
+
assert.match(report, /Blocking or unresolved risks/)
|
|
88
|
+
assert.match(report, /Role task and authority hypotheses/)
|
|
89
|
+
assert.match(report, /Interview questions/)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('requires an E3 source to verify company identity', () => {
|
|
93
|
+
let scoutCase = createCase({
|
|
94
|
+
caseId: 'demo',
|
|
95
|
+
companyName: 'Example Co',
|
|
96
|
+
roleTitle: 'HR Head',
|
|
97
|
+
})
|
|
98
|
+
scoutCase = addSource(scoutCase, {
|
|
99
|
+
sourceId: 'registry',
|
|
100
|
+
type: 'company_registry',
|
|
101
|
+
title: 'Official registry record',
|
|
102
|
+
url: 'https://www.gsxt.gov.cn/registry/REG-123',
|
|
103
|
+
capturedAt: '2026-08-14T00:00:00.000Z',
|
|
104
|
+
evidenceLevel: 'E3',
|
|
105
|
+
status: 'captured',
|
|
106
|
+
})
|
|
107
|
+
scoutCase = verifyIdentity(scoutCase, {
|
|
108
|
+
legalEntity: 'Example Company Limited',
|
|
109
|
+
registrationNumber: 'REG-123',
|
|
110
|
+
registeredRegion: 'Shenzhen',
|
|
111
|
+
legalRepresentative: 'Example Person',
|
|
112
|
+
brandRelationship: 'Example Co is the registered brand of the legal entity.',
|
|
113
|
+
sourceIds: ['registry'],
|
|
114
|
+
})
|
|
115
|
+
scoutCase = addClaim(scoutCase, {
|
|
116
|
+
claimId: 'claim-1',
|
|
117
|
+
text: 'The registry confirms the company identity.',
|
|
118
|
+
status: 'verified',
|
|
119
|
+
evidenceLevel: 'E3',
|
|
120
|
+
dimension: 'identity',
|
|
121
|
+
impact: 'material',
|
|
122
|
+
sourceIds: ['registry'],
|
|
123
|
+
confidenceNote: 'The linked registry record contains the legal entity identifiers.',
|
|
124
|
+
nextAction: 'Use the verified entity for subsequent research.',
|
|
125
|
+
})
|
|
126
|
+
scoutCase = decideCase(scoutCase)
|
|
127
|
+
|
|
128
|
+
assert.equal(scoutCase.subject.identityStatus, 'verified')
|
|
129
|
+
assert.equal(scoutCase.subject.possibleLegalEntity, 'Example Company Limited')
|
|
130
|
+
assert.deepEqual(scoutCase.subject.identitySourceIds, ['registry'])
|
|
131
|
+
assert.equal(scoutCase.decision, 'VERIFY')
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
test('rejects a self-labelled E3 source that is not from a trusted authority origin', () => {
|
|
135
|
+
const scoutCase = createCase({
|
|
136
|
+
caseId: 'demo',
|
|
137
|
+
companyName: 'Example Co',
|
|
138
|
+
roleTitle: 'HR Head',
|
|
139
|
+
})
|
|
140
|
+
assert.throws(() => addSource(scoutCase, {
|
|
141
|
+
sourceId: 'blog',
|
|
142
|
+
type: 'other',
|
|
143
|
+
title: 'Unverified blog post',
|
|
144
|
+
url: 'https://example.com/blog',
|
|
145
|
+
capturedAt: '2026-08-14T00:00:00.000Z',
|
|
146
|
+
evidenceLevel: 'E3',
|
|
147
|
+
status: 'captured',
|
|
148
|
+
}), /supported registry, regulator, or official filing/)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
test('rejects verified claims backed only by E0 evidence', () => {
|
|
152
|
+
let scoutCase = createCase({
|
|
153
|
+
caseId: 'demo',
|
|
154
|
+
companyName: 'Example Co',
|
|
155
|
+
roleTitle: 'HR Head',
|
|
156
|
+
})
|
|
157
|
+
scoutCase = addSource(scoutCase, {
|
|
158
|
+
sourceId: 'guess',
|
|
159
|
+
type: 'other',
|
|
160
|
+
title: 'Model guess',
|
|
161
|
+
url: null,
|
|
162
|
+
capturedAt: null,
|
|
163
|
+
evidenceLevel: 'E0',
|
|
164
|
+
status: 'captured',
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
assert.throws(() => addClaim(scoutCase, {
|
|
168
|
+
claimId: 'role',
|
|
169
|
+
text: 'The role exists.',
|
|
170
|
+
status: 'verified',
|
|
171
|
+
evidenceLevel: 'E0',
|
|
172
|
+
dimension: 'role_existence',
|
|
173
|
+
impact: 'blocking',
|
|
174
|
+
sourceIds: ['guess'],
|
|
175
|
+
confidenceNote: 'No evidence exists.',
|
|
176
|
+
nextAction: 'Confirm the role with a real source.',
|
|
177
|
+
}), /at least E1 evidence/)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
test('rejects a claim whose evidence level exceeds its sources', () => {
|
|
181
|
+
let scoutCase = createCase({
|
|
182
|
+
caseId: 'demo',
|
|
183
|
+
companyName: 'Example Co',
|
|
184
|
+
roleTitle: 'HR Head',
|
|
185
|
+
})
|
|
186
|
+
scoutCase = addSource(scoutCase, {
|
|
187
|
+
sourceId: 'job-posting',
|
|
188
|
+
type: 'job_posting',
|
|
189
|
+
title: 'Example job posting',
|
|
190
|
+
url: 'https://example.com/job',
|
|
191
|
+
capturedAt: '2026-08-14T00:00:00.000Z',
|
|
192
|
+
evidenceLevel: 'E1',
|
|
193
|
+
status: 'captured',
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
assert.throws(() => addClaim(scoutCase, {
|
|
197
|
+
claimId: 'claim-1',
|
|
198
|
+
text: 'This claim overstates its source strength.',
|
|
199
|
+
status: 'verified',
|
|
200
|
+
evidenceLevel: 'E3',
|
|
201
|
+
dimension: 'risk',
|
|
202
|
+
impact: 'material',
|
|
203
|
+
sourceIds: ['job-posting'],
|
|
204
|
+
confidenceNote: 'The source does not justify an E3 label.',
|
|
205
|
+
nextAction: 'Find an authoritative source.',
|
|
206
|
+
}), /exceeds its strongest source/)
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
test('replays the Snapmaker fixture offline with a stable VERIFY result', () => {
|
|
210
|
+
const fixture = JSON.parse(readFileSync(
|
|
211
|
+
new URL('../docs/fixtures/dsh-scout/snapmaker-hr-head.json', import.meta.url),
|
|
212
|
+
'utf8',
|
|
213
|
+
))
|
|
214
|
+
let scoutCase = createCase({
|
|
215
|
+
caseId: fixture.caseId,
|
|
216
|
+
companyName: fixture.subject.name,
|
|
217
|
+
roleTitle: fixture.role.title,
|
|
218
|
+
location: fixture.role.location,
|
|
219
|
+
decisionObjective: fixture.decisionObjective,
|
|
220
|
+
})
|
|
221
|
+
for (const source of fixture.sources) scoutCase = addSource(scoutCase, source)
|
|
222
|
+
for (const claim of fixture.claims) scoutCase = addClaim(scoutCase, claim)
|
|
223
|
+
scoutCase = decideCase(scoutCase)
|
|
224
|
+
|
|
225
|
+
const report = renderReport(scoutCase)
|
|
226
|
+
assert.equal(scoutCase.decision, fixture.expectedDecision)
|
|
227
|
+
assert.match(report, /历史材料不能替代运行时实时核验|公司主体仍未核验/)
|
|
228
|
+
assert.match(report, /Interview questions/)
|
|
229
|
+
assert.equal(scoutCase.interviewQuestions.length, 5)
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
test('verifies a claim and preserves its previous evidence state', () => {
|
|
233
|
+
let scoutCase = createCase({
|
|
234
|
+
caseId: 'demo',
|
|
235
|
+
companyName: 'Example Co',
|
|
236
|
+
roleTitle: 'HR Head',
|
|
237
|
+
})
|
|
238
|
+
scoutCase = addClaim(scoutCase, {
|
|
239
|
+
claimId: 'claim-identity',
|
|
240
|
+
text: 'The legal entity matches the brand.',
|
|
241
|
+
status: 'needs_verification',
|
|
242
|
+
evidenceLevel: 'E0',
|
|
243
|
+
dimension: 'identity',
|
|
244
|
+
impact: 'blocking',
|
|
245
|
+
sourceIds: [],
|
|
246
|
+
confidenceNote: 'No current registry evidence has been attached.',
|
|
247
|
+
nextAction: 'Check the official registry.',
|
|
248
|
+
})
|
|
249
|
+
scoutCase = addSource(scoutCase, {
|
|
250
|
+
sourceId: 'registry',
|
|
251
|
+
type: 'company_registry',
|
|
252
|
+
title: 'Official registry record',
|
|
253
|
+
url: 'https://www.gsxt.gov.cn/registry/REG-123',
|
|
254
|
+
capturedAt: '2026-08-14T00:00:00.000Z',
|
|
255
|
+
evidenceLevel: 'E3',
|
|
256
|
+
status: 'captured',
|
|
257
|
+
})
|
|
258
|
+
scoutCase = verifyClaim(scoutCase, {
|
|
259
|
+
claimId: 'claim-identity',
|
|
260
|
+
evidenceLevel: 'E3',
|
|
261
|
+
sourceIds: ['registry'],
|
|
262
|
+
confidenceNote: 'The registry source resolves the prior identity uncertainty.',
|
|
263
|
+
nextAction: 'Use the legal entity in all subsequent searches.',
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
const claim = scoutCase.claims[0]
|
|
267
|
+
assert.equal(claim.status, 'verified')
|
|
268
|
+
assert.deepEqual(claim.sourceIds, ['registry'])
|
|
269
|
+
assert.deepEqual(claim.history?.[0].sourceIds, [])
|
|
270
|
+
assert.equal(claim.history?.[0].status, 'needs_verification')
|
|
271
|
+
assert.match(claim.history?.[0].confidenceNote, /No current registry/)
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
test('only proceeds after identity and all required role dimensions are verified', () => {
|
|
275
|
+
let scoutCase = createCase({
|
|
276
|
+
caseId: 'demo',
|
|
277
|
+
companyName: 'Example Co',
|
|
278
|
+
roleTitle: 'HR Head',
|
|
279
|
+
})
|
|
280
|
+
scoutCase = addSource(scoutCase, {
|
|
281
|
+
sourceId: 'registry',
|
|
282
|
+
type: 'company_registry',
|
|
283
|
+
title: 'Official registry record',
|
|
284
|
+
url: 'https://www.gsxt.gov.cn/registry/REG-123',
|
|
285
|
+
capturedAt: '2026-08-14T00:00:00.000Z',
|
|
286
|
+
evidenceLevel: 'E3',
|
|
287
|
+
status: 'captured',
|
|
288
|
+
})
|
|
289
|
+
scoutCase = addSource(scoutCase, {
|
|
290
|
+
sourceId: 'interview',
|
|
291
|
+
type: 'user_provided',
|
|
292
|
+
title: 'Hiring manager interview notes',
|
|
293
|
+
url: null,
|
|
294
|
+
capturedAt: '2026-08-14T00:00:00.000Z',
|
|
295
|
+
evidenceLevel: 'E1',
|
|
296
|
+
status: 'captured',
|
|
297
|
+
})
|
|
298
|
+
scoutCase = verifyIdentity(scoutCase, {
|
|
299
|
+
legalEntity: 'Example Company Limited',
|
|
300
|
+
registrationNumber: 'REG-123',
|
|
301
|
+
registeredRegion: 'Shenzhen',
|
|
302
|
+
legalRepresentative: 'Example Person',
|
|
303
|
+
brandRelationship: 'Example Co is the registered brand of the legal entity.',
|
|
304
|
+
sourceIds: ['registry'],
|
|
305
|
+
})
|
|
306
|
+
for (const [claimId, dimension, text] of [
|
|
307
|
+
['role', 'role_existence', 'The open role is confirmed.'],
|
|
308
|
+
['reporting', 'reporting_line', 'The role reports to the CEO.'],
|
|
309
|
+
['mandate', 'mandate', 'The role owns the approved people roadmap.'],
|
|
310
|
+
]) {
|
|
311
|
+
scoutCase = addClaim(scoutCase, {
|
|
312
|
+
claimId,
|
|
313
|
+
text,
|
|
314
|
+
status: 'verified',
|
|
315
|
+
evidenceLevel: 'E1',
|
|
316
|
+
dimension,
|
|
317
|
+
impact: 'blocking',
|
|
318
|
+
sourceIds: ['interview'],
|
|
319
|
+
confidenceNote: 'The hiring manager confirmed this point directly.',
|
|
320
|
+
nextAction: 'Reconfirm it in the written offer or next interview.',
|
|
321
|
+
})
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
assert.equal(decideCase(scoutCase).decision, 'PROCEED')
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
test('isolates identical case ids by DSH agent session identity', async () => {
|
|
328
|
+
const registered = []
|
|
329
|
+
apply({
|
|
330
|
+
tools: {
|
|
331
|
+
register(tool) {
|
|
332
|
+
registered.push(tool)
|
|
333
|
+
return () => undefined
|
|
334
|
+
},
|
|
335
|
+
},
|
|
336
|
+
effect(execute) {
|
|
337
|
+
Array.from(execute())
|
|
338
|
+
},
|
|
339
|
+
})
|
|
340
|
+
const tools = new Map(registered.map(tool => [tool.name, tool]))
|
|
341
|
+
const start = tools.get('scout_start')
|
|
342
|
+
const report = tools.get('scout_report')
|
|
343
|
+
|
|
344
|
+
await start.execute({
|
|
345
|
+
caseId: 'same-id',
|
|
346
|
+
companyName: 'Company A',
|
|
347
|
+
roleTitle: 'Role A',
|
|
348
|
+
location: '',
|
|
349
|
+
}, { agent: { id: 'session-a' } })
|
|
350
|
+
await start.execute({
|
|
351
|
+
caseId: 'same-id',
|
|
352
|
+
companyName: 'Company B',
|
|
353
|
+
roleTitle: 'Role B',
|
|
354
|
+
location: '',
|
|
355
|
+
}, { agent: { id: 'session-b' } })
|
|
356
|
+
|
|
357
|
+
const reportA = await report.execute({ caseId: 'same-id' }, { agent: { id: 'session-a' } })
|
|
358
|
+
const reportB = await report.execute({ caseId: 'same-id' }, { agent: { id: 'session-b' } })
|
|
359
|
+
assert.match(reportA, /Company A — Role A/)
|
|
360
|
+
assert.doesNotMatch(reportA, /Company B/)
|
|
361
|
+
assert.match(reportB, /Company B — Role B/)
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
test('renders an evidence summary, source URLs, and a prioritized verification checklist', () => {
|
|
365
|
+
let scoutCase = createCase({
|
|
366
|
+
caseId: 'demo',
|
|
367
|
+
companyName: 'Example Co',
|
|
368
|
+
roleTitle: 'HR Head',
|
|
369
|
+
})
|
|
370
|
+
scoutCase = addSource(scoutCase, {
|
|
371
|
+
sourceId: 'job-posting',
|
|
372
|
+
type: 'job_posting',
|
|
373
|
+
title: 'Example job posting',
|
|
374
|
+
url: 'https://example.com/job',
|
|
375
|
+
capturedAt: '2026-08-14T00:00:00.000Z',
|
|
376
|
+
evidenceLevel: 'E1',
|
|
377
|
+
status: 'captured',
|
|
378
|
+
})
|
|
379
|
+
scoutCase = addClaim(scoutCase, {
|
|
380
|
+
claimId: 'claim-role',
|
|
381
|
+
text: 'The role exists.',
|
|
382
|
+
status: 'reported',
|
|
383
|
+
evidenceLevel: 'E1',
|
|
384
|
+
dimension: 'role_existence',
|
|
385
|
+
impact: 'blocking',
|
|
386
|
+
sourceIds: ['job-posting'],
|
|
387
|
+
confidenceNote: 'The job posting is a company-controlled source.',
|
|
388
|
+
nextAction: 'Confirm the mandate and budget in the interview.',
|
|
389
|
+
})
|
|
390
|
+
scoutCase = addClaim(scoutCase, {
|
|
391
|
+
claimId: 'claim-risk',
|
|
392
|
+
text: 'A litigation risk is reported.',
|
|
393
|
+
status: 'reported',
|
|
394
|
+
evidenceLevel: 'E1',
|
|
395
|
+
dimension: 'risk',
|
|
396
|
+
impact: 'material',
|
|
397
|
+
sourceIds: ['job-posting'],
|
|
398
|
+
confidenceNote: 'A media report mentions the risk.',
|
|
399
|
+
nextAction: 'Check the court docket.',
|
|
400
|
+
})
|
|
401
|
+
const report = renderReport(scoutCase)
|
|
402
|
+
|
|
403
|
+
assert.match(report, /证据概况:2 reported/)
|
|
404
|
+
assert.match(report, /https:\/\/example\.com\/job/)
|
|
405
|
+
assert.match(report, /## Verification checklist/)
|
|
406
|
+
const checklistStart = report.indexOf('## Verification checklist')
|
|
407
|
+
const checklist = report.slice(checklistStart, report.indexOf('## Claim ledger'))
|
|
408
|
+
assert.ok(checklist.indexOf('[blocking]') < checklist.indexOf('[material]'), 'blocking items rank before material')
|
|
409
|
+
assert.match(checklist, /→ 下一步:Confirm the mandate/)
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
test('exports a disposable DSH plugin tool surface', async () => { const registered = []
|
|
413
|
+
const disposed = []
|
|
414
|
+
const effects = []
|
|
415
|
+
apply({
|
|
416
|
+
tools: {
|
|
417
|
+
register(tool) {
|
|
418
|
+
registered.push(tool)
|
|
419
|
+
return () => disposed.push(tool.name)
|
|
420
|
+
},
|
|
421
|
+
},
|
|
422
|
+
effect(execute) {
|
|
423
|
+
effects.push(...execute())
|
|
424
|
+
},
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
assert.equal(name, 'dsh-scout')
|
|
428
|
+
assert.deepEqual(inject, ['tools', 'fs', 'web'])
|
|
429
|
+
assert.deepEqual(
|
|
430
|
+
registered.map(tool => tool.name).sort(),
|
|
431
|
+
[
|
|
432
|
+
'scout_add_claim',
|
|
433
|
+
'scout_add_source',
|
|
434
|
+
'scout_compare',
|
|
435
|
+
'scout_export',
|
|
436
|
+
'scout_import',
|
|
437
|
+
'scout_ingest',
|
|
438
|
+
'scout_questions',
|
|
439
|
+
'scout_report',
|
|
440
|
+
'scout_search',
|
|
441
|
+
'scout_start',
|
|
442
|
+
'scout_verify_claim',
|
|
443
|
+
'scout_verify_identity',
|
|
444
|
+
],
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
for (const dispose of effects.reverse()) dispose()
|
|
448
|
+
assert.deepEqual(disposed.sort(), registered.map(tool => tool.name).sort())
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
test('round-trips a case through the five-file export', () => {
|
|
452
|
+
let scoutCase = createCase({
|
|
453
|
+
caseId: 'demo',
|
|
454
|
+
companyName: 'Example Co',
|
|
455
|
+
roleTitle: 'HR Head',
|
|
456
|
+
})
|
|
457
|
+
scoutCase = appendEvent(scoutCase, {
|
|
458
|
+
eventId: 'evt-1',
|
|
459
|
+
type: 'case_started',
|
|
460
|
+
at: '2026-08-16T00:00:00.000Z',
|
|
461
|
+
detail: { companyName: 'Example Co' },
|
|
462
|
+
})
|
|
463
|
+
scoutCase = addSource(scoutCase, {
|
|
464
|
+
sourceId: 'job-posting',
|
|
465
|
+
type: 'job_posting',
|
|
466
|
+
title: 'Example job posting',
|
|
467
|
+
url: 'https://example.com/job',
|
|
468
|
+
capturedAt: '2026-08-14T00:00:00.000Z',
|
|
469
|
+
evidenceLevel: 'E1',
|
|
470
|
+
status: 'captured',
|
|
471
|
+
})
|
|
472
|
+
scoutCase = addClaim(scoutCase, {
|
|
473
|
+
claimId: 'claim-1',
|
|
474
|
+
text: 'The role exists.',
|
|
475
|
+
status: 'reported',
|
|
476
|
+
evidenceLevel: 'E1',
|
|
477
|
+
dimension: 'role_existence',
|
|
478
|
+
impact: 'blocking',
|
|
479
|
+
sourceIds: ['job-posting'],
|
|
480
|
+
confidenceNote: 'The job posting is a company-controlled source.',
|
|
481
|
+
nextAction: 'Confirm the mandate in the interview.',
|
|
482
|
+
})
|
|
483
|
+
scoutCase = decideCase(scoutCase)
|
|
484
|
+
|
|
485
|
+
const files = exportCaseFiles(scoutCase)
|
|
486
|
+
assert.deepEqual(Object.keys(files).sort(), ['case.json', 'claims.json', 'events.jsonl', 'report.md', 'sources.json'])
|
|
487
|
+
assert.match(files['report.md'], /## Verification checklist/)
|
|
488
|
+
assert.match(files['events.jsonl'], /"type":"case_started"/)
|
|
489
|
+
|
|
490
|
+
const restored = importCaseFromFiles(files)
|
|
491
|
+
assert.equal(restored.caseId, 'demo')
|
|
492
|
+
assert.equal(restored.subject.name, 'Example Co')
|
|
493
|
+
assert.equal(restored.sources[0].sourceId, 'job-posting')
|
|
494
|
+
assert.equal(restored.claims[0].claimId, 'claim-1')
|
|
495
|
+
assert.equal(restored.events?.[0].type, 'case_started')
|
|
496
|
+
assert.equal(restored.decision, 'VERIFY')
|
|
497
|
+
})
|
|
498
|
+
|
|
499
|
+
test('rejects an incomplete or mismatched export payload', () => {
|
|
500
|
+
const files = exportCaseFiles(createCase({ caseId: 'demo', companyName: 'Example Co', roleTitle: 'HR Head' }))
|
|
501
|
+
delete files['sources.json']
|
|
502
|
+
assert.throws(() => importCaseFromFiles(files), /Missing export files/)
|
|
503
|
+
const intact = exportCaseFiles(createCase({ caseId: 'demo', companyName: 'Example Co', roleTitle: 'HR Head' }))
|
|
504
|
+
assert.throws(() => importCaseFromFiles({ ...intact, 'case.json': JSON.stringify({ schemaVersion: 'dsh-scout.case.v1' }) }), /Unsupported schema/)
|
|
505
|
+
})
|
|
506
|
+
|
|
507
|
+
test('exports and imports a case through the fs-backed tools', async () => {
|
|
508
|
+
const registered = []
|
|
509
|
+
const store = new Map()
|
|
510
|
+
const mockFs = {
|
|
511
|
+
async resolve(path) {
|
|
512
|
+
return { targetKey: path, displayPath: path }
|
|
513
|
+
},
|
|
514
|
+
async readText(target) {
|
|
515
|
+
if (!store.has(target.displayPath)) throw new Error(`not found: ${target.displayPath}`)
|
|
516
|
+
return store.get(target.displayPath)
|
|
517
|
+
},
|
|
518
|
+
async writeText(target, content) {
|
|
519
|
+
store.set(target.displayPath, content)
|
|
520
|
+
return { operation: 'create' }
|
|
521
|
+
},
|
|
522
|
+
}
|
|
523
|
+
apply({
|
|
524
|
+
tools: {
|
|
525
|
+
register(tool) {
|
|
526
|
+
registered.push(tool)
|
|
527
|
+
return () => undefined
|
|
528
|
+
},
|
|
529
|
+
},
|
|
530
|
+
fs: mockFs,
|
|
531
|
+
effect(execute) {
|
|
532
|
+
Array.from(execute())
|
|
533
|
+
},
|
|
534
|
+
})
|
|
535
|
+
const tools = new Map(registered.map(tool => [tool.name, tool]))
|
|
536
|
+
const start = tools.get('scout_start')
|
|
537
|
+
const addSourceTool = tools.get('scout_add_source')
|
|
538
|
+
const addClaimTool = tools.get('scout_add_claim')
|
|
539
|
+
const exportTool = tools.get('scout_export')
|
|
540
|
+
const importTool = tools.get('scout_import')
|
|
541
|
+
|
|
542
|
+
await start.execute({
|
|
543
|
+
caseId: 'persisted',
|
|
544
|
+
companyName: 'Example Co',
|
|
545
|
+
roleTitle: 'HR Head',
|
|
546
|
+
location: 'Shenzhen',
|
|
547
|
+
}, { agent: { id: 'session-a' } })
|
|
548
|
+
await addSourceTool.execute({
|
|
549
|
+
caseId: 'persisted',
|
|
550
|
+
sourceId: 'job-posting',
|
|
551
|
+
sourceType: 'job_posting',
|
|
552
|
+
title: 'Example job posting',
|
|
553
|
+
url: 'https://example.com/job',
|
|
554
|
+
evidenceLevel: 'E1',
|
|
555
|
+
}, { agent: { id: 'session-a' } })
|
|
556
|
+
await addClaimTool.execute({
|
|
557
|
+
caseId: 'persisted',
|
|
558
|
+
claimId: 'claim-1',
|
|
559
|
+
text: 'The role exists.',
|
|
560
|
+
status: 'reported',
|
|
561
|
+
evidenceLevel: 'E1',
|
|
562
|
+
dimension: 'role_existence',
|
|
563
|
+
impact: 'blocking',
|
|
564
|
+
sourceIds: 'job-posting',
|
|
565
|
+
confidenceNote: 'The job posting is a company-controlled source.',
|
|
566
|
+
nextAction: 'Confirm the mandate in the interview.',
|
|
567
|
+
}, { agent: { id: 'session-a' } })
|
|
568
|
+
|
|
569
|
+
const exportResult = JSON.parse(await exportTool.execute({
|
|
570
|
+
caseId: 'persisted',
|
|
571
|
+
targetDir: '/tmp/scout-cases/persisted',
|
|
572
|
+
}, { agent: { id: 'session-a' } }))
|
|
573
|
+
assert.equal(exportResult.files.length, 5)
|
|
574
|
+
assert.equal(store.size, 5)
|
|
575
|
+
assert.ok(store.has('/tmp/scout-cases/persisted/case.json'))
|
|
576
|
+
|
|
577
|
+
const importResult = JSON.parse(await importTool.execute({
|
|
578
|
+
caseId: 'persisted',
|
|
579
|
+
sourceDir: '/tmp/scout-cases/persisted',
|
|
580
|
+
}, { agent: { id: 'session-a' } }))
|
|
581
|
+
assert.equal(importResult.caseId, 'persisted')
|
|
582
|
+
assert.equal(importResult.sources.length, 1)
|
|
583
|
+
assert.equal(importResult.claims.length, 1)
|
|
584
|
+
assert.ok(importResult.events?.some(event => event.type === 'case_imported'))
|
|
585
|
+
assert.equal(importResult.decision, 'VERIFY')
|
|
586
|
+
})
|
|
587
|
+
|
|
588
|
+
test('generates a deduplicated, prioritized interview question list', () => {
|
|
589
|
+
let scoutCase = createCase({
|
|
590
|
+
caseId: 'demo',
|
|
591
|
+
companyName: 'Example Co',
|
|
592
|
+
roleTitle: 'HR Head',
|
|
593
|
+
})
|
|
594
|
+
scoutCase = addSource(scoutCase, {
|
|
595
|
+
sourceId: 'job-posting',
|
|
596
|
+
type: 'job_posting',
|
|
597
|
+
title: 'Example job posting',
|
|
598
|
+
url: 'https://example.com/job',
|
|
599
|
+
capturedAt: '2026-08-14T00:00:00.000Z',
|
|
600
|
+
evidenceLevel: 'E1',
|
|
601
|
+
status: 'captured',
|
|
602
|
+
})
|
|
603
|
+
scoutCase = addClaim(scoutCase, {
|
|
604
|
+
claimId: 'claim-role',
|
|
605
|
+
text: 'The role exists.',
|
|
606
|
+
status: 'reported',
|
|
607
|
+
evidenceLevel: 'E1',
|
|
608
|
+
dimension: 'role_existence',
|
|
609
|
+
impact: 'blocking',
|
|
610
|
+
sourceIds: ['job-posting'],
|
|
611
|
+
confidenceNote: 'A source describes the role.',
|
|
612
|
+
nextAction: 'Confirm the mandate in the interview.',
|
|
613
|
+
})
|
|
614
|
+
scoutCase = addClaim(scoutCase, {
|
|
615
|
+
claimId: 'claim-reporting',
|
|
616
|
+
text: 'Reporting line is unknown.',
|
|
617
|
+
status: 'unknown',
|
|
618
|
+
evidenceLevel: 'E0',
|
|
619
|
+
dimension: 'reporting_line',
|
|
620
|
+
impact: 'blocking',
|
|
621
|
+
sourceIds: [],
|
|
622
|
+
confidenceNote: 'No source discloses the reporting line.',
|
|
623
|
+
nextAction: 'Ask who this role reports to.',
|
|
624
|
+
})
|
|
625
|
+
|
|
626
|
+
const questions = generateInterviewQuestions(scoutCase)
|
|
627
|
+
assert.ok(questions.includes('Confirm the mandate in the interview.'))
|
|
628
|
+
assert.ok(questions.includes('Ask who this role reports to.'))
|
|
629
|
+
// missing verified mandate dimension gets its default question
|
|
630
|
+
assert.ok(questions.some(q => q.includes('最终决定权')))
|
|
631
|
+
// defaults are appended and deduplicated
|
|
632
|
+
assert.ok(questions.includes(scoutCase.interviewQuestions[0]))
|
|
633
|
+
assert.equal(new Set(questions).size, questions.length)
|
|
634
|
+
assert.ok(questions.length <= 12)
|
|
635
|
+
})
|
|
636
|
+
|
|
637
|
+
test('auto-persists each mutation when configured, and exports to the default dir', async () => {
|
|
638
|
+
const registered = []
|
|
639
|
+
const store = new Map()
|
|
640
|
+
const mockFs = {
|
|
641
|
+
async resolve(path) {
|
|
642
|
+
return { targetKey: path, displayPath: path }
|
|
643
|
+
},
|
|
644
|
+
async readText(target) {
|
|
645
|
+
if (!store.has(target.displayPath)) throw new Error(`not found: ${target.displayPath}`)
|
|
646
|
+
return store.get(target.displayPath)
|
|
647
|
+
},
|
|
648
|
+
async writeText(target, content) {
|
|
649
|
+
store.set(target.displayPath, content)
|
|
650
|
+
return { operation: 'create' }
|
|
651
|
+
},
|
|
652
|
+
}
|
|
653
|
+
apply({
|
|
654
|
+
tools: {
|
|
655
|
+
register(tool) {
|
|
656
|
+
registered.push(tool)
|
|
657
|
+
return () => undefined
|
|
658
|
+
},
|
|
659
|
+
},
|
|
660
|
+
fs: mockFs,
|
|
661
|
+
effect(execute) {
|
|
662
|
+
Array.from(execute())
|
|
663
|
+
},
|
|
664
|
+
}, { scoutDir: '/tmp/scout-cases', autoPersist: true })
|
|
665
|
+
const tools = new Map(registered.map(tool => [tool.name, tool]))
|
|
666
|
+
const start = tools.get('scout_start')
|
|
667
|
+
const exportTool = tools.get('scout_export')
|
|
668
|
+
|
|
669
|
+
await start.execute({
|
|
670
|
+
caseId: 'auto',
|
|
671
|
+
companyName: 'Example Co',
|
|
672
|
+
roleTitle: 'HR Head',
|
|
673
|
+
location: '',
|
|
674
|
+
}, { agent: { id: 'session-a' } })
|
|
675
|
+
|
|
676
|
+
// autoPersist wrote the five files under scoutDir/<caseId>
|
|
677
|
+
assert.ok(store.has('/tmp/scout-cases/auto/case.json'))
|
|
678
|
+
assert.ok(store.has('/tmp/scout-cases/auto/report.md'))
|
|
679
|
+
assert.equal(store.size, 5)
|
|
680
|
+
|
|
681
|
+
// export without targetDir uses the configured scoutDir/<caseId>
|
|
682
|
+
const exportResult = JSON.parse(await exportTool.execute({
|
|
683
|
+
caseId: 'auto',
|
|
684
|
+
}, { agent: { id: 'session-a' } }))
|
|
685
|
+
assert.equal(exportResult.targetDir, '/tmp/scout-cases/auto')
|
|
686
|
+
assert.equal(exportResult.persisted, true)
|
|
687
|
+
assert.equal(exportResult.files.length, 5)
|
|
688
|
+
})
|
|
689
|
+
|
|
690
|
+
test('scout_questions tool returns the derived question list', async () => {
|
|
691
|
+
const registered = []
|
|
692
|
+
apply({
|
|
693
|
+
tools: {
|
|
694
|
+
register(tool) {
|
|
695
|
+
registered.push(tool)
|
|
696
|
+
return () => undefined
|
|
697
|
+
},
|
|
698
|
+
},
|
|
699
|
+
effect(execute) {
|
|
700
|
+
Array.from(execute())
|
|
701
|
+
},
|
|
702
|
+
})
|
|
703
|
+
const tools = new Map(registered.map(tool => [tool.name, tool]))
|
|
704
|
+
const start = tools.get('scout_start')
|
|
705
|
+
const questions = tools.get('scout_questions')
|
|
706
|
+
await start.execute({
|
|
707
|
+
caseId: 'demo',
|
|
708
|
+
companyName: 'Example Co',
|
|
709
|
+
roleTitle: 'HR Head',
|
|
710
|
+
location: '',
|
|
711
|
+
}, { agent: { id: 'session-a' } })
|
|
712
|
+
const result = JSON.parse(await questions.execute({ caseId: 'demo' }, { agent: { id: 'session-a' } }))
|
|
713
|
+
assert.ok(Array.isArray(result))
|
|
714
|
+
assert.ok(result.length >= 5)
|
|
715
|
+
})
|
|
716
|
+
|
|
717
|
+
test('auto-persist and default export dir degrade gracefully without fs or scoutDir', async () => {
|
|
718
|
+
// autoPersist enabled but no fs service: mutations still succeed
|
|
719
|
+
const noFs = []
|
|
720
|
+
apply({
|
|
721
|
+
tools: { register(tool) { noFs.push(tool); return () => undefined } },
|
|
722
|
+
effect(execute) { Array.from(execute()) },
|
|
723
|
+
}, { scoutDir: '/nowhere', autoPersist: true })
|
|
724
|
+
const startNoFs = new Map(noFs.map(t => [t.name, t])).get('scout_start')
|
|
725
|
+
const started = JSON.parse(await startNoFs.execute({
|
|
726
|
+
caseId: 'no-fs',
|
|
727
|
+
companyName: 'Example Co',
|
|
728
|
+
roleTitle: 'HR Head',
|
|
729
|
+
location: '',
|
|
730
|
+
}, { agent: { id: 'session-a' } }))
|
|
731
|
+
assert.equal(started.caseId, 'no-fs')
|
|
732
|
+
assert.ok(started.events?.some(e => e.type === 'case_started'))
|
|
733
|
+
|
|
734
|
+
// no scoutDir configured: export without targetDir falls back to ./dsh-scout/<caseId>
|
|
735
|
+
const registered = []
|
|
736
|
+
const store = new Map()
|
|
737
|
+
const mockFs = {
|
|
738
|
+
async resolve(path) { return { targetKey: path, displayPath: path } },
|
|
739
|
+
async readText(target) {
|
|
740
|
+
if (!store.has(target.displayPath)) throw new Error(`not found: ${target.displayPath}`)
|
|
741
|
+
return store.get(target.displayPath)
|
|
742
|
+
},
|
|
743
|
+
async writeText(target, content) { store.set(target.displayPath, content); return { operation: 'create' } },
|
|
744
|
+
}
|
|
745
|
+
apply({
|
|
746
|
+
tools: { register(tool) { registered.push(tool); return () => undefined } },
|
|
747
|
+
fs: mockFs,
|
|
748
|
+
effect(execute) { Array.from(execute()) },
|
|
749
|
+
})
|
|
750
|
+
const tools = new Map(registered.map(t => [t.name, t]))
|
|
751
|
+
await tools.get('scout_start').execute({
|
|
752
|
+
caseId: 'default-dir',
|
|
753
|
+
companyName: 'Example Co',
|
|
754
|
+
roleTitle: 'HR Head',
|
|
755
|
+
location: '',
|
|
756
|
+
}, { agent: { id: 'session-a' } })
|
|
757
|
+
const exported = JSON.parse(await tools.get('scout_export').execute(
|
|
758
|
+
{ caseId: 'default-dir' },
|
|
759
|
+
{ agent: { id: 'session-a' } },
|
|
760
|
+
))
|
|
761
|
+
assert.equal(exported.targetDir, 'dsh-scout/default-dir')
|
|
762
|
+
assert.ok(store.has('dsh-scout/default-dir/case.json'))
|
|
763
|
+
})
|
|
764
|
+
|
|
765
|
+
test('covers question-generation, duplicate-id, authority-url, and import edge cases', () => {
|
|
766
|
+
// empty case: 3 missing-dimension questions + 5 defaults = 8
|
|
767
|
+
const empty = createCase({ caseId: 'e', companyName: 'C', roleTitle: 'R' })
|
|
768
|
+
assert.equal(generateInterviewQuestions(empty).length, 8)
|
|
769
|
+
|
|
770
|
+
// fully verified role dimensions: only the 5 defaults remain
|
|
771
|
+
let verifiedCase = createCase({ caseId: 'v', companyName: 'C', roleTitle: 'R' })
|
|
772
|
+
verifiedCase = addSource(verifiedCase, {
|
|
773
|
+
sourceId: 's1', type: 'user_provided', title: 'notes', url: null,
|
|
774
|
+
capturedAt: '2026-08-16T00:00:00.000Z', evidenceLevel: 'E1', status: 'captured',
|
|
775
|
+
})
|
|
776
|
+
for (const [claimId, dimension] of [
|
|
777
|
+
['role', 'role_existence'], ['reporting', 'reporting_line'], ['mandate', 'mandate'],
|
|
778
|
+
]) {
|
|
779
|
+
verifiedCase = addClaim(verifiedCase, {
|
|
780
|
+
claimId, text: 'confirmed', status: 'verified', evidenceLevel: 'E1', dimension,
|
|
781
|
+
impact: 'blocking', sourceIds: ['s1'], confidenceNote: 'direct', nextAction: 'done',
|
|
782
|
+
})
|
|
783
|
+
}
|
|
784
|
+
const verifiedQuestions = generateInterviewQuestions(verifiedCase)
|
|
785
|
+
assert.equal(verifiedQuestions.length, 5)
|
|
786
|
+
|
|
787
|
+
// blank nextAction is filtered and the 12-item cap truncates
|
|
788
|
+
let capped = createCase({ caseId: 'c', companyName: 'C', roleTitle: 'R' })
|
|
789
|
+
capped = addSource(capped, {
|
|
790
|
+
sourceId: 's1', type: 'user_provided', title: 'notes', url: null,
|
|
791
|
+
capturedAt: '2026-08-16T00:00:00.000Z', evidenceLevel: 'E1', status: 'captured',
|
|
792
|
+
})
|
|
793
|
+
for (let i = 0; i < 20; i += 1) {
|
|
794
|
+
capped = addClaim(capped, {
|
|
795
|
+
claimId: `c${i}`, text: `pending ${i}`, status: 'reported', evidenceLevel: 'E1',
|
|
796
|
+
dimension: 'risk', impact: 'material', sourceIds: ['s1'],
|
|
797
|
+
confidenceNote: 'media', nextAction: i === 0 ? ' ' : `action ${i}`,
|
|
798
|
+
})
|
|
799
|
+
}
|
|
800
|
+
const cappedQuestions = generateInterviewQuestions(capped)
|
|
801
|
+
assert.equal(cappedQuestions.length, 12)
|
|
802
|
+
assert.ok(!cappedQuestions.some(q => q.trim() === ' '))
|
|
803
|
+
|
|
804
|
+
// duplicate ids are rejected at the model layer (pure functions return new cases)
|
|
805
|
+
let dup = createCase({ caseId: 'd', companyName: 'C', roleTitle: 'R' })
|
|
806
|
+
const source = {
|
|
807
|
+
sourceId: 's1', type: 'job_posting', title: 'posting', url: null,
|
|
808
|
+
capturedAt: '2026-08-16T00:00:00.000Z', evidenceLevel: 'E1', status: 'captured',
|
|
809
|
+
}
|
|
810
|
+
dup = addSource(dup, source)
|
|
811
|
+
assert.throws(() => addSource(dup, source), /Duplicate source/)
|
|
812
|
+
const claim = {
|
|
813
|
+
claimId: 'c1', text: 'x', status: 'unknown', evidenceLevel: 'E0', dimension: 'risk',
|
|
814
|
+
impact: 'material', sourceIds: [], confidenceNote: '', nextAction: '',
|
|
815
|
+
}
|
|
816
|
+
dup = addClaim(dup, claim)
|
|
817
|
+
assert.throws(() => addClaim(dup, claim), /Duplicate claim/)
|
|
818
|
+
const event = { eventId: 'evt-1', type: 'case_started', at: '2026-08-16T00:00:00.000Z', detail: {} }
|
|
819
|
+
dup = appendEvent(dup, event)
|
|
820
|
+
assert.throws(() => appendEvent(dup, event), /Duplicate event/)
|
|
821
|
+
|
|
822
|
+
// trusted-authority URL rejections
|
|
823
|
+
assert.equal(isTrustedAuthorityUrl('http://www.gsxt.gov.cn/'), false)
|
|
824
|
+
assert.equal(isTrustedAuthorityUrl('https://www.gsxt.gov.cn:8080/'), false)
|
|
825
|
+
assert.equal(isTrustedAuthorityUrl('https://user:pass@www.gsxt.gov.cn/'), false)
|
|
826
|
+
assert.equal(isTrustedAuthorityUrl('https://example.com/'), false)
|
|
827
|
+
assert.equal(isTrustedAuthorityUrl(null), false)
|
|
828
|
+
assert.equal(isTrustedAuthorityUrl('https://www.gsxt.gov.cn/'), true)
|
|
829
|
+
|
|
830
|
+
// import rejects malformed claim entries and wraps JSON syntax errors
|
|
831
|
+
const files = exportCaseFiles(createCase({ caseId: 'x', companyName: 'C', roleTitle: 'R' }))
|
|
832
|
+
assert.throws(() => importCaseFromFiles({ ...files, 'claims.json': JSON.stringify([{ claimId: 'c1' }]) }), /Invalid claim entry/)
|
|
833
|
+
assert.throws(() => importCaseFromFiles({ ...files, 'case.json': '{not json' }), /Invalid case\.json/)
|
|
834
|
+
})
|
|
835
|
+
|
|
836
|
+
test('infers source type and evidence level from URLs', () => {
|
|
837
|
+
// official registry/regulator origins
|
|
838
|
+
assert.deepEqual(inferSourceType('https://www.gsxt.gov.cn/foo'), { type: 'company_registry', inferred: true })
|
|
839
|
+
assert.deepEqual(inferSourceType('https://example.gov.cn/'), { type: 'regulator', inferred: true })
|
|
840
|
+
assert.deepEqual(inferEvidenceLevel('https://www.gsxt.gov.cn/foo', 'company_registry'), { evidenceLevel: 'E3', inferred: true })
|
|
841
|
+
// job platforms
|
|
842
|
+
assert.deepEqual(inferSourceType('https://m.liepin.com/job/1.shtml'), { type: 'job_posting', inferred: true })
|
|
843
|
+
assert.deepEqual(inferEvidenceLevel('https://m.liepin.com/job/1.shtml', 'job_posting'), { evidenceLevel: 'E2', inferred: true })
|
|
844
|
+
// unknown origins
|
|
845
|
+
assert.deepEqual(inferSourceType('https://example.com/article'), { type: 'other', inferred: true })
|
|
846
|
+
assert.deepEqual(inferEvidenceLevel('https://example.com/article', 'other'), { evidenceLevel: 'E2', inferred: true })
|
|
847
|
+
// user-provided
|
|
848
|
+
assert.deepEqual(inferEvidenceLevel(null, 'user_provided'), { evidenceLevel: 'E1', inferred: true })
|
|
849
|
+
// explicit values win and are not marked inferred
|
|
850
|
+
assert.deepEqual(inferSourceType('https://example.com/', 'independent_media'), { type: 'independent_media', inferred: false })
|
|
851
|
+
assert.deepEqual(inferEvidenceLevel('https://example.com/', 'job_posting', 'E1'), { evidenceLevel: 'E1', inferred: false })
|
|
852
|
+
})
|
|
853
|
+
|
|
854
|
+
test('scout_ingest batch-registers sources with inference and isolates errors', async () => {
|
|
855
|
+
const registered = []
|
|
856
|
+
apply({
|
|
857
|
+
tools: { register(tool) { registered.push(tool); return () => undefined } },
|
|
858
|
+
effect(execute) { Array.from(execute()) },
|
|
859
|
+
})
|
|
860
|
+
const tools = new Map(registered.map(t => [t.name, t]))
|
|
861
|
+
await tools.get('scout_start').execute({
|
|
862
|
+
caseId: 'ingest',
|
|
863
|
+
companyName: 'Example Co',
|
|
864
|
+
roleTitle: 'HR Head',
|
|
865
|
+
location: '',
|
|
866
|
+
}, { agent: { id: 'session-a' } })
|
|
867
|
+
|
|
868
|
+
const result = JSON.parse(await tools.get('scout_ingest').execute({
|
|
869
|
+
caseId: 'ingest',
|
|
870
|
+
itemsJson: JSON.stringify([
|
|
871
|
+
{ url: 'https://www.gsxt.gov.cn/registry/1', title: 'Registry record' },
|
|
872
|
+
{ url: 'https://m.liepin.com/job/2.shtml' },
|
|
873
|
+
{ url: 'https://example.com/blog' },
|
|
874
|
+
{ url: 'https://not-e3.example.com/', sourceType: 'company_registry', evidenceLevel: 'E3' },
|
|
875
|
+
{ url: '' },
|
|
876
|
+
]),
|
|
877
|
+
}, { agent: { id: 'session-a' } }))
|
|
878
|
+
|
|
879
|
+
assert.equal(result.added.length, 3)
|
|
880
|
+
assert.equal(result.errors.length, 2)
|
|
881
|
+
assert.deepEqual(result.added[0], {
|
|
882
|
+
sourceId: 'src-1', title: 'Registry record', url: 'https://www.gsxt.gov.cn/registry/1',
|
|
883
|
+
type: 'company_registry', evidenceLevel: 'E3',
|
|
884
|
+
inferred: { type: true, evidenceLevel: true },
|
|
885
|
+
})
|
|
886
|
+
assert.equal(result.added[1].type, 'job_posting')
|
|
887
|
+
assert.equal(result.added[1].evidenceLevel, 'E2')
|
|
888
|
+
assert.equal(result.added[2].type, 'other')
|
|
889
|
+
// the invalid E3 item is isolated in errors and does not abort the batch
|
|
890
|
+
assert.match(result.errors[0].error, /E3 sources require/)
|
|
891
|
+
assert.equal(result.errors[1].error, 'url is required')
|
|
892
|
+
|
|
893
|
+
// sources are registered in the case and carry events
|
|
894
|
+
const report = await tools.get('scout_report').execute({ caseId: 'ingest' }, { agent: { id: 'session-a' } })
|
|
895
|
+
assert.match(report, /src-1/)
|
|
896
|
+
assert.match(report, /src-3/)
|
|
897
|
+
assert.doesNotMatch(report, /src-4/)
|
|
898
|
+
})
|
|
899
|
+
|
|
900
|
+
test('scout_ingest isolates null items and invalid explicit enums, and avoids substring false positives', async () => {
|
|
901
|
+
// substring false positives are gone
|
|
902
|
+
assert.deepEqual(inferSourceType('https://notaboss.example.com/'), { type: 'other', inferred: true })
|
|
903
|
+
assert.deepEqual(inferSourceType('https://www.zhipin.com/job/1.html'), { type: 'job_posting', inferred: true })
|
|
904
|
+
assert.deepEqual(inferSourceType('https://bambulab.jobs.feishu.cn/position/1'), { type: 'job_posting', inferred: true })
|
|
905
|
+
assert.deepEqual(inferSourceType('https://www.liepin.com/job/1.shtml'), { type: 'job_posting', inferred: true })
|
|
906
|
+
|
|
907
|
+
const registered = []
|
|
908
|
+
apply({
|
|
909
|
+
tools: { register(tool) { registered.push(tool); return () => undefined } },
|
|
910
|
+
effect(execute) { Array.from(execute()) },
|
|
911
|
+
})
|
|
912
|
+
const tools = new Map(registered.map(t => [t.name, t]))
|
|
913
|
+
await tools.get('scout_start').execute({
|
|
914
|
+
caseId: 'robust', companyName: 'Example Co', roleTitle: 'HR Head', location: '',
|
|
915
|
+
}, { agent: { id: 'session-a' } })
|
|
916
|
+
|
|
917
|
+
const result = JSON.parse(await tools.get('scout_ingest').execute({
|
|
918
|
+
caseId: 'robust',
|
|
919
|
+
itemsJson: JSON.stringify([
|
|
920
|
+
null,
|
|
921
|
+
{ url: 'https://m.liepin.com/job/1.shtml' },
|
|
922
|
+
{ url: 'https://www.gsxt.gov.cn/registry/1' },
|
|
923
|
+
{ url: 'https://example.com/x', sourceType: 'regulatory_typo' },
|
|
924
|
+
{ url: 'https://example.com/y', evidenceLevel: 'E9' },
|
|
925
|
+
]),
|
|
926
|
+
}, { agent: { id: 'session-a' } }))
|
|
927
|
+
|
|
928
|
+
// null item and invalid explicit enums are isolated; valid items still registered
|
|
929
|
+
assert.equal(result.added.length, 2)
|
|
930
|
+
assert.equal(result.errors.length, 3)
|
|
931
|
+
assert.equal(result.errors[0].error, 'item must be an object')
|
|
932
|
+
assert.match(result.errors[1].error, /invalid sourceType/)
|
|
933
|
+
assert.match(result.errors[2].error, /invalid evidenceLevel/)
|
|
934
|
+
assert.equal(result.added[0].type, 'job_posting')
|
|
935
|
+
assert.equal(result.added[1].type, 'company_registry')
|
|
936
|
+
assert.equal(result.added[1].evidenceLevel, 'E3')
|
|
937
|
+
|
|
938
|
+
// the previously added sources survive the batch
|
|
939
|
+
const report = await tools.get('scout_report').execute({ caseId: 'robust' }, { agent: { id: 'session-a' } })
|
|
940
|
+
assert.match(report, /src-1/)
|
|
941
|
+
assert.match(report, /src-2/)
|
|
942
|
+
assert.doesNotMatch(report, /src-3/)
|
|
943
|
+
})
|
|
944
|
+
|
|
945
|
+
test('renders a side-by-side comparison of two cases', () => {
|
|
946
|
+
const makeCase = (caseId, companyName, verifiedIdentity) => {
|
|
947
|
+
let scoutCase = createCase({
|
|
948
|
+
caseId, companyName, roleTitle: 'HR Head', location: 'Shenzhen',
|
|
949
|
+
})
|
|
950
|
+
scoutCase = addSource(scoutCase, {
|
|
951
|
+
sourceId: 's1', type: 'job_posting', title: 'posting', url: 'https://example.com/job',
|
|
952
|
+
capturedAt: '2026-08-16T00:00:00.000Z', evidenceLevel: 'E1', status: 'captured',
|
|
953
|
+
})
|
|
954
|
+
scoutCase = addClaim(scoutCase, {
|
|
955
|
+
claimId: 'c1', text: `${companyName} pays 30k.`, status: 'reported', evidenceLevel: 'E1',
|
|
956
|
+
dimension: 'mandate', impact: 'material', sourceIds: ['s1'],
|
|
957
|
+
confidenceNote: 'reported', nextAction: 'Confirm in interview.',
|
|
958
|
+
})
|
|
959
|
+
scoutCase = addClaim(scoutCase, {
|
|
960
|
+
claimId: 'c2', text: `${companyName} has a legal risk.`, status: 'unknown', evidenceLevel: 'E0',
|
|
961
|
+
dimension: 'risk', impact: 'blocking', sourceIds: [],
|
|
962
|
+
confidenceNote: 'unknown', nextAction: 'Check the docket.',
|
|
963
|
+
})
|
|
964
|
+
if (verifiedIdentity) {
|
|
965
|
+
scoutCase = addSource(scoutCase, {
|
|
966
|
+
sourceId: 'reg', type: 'company_registry', title: 'registry', url: 'https://www.gsxt.gov.cn/r',
|
|
967
|
+
capturedAt: '2026-08-16T00:00:00.000Z', evidenceLevel: 'E3', status: 'captured',
|
|
968
|
+
})
|
|
969
|
+
scoutCase = verifyIdentity(scoutCase, {
|
|
970
|
+
legalEntity: companyName, registrationNumber: 'REG-1', registeredRegion: 'Shenzhen',
|
|
971
|
+
legalRepresentative: 'P', brandRelationship: 'brand', sourceIds: ['reg'],
|
|
972
|
+
})
|
|
973
|
+
}
|
|
974
|
+
return decideCase(scoutCase)
|
|
975
|
+
}
|
|
976
|
+
const caseA = makeCase('a', 'Company A', true)
|
|
977
|
+
const caseB = makeCase('b', 'Company B', false)
|
|
978
|
+
|
|
979
|
+
const report = renderComparison([caseA, caseB])
|
|
980
|
+
assert.match(report, /# 公司\/岗位对比(2 个案例)/)
|
|
981
|
+
assert.match(report, /## 决策与主体核验对比/)
|
|
982
|
+
assert.match(report, /Company A — HR Head \| VERIFY \| ✅ 已核验/)
|
|
983
|
+
assert.match(report, /Company B — HR Head \| VERIFY \| ⚠️ 待核验/)
|
|
984
|
+
assert.match(report, /### Company A — HR Head(a)/)
|
|
985
|
+
assert.match(report, /### Company B — HR Head(b)/)
|
|
986
|
+
assert.match(report, /Company A pays 30k\./)
|
|
987
|
+
assert.match(report, /Company B has a legal risk\./)
|
|
988
|
+
assert.match(report, /## 面试问题(合并去重,前 12 条)/)
|
|
989
|
+
|
|
990
|
+
assert.throws(() => renderComparison([caseA]), /at least two/)
|
|
991
|
+
const five = [caseA, caseB, caseA, caseB, caseA, caseB]
|
|
992
|
+
assert.throws(() => renderComparison(five), /at most five/)
|
|
993
|
+
})
|
|
994
|
+
|
|
995
|
+
test('scout_compare tool compares cases and inference rejects invalid explicit enums', async () => {
|
|
996
|
+
// model-layer enum defense (review L3)
|
|
997
|
+
assert.deepEqual(inferSourceType('https://example.com/', 'not_a_type'), { type: 'other', inferred: true })
|
|
998
|
+
assert.deepEqual(inferEvidenceLevel('https://example.com/', 'job_posting', 'E9'), { evidenceLevel: 'E2', inferred: true })
|
|
999
|
+
|
|
1000
|
+
const registered = []
|
|
1001
|
+
apply({
|
|
1002
|
+
tools: { register(tool) { registered.push(tool); return () => undefined } },
|
|
1003
|
+
effect(execute) { Array.from(execute()) },
|
|
1004
|
+
})
|
|
1005
|
+
const tools = new Map(registered.map(t => [t.name, t]))
|
|
1006
|
+
for (const [caseId, company] of [['a', 'Company A'], ['b', 'Company B']]) {
|
|
1007
|
+
await tools.get('scout_start').execute({
|
|
1008
|
+
caseId, companyName: company, roleTitle: 'HR Head', location: '',
|
|
1009
|
+
}, { agent: { id: 'session-a' } })
|
|
1010
|
+
}
|
|
1011
|
+
const result = await tools.get('scout_compare').execute(
|
|
1012
|
+
{ caseIds: 'a, b' },
|
|
1013
|
+
{ agent: { id: 'session-a' } },
|
|
1014
|
+
)
|
|
1015
|
+
assert.match(result, /公司\/岗位对比(2 个案例)/)
|
|
1016
|
+
assert.match(result, /Company A/)
|
|
1017
|
+
assert.match(result, /Company B/)
|
|
1018
|
+
})
|
|
1019
|
+
|
|
1020
|
+
test('scout_compare deduplicates case ids and empty claims render cleanly', async () => {
|
|
1021
|
+
const registered = []
|
|
1022
|
+
apply({
|
|
1023
|
+
tools: { register(tool) { registered.push(tool); return () => undefined } },
|
|
1024
|
+
effect(execute) { Array.from(execute()) },
|
|
1025
|
+
})
|
|
1026
|
+
const tools = new Map(registered.map(t => [t.name, t]))
|
|
1027
|
+
for (const [caseId, company] of [['a', 'Company A'], ['b', 'Company B']]) {
|
|
1028
|
+
await tools.get('scout_start').execute({
|
|
1029
|
+
caseId, companyName: company, roleTitle: 'HR Head', location: '',
|
|
1030
|
+
}, { agent: { id: 'session-a' } })
|
|
1031
|
+
}
|
|
1032
|
+
const compare = tools.get('scout_compare')
|
|
1033
|
+
|
|
1034
|
+
// duplicate ids are deduplicated: 'a,a,b' -> a, b
|
|
1035
|
+
const deduped = await compare.execute({ caseIds: 'a, a, b' }, { agent: { id: 'session-a' } })
|
|
1036
|
+
assert.match(deduped, /公司\/岗位对比(2 个案例)/)
|
|
1037
|
+
|
|
1038
|
+
// only one distinct id -> explicit error
|
|
1039
|
+
await assert.rejects(
|
|
1040
|
+
compare.execute({ caseIds: 'a, a' }, { agent: { id: 'session-a' } }),
|
|
1041
|
+
/at least two distinct case ids/,
|
|
1042
|
+
)
|
|
1043
|
+
// blank ids -> explicit error
|
|
1044
|
+
await assert.rejects(
|
|
1045
|
+
compare.execute({ caseIds: ' , ' }, { agent: { id: 'session-a' } }),
|
|
1046
|
+
/No case ids provided/,
|
|
1047
|
+
)
|
|
1048
|
+
// missing case -> error hints at the agent session boundary
|
|
1049
|
+
await assert.rejects(
|
|
1050
|
+
compare.execute({ caseIds: 'a, missing' }, { agent: { id: 'session-a' } }),
|
|
1051
|
+
/Unknown case: missing \(not found in this agent session\)/,
|
|
1052
|
+
)
|
|
1053
|
+
|
|
1054
|
+
// empty claims render without an empty parentheses suffix
|
|
1055
|
+
const empty = renderComparison([
|
|
1056
|
+
createCase({ caseId: 'x', companyName: 'X', roleTitle: 'R' }),
|
|
1057
|
+
createCase({ caseId: 'y', companyName: 'Y', roleTitle: 'R' }),
|
|
1058
|
+
])
|
|
1059
|
+
assert.match(empty, /主张 0 条\n/)
|
|
1060
|
+
assert.doesNotMatch(empty, /主张 0 条()/)
|
|
1061
|
+
})
|
|
1062
|
+
|
|
1063
|
+
test('scout_ingest drafts claims from items alongside sources', async () => {
|
|
1064
|
+
// foreign registries classify as company_registry
|
|
1065
|
+
assert.deepEqual(inferSourceType('https://w2.brreg.no/company/1'), { type: 'company_registry', inferred: true })
|
|
1066
|
+
assert.deepEqual(inferSourceType('https://www.handelsregister.de/rp_web/mask.do'), { type: 'company_registry', inferred: true })
|
|
1067
|
+
assert.deepEqual(inferSourceType('https://www1.hkexnews.hk/search/titlesearch.xhtml'), { type: 'company_registry', inferred: true })
|
|
1068
|
+
|
|
1069
|
+
const registered = []
|
|
1070
|
+
apply({
|
|
1071
|
+
tools: { register(tool) { registered.push(tool); return () => undefined } },
|
|
1072
|
+
effect(execute) { Array.from(execute()) },
|
|
1073
|
+
})
|
|
1074
|
+
const tools = new Map(registered.map(t => [t.name, t]))
|
|
1075
|
+
await tools.get('scout_start').execute({
|
|
1076
|
+
caseId: 'draft', companyName: 'Example Co', roleTitle: 'HR Head', location: '',
|
|
1077
|
+
}, { agent: { id: 'session-a' } })
|
|
1078
|
+
|
|
1079
|
+
const result = JSON.parse(await tools.get('scout_ingest').execute({
|
|
1080
|
+
caseId: 'draft',
|
|
1081
|
+
itemsJson: JSON.stringify([
|
|
1082
|
+
{
|
|
1083
|
+
url: 'https://m.liepin.com/job/1.shtml',
|
|
1084
|
+
claim: { text: '该岗位月薪 13k–26k。', dimension: 'financing_and_commercialization', impact: 'material' },
|
|
1085
|
+
},
|
|
1086
|
+
{
|
|
1087
|
+
url: 'https://www.gsxt.gov.cn/registry/1',
|
|
1088
|
+
claim: { text: '主体为深圳某科技有限公司。', dimension: 'identity', status: 'verified', evidenceLevel: 'E3' },
|
|
1089
|
+
},
|
|
1090
|
+
// claim missing dimension -> source kept, claim rejected
|
|
1091
|
+
{ url: 'https://example.com/a', claim: { text: 'no dimension' } },
|
|
1092
|
+
// claim evidence level exceeds the E2 source -> claim rejected, source kept
|
|
1093
|
+
{ url: 'https://example.com/b', claim: { text: 'overstated', dimension: 'risk', evidenceLevel: 'E3' } },
|
|
1094
|
+
]),
|
|
1095
|
+
}, { agent: { id: 'session-a' } }))
|
|
1096
|
+
|
|
1097
|
+
assert.equal(result.added.length, 4)
|
|
1098
|
+
assert.equal(result.errors.length, 2)
|
|
1099
|
+
// claim defaults: reported / material / source evidence level
|
|
1100
|
+
assert.deepEqual(result.added[0].claims, ['claim-1'])
|
|
1101
|
+
// verified claim on an E3 registry source is accepted
|
|
1102
|
+
assert.deepEqual(result.added[1].claims, ['claim-2'])
|
|
1103
|
+
// source survived even though its claim was rejected
|
|
1104
|
+
assert.equal(result.added[2].claims, undefined)
|
|
1105
|
+
assert.match(result.errors[0].error, /invalid claim\.dimension/)
|
|
1106
|
+
assert.match(result.errors[1].error, /exceeds its strongest source/)
|
|
1107
|
+
|
|
1108
|
+
// claims landed in the case with source links and events
|
|
1109
|
+
const report = await tools.get('scout_report').execute({ caseId: 'draft' }, { agent: { id: 'session-a' } })
|
|
1110
|
+
assert.match(report, /该岗位月薪 13k–26k。/)
|
|
1111
|
+
assert.match(report, /主体为深圳某科技有限公司。/)
|
|
1112
|
+
assert.doesNotMatch(report, /no dimension/)
|
|
1113
|
+
})
|
|
1114
|
+
|
|
1115
|
+
test('scout_ingest rejects invalid claim enums explicitly and keeps claim ids collision-safe', async () => {
|
|
1116
|
+
const registered = []
|
|
1117
|
+
apply({
|
|
1118
|
+
tools: { register(tool) { registered.push(tool); return () => undefined } },
|
|
1119
|
+
effect(execute) { Array.from(execute()) },
|
|
1120
|
+
})
|
|
1121
|
+
const tools = new Map(registered.map(t => [t.name, t]))
|
|
1122
|
+
await tools.get('scout_start').execute({
|
|
1123
|
+
caseId: 'enum', companyName: 'Example Co', roleTitle: 'HR Head', location: '',
|
|
1124
|
+
}, { agent: { id: 'session-a' } })
|
|
1125
|
+
|
|
1126
|
+
// pre-seed a claim so the next generated id must skip claim-1
|
|
1127
|
+
await tools.get('scout_add_claim').execute({
|
|
1128
|
+
caseId: 'enum',
|
|
1129
|
+
claimId: 'claim-1',
|
|
1130
|
+
text: 'pre-seeded',
|
|
1131
|
+
status: 'unknown',
|
|
1132
|
+
evidenceLevel: 'E0',
|
|
1133
|
+
dimension: 'risk',
|
|
1134
|
+
impact: 'material',
|
|
1135
|
+
sourceIds: '',
|
|
1136
|
+
confidenceNote: 'none',
|
|
1137
|
+
nextAction: 'verify',
|
|
1138
|
+
}, { agent: { id: 'session-a' } })
|
|
1139
|
+
|
|
1140
|
+
const result = JSON.parse(await tools.get('scout_ingest').execute({
|
|
1141
|
+
caseId: 'enum',
|
|
1142
|
+
itemsJson: JSON.stringify([
|
|
1143
|
+
{ url: 'https://m.liepin.com/job/1.shtml', claim: { text: 'ok claim', dimension: 'risk', status: 'contradictedd' } },
|
|
1144
|
+
{ url: 'https://m.liepin.com/job/2.shtml', claim: { text: 'bad impact', dimension: 'risk', impact: 'huge' } },
|
|
1145
|
+
{ url: 'https://m.liepin.com/job/3.shtml', claim: { text: 'bad level', dimension: 'risk', evidenceLevel: 'E9' } },
|
|
1146
|
+
{ url: 'https://m.liepin.com/job/4.shtml', claim: { text: 'good claim', dimension: 'mandate' } },
|
|
1147
|
+
]),
|
|
1148
|
+
}, { agent: { id: 'session-a' } }))
|
|
1149
|
+
|
|
1150
|
+
// all four sources registered; three claims rejected with explicit enum errors
|
|
1151
|
+
assert.equal(result.added.length, 4)
|
|
1152
|
+
assert.equal(result.errors.length, 3)
|
|
1153
|
+
assert.match(result.errors[0].error, /invalid claim\.status: contradictedd/)
|
|
1154
|
+
assert.match(result.errors[1].error, /invalid claim\.impact: huge/)
|
|
1155
|
+
assert.match(result.errors[2].error, /invalid claim\.evidenceLevel: E9/)
|
|
1156
|
+
// the valid claim skipped the pre-seeded claim-1
|
|
1157
|
+
assert.deepEqual(result.added[3].claims, ['claim-2'])
|
|
1158
|
+
})
|
|
1159
|
+
|
|
1160
|
+
test('covers registry boundaries, non-object claims, and verified-E0 rejection via claim drafts', async () => {
|
|
1161
|
+
// registry classification boundaries
|
|
1162
|
+
assert.deepEqual(inferSourceType('https://brreg.no/'), { type: 'company_registry', inferred: true })
|
|
1163
|
+
assert.deepEqual(inferSourceType('https://www.sse.com.cn/'), { type: 'company_registry', inferred: true })
|
|
1164
|
+
assert.deepEqual(inferSourceType('https://www.szse.cn/'), { type: 'company_registry', inferred: true })
|
|
1165
|
+
assert.deepEqual(inferSourceType('https://brreg.no.evil.com/'), { type: 'other', inferred: true })
|
|
1166
|
+
|
|
1167
|
+
const registered = []
|
|
1168
|
+
const store = new Map()
|
|
1169
|
+
apply({
|
|
1170
|
+
tools: { register(tool) { registered.push(tool); return () => undefined } },
|
|
1171
|
+
fs: {
|
|
1172
|
+
async resolve(path) { return { targetKey: path, displayPath: path } },
|
|
1173
|
+
async readText(target) { return store.get(target.displayPath) },
|
|
1174
|
+
async writeText(target, content) { store.set(target.displayPath, content); return { operation: 'create' } },
|
|
1175
|
+
},
|
|
1176
|
+
effect(execute) { Array.from(execute()) },
|
|
1177
|
+
})
|
|
1178
|
+
const tools = new Map(registered.map(t => [t.name, t]))
|
|
1179
|
+
await tools.get('scout_start').execute({
|
|
1180
|
+
caseId: 'edge', companyName: 'Example Co', roleTitle: 'HR Head', location: '',
|
|
1181
|
+
}, { agent: { id: 'session-a' } })
|
|
1182
|
+
|
|
1183
|
+
const result = JSON.parse(await tools.get('scout_ingest').execute({
|
|
1184
|
+
caseId: 'edge',
|
|
1185
|
+
itemsJson: JSON.stringify([
|
|
1186
|
+
{ url: 'https://m.liepin.com/job/1.shtml', claim: 'not-an-object' },
|
|
1187
|
+
{ url: 'https://example.com/e0', sourceType: 'other', evidenceLevel: 'E0', claim: { text: 'no evidence', dimension: 'risk', status: 'verified', evidenceLevel: 'E0' } },
|
|
1188
|
+
{ url: 'https://m.liepin.com/job/2.shtml', claim: { text: 'event payload check', dimension: 'mandate' } },
|
|
1189
|
+
]),
|
|
1190
|
+
}, { agent: { id: 'session-a' } }))
|
|
1191
|
+
|
|
1192
|
+
// non-object claim and verified-E0 claim are rejected; sources survive
|
|
1193
|
+
assert.equal(result.added.length, 3)
|
|
1194
|
+
assert.equal(result.errors.length, 2)
|
|
1195
|
+
assert.match(result.errors[0].error, /claim must be an object/)
|
|
1196
|
+
assert.match(result.errors[1].error, /at least E1 evidence/)
|
|
1197
|
+
assert.equal(result.added[1].claims, undefined) // E0 verified claim not attached
|
|
1198
|
+
assert.deepEqual(result.added[2].claims, ['claim-2'])
|
|
1199
|
+
|
|
1200
|
+
// claim_added event detail carries text and sourceIds in the export
|
|
1201
|
+
await tools.get('scout_export').execute(
|
|
1202
|
+
{ caseId: 'edge' },
|
|
1203
|
+
{ agent: { id: 'session-a' } },
|
|
1204
|
+
)
|
|
1205
|
+
const events = store.get('dsh-scout/edge/events.jsonl')
|
|
1206
|
+
assert.ok(events.includes('"text":"event payload check"'))
|
|
1207
|
+
assert.ok(events.includes('"sourceIds":["src-3"]'))
|
|
1208
|
+
})
|
|
1209
|
+
|
|
1210
|
+
test('scout_search registers provider results as sources and requires the web service', async () => {
|
|
1211
|
+
const registered = []
|
|
1212
|
+
const searched = []
|
|
1213
|
+
apply({
|
|
1214
|
+
tools: { register(tool) { registered.push(tool); return () => undefined } },
|
|
1215
|
+
web: {
|
|
1216
|
+
async search(request) {
|
|
1217
|
+
searched.push(request)
|
|
1218
|
+
return {
|
|
1219
|
+
content: 'answer snippet',
|
|
1220
|
+
truncated: false,
|
|
1221
|
+
sources: [
|
|
1222
|
+
{ url: 'https://www.gsxt.gov.cn/registry/9', title: 'Registry' },
|
|
1223
|
+
{ url: 'https://m.liepin.com/job/7.shtml' },
|
|
1224
|
+
{ url: 'https://example.com/unknown', snippet: 'a snippet' },
|
|
1225
|
+
],
|
|
1226
|
+
}
|
|
1227
|
+
},
|
|
1228
|
+
},
|
|
1229
|
+
effect(execute) { Array.from(execute()) },
|
|
1230
|
+
})
|
|
1231
|
+
const tools = new Map(registered.map(t => [t.name, t]))
|
|
1232
|
+
await tools.get('scout_start').execute({
|
|
1233
|
+
caseId: 'search', companyName: 'Example Co', roleTitle: 'HR Head', location: '',
|
|
1234
|
+
}, { agent: { id: 'session-a' } })
|
|
1235
|
+
|
|
1236
|
+
const result = JSON.parse(await tools.get('scout_search').execute({
|
|
1237
|
+
caseId: 'search',
|
|
1238
|
+
query: 'Example Co company registry',
|
|
1239
|
+
limit: 10,
|
|
1240
|
+
}, { agent: { id: 'session-a' } }))
|
|
1241
|
+
|
|
1242
|
+
assert.equal(searched.length, 1)
|
|
1243
|
+
assert.equal(searched[0].query, 'Example Co company registry')
|
|
1244
|
+
assert.equal(searched[0].maxResults, 10)
|
|
1245
|
+
assert.equal(result.added.length, 3)
|
|
1246
|
+
assert.equal(result.errors.length, 0)
|
|
1247
|
+
assert.equal(result.added[0].type, 'company_registry')
|
|
1248
|
+
assert.equal(result.added[0].evidenceLevel, 'E3')
|
|
1249
|
+
assert.equal(result.added[1].type, 'job_posting')
|
|
1250
|
+
assert.equal(result.added[2].snippet, 'a snippet')
|
|
1251
|
+
assert.equal(result.content, 'answer snippet')
|
|
1252
|
+
assert.equal(result.truncated, false)
|
|
1253
|
+
|
|
1254
|
+
// sources landed in the case
|
|
1255
|
+
const report = await tools.get('scout_report').execute({ caseId: 'search' }, { agent: { id: 'session-a' } })
|
|
1256
|
+
assert.match(report, /src-1/)
|
|
1257
|
+
assert.match(report, /src-3/)
|
|
1258
|
+
|
|
1259
|
+
// web service missing -> explicit error
|
|
1260
|
+
const noWeb = []
|
|
1261
|
+
apply({
|
|
1262
|
+
tools: { register(tool) { noWeb.push(tool); return () => undefined } },
|
|
1263
|
+
effect(execute) { Array.from(execute()) },
|
|
1264
|
+
})
|
|
1265
|
+
const toolsNoWeb = new Map(noWeb.map(t => [t.name, t]))
|
|
1266
|
+
await toolsNoWeb.get('scout_start').execute({
|
|
1267
|
+
caseId: 'x', companyName: 'C', roleTitle: 'R', location: '',
|
|
1268
|
+
}, { agent: { id: 'session-b' } })
|
|
1269
|
+
await assert.rejects(
|
|
1270
|
+
toolsNoWeb.get('scout_search').execute({ caseId: 'x', query: 'q' }, { agent: { id: 'session-b' } }),
|
|
1271
|
+
/Web search service is unavailable/,
|
|
1272
|
+
)
|
|
1273
|
+
})
|
|
1274
|
+
|
|
1275
|
+
test('scout_search handles provider edge cases and limit bounds', async () => {
|
|
1276
|
+
const registered = []
|
|
1277
|
+
const providerCalls = []
|
|
1278
|
+
let failSearch = false
|
|
1279
|
+
apply({
|
|
1280
|
+
tools: { register(tool) { registered.push(tool); return () => undefined } },
|
|
1281
|
+
web: {
|
|
1282
|
+
async search(request) {
|
|
1283
|
+
if (failSearch) throw new Error('provider down')
|
|
1284
|
+
providerCalls.push(request)
|
|
1285
|
+
return {
|
|
1286
|
+
sources: [
|
|
1287
|
+
null,
|
|
1288
|
+
{ url: 123 },
|
|
1289
|
+
{ url: '' },
|
|
1290
|
+
{ url: 'https://m.liepin.com/job/1.shtml', publishedAt: '2026-08-16' },
|
|
1291
|
+
],
|
|
1292
|
+
truncated: undefined,
|
|
1293
|
+
}
|
|
1294
|
+
},
|
|
1295
|
+
},
|
|
1296
|
+
effect(execute) { Array.from(execute()) },
|
|
1297
|
+
})
|
|
1298
|
+
const tools = new Map(registered.map(t => [t.name, t]))
|
|
1299
|
+
await tools.get('scout_start').execute({
|
|
1300
|
+
caseId: 'edge', companyName: 'Example Co', roleTitle: 'HR Head', location: '',
|
|
1301
|
+
}, { agent: { id: 'session-a' } })
|
|
1302
|
+
|
|
1303
|
+
// limit: 0 / -1 normalize to default 5, 100 caps at 10
|
|
1304
|
+
for (const [limit, expected] of [[0, 5], [-1, 5], [100, 10]]) {
|
|
1305
|
+
await tools.get('scout_search').execute(
|
|
1306
|
+
{ caseId: 'edge', query: 'q', limit },
|
|
1307
|
+
{ agent: { id: 'session-a' } },
|
|
1308
|
+
)
|
|
1309
|
+
}
|
|
1310
|
+
assert.deepEqual(providerCalls.map(c => c.maxResults), [5, 5, 10])
|
|
1311
|
+
// NaN is rejected by the tool-layer argument validation
|
|
1312
|
+
await assert.rejects(
|
|
1313
|
+
tools.get('scout_search').execute({ caseId: 'edge', query: 'q', limit: NaN }, { agent: { id: 'session-a' } }),
|
|
1314
|
+
/finite JSON number/,
|
|
1315
|
+
)
|
|
1316
|
+
|
|
1317
|
+
// malformed sources are isolated; valid ones register with publishedAt
|
|
1318
|
+
const result = JSON.parse(await tools.get('scout_search').execute(
|
|
1319
|
+
{ caseId: 'edge', query: 'q' },
|
|
1320
|
+
{ agent: { id: 'session-a' } },
|
|
1321
|
+
))
|
|
1322
|
+
assert.equal(result.errors.length, 3)
|
|
1323
|
+
assert.match(result.errors[0].error, /result 0 must be an object/)
|
|
1324
|
+
assert.match(result.errors[1].error, /result 1 missing url/)
|
|
1325
|
+
assert.match(result.errors[2].error, /result 2 missing url/)
|
|
1326
|
+
assert.equal(result.added.length, 1)
|
|
1327
|
+
assert.equal(result.added[0].type, 'job_posting')
|
|
1328
|
+
assert.equal(result.added[0].publishedAt, '2026-08-16')
|
|
1329
|
+
// truncated missing -> false, never dropped from the payload
|
|
1330
|
+
assert.equal(result.truncated, false)
|
|
1331
|
+
|
|
1332
|
+
// provider throwing -> tool rejects with the provider error
|
|
1333
|
+
failSearch = true
|
|
1334
|
+
await assert.rejects(
|
|
1335
|
+
tools.get('scout_search').execute({ caseId: 'edge', query: 'q' }, { agent: { id: 'session-a' } }),
|
|
1336
|
+
/provider down/,
|
|
1337
|
+
)
|
|
1338
|
+
|
|
1339
|
+
// non-array sources -> reported as an error, tool still succeeds
|
|
1340
|
+
const noArray = []
|
|
1341
|
+
apply({
|
|
1342
|
+
tools: { register(tool) { noArray.push(tool); return () => undefined } },
|
|
1343
|
+
web: { async search() { return { sources: 'nope' } } },
|
|
1344
|
+
effect(execute) { Array.from(execute()) },
|
|
1345
|
+
})
|
|
1346
|
+
const tools2 = new Map(noArray.map(t => [t.name, t]))
|
|
1347
|
+
await tools2.get('scout_start').execute({
|
|
1348
|
+
caseId: 'na', companyName: 'C', roleTitle: 'R', location: '',
|
|
1349
|
+
}, { agent: { id: 'session-a' } })
|
|
1350
|
+
const nonArray = JSON.parse(await tools2.get('scout_search').execute(
|
|
1351
|
+
{ caseId: 'na', query: 'q' },
|
|
1352
|
+
{ agent: { id: 'session-a' } },
|
|
1353
|
+
))
|
|
1354
|
+
assert.equal(nonArray.added.length, 0)
|
|
1355
|
+
assert.match(nonArray.errors[0].error, /non-array sources/)
|
|
1356
|
+
})
|