dsh-scout 0.9.0 → 0.9.2
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/CHANGELOG.md +91 -0
- package/README.en.md +237 -39
- package/README.md +131 -26
- package/assets/icon.svg +7 -0
- package/dist/i18n.d.ts +115 -0
- package/dist/i18n.js +299 -0
- package/dist/index.d.ts +14 -9
- package/dist/index.js +682 -206
- package/dist/model.d.ts +58 -8
- package/dist/model.js +260 -107
- package/docs/dsh-scout-product-contract.md +51 -5
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +15 -7
- package/scripts/check-release.mjs +219 -4
- package/scripts/profile-smoke.mjs +1118 -41
- package/tests/acceptance.test.mjs +1684 -0
- package/tests/model.test.mjs +496 -778
- package/tests/package.test.mjs +246 -0
- package/tests/tools.test.mjs +974 -0
package/tests/model.test.mjs
CHANGED
|
@@ -5,20 +5,30 @@ import {
|
|
|
5
5
|
addClaim,
|
|
6
6
|
addSource,
|
|
7
7
|
appendEvent,
|
|
8
|
+
CLAIM_DIMENSIONS,
|
|
9
|
+
computeCoverage,
|
|
8
10
|
createCase,
|
|
9
11
|
decideCase,
|
|
12
|
+
EVENT_TYPES,
|
|
13
|
+
explainDecision,
|
|
10
14
|
exportCaseFiles,
|
|
15
|
+
findSourceByUrl,
|
|
16
|
+
formatScoutString,
|
|
11
17
|
generateInterviewQuestions,
|
|
12
18
|
importCaseFromFiles,
|
|
13
19
|
inferEvidenceLevel,
|
|
14
20
|
inferSourceType,
|
|
21
|
+
isScoutLanguage,
|
|
15
22
|
isTrustedAuthorityUrl,
|
|
23
|
+
normalizeScoutLanguage,
|
|
24
|
+
normalizeSourceUrl,
|
|
16
25
|
renderComparison,
|
|
17
26
|
renderReport,
|
|
27
|
+
SCOUT_LANGUAGES,
|
|
28
|
+
scoutStrings,
|
|
18
29
|
verifyClaim,
|
|
19
30
|
verifyIdentity,
|
|
20
31
|
} from '../dist/model.js'
|
|
21
|
-
import { apply, Config, inject, name } from '../dist/index.js'
|
|
22
32
|
|
|
23
33
|
test('starts conservatively until company identity is verified', () => {
|
|
24
34
|
const scoutCase = createCase({
|
|
@@ -324,44 +334,6 @@ test('only proceeds after identity and all required role dimensions are verified
|
|
|
324
334
|
assert.equal(decideCase(scoutCase).decision, 'PROCEED')
|
|
325
335
|
})
|
|
326
336
|
|
|
327
|
-
test('isolates identical case ids by DSH agent session identity', async () => {
|
|
328
|
-
const registered = []
|
|
329
|
-
apply({
|
|
330
|
-
get(name) { return this[name] },
|
|
331
|
-
tools: {
|
|
332
|
-
register(tool) {
|
|
333
|
-
registered.push(tool)
|
|
334
|
-
return () => undefined
|
|
335
|
-
},
|
|
336
|
-
},
|
|
337
|
-
effect(execute) {
|
|
338
|
-
Array.from(execute())
|
|
339
|
-
},
|
|
340
|
-
})
|
|
341
|
-
const tools = new Map(registered.map(tool => [tool.name, tool]))
|
|
342
|
-
const start = tools.get('scout_start')
|
|
343
|
-
const report = tools.get('scout_report')
|
|
344
|
-
|
|
345
|
-
await start.execute({
|
|
346
|
-
caseId: 'same-id',
|
|
347
|
-
companyName: 'Company A',
|
|
348
|
-
roleTitle: 'Role A',
|
|
349
|
-
location: '',
|
|
350
|
-
}, { agent: { id: 'session-a' } })
|
|
351
|
-
await start.execute({
|
|
352
|
-
caseId: 'same-id',
|
|
353
|
-
companyName: 'Company B',
|
|
354
|
-
roleTitle: 'Role B',
|
|
355
|
-
location: '',
|
|
356
|
-
}, { agent: { id: 'session-b' } })
|
|
357
|
-
|
|
358
|
-
const reportA = await report.execute({ caseId: 'same-id' }, { agent: { id: 'session-a' } })
|
|
359
|
-
const reportB = await report.execute({ caseId: 'same-id' }, { agent: { id: 'session-b' } })
|
|
360
|
-
assert.match(reportA, /Company A — Role A/)
|
|
361
|
-
assert.doesNotMatch(reportA, /Company B/)
|
|
362
|
-
assert.match(reportB, /Company B — Role B/)
|
|
363
|
-
})
|
|
364
|
-
|
|
365
337
|
test('renders an evidence summary, source URLs, and a prioritized verification checklist', () => {
|
|
366
338
|
let scoutCase = createCase({
|
|
367
339
|
caseId: 'demo',
|
|
@@ -410,46 +382,6 @@ test('renders an evidence summary, source URLs, and a prioritized verification c
|
|
|
410
382
|
assert.match(checklist, /→ 下一步:Confirm the mandate/)
|
|
411
383
|
})
|
|
412
384
|
|
|
413
|
-
test('exports a disposable DSH plugin tool surface', async () => { const registered = []
|
|
414
|
-
const disposed = []
|
|
415
|
-
const effects = []
|
|
416
|
-
apply({
|
|
417
|
-
get(name) { return this[name] },
|
|
418
|
-
tools: {
|
|
419
|
-
register(tool) {
|
|
420
|
-
registered.push(tool)
|
|
421
|
-
return () => disposed.push(tool.name)
|
|
422
|
-
},
|
|
423
|
-
},
|
|
424
|
-
effect(execute) {
|
|
425
|
-
effects.push(...execute())
|
|
426
|
-
},
|
|
427
|
-
})
|
|
428
|
-
|
|
429
|
-
assert.equal(name, 'dsh-scout')
|
|
430
|
-
assert.deepEqual(inject, ['tools'])
|
|
431
|
-
assert.deepEqual(
|
|
432
|
-
registered.map(tool => tool.name).sort(),
|
|
433
|
-
[
|
|
434
|
-
'scout_add_claim',
|
|
435
|
-
'scout_add_source',
|
|
436
|
-
'scout_compare',
|
|
437
|
-
'scout_export',
|
|
438
|
-
'scout_import',
|
|
439
|
-
'scout_ingest',
|
|
440
|
-
'scout_questions',
|
|
441
|
-
'scout_report',
|
|
442
|
-
'scout_search',
|
|
443
|
-
'scout_start',
|
|
444
|
-
'scout_verify_claim',
|
|
445
|
-
'scout_verify_identity',
|
|
446
|
-
],
|
|
447
|
-
)
|
|
448
|
-
|
|
449
|
-
for (const dispose of effects.reverse()) dispose()
|
|
450
|
-
assert.deepEqual(disposed.sort(), registered.map(tool => tool.name).sort())
|
|
451
|
-
})
|
|
452
|
-
|
|
453
385
|
test('round-trips a case through the five-file export', () => {
|
|
454
386
|
let scoutCase = createCase({
|
|
455
387
|
caseId: 'demo',
|
|
@@ -506,88 +438,6 @@ test('rejects an incomplete or mismatched export payload', () => {
|
|
|
506
438
|
assert.throws(() => importCaseFromFiles({ ...intact, 'case.json': JSON.stringify({ schemaVersion: 'dsh-scout.case.v1' }) }), /Unsupported schema/)
|
|
507
439
|
})
|
|
508
440
|
|
|
509
|
-
test('exports and imports a case through the fs-backed tools', async () => {
|
|
510
|
-
const registered = []
|
|
511
|
-
const store = new Map()
|
|
512
|
-
const mockFs = {
|
|
513
|
-
async resolve(path) {
|
|
514
|
-
return { targetKey: path, displayPath: path }
|
|
515
|
-
},
|
|
516
|
-
async readText(target) {
|
|
517
|
-
if (!store.has(target.displayPath)) throw new Error(`not found: ${target.displayPath}`)
|
|
518
|
-
return store.get(target.displayPath)
|
|
519
|
-
},
|
|
520
|
-
async writeText(target, content) {
|
|
521
|
-
store.set(target.displayPath, content)
|
|
522
|
-
return { operation: 'create' }
|
|
523
|
-
},
|
|
524
|
-
}
|
|
525
|
-
apply({
|
|
526
|
-
get(name) { return this[name] },
|
|
527
|
-
tools: {
|
|
528
|
-
register(tool) {
|
|
529
|
-
registered.push(tool)
|
|
530
|
-
return () => undefined
|
|
531
|
-
},
|
|
532
|
-
},
|
|
533
|
-
fs: mockFs,
|
|
534
|
-
effect(execute) {
|
|
535
|
-
Array.from(execute())
|
|
536
|
-
},
|
|
537
|
-
})
|
|
538
|
-
const tools = new Map(registered.map(tool => [tool.name, tool]))
|
|
539
|
-
const start = tools.get('scout_start')
|
|
540
|
-
const addSourceTool = tools.get('scout_add_source')
|
|
541
|
-
const addClaimTool = tools.get('scout_add_claim')
|
|
542
|
-
const exportTool = tools.get('scout_export')
|
|
543
|
-
const importTool = tools.get('scout_import')
|
|
544
|
-
|
|
545
|
-
await start.execute({
|
|
546
|
-
caseId: 'persisted',
|
|
547
|
-
companyName: 'Example Co',
|
|
548
|
-
roleTitle: 'HR Head',
|
|
549
|
-
location: 'Shenzhen',
|
|
550
|
-
}, { agent: { id: 'session-a' } })
|
|
551
|
-
await addSourceTool.execute({
|
|
552
|
-
caseId: 'persisted',
|
|
553
|
-
sourceId: 'job-posting',
|
|
554
|
-
sourceType: 'job_posting',
|
|
555
|
-
title: 'Example job posting',
|
|
556
|
-
url: 'https://example.com/job',
|
|
557
|
-
evidenceLevel: 'E1',
|
|
558
|
-
}, { agent: { id: 'session-a' } })
|
|
559
|
-
await addClaimTool.execute({
|
|
560
|
-
caseId: 'persisted',
|
|
561
|
-
claimId: 'claim-1',
|
|
562
|
-
text: 'The role exists.',
|
|
563
|
-
status: 'reported',
|
|
564
|
-
evidenceLevel: 'E1',
|
|
565
|
-
dimension: 'role_existence',
|
|
566
|
-
impact: 'blocking',
|
|
567
|
-
sourceIds: 'job-posting',
|
|
568
|
-
confidenceNote: 'The job posting is a company-controlled source.',
|
|
569
|
-
nextAction: 'Confirm the mandate in the interview.',
|
|
570
|
-
}, { agent: { id: 'session-a' } })
|
|
571
|
-
|
|
572
|
-
const exportResult = JSON.parse(await exportTool.execute({
|
|
573
|
-
caseId: 'persisted',
|
|
574
|
-
targetDir: '/tmp/scout-cases/persisted',
|
|
575
|
-
}, { agent: { id: 'session-a' } }))
|
|
576
|
-
assert.equal(exportResult.files.length, 5)
|
|
577
|
-
assert.equal(store.size, 5)
|
|
578
|
-
assert.ok(store.has('/tmp/scout-cases/persisted/case.json'))
|
|
579
|
-
|
|
580
|
-
const importResult = JSON.parse(await importTool.execute({
|
|
581
|
-
caseId: 'persisted',
|
|
582
|
-
sourceDir: '/tmp/scout-cases/persisted',
|
|
583
|
-
}, { agent: { id: 'session-a' } }))
|
|
584
|
-
assert.equal(importResult.caseId, 'persisted')
|
|
585
|
-
assert.equal(importResult.sources.length, 1)
|
|
586
|
-
assert.equal(importResult.claims.length, 1)
|
|
587
|
-
assert.ok(importResult.events?.some(event => event.type === 'case_imported'))
|
|
588
|
-
assert.equal(importResult.decision, 'VERIFY')
|
|
589
|
-
})
|
|
590
|
-
|
|
591
441
|
test('generates a deduplicated, prioritized interview question list', () => {
|
|
592
442
|
let scoutCase = createCase({
|
|
593
443
|
caseId: 'demo',
|
|
@@ -630,149 +480,38 @@ test('generates a deduplicated, prioritized interview question list', () => {
|
|
|
630
480
|
assert.ok(questions.includes('Confirm the mandate in the interview.'))
|
|
631
481
|
assert.ok(questions.includes('Ask who this role reports to.'))
|
|
632
482
|
// missing verified mandate dimension gets its default question
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
483
|
+
const mandateQuestion = questions.findIndex(q => q.includes('最终决定权'))
|
|
484
|
+
assert.ok(mandateQuestion >= 0)
|
|
485
|
+
// 0.9.1 ordering: untouched dimensions rank after the missing required ones
|
|
486
|
+
const untouchedQuestion = questions.findIndex(q => q.includes('法定名称'))
|
|
487
|
+
assert.ok(
|
|
488
|
+
untouchedQuestion > mandateQuestion,
|
|
489
|
+
'untouched-dimension questions rank after missing required dimensions',
|
|
490
|
+
)
|
|
491
|
+
// dedupe and the 12-item cap keep holding
|
|
636
492
|
assert.equal(new Set(questions).size, questions.length)
|
|
637
|
-
assert.
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
const registered = []
|
|
642
|
-
const store = new Map()
|
|
643
|
-
const mockFs = {
|
|
644
|
-
async resolve(path) {
|
|
645
|
-
return { targetKey: path, displayPath: path }
|
|
646
|
-
},
|
|
647
|
-
async readText(target) {
|
|
648
|
-
if (!store.has(target.displayPath)) throw new Error(`not found: ${target.displayPath}`)
|
|
649
|
-
return store.get(target.displayPath)
|
|
650
|
-
},
|
|
651
|
-
async writeText(target, content) {
|
|
652
|
-
store.set(target.displayPath, content)
|
|
653
|
-
return { operation: 'create' }
|
|
654
|
-
},
|
|
655
|
-
}
|
|
656
|
-
apply({
|
|
657
|
-
get(name) { return this[name] },
|
|
658
|
-
tools: {
|
|
659
|
-
register(tool) {
|
|
660
|
-
registered.push(tool)
|
|
661
|
-
return () => undefined
|
|
662
|
-
},
|
|
663
|
-
},
|
|
664
|
-
fs: mockFs,
|
|
665
|
-
effect(execute) {
|
|
666
|
-
Array.from(execute())
|
|
667
|
-
},
|
|
668
|
-
}, { scoutDir: '/tmp/scout-cases', autoPersist: true })
|
|
669
|
-
const tools = new Map(registered.map(tool => [tool.name, tool]))
|
|
670
|
-
const start = tools.get('scout_start')
|
|
671
|
-
const exportTool = tools.get('scout_export')
|
|
672
|
-
|
|
673
|
-
await start.execute({
|
|
674
|
-
caseId: 'auto',
|
|
675
|
-
companyName: 'Example Co',
|
|
676
|
-
roleTitle: 'HR Head',
|
|
677
|
-
location: '',
|
|
678
|
-
}, { agent: { id: 'session-a' } })
|
|
679
|
-
|
|
680
|
-
// autoPersist wrote the five files under scoutDir/<caseId>
|
|
681
|
-
assert.ok(store.has('/tmp/scout-cases/auto/case.json'))
|
|
682
|
-
assert.ok(store.has('/tmp/scout-cases/auto/report.md'))
|
|
683
|
-
assert.equal(store.size, 5)
|
|
684
|
-
|
|
685
|
-
// export without targetDir uses the configured scoutDir/<caseId>
|
|
686
|
-
const exportResult = JSON.parse(await exportTool.execute({
|
|
687
|
-
caseId: 'auto',
|
|
688
|
-
}, { agent: { id: 'session-a' } }))
|
|
689
|
-
assert.equal(exportResult.targetDir, '/tmp/scout-cases/auto')
|
|
690
|
-
assert.equal(exportResult.persisted, true)
|
|
691
|
-
assert.equal(exportResult.files.length, 5)
|
|
692
|
-
})
|
|
693
|
-
|
|
694
|
-
test('scout_questions tool returns the derived question list', async () => {
|
|
695
|
-
const registered = []
|
|
696
|
-
apply({
|
|
697
|
-
get(name) { return this[name] },
|
|
698
|
-
tools: {
|
|
699
|
-
register(tool) {
|
|
700
|
-
registered.push(tool)
|
|
701
|
-
return () => undefined
|
|
702
|
-
},
|
|
703
|
-
},
|
|
704
|
-
effect(execute) {
|
|
705
|
-
Array.from(execute())
|
|
706
|
-
},
|
|
707
|
-
})
|
|
708
|
-
const tools = new Map(registered.map(tool => [tool.name, tool]))
|
|
709
|
-
const start = tools.get('scout_start')
|
|
710
|
-
const questions = tools.get('scout_questions')
|
|
711
|
-
await start.execute({
|
|
712
|
-
caseId: 'demo',
|
|
713
|
-
companyName: 'Example Co',
|
|
714
|
-
roleTitle: 'HR Head',
|
|
715
|
-
location: '',
|
|
716
|
-
}, { agent: { id: 'session-a' } })
|
|
717
|
-
const result = JSON.parse(await questions.execute({ caseId: 'demo' }, { agent: { id: 'session-a' } }))
|
|
718
|
-
assert.ok(Array.isArray(result))
|
|
719
|
-
assert.ok(result.length >= 5)
|
|
720
|
-
})
|
|
721
|
-
|
|
722
|
-
test('auto-persist and default export dir degrade gracefully without fs or scoutDir', async () => {
|
|
723
|
-
// autoPersist enabled but no fs service: mutations still succeed
|
|
724
|
-
const noFs = []
|
|
725
|
-
apply({
|
|
726
|
-
get(name) { return this[name] },
|
|
727
|
-
tools: { register(tool) { noFs.push(tool); return () => undefined } },
|
|
728
|
-
effect(execute) { Array.from(execute()) },
|
|
729
|
-
}, { scoutDir: '/nowhere', autoPersist: true })
|
|
730
|
-
const startNoFs = new Map(noFs.map(t => [t.name, t])).get('scout_start')
|
|
731
|
-
const started = JSON.parse(await startNoFs.execute({
|
|
732
|
-
caseId: 'no-fs',
|
|
733
|
-
companyName: 'Example Co',
|
|
734
|
-
roleTitle: 'HR Head',
|
|
735
|
-
location: '',
|
|
736
|
-
}, { agent: { id: 'session-a' } }))
|
|
737
|
-
assert.equal(started.caseId, 'no-fs')
|
|
738
|
-
assert.ok(started.events?.some(e => e.type === 'case_started'))
|
|
739
|
-
|
|
740
|
-
// no scoutDir configured: export without targetDir falls back to ./dsh-scout/<caseId>
|
|
741
|
-
const registered = []
|
|
742
|
-
const store = new Map()
|
|
743
|
-
const mockFs = {
|
|
744
|
-
async resolve(path) { return { targetKey: path, displayPath: path } },
|
|
745
|
-
async readText(target) {
|
|
746
|
-
if (!store.has(target.displayPath)) throw new Error(`not found: ${target.displayPath}`)
|
|
747
|
-
return store.get(target.displayPath)
|
|
748
|
-
},
|
|
749
|
-
async writeText(target, content) { store.set(target.displayPath, content); return { operation: 'create' } },
|
|
493
|
+
assert.equal(questions.length, 12)
|
|
494
|
+
// the curated questions reserve their full quota (task-7 budget rule)
|
|
495
|
+
for (const curated of scoutCase.interviewQuestions) {
|
|
496
|
+
assert.ok(questions.includes(curated), `curated question kept: ${curated}`)
|
|
750
497
|
}
|
|
751
|
-
apply({
|
|
752
|
-
get(name) { return this[name] },
|
|
753
|
-
tools: { register(tool) { registered.push(tool); return () => undefined } },
|
|
754
|
-
fs: mockFs,
|
|
755
|
-
effect(execute) { Array.from(execute()) },
|
|
756
|
-
})
|
|
757
|
-
const tools = new Map(registered.map(t => [t.name, t]))
|
|
758
|
-
await tools.get('scout_start').execute({
|
|
759
|
-
caseId: 'default-dir',
|
|
760
|
-
companyName: 'Example Co',
|
|
761
|
-
roleTitle: 'HR Head',
|
|
762
|
-
location: '',
|
|
763
|
-
}, { agent: { id: 'session-a' } })
|
|
764
|
-
const exported = JSON.parse(await tools.get('scout_export').execute(
|
|
765
|
-
{ caseId: 'default-dir' },
|
|
766
|
-
{ agent: { id: 'session-a' } },
|
|
767
|
-
))
|
|
768
|
-
assert.equal(exported.targetDir, 'dsh-scout/default-dir')
|
|
769
|
-
assert.ok(store.has('dsh-scout/default-dir/case.json'))
|
|
770
498
|
})
|
|
771
499
|
|
|
772
500
|
test('covers question-generation, duplicate-id, authority-url, and import edge cases', () => {
|
|
773
|
-
// empty case: 3
|
|
501
|
+
// empty case: the 3 required dimensions first, then the other 8 untouched
|
|
502
|
+
// dimensions, then defaults — deduplicated and capped at 12
|
|
774
503
|
const empty = createCase({ caseId: 'e', companyName: 'C', roleTitle: 'R' })
|
|
775
|
-
|
|
504
|
+
const emptyQuestions = generateInterviewQuestions(empty)
|
|
505
|
+
assert.equal(emptyQuestions.length, 12)
|
|
506
|
+
assert.deepEqual(emptyQuestions.slice(0, 3), [
|
|
507
|
+
'该岗位当前是否真实在招?招聘状态与到岗时间如何?',
|
|
508
|
+
'该岗位直接向谁汇报?虚线/实线如何划分?',
|
|
509
|
+
'该岗位在哪些事项上拥有最终决定权?团队与预算规模如何?',
|
|
510
|
+
])
|
|
511
|
+
assert.ok(emptyQuestions.includes(empty.interviewQuestions[0]))
|
|
512
|
+
for (const curated of empty.interviewQuestions) {
|
|
513
|
+
assert.ok(emptyQuestions.includes(curated), `curated question kept: ${curated}`)
|
|
514
|
+
}
|
|
776
515
|
|
|
777
516
|
// fully verified role dimensions: only the 5 defaults remain
|
|
778
517
|
let verifiedCase = createCase({ caseId: 'v', companyName: 'C', roleTitle: 'R' })
|
|
@@ -788,8 +527,12 @@ test('covers question-generation, duplicate-id, authority-url, and import edge c
|
|
|
788
527
|
impact: 'blocking', sourceIds: ['s1'], confidenceNote: 'direct', nextAction: 'done',
|
|
789
528
|
})
|
|
790
529
|
}
|
|
530
|
+
// role dimensions verified: the other 8 dimensions are still untouched, so
|
|
531
|
+
// their questions take the 7 head slots and all 5 curated questions follow
|
|
791
532
|
const verifiedQuestions = generateInterviewQuestions(verifiedCase)
|
|
792
|
-
assert.equal(verifiedQuestions.length,
|
|
533
|
+
assert.equal(verifiedQuestions.length, 12)
|
|
534
|
+
assert.deepEqual(verifiedQuestions.slice(-5), verifiedCase.interviewQuestions)
|
|
535
|
+
assert.ok(verifiedQuestions.includes('公司主体的法定名称、注册号与注册地是什么?如何与品牌对应?'))
|
|
793
536
|
|
|
794
537
|
// blank nextAction is filtered and the 12-item cap truncates
|
|
795
538
|
let capped = createCase({ caseId: 'c', companyName: 'C', roleTitle: 'R' })
|
|
@@ -840,6 +583,53 @@ test('covers question-generation, duplicate-id, authority-url, and import edge c
|
|
|
840
583
|
assert.throws(() => importCaseFromFiles({ ...files, 'case.json': '{not json' }), /Invalid case\.json/)
|
|
841
584
|
})
|
|
842
585
|
|
|
586
|
+
test('reserves the 12-item quota for curated questions', () => {
|
|
587
|
+
// fresh case: 11 deduplicated head questions, budget 7, plus all 5 curated
|
|
588
|
+
const fresh = createCase({ caseId: 'fresh', companyName: 'C', roleTitle: 'R' })
|
|
589
|
+
const freshQuestions = generateInterviewQuestions(fresh)
|
|
590
|
+
assert.equal(freshQuestions.length, 12)
|
|
591
|
+
assert.deepEqual(freshQuestions.slice(0, 3), [
|
|
592
|
+
'该岗位当前是否真实在招?招聘状态与到岗时间如何?',
|
|
593
|
+
'该岗位直接向谁汇报?虚线/实线如何划分?',
|
|
594
|
+
'该岗位在哪些事项上拥有最终决定权?团队与预算规模如何?',
|
|
595
|
+
])
|
|
596
|
+
assert.deepEqual(freshQuestions.slice(7), fresh.interviewQuestions)
|
|
597
|
+
|
|
598
|
+
// required role dimensions verified, the other 8 dimensions untouched
|
|
599
|
+
let requiredOnly = createCase({ caseId: 'required', companyName: 'C', roleTitle: 'R' })
|
|
600
|
+
requiredOnly = addSource(requiredOnly, {
|
|
601
|
+
sourceId: 's1', type: 'user_provided', title: 'notes', url: null,
|
|
602
|
+
capturedAt: '2026-08-16T00:00:00.000Z', evidenceLevel: 'E1', status: 'captured',
|
|
603
|
+
})
|
|
604
|
+
for (const [claimId, dimension] of [
|
|
605
|
+
['role', 'role_existence'], ['reporting', 'reporting_line'], ['mandate', 'mandate'],
|
|
606
|
+
]) {
|
|
607
|
+
requiredOnly = addClaim(requiredOnly, {
|
|
608
|
+
claimId, text: 'confirmed', status: 'verified', evidenceLevel: 'E1', dimension,
|
|
609
|
+
impact: 'blocking', sourceIds: ['s1'], confidenceNote: 'direct', nextAction: 'done',
|
|
610
|
+
})
|
|
611
|
+
}
|
|
612
|
+
const requiredQuestions = generateInterviewQuestions(requiredOnly)
|
|
613
|
+
assert.equal(requiredQuestions.length, 12)
|
|
614
|
+
assert.deepEqual(requiredQuestions.slice(7), requiredOnly.interviewQuestions)
|
|
615
|
+
|
|
616
|
+
// fully verified with no untouched dimension: exactly the 0.9.0 curated list
|
|
617
|
+
let complete = createCase({ caseId: 'complete', companyName: 'C', roleTitle: 'R' })
|
|
618
|
+
complete = addSource(complete, {
|
|
619
|
+
sourceId: 's1', type: 'user_provided', title: 'notes', url: null,
|
|
620
|
+
capturedAt: '2026-08-16T00:00:00.000Z', evidenceLevel: 'E1', status: 'captured',
|
|
621
|
+
})
|
|
622
|
+
for (const dimension of CLAIM_DIMENSIONS) {
|
|
623
|
+
complete = addClaim(complete, {
|
|
624
|
+
claimId: `c-${dimension}`, text: 'confirmed', status: 'verified', evidenceLevel: 'E1',
|
|
625
|
+
dimension, impact: 'informational', sourceIds: ['s1'],
|
|
626
|
+
confidenceNote: 'direct', nextAction: 'done',
|
|
627
|
+
})
|
|
628
|
+
}
|
|
629
|
+
assert.deepEqual(generateInterviewQuestions(complete), complete.interviewQuestions)
|
|
630
|
+
assert.equal(generateInterviewQuestions(complete).length, 5)
|
|
631
|
+
})
|
|
632
|
+
|
|
843
633
|
test('infers source type and evidence level from URLs', () => {
|
|
844
634
|
// official registry/regulator origins
|
|
845
635
|
assert.deepEqual(inferSourceType('https://www.gsxt.gov.cn/foo'), { type: 'company_registry', inferred: true })
|
|
@@ -858,99 +648,6 @@ test('infers source type and evidence level from URLs', () => {
|
|
|
858
648
|
assert.deepEqual(inferEvidenceLevel('https://example.com/', 'job_posting', 'E1'), { evidenceLevel: 'E1', inferred: false })
|
|
859
649
|
})
|
|
860
650
|
|
|
861
|
-
test('scout_ingest batch-registers sources with inference and isolates errors', async () => {
|
|
862
|
-
const registered = []
|
|
863
|
-
apply({
|
|
864
|
-
get(name) { return this[name] },
|
|
865
|
-
tools: { register(tool) { registered.push(tool); return () => undefined } },
|
|
866
|
-
effect(execute) { Array.from(execute()) },
|
|
867
|
-
})
|
|
868
|
-
const tools = new Map(registered.map(t => [t.name, t]))
|
|
869
|
-
await tools.get('scout_start').execute({
|
|
870
|
-
caseId: 'ingest',
|
|
871
|
-
companyName: 'Example Co',
|
|
872
|
-
roleTitle: 'HR Head',
|
|
873
|
-
location: '',
|
|
874
|
-
}, { agent: { id: 'session-a' } })
|
|
875
|
-
|
|
876
|
-
const result = JSON.parse(await tools.get('scout_ingest').execute({
|
|
877
|
-
caseId: 'ingest',
|
|
878
|
-
itemsJson: JSON.stringify([
|
|
879
|
-
{ url: 'https://www.gsxt.gov.cn/registry/1', title: 'Registry record' },
|
|
880
|
-
{ url: 'https://m.liepin.com/job/2.shtml' },
|
|
881
|
-
{ url: 'https://example.com/blog' },
|
|
882
|
-
{ url: 'https://not-e3.example.com/', sourceType: 'company_registry', evidenceLevel: 'E3' },
|
|
883
|
-
{ url: '' },
|
|
884
|
-
]),
|
|
885
|
-
}, { agent: { id: 'session-a' } }))
|
|
886
|
-
|
|
887
|
-
assert.equal(result.added.length, 3)
|
|
888
|
-
assert.equal(result.errors.length, 2)
|
|
889
|
-
assert.deepEqual(result.added[0], {
|
|
890
|
-
sourceId: 'src-1', title: 'Registry record', url: 'https://www.gsxt.gov.cn/registry/1',
|
|
891
|
-
type: 'company_registry', evidenceLevel: 'E3',
|
|
892
|
-
inferred: { type: true, evidenceLevel: true },
|
|
893
|
-
})
|
|
894
|
-
assert.equal(result.added[1].type, 'job_posting')
|
|
895
|
-
assert.equal(result.added[1].evidenceLevel, 'E2')
|
|
896
|
-
assert.equal(result.added[2].type, 'other')
|
|
897
|
-
// the invalid E3 item is isolated in errors and does not abort the batch
|
|
898
|
-
assert.match(result.errors[0].error, /E3 sources require/)
|
|
899
|
-
assert.equal(result.errors[1].error, 'url is required')
|
|
900
|
-
|
|
901
|
-
// sources are registered in the case and carry events
|
|
902
|
-
const report = await tools.get('scout_report').execute({ caseId: 'ingest' }, { agent: { id: 'session-a' } })
|
|
903
|
-
assert.match(report, /src-1/)
|
|
904
|
-
assert.match(report, /src-3/)
|
|
905
|
-
assert.doesNotMatch(report, /src-4/)
|
|
906
|
-
})
|
|
907
|
-
|
|
908
|
-
test('scout_ingest isolates null items and invalid explicit enums, and avoids substring false positives', async () => {
|
|
909
|
-
// substring false positives are gone
|
|
910
|
-
assert.deepEqual(inferSourceType('https://notaboss.example.com/'), { type: 'other', inferred: true })
|
|
911
|
-
assert.deepEqual(inferSourceType('https://www.zhipin.com/job/1.html'), { type: 'job_posting', inferred: true })
|
|
912
|
-
assert.deepEqual(inferSourceType('https://bambulab.jobs.feishu.cn/position/1'), { type: 'job_posting', inferred: true })
|
|
913
|
-
assert.deepEqual(inferSourceType('https://www.liepin.com/job/1.shtml'), { type: 'job_posting', inferred: true })
|
|
914
|
-
|
|
915
|
-
const registered = []
|
|
916
|
-
apply({
|
|
917
|
-
get(name) { return this[name] },
|
|
918
|
-
tools: { register(tool) { registered.push(tool); return () => undefined } },
|
|
919
|
-
effect(execute) { Array.from(execute()) },
|
|
920
|
-
})
|
|
921
|
-
const tools = new Map(registered.map(t => [t.name, t]))
|
|
922
|
-
await tools.get('scout_start').execute({
|
|
923
|
-
caseId: 'robust', companyName: 'Example Co', roleTitle: 'HR Head', location: '',
|
|
924
|
-
}, { agent: { id: 'session-a' } })
|
|
925
|
-
|
|
926
|
-
const result = JSON.parse(await tools.get('scout_ingest').execute({
|
|
927
|
-
caseId: 'robust',
|
|
928
|
-
itemsJson: JSON.stringify([
|
|
929
|
-
null,
|
|
930
|
-
{ url: 'https://m.liepin.com/job/1.shtml' },
|
|
931
|
-
{ url: 'https://www.gsxt.gov.cn/registry/1' },
|
|
932
|
-
{ url: 'https://example.com/x', sourceType: 'regulatory_typo' },
|
|
933
|
-
{ url: 'https://example.com/y', evidenceLevel: 'E9' },
|
|
934
|
-
]),
|
|
935
|
-
}, { agent: { id: 'session-a' } }))
|
|
936
|
-
|
|
937
|
-
// null item and invalid explicit enums are isolated; valid items still registered
|
|
938
|
-
assert.equal(result.added.length, 2)
|
|
939
|
-
assert.equal(result.errors.length, 3)
|
|
940
|
-
assert.equal(result.errors[0].error, 'item must be an object')
|
|
941
|
-
assert.match(result.errors[1].error, /invalid sourceType/)
|
|
942
|
-
assert.match(result.errors[2].error, /invalid evidenceLevel/)
|
|
943
|
-
assert.equal(result.added[0].type, 'job_posting')
|
|
944
|
-
assert.equal(result.added[1].type, 'company_registry')
|
|
945
|
-
assert.equal(result.added[1].evidenceLevel, 'E3')
|
|
946
|
-
|
|
947
|
-
// the previously added sources survive the batch
|
|
948
|
-
const report = await tools.get('scout_report').execute({ caseId: 'robust' }, { agent: { id: 'session-a' } })
|
|
949
|
-
assert.match(report, /src-1/)
|
|
950
|
-
assert.match(report, /src-2/)
|
|
951
|
-
assert.doesNotMatch(report, /src-3/)
|
|
952
|
-
})
|
|
953
|
-
|
|
954
651
|
test('renders a side-by-side comparison of two cases', () => {
|
|
955
652
|
const makeCase = (caseId, companyName, verifiedIdentity) => {
|
|
956
653
|
let scoutCase = createCase({
|
|
@@ -1001,421 +698,442 @@ test('renders a side-by-side comparison of two cases', () => {
|
|
|
1001
698
|
assert.throws(() => renderComparison(five), /at most five/)
|
|
1002
699
|
})
|
|
1003
700
|
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
assert.deepEqual(inferEvidenceLevel('https://example.com/', 'job_posting', 'E9'), { evidenceLevel: 'E2', inferred: true })
|
|
701
|
+
// ---------------------------------------------------------------------------
|
|
702
|
+
// 0.9.1 model layer: languages, coverage, decision reason codes, source lookup
|
|
703
|
+
// ---------------------------------------------------------------------------
|
|
1008
704
|
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
705
|
+
/** Deterministic case reused by the byte-for-byte zh-CN regression baseline. */
|
|
706
|
+
function legacyCase() {
|
|
707
|
+
let scoutCase = createCase({ caseId: 'legacy', companyName: 'Legacy Co', roleTitle: 'HR Head' })
|
|
708
|
+
scoutCase = addSource(scoutCase, {
|
|
709
|
+
sourceId: 'job-posting', type: 'job_posting', title: 'Legacy job posting',
|
|
710
|
+
url: 'https://example.com/job', capturedAt: '2026-08-16T00:00:00.000Z',
|
|
711
|
+
evidenceLevel: 'E1', status: 'captured',
|
|
1014
712
|
})
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
713
|
+
scoutCase = addClaim(scoutCase, {
|
|
714
|
+
claimId: 'claim-1', text: 'The role may own the people roadmap.', status: 'reported',
|
|
715
|
+
evidenceLevel: 'E1', dimension: 'mandate', impact: 'blocking', sourceIds: ['job-posting'],
|
|
716
|
+
confidenceNote: 'Only the job posting says so.', nextAction: 'Confirm the mandate in the interview.',
|
|
717
|
+
})
|
|
718
|
+
return decideCase(scoutCase)
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// Captured verbatim from dsh-scout 0.9.0 (HEAD 3406a4e) before the 0.9.1
|
|
722
|
+
// Coverage block existed. Only that block may be inserted: any other byte
|
|
723
|
+
// change to the zh-CN report is a regression.
|
|
724
|
+
const LEGACY_REPORT_GOLDEN = [
|
|
725
|
+
"# Legacy Co — HR Head",
|
|
726
|
+
"",
|
|
727
|
+
"- 判断:**VERIFY**",
|
|
728
|
+
"- 理由:公司主体仍未核验,不能直接推进。",
|
|
729
|
+
"- 目标:判断是否值得进入下一轮,并准备面试反问",
|
|
730
|
+
"- 证据概况:1 reported",
|
|
731
|
+
"",
|
|
732
|
+
"## Key supporting evidence (up to 3)",
|
|
733
|
+
"",
|
|
734
|
+
"- 暂无已核验的支持证据",
|
|
735
|
+
"",
|
|
736
|
+
"## Blocking or unresolved risks (up to 3)",
|
|
737
|
+
"",
|
|
738
|
+
"- **reported / E1 / blocking** The role may own the people roadmap.(维度:mandate;来源:job-posting;置信说明:Only the job posting says so.;下一步:Confirm the mandate in the interview.)",
|
|
739
|
+
"",
|
|
740
|
+
"## Role task and authority hypotheses",
|
|
741
|
+
"",
|
|
742
|
+
"- **reported / E1 / blocking** The role may own the people roadmap.(维度:mandate;来源:job-posting;置信说明:Only the job posting says so.;下一步:Confirm the mandate in the interview.)",
|
|
743
|
+
"",
|
|
744
|
+
"## Verification checklist",
|
|
745
|
+
"",
|
|
746
|
+
"- [blocking] claim-1: The role may own the people roadmap. → 下一步:Confirm the mandate in the interview.",
|
|
747
|
+
"",
|
|
748
|
+
"## Claim ledger",
|
|
749
|
+
"",
|
|
750
|
+
"- **reported / E1 / blocking** The role may own the people roadmap.(维度:mandate;来源:job-posting;置信说明:Only the job posting says so.;下一步:Confirm the mandate in the interview.)",
|
|
751
|
+
"",
|
|
752
|
+
"## Sources",
|
|
753
|
+
"",
|
|
754
|
+
"- job-posting: Legacy job posting [job_posting / E1] — https://example.com/job",
|
|
755
|
+
"",
|
|
756
|
+
"## Interview questions",
|
|
757
|
+
"",
|
|
758
|
+
"- 这个岗位在前三个月最重要的业务结果是什么?",
|
|
759
|
+
"- 该岗位直接向谁汇报,在哪些事项上拥有最终决定权?",
|
|
760
|
+
"- 现有团队、预算和外部供应商资源分别是什么?",
|
|
761
|
+
"- 当前最影响业务增长的组织问题是什么?",
|
|
762
|
+
"- 六个月后,哪些可观察结果代表这个岗位取得成功?",
|
|
763
|
+
"",
|
|
764
|
+
"> 本报告只表达当前证据边界内的判断,不替代法律、投资或医疗意见。",
|
|
765
|
+
"",
|
|
766
|
+
].join('\n')
|
|
767
|
+
|
|
768
|
+
test('keeps the zh-CN report byte-identical apart from the Coverage block', () => {
|
|
769
|
+
const report = renderReport(legacyCase())
|
|
770
|
+
const lines = report.split('\n')
|
|
771
|
+
const start = lines.indexOf('## Coverage')
|
|
772
|
+
assert.ok(start > 0, 'the Coverage block is present')
|
|
773
|
+
let end = start + 1
|
|
774
|
+
while (end < lines.length && !lines[end].startsWith('## ')) end += 1
|
|
775
|
+
const withoutCoverage = [...lines.slice(0, start), ...lines.slice(end)].join('\n')
|
|
776
|
+
assert.equal(withoutCoverage, LEGACY_REPORT_GOLDEN)
|
|
777
|
+
assert.match(report, /## Coverage\n\n- identity/)
|
|
1028
778
|
})
|
|
1029
779
|
|
|
1030
|
-
test('
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
get(name) { return this[name] },
|
|
1034
|
-
tools: { register(tool) { registered.push(tool); return () => undefined } },
|
|
1035
|
-
effect(execute) { Array.from(execute()) },
|
|
780
|
+
test('renders an English report and English questions, defaulting to the case language', () => {
|
|
781
|
+
let scoutCase = createCase({
|
|
782
|
+
caseId: 'en', companyName: 'English Co', roleTitle: 'CTO', language: 'en',
|
|
1036
783
|
})
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
784
|
+
assert.equal(scoutCase.language, 'en')
|
|
785
|
+
assert.match(scoutCase.decisionReason, /not been verified yet/)
|
|
786
|
+
scoutCase = addSource(scoutCase, {
|
|
787
|
+
sourceId: 'job-posting', type: 'job_posting', title: 'English job posting',
|
|
788
|
+
url: 'https://example.com/job', capturedAt: '2026-08-16T00:00:00.000Z',
|
|
789
|
+
evidenceLevel: 'E1', status: 'captured',
|
|
790
|
+
})
|
|
791
|
+
scoutCase = addClaim(scoutCase, {
|
|
792
|
+
claimId: 'claim-1', text: 'The role may own the people roadmap.', status: 'reported',
|
|
793
|
+
evidenceLevel: 'E1', dimension: 'mandate', impact: 'blocking', sourceIds: ['job-posting'],
|
|
794
|
+
confidenceNote: 'Only the job posting says so.', nextAction: 'Confirm the mandate in the interview.',
|
|
795
|
+
})
|
|
796
|
+
scoutCase = decideCase(scoutCase)
|
|
1048
797
|
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
)
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
/No case ids provided/,
|
|
1058
|
-
)
|
|
1059
|
-
// missing case -> error hints at the agent session boundary
|
|
1060
|
-
await assert.rejects(
|
|
1061
|
-
compare.execute({ caseIds: 'a, missing' }, { agent: { id: 'session-a' } }),
|
|
1062
|
-
/Unknown case: missing \(not found in this agent session\)/,
|
|
1063
|
-
)
|
|
798
|
+
const report = renderReport(scoutCase)
|
|
799
|
+
assert.match(report, /- Decision: \*\*VERIFY\*\*/)
|
|
800
|
+
assert.match(report, /- Reason: The company identity is not verified yet/)
|
|
801
|
+
assert.match(report, /- identity \(Company identity\): verified 0 \/ open 0 \/ untouched: yes/)
|
|
802
|
+
assert.match(report, /- mandate \(Mandate and authority\): verified 0 \/ open 1 \/ untouched: no/)
|
|
803
|
+
assert.match(report, /Untouched dimensions \(10\):/)
|
|
804
|
+
assert.match(report, /The role may own the people roadmap\. \(dimension: mandate; sources: job-posting/)
|
|
805
|
+
assert.doesNotMatch(report, /[\u4e00-\u9fff]/, 'the English report contains no CJK characters')
|
|
1064
806
|
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
807
|
+
const questions = generateInterviewQuestions(scoutCase)
|
|
808
|
+
assert.equal(questions.length, 12)
|
|
809
|
+
assert.equal(questions[0], 'Confirm the mandate in the interview.')
|
|
810
|
+
assert.ok(questions.includes('Is this role actually open right now, and what are the hiring status and start date?'))
|
|
811
|
+
assert.ok(questions.every(question => !/[\u4e00-\u9fff]/.test(question)))
|
|
812
|
+
|
|
813
|
+
// an explicit option overrides the case language in either direction
|
|
814
|
+
const zhReport = renderReport(scoutCase, { language: 'zh-CN' })
|
|
815
|
+
assert.match(zhReport, /- 判断:\*\*VERIFY\*\*/)
|
|
816
|
+
assert.doesNotMatch(zhReport, /- Decision:/)
|
|
817
|
+
const zhQuestions = generateInterviewQuestions(scoutCase, { language: 'zh-CN' })
|
|
818
|
+
assert.ok(zhQuestions.includes('公司主体的法定名称、注册号与注册地是什么?如何与品牌对应?'))
|
|
819
|
+
// the comparison report follows the same language resolution
|
|
820
|
+
const comparison = renderComparison([
|
|
821
|
+
scoutCase,
|
|
822
|
+
createCase({ caseId: 'en-2', companyName: 'Other Co', roleTitle: 'CTO', language: 'en' }),
|
|
1069
823
|
])
|
|
1070
|
-
assert.match(
|
|
1071
|
-
assert.
|
|
824
|
+
assert.match(comparison, /# Company \/ role comparison \(2 cases\)/)
|
|
825
|
+
assert.match(comparison, /\| Case \| Decision \| Identity \| Claims \|/)
|
|
826
|
+
assert.match(comparison, /## Interview questions \(merged and deduplicated, first 12\)/)
|
|
827
|
+
assert.doesNotMatch(comparison, /[\u4e00-\u9fff]/, 'the English comparison contains no CJK characters')
|
|
828
|
+
// newly created cases default to zh-CN
|
|
829
|
+
assert.equal(createCase({ caseId: 'z', companyName: 'C', roleTitle: 'R' }).language, 'zh-CN')
|
|
1072
830
|
})
|
|
1073
831
|
|
|
1074
|
-
test('
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
assert.
|
|
1078
|
-
assert.deepEqual(
|
|
1079
|
-
|
|
1080
|
-
const registered = []
|
|
1081
|
-
apply({
|
|
1082
|
-
get(name) { return this[name] },
|
|
1083
|
-
tools: { register(tool) { registered.push(tool); return () => undefined } },
|
|
1084
|
-
effect(execute) { Array.from(execute()) },
|
|
1085
|
-
})
|
|
1086
|
-
const tools = new Map(registered.map(t => [t.name, t]))
|
|
1087
|
-
await tools.get('scout_start').execute({
|
|
1088
|
-
caseId: 'draft', companyName: 'Example Co', roleTitle: 'HR Head', location: '',
|
|
1089
|
-
}, { agent: { id: 'session-a' } })
|
|
1090
|
-
|
|
1091
|
-
const result = JSON.parse(await tools.get('scout_ingest').execute({
|
|
1092
|
-
caseId: 'draft',
|
|
1093
|
-
itemsJson: JSON.stringify([
|
|
1094
|
-
{
|
|
1095
|
-
url: 'https://m.liepin.com/job/1.shtml',
|
|
1096
|
-
claim: { text: '该岗位月薪 13k–26k。', dimension: 'financing_and_commercialization', impact: 'material' },
|
|
1097
|
-
},
|
|
1098
|
-
{
|
|
1099
|
-
url: 'https://www.gsxt.gov.cn/registry/1',
|
|
1100
|
-
claim: { text: '主体为深圳某科技有限公司。', dimension: 'identity', status: 'verified', evidenceLevel: 'E3' },
|
|
1101
|
-
},
|
|
1102
|
-
// claim missing dimension -> source kept, claim rejected
|
|
1103
|
-
{ url: 'https://example.com/a', claim: { text: 'no dimension' } },
|
|
1104
|
-
// claim evidence level exceeds the E2 source -> claim rejected, source kept
|
|
1105
|
-
{ url: 'https://example.com/b', claim: { text: 'overstated', dimension: 'risk', evidenceLevel: 'E3' } },
|
|
1106
|
-
]),
|
|
1107
|
-
}, { agent: { id: 'session-a' } }))
|
|
1108
|
-
|
|
1109
|
-
assert.equal(result.added.length, 4)
|
|
1110
|
-
assert.equal(result.errors.length, 2)
|
|
1111
|
-
// claim defaults: reported / material / source evidence level
|
|
1112
|
-
assert.deepEqual(result.added[0].claims, ['claim-1'])
|
|
1113
|
-
// verified claim on an E3 registry source is accepted
|
|
1114
|
-
assert.deepEqual(result.added[1].claims, ['claim-2'])
|
|
1115
|
-
// source survived even though its claim was rejected
|
|
1116
|
-
assert.equal(result.added[2].claims, undefined)
|
|
1117
|
-
assert.match(result.errors[0].error, /invalid claim\.dimension/)
|
|
1118
|
-
assert.match(result.errors[1].error, /exceeds its strongest source/)
|
|
1119
|
-
|
|
1120
|
-
// claims landed in the case with source links and events
|
|
1121
|
-
const report = await tools.get('scout_report').execute({ caseId: 'draft' }, { agent: { id: 'session-a' } })
|
|
1122
|
-
assert.match(report, /该岗位月薪 13k–26k。/)
|
|
1123
|
-
assert.match(report, /主体为深圳某科技有限公司。/)
|
|
1124
|
-
assert.doesNotMatch(report, /no dimension/)
|
|
1125
|
-
})
|
|
832
|
+
test('computes coverage for all 11 dimensions, including untouched ones', () => {
|
|
833
|
+
let scoutCase = createCase({ caseId: 'coverage', companyName: 'Coverage Co', roleTitle: 'HR Head' })
|
|
834
|
+
const emptyCoverage = computeCoverage(scoutCase)
|
|
835
|
+
assert.equal(emptyCoverage.length, 11)
|
|
836
|
+
assert.deepEqual(emptyCoverage.map(item => item.dimension), [...CLAIM_DIMENSIONS])
|
|
837
|
+
assert.ok(emptyCoverage.every(item => item.untouched && item.verified === 0 && item.open === 0))
|
|
1126
838
|
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
839
|
+
scoutCase = addSource(scoutCase, {
|
|
840
|
+
sourceId: 'interview', type: 'user_provided', title: 'Interview notes',
|
|
841
|
+
url: 'https://example.com/notes', capturedAt: '2026-08-16T00:00:00.000Z',
|
|
842
|
+
evidenceLevel: 'E1', status: 'captured',
|
|
843
|
+
})
|
|
844
|
+
scoutCase = addClaim(scoutCase, {
|
|
845
|
+
claimId: 'identity-claim', text: 'The legal entity matches the brand.', status: 'verified',
|
|
846
|
+
evidenceLevel: 'E1', dimension: 'identity', impact: 'material', sourceIds: ['interview'],
|
|
847
|
+
confidenceNote: 'Confirmed by the interview.', nextAction: 'Use it in later searches.',
|
|
848
|
+
})
|
|
849
|
+
scoutCase = addClaim(scoutCase, {
|
|
850
|
+
claimId: 'risk-claim', text: 'A litigation risk is reported.', status: 'reported',
|
|
851
|
+
evidenceLevel: 'E1', dimension: 'risk', impact: 'material', sourceIds: ['interview'],
|
|
852
|
+
confidenceNote: 'Reported by one person.', nextAction: 'Check the court docket.',
|
|
1133
853
|
})
|
|
1134
|
-
const tools = new Map(registered.map(t => [t.name, t]))
|
|
1135
|
-
await tools.get('scout_start').execute({
|
|
1136
|
-
caseId: 'enum', companyName: 'Example Co', roleTitle: 'HR Head', location: '',
|
|
1137
|
-
}, { agent: { id: 'session-a' } })
|
|
1138
|
-
|
|
1139
|
-
// pre-seed a claim so the next generated id must skip claim-1
|
|
1140
|
-
await tools.get('scout_add_claim').execute({
|
|
1141
|
-
caseId: 'enum',
|
|
1142
|
-
claimId: 'claim-1',
|
|
1143
|
-
text: 'pre-seeded',
|
|
1144
|
-
status: 'unknown',
|
|
1145
|
-
evidenceLevel: 'E0',
|
|
1146
|
-
dimension: 'risk',
|
|
1147
|
-
impact: 'material',
|
|
1148
|
-
sourceIds: '',
|
|
1149
|
-
confidenceNote: 'none',
|
|
1150
|
-
nextAction: 'verify',
|
|
1151
|
-
}, { agent: { id: 'session-a' } })
|
|
1152
|
-
|
|
1153
|
-
const result = JSON.parse(await tools.get('scout_ingest').execute({
|
|
1154
|
-
caseId: 'enum',
|
|
1155
|
-
itemsJson: JSON.stringify([
|
|
1156
|
-
{ url: 'https://m.liepin.com/job/1.shtml', claim: { text: 'ok claim', dimension: 'risk', status: 'contradictedd' } },
|
|
1157
|
-
{ url: 'https://m.liepin.com/job/2.shtml', claim: { text: 'bad impact', dimension: 'risk', impact: 'huge' } },
|
|
1158
|
-
{ url: 'https://m.liepin.com/job/3.shtml', claim: { text: 'bad level', dimension: 'risk', evidenceLevel: 'E9' } },
|
|
1159
|
-
{ url: 'https://m.liepin.com/job/4.shtml', claim: { text: 'good claim', dimension: 'mandate' } },
|
|
1160
|
-
]),
|
|
1161
|
-
}, { agent: { id: 'session-a' } }))
|
|
1162
|
-
|
|
1163
|
-
// all four sources registered; three claims rejected with explicit enum errors
|
|
1164
|
-
assert.equal(result.added.length, 4)
|
|
1165
|
-
assert.equal(result.errors.length, 3)
|
|
1166
|
-
assert.match(result.errors[0].error, /invalid claim\.status: contradictedd/)
|
|
1167
|
-
assert.match(result.errors[1].error, /invalid claim\.impact: huge/)
|
|
1168
|
-
assert.match(result.errors[2].error, /invalid claim\.evidenceLevel: E9/)
|
|
1169
|
-
// the valid claim skipped the pre-seeded claim-1
|
|
1170
|
-
assert.deepEqual(result.added[3].claims, ['claim-2'])
|
|
1171
|
-
})
|
|
1172
854
|
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
assert.deepEqual(
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
apply({
|
|
1183
|
-
get(name) { return this[name] },
|
|
1184
|
-
tools: { register(tool) { registered.push(tool); return () => undefined } },
|
|
1185
|
-
fs: {
|
|
1186
|
-
async resolve(path) { return { targetKey: path, displayPath: path } },
|
|
1187
|
-
async readText(target) { return store.get(target.displayPath) },
|
|
1188
|
-
async writeText(target, content) { store.set(target.displayPath, content); return { operation: 'create' } },
|
|
1189
|
-
},
|
|
1190
|
-
effect(execute) { Array.from(execute()) },
|
|
855
|
+
const coverage = computeCoverage(scoutCase)
|
|
856
|
+
assert.deepEqual(coverage.find(item => item.dimension === 'identity'), {
|
|
857
|
+
dimension: 'identity', verified: 1, open: 0, untouched: false,
|
|
858
|
+
})
|
|
859
|
+
assert.deepEqual(coverage.find(item => item.dimension === 'risk'), {
|
|
860
|
+
dimension: 'risk', verified: 0, open: 1, untouched: false,
|
|
861
|
+
})
|
|
862
|
+
assert.deepEqual(coverage.find(item => item.dimension === 'mandate'), {
|
|
863
|
+
dimension: 'mandate', verified: 0, open: 0, untouched: true,
|
|
1191
864
|
})
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
{ url: 'https://example.com/e0', sourceType: 'other', evidenceLevel: 'E0', claim: { text: 'no evidence', dimension: 'risk', status: 'verified', evidenceLevel: 'E0' } },
|
|
1202
|
-
{ url: 'https://m.liepin.com/job/2.shtml', claim: { text: 'event payload check', dimension: 'mandate' } },
|
|
1203
|
-
]),
|
|
1204
|
-
}, { agent: { id: 'session-a' } }))
|
|
1205
|
-
|
|
1206
|
-
// non-object claim and verified-E0 claim are rejected; sources survive
|
|
1207
|
-
assert.equal(result.added.length, 3)
|
|
1208
|
-
assert.equal(result.errors.length, 2)
|
|
1209
|
-
assert.match(result.errors[0].error, /claim must be an object/)
|
|
1210
|
-
assert.match(result.errors[1].error, /at least E1 evidence/)
|
|
1211
|
-
assert.equal(result.added[1].claims, undefined) // E0 verified claim not attached
|
|
1212
|
-
assert.deepEqual(result.added[2].claims, ['claim-2'])
|
|
1213
|
-
|
|
1214
|
-
// claim_added event detail carries text and sourceIds in the export
|
|
1215
|
-
await tools.get('scout_export').execute(
|
|
1216
|
-
{ caseId: 'edge' },
|
|
1217
|
-
{ agent: { id: 'session-a' } },
|
|
865
|
+
|
|
866
|
+
const report = renderReport(scoutCase)
|
|
867
|
+
assert.match(report, /## Coverage/)
|
|
868
|
+
assert.ok(report.indexOf('## Coverage') < report.indexOf('## Key supporting evidence'))
|
|
869
|
+
assert.match(report, /- identity(主体身份):已核验 1 \/ 待核验 0 \/ 未调查:否/)
|
|
870
|
+
assert.match(report, /- mandate(授权与职责):已核验 0 \/ 待核验 0 \/ 未调查:是/)
|
|
871
|
+
assert.match(
|
|
872
|
+
report,
|
|
873
|
+
/未调查维度(9):role_existence、reporting_line、mandate、company_and_founder、product_and_technology、market_and_competition、financing_and_commercialization、organization_and_hr、role_fit/,
|
|
1218
874
|
)
|
|
1219
|
-
|
|
1220
|
-
assert.ok(
|
|
1221
|
-
|
|
875
|
+
// coverage drives the question list
|
|
876
|
+
assert.ok(generateInterviewQuestions(scoutCase)
|
|
877
|
+
.includes('该岗位当前是否真实在招?招聘状态与到岗时间如何?'))
|
|
1222
878
|
})
|
|
1223
879
|
|
|
1224
|
-
test('
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
tools: { register(tool) { registered.push(tool); return () => undefined } },
|
|
1230
|
-
web: {
|
|
1231
|
-
async search(request) {
|
|
1232
|
-
searched.push(request)
|
|
1233
|
-
return {
|
|
1234
|
-
content: 'answer snippet',
|
|
1235
|
-
truncated: false,
|
|
1236
|
-
sources: [
|
|
1237
|
-
{ url: 'https://www.gsxt.gov.cn/registry/9', title: 'Registry' },
|
|
1238
|
-
{ url: 'https://m.liepin.com/job/7.shtml' },
|
|
1239
|
-
{ url: 'https://example.com/unknown', snippet: 'a snippet' },
|
|
1240
|
-
],
|
|
1241
|
-
}
|
|
1242
|
-
},
|
|
1243
|
-
},
|
|
1244
|
-
effect(execute) { Array.from(execute()) },
|
|
880
|
+
test('finds a source by normalized URL and rejects empty or invalid URLs', () => {
|
|
881
|
+
let scoutCase = createCase({ caseId: 'url', companyName: 'C', roleTitle: 'R' })
|
|
882
|
+
scoutCase = addSource(scoutCase, {
|
|
883
|
+
sourceId: 's1', type: 'job_posting', title: 'posting', url: 'https://Example.COM/job/',
|
|
884
|
+
capturedAt: null, evidenceLevel: 'E1', status: 'captured',
|
|
1245
885
|
})
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
}, { agent: { id: 'session-a' } })
|
|
1250
|
-
|
|
1251
|
-
const result = JSON.parse(await tools.get('scout_search').execute({
|
|
1252
|
-
caseId: 'search',
|
|
1253
|
-
query: 'Example Co company registry',
|
|
1254
|
-
limit: 10,
|
|
1255
|
-
}, { agent: { id: 'session-a' } }))
|
|
1256
|
-
|
|
1257
|
-
assert.equal(searched.length, 1)
|
|
1258
|
-
assert.equal(searched[0].query, 'Example Co company registry')
|
|
1259
|
-
assert.equal(searched[0].maxResults, 10)
|
|
1260
|
-
assert.equal(result.added.length, 3)
|
|
1261
|
-
assert.equal(result.errors.length, 0)
|
|
1262
|
-
assert.equal(result.added[0].type, 'company_registry')
|
|
1263
|
-
assert.equal(result.added[0].evidenceLevel, 'E3')
|
|
1264
|
-
assert.equal(result.added[1].type, 'job_posting')
|
|
1265
|
-
assert.equal(result.added[2].snippet, 'a snippet')
|
|
1266
|
-
assert.equal(result.content, 'answer snippet')
|
|
1267
|
-
assert.equal(result.truncated, false)
|
|
1268
|
-
|
|
1269
|
-
// sources landed in the case
|
|
1270
|
-
const report = await tools.get('scout_report').execute({ caseId: 'search' }, { agent: { id: 'session-a' } })
|
|
1271
|
-
assert.match(report, /src-1/)
|
|
1272
|
-
assert.match(report, /src-3/)
|
|
1273
|
-
|
|
1274
|
-
// web service missing -> explicit error
|
|
1275
|
-
const noWeb = []
|
|
1276
|
-
apply({
|
|
1277
|
-
get(name) { return this[name] },
|
|
1278
|
-
tools: { register(tool) { noWeb.push(tool); return () => undefined } },
|
|
1279
|
-
effect(execute) { Array.from(execute()) },
|
|
886
|
+
scoutCase = addSource(scoutCase, {
|
|
887
|
+
sourceId: 's2', type: 'other', title: 'no url', url: null,
|
|
888
|
+
capturedAt: null, evidenceLevel: 'E1', status: 'captured',
|
|
1280
889
|
})
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
)
|
|
890
|
+
|
|
891
|
+
assert.equal(findSourceByUrl(scoutCase, 'https://example.com/job')?.sourceId, 's1')
|
|
892
|
+
assert.equal(findSourceByUrl(scoutCase, ' https://EXAMPLE.com/job// ')?.sourceId, 's1')
|
|
893
|
+
assert.equal(findSourceByUrl(scoutCase, 'https://example.com/job#section')?.sourceId, 's1')
|
|
894
|
+
assert.equal(findSourceByUrl(scoutCase, 'https://example.com/job?x=1'), undefined)
|
|
895
|
+
assert.equal(findSourceByUrl(scoutCase, 'https://example.com/other'), undefined)
|
|
896
|
+
assert.equal(findSourceByUrl(scoutCase, ''), undefined)
|
|
897
|
+
assert.equal(findSourceByUrl(scoutCase, ' '), undefined)
|
|
898
|
+
assert.equal(findSourceByUrl(scoutCase, 'not a url'), undefined)
|
|
899
|
+
assert.equal(findSourceByUrl(scoutCase, 'example.com/job'), undefined)
|
|
900
|
+
|
|
901
|
+
assert.equal(normalizeSourceUrl('https://Example.com/job/'), 'https://example.com/job')
|
|
902
|
+
assert.equal(normalizeSourceUrl(' https://example.com '), 'https://example.com')
|
|
903
|
+
assert.equal(normalizeSourceUrl(null), undefined)
|
|
904
|
+
assert.equal(normalizeSourceUrl(''), undefined)
|
|
1289
905
|
})
|
|
1290
906
|
|
|
1291
|
-
test('
|
|
1292
|
-
const
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
907
|
+
test('recomputes decision reason codes from subject and claims', () => {
|
|
908
|
+
const empty = createCase({ caseId: 'r', companyName: 'C', roleTitle: 'R' })
|
|
909
|
+
assert.equal(explainDecision(empty), 'identity')
|
|
910
|
+
assert.equal(decideCase(empty).decisionReasonCode, 'identity')
|
|
911
|
+
assert.equal(decideCase(empty).decisionReason, '公司主体仍未核验,不能直接推进。')
|
|
912
|
+
|
|
913
|
+
const identified = (caseId) => {
|
|
914
|
+
let scoutCase = createCase({ caseId, companyName: 'C', roleTitle: 'R' })
|
|
915
|
+
scoutCase = addSource(scoutCase, {
|
|
916
|
+
sourceId: 'registry', type: 'company_registry', title: 'registry',
|
|
917
|
+
url: 'https://www.gsxt.gov.cn/registry/REG-1', capturedAt: '2026-08-16T00:00:00.000Z',
|
|
918
|
+
evidenceLevel: 'E3', status: 'captured',
|
|
919
|
+
})
|
|
920
|
+
return verifyIdentity(scoutCase, {
|
|
921
|
+
legalEntity: 'C Ltd', registrationNumber: 'REG-1', registeredRegion: 'Shenzhen',
|
|
922
|
+
legalRepresentative: 'P', brandRelationship: 'brand', sourceIds: ['registry'],
|
|
923
|
+
})
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
let unresolved = identified('u')
|
|
927
|
+
unresolved = addClaim(unresolved, {
|
|
928
|
+
claimId: 'c1', text: 'The role exists.', status: 'reported', evidenceLevel: 'E3',
|
|
929
|
+
dimension: 'role_existence', impact: 'blocking', sourceIds: ['registry'],
|
|
930
|
+
confidenceNote: 'registry', nextAction: 'confirm in the interview',
|
|
1314
931
|
})
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
932
|
+
assert.equal(explainDecision(unresolved), 'unresolved')
|
|
933
|
+
assert.equal(decideCase(unresolved).decision, 'VERIFY')
|
|
934
|
+
assert.equal(decideCase(unresolved).decisionReason, '仍有阻断级或重要结论未完成核验,不能直接推进。')
|
|
935
|
+
|
|
936
|
+
const missing = identified('m')
|
|
937
|
+
assert.equal(explainDecision(missing), 'missing_dimension')
|
|
938
|
+
assert.equal(decideCase(missing).decision, 'VERIFY')
|
|
939
|
+
assert.equal(decideCase(missing).decisionReason, '缺少已核验的必需维度:role_existence。')
|
|
940
|
+
|
|
941
|
+
let proceed = identified('p')
|
|
942
|
+
for (const [claimId, dimension] of [
|
|
943
|
+
['role', 'role_existence'], ['reporting', 'reporting_line'], ['mandate', 'mandate'],
|
|
944
|
+
]) {
|
|
945
|
+
proceed = addClaim(proceed, {
|
|
946
|
+
claimId, text: 'confirmed', status: 'verified', evidenceLevel: 'E3', dimension,
|
|
947
|
+
impact: 'blocking', sourceIds: ['registry'], confidenceNote: 'registry', nextAction: 'done',
|
|
948
|
+
})
|
|
1326
949
|
}
|
|
1327
|
-
assert.
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
950
|
+
assert.equal(explainDecision(proceed), 'proceed')
|
|
951
|
+
const decided = decideCase(proceed)
|
|
952
|
+
assert.equal(decided.decision, 'PROCEED')
|
|
953
|
+
assert.equal(decided.decisionReasonCode, 'proceed')
|
|
954
|
+
assert.equal(decided.decisionReason, '当前关键结论均有来源支撑,建议进入下一轮并继续现场核验。')
|
|
955
|
+
|
|
956
|
+
let stop = identified('s')
|
|
957
|
+
stop = addClaim(stop, {
|
|
958
|
+
claimId: 'c1', text: 'The role is closed.', status: 'contradicted', evidenceLevel: 'E3',
|
|
959
|
+
dimension: 'role_existence', impact: 'blocking', sourceIds: ['registry'],
|
|
960
|
+
confidenceNote: 'contradicts the posting', nextAction: 'resolve the conflict',
|
|
961
|
+
})
|
|
962
|
+
assert.equal(explainDecision(stop), 'unresolved')
|
|
963
|
+
const stopped = decideCase(stop)
|
|
964
|
+
assert.equal(stopped.decision, 'STOP')
|
|
965
|
+
assert.equal(stopped.decisionReasonCode, 'unresolved')
|
|
966
|
+
assert.equal(stopped.decisionReason, '存在相互矛盾的关键事实,需先解决冲突。')
|
|
967
|
+
|
|
968
|
+
// the stored reason is localized from the case language
|
|
969
|
+
const english = decideCase(identified('en'), { language: 'en' })
|
|
970
|
+
assert.equal(english.decisionReasonCode, 'missing_dimension')
|
|
971
|
+
assert.equal(english.decisionReason, 'Missing verified required dimension: role_existence.')
|
|
972
|
+
})
|
|
1333
973
|
|
|
1334
|
-
|
|
1335
|
-
const
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
)
|
|
974
|
+
test('imports a legacy five-file export without the new optional fields', () => {
|
|
975
|
+
const legacyCaseJson = {
|
|
976
|
+
schemaVersion: 'dsh-scout.case.v0',
|
|
977
|
+
caseId: 'legacy',
|
|
978
|
+
title: 'Legacy Co — HR Head',
|
|
979
|
+
language: 'zh-CN',
|
|
980
|
+
decisionObjective: '判断是否值得进入下一轮,并准备面试反问',
|
|
981
|
+
subject: { name: 'Legacy Co', identitySourceIds: [], identityStatus: 'needs_verification' },
|
|
982
|
+
role: { title: 'HR Head', fitQuestion: '该岗位是否拥有足够的业务授权,值得继续推进?' },
|
|
983
|
+
decision: 'VERIFY',
|
|
984
|
+
decisionReason: '尚未完成公司主体和岗位授权核验。',
|
|
985
|
+
interviewQuestions: ['第一个问题?'],
|
|
986
|
+
}
|
|
987
|
+
const payload = {
|
|
988
|
+
'case.json': JSON.stringify(legacyCaseJson),
|
|
989
|
+
'sources.json': '[]',
|
|
990
|
+
'claims.json': '[]',
|
|
991
|
+
'events.jsonl': '',
|
|
992
|
+
}
|
|
993
|
+
const restored = importCaseFromFiles(payload)
|
|
994
|
+
assert.equal(restored.caseId, 'legacy')
|
|
995
|
+
assert.equal(restored.language, 'zh-CN')
|
|
996
|
+
assert.equal(restored.decisionReasonCode, undefined)
|
|
997
|
+
assert.equal('decisionReasonCode' in restored, false)
|
|
998
|
+
assert.deepEqual(restored.interviewQuestions, ['第一个问题?'])
|
|
999
|
+
assert.match(renderReport(restored), /## Coverage/)
|
|
1000
|
+
|
|
1001
|
+
// a payload that predates the language field still imports as zh-CN
|
|
1002
|
+
const withoutLanguage = importCaseFromFiles({
|
|
1003
|
+
...payload,
|
|
1004
|
+
'case.json': JSON.stringify({ ...legacyCaseJson, language: undefined }),
|
|
1005
|
+
})
|
|
1006
|
+
assert.equal(withoutLanguage.language, 'zh-CN')
|
|
1007
|
+
|
|
1008
|
+
// the new reason code round-trips, and an unknown code is dropped
|
|
1009
|
+
const decided = decideCase(legacyCase())
|
|
1010
|
+
assert.equal(decided.decisionReasonCode, 'identity')
|
|
1011
|
+
const files = exportCaseFiles(decided)
|
|
1012
|
+
assert.match(files['case.json'], /"decisionReasonCode": "identity"/)
|
|
1013
|
+
const roundTripped = importCaseFromFiles(files)
|
|
1014
|
+
assert.equal(roundTripped.decisionReasonCode, 'identity')
|
|
1015
|
+
assert.equal(roundTripped.decision, decided.decision)
|
|
1016
|
+
const bogus = importCaseFromFiles({
|
|
1017
|
+
...files,
|
|
1018
|
+
'case.json': files['case.json'].replace('"decisionReasonCode": "identity"', '"decisionReasonCode": "bogus"'),
|
|
1019
|
+
})
|
|
1020
|
+
assert.equal(bogus.decisionReasonCode, undefined)
|
|
1355
1021
|
|
|
1356
|
-
//
|
|
1357
|
-
const
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1022
|
+
// an undecided case stays free of the optional field
|
|
1023
|
+
const legacyFiles = exportCaseFiles(createCase({ caseId: 'l', companyName: 'C', roleTitle: 'R' }))
|
|
1024
|
+
assert.doesNotMatch(legacyFiles['case.json'], /decisionReasonCode/)
|
|
1025
|
+
})
|
|
1026
|
+
|
|
1027
|
+
test('accepts a case_rehydrated event through the closed event union', () => {
|
|
1028
|
+
assert.ok(EVENT_TYPES.includes('case_rehydrated'))
|
|
1029
|
+
let scoutCase = createCase({ caseId: 'rehydrate', companyName: 'C', roleTitle: 'R' })
|
|
1030
|
+
scoutCase = appendEvent(scoutCase, {
|
|
1031
|
+
eventId: 'evt-rehydrated', type: 'case_rehydrated', at: '2026-08-16T00:00:00.000Z',
|
|
1032
|
+
detail: { dir: '/tmp/dsh-scout/rehydrate' },
|
|
1363
1033
|
})
|
|
1364
|
-
|
|
1365
|
-
await tools2.get('scout_start').execute({
|
|
1366
|
-
caseId: 'na', companyName: 'C', roleTitle: 'R', location: '',
|
|
1367
|
-
}, { agent: { id: 'session-a' } })
|
|
1368
|
-
const nonArray = JSON.parse(await tools2.get('scout_search').execute(
|
|
1369
|
-
{ caseId: 'na', query: 'q' },
|
|
1370
|
-
{ agent: { id: 'session-a' } },
|
|
1371
|
-
))
|
|
1372
|
-
assert.equal(nonArray.added.length, 0)
|
|
1373
|
-
assert.match(nonArray.errors[0].error, /non-array sources/)
|
|
1034
|
+
assert.equal(scoutCase.events?.at(-1)?.type, 'case_rehydrated')
|
|
1374
1035
|
})
|
|
1375
1036
|
|
|
1376
|
-
test('
|
|
1377
|
-
|
|
1378
|
-
assert.equal(
|
|
1379
|
-
assert.equal(
|
|
1380
|
-
assert.
|
|
1381
|
-
assert.
|
|
1037
|
+
test('exposes the language dictionary and resolution helpers', () => {
|
|
1038
|
+
assert.deepEqual([...SCOUT_LANGUAGES], ['zh-CN', 'en'])
|
|
1039
|
+
assert.equal(isScoutLanguage('en'), true)
|
|
1040
|
+
assert.equal(isScoutLanguage('fr'), false)
|
|
1041
|
+
assert.equal(isScoutLanguage(undefined), false)
|
|
1042
|
+
assert.equal(normalizeScoutLanguage('en'), 'en')
|
|
1043
|
+
assert.equal(normalizeScoutLanguage('fr'), 'zh-CN')
|
|
1044
|
+
assert.equal(normalizeScoutLanguage(null), 'zh-CN')
|
|
1045
|
+
|
|
1046
|
+
const zh = scoutStrings(undefined)
|
|
1047
|
+
const en = scoutStrings('en')
|
|
1048
|
+
assert.equal(zh.language, 'zh-CN')
|
|
1049
|
+
assert.equal(en.language, 'en')
|
|
1050
|
+
// an unsupported runtime value falls back to the default table
|
|
1051
|
+
assert.equal(scoutStrings('fr').language, 'zh-CN')
|
|
1052
|
+
assert.equal(zh.statuses.verified, '已核验')
|
|
1053
|
+
assert.equal(en.statuses.needs_verification, 'needs verification')
|
|
1054
|
+
assert.equal(zh.impacts.blocking, '阻断级')
|
|
1055
|
+
assert.equal(en.evidenceLevels.E3, 'E3 (authoritative source)')
|
|
1056
|
+
assert.equal(zh.dimensions.financing_and_commercialization, '融资与商业化')
|
|
1057
|
+
assert.equal(en.dimensions.financing_and_commercialization, 'Financing and commercialization')
|
|
1058
|
+
assert.equal(zh.decisions.PROCEED, '继续推进')
|
|
1059
|
+
assert.equal(en.decisions.PROCEED, 'proceed')
|
|
1060
|
+
assert.equal(zh.defaults.interviewQuestions.length, 5)
|
|
1061
|
+
assert.equal(en.defaults.interviewQuestions.length, 5)
|
|
1062
|
+
// template filling leaves unknown slots verbatim
|
|
1063
|
+
assert.equal(formatScoutString('- {a} / {b}', { a: 1 }), '- 1 / {b}')
|
|
1064
|
+
assert.equal(formatScoutString('- 判断:{decision}', { decision: 'VERIFY' }), '- 判断:VERIFY')
|
|
1382
1065
|
})
|
|
1383
1066
|
|
|
1384
|
-
test('
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1067
|
+
test('renders the exported report.md in the requested language', () => {
|
|
1068
|
+
// an en case exports an en report.md by default
|
|
1069
|
+
const enCase = createCase({ caseId: 'export-en', companyName: 'English Co', roleTitle: 'CTO', language: 'en' })
|
|
1070
|
+
assert.equal(exportCaseFiles(enCase)['report.md'], renderReport(enCase))
|
|
1071
|
+
assert.doesNotMatch(exportCaseFiles(enCase)['report.md'], /[\u4e00-\u9fff]/)
|
|
1072
|
+
|
|
1073
|
+
// a zh case can be exported in the requested language without touching case.json
|
|
1074
|
+
const zhCase = legacyCase()
|
|
1075
|
+
const zhFiles = exportCaseFiles(zhCase)
|
|
1076
|
+
assert.equal(zhFiles['report.md'], renderReport(zhCase))
|
|
1077
|
+
assert.match(zhFiles['report.md'], /^- 判断:\*\*VERIFY\*\*$/m)
|
|
1078
|
+
const enFiles = exportCaseFiles(zhCase, { language: 'en' })
|
|
1079
|
+
assert.equal(enFiles['report.md'], renderReport(zhCase, { language: 'en' }))
|
|
1080
|
+
// the framework text follows the requested language; stored case data
|
|
1081
|
+
// (claim text, decisionReason, curated questions) is not translated
|
|
1082
|
+
assert.match(enFiles['report.md'], /^- Decision: \*\*VERIFY\*\*$/m)
|
|
1083
|
+
assert.match(enFiles['report.md'], /^Untouched dimensions \(10\):/m)
|
|
1084
|
+
assert.doesNotMatch(enFiles['report.md'], /- 判断:/)
|
|
1085
|
+
assert.equal(
|
|
1086
|
+
JSON.parse(enFiles['case.json']).language,
|
|
1087
|
+
'zh-CN',
|
|
1088
|
+
'case.json keeps the language the case was created in',
|
|
1089
|
+
)
|
|
1090
|
+
assert.equal(enFiles['case.json'], zhFiles['case.json'])
|
|
1091
|
+
|
|
1092
|
+
// and an en case can be exported back to zh-CN
|
|
1093
|
+
assert.equal(
|
|
1094
|
+
exportCaseFiles(enCase, { language: 'zh-CN' })['report.md'],
|
|
1095
|
+
renderReport(enCase, { language: 'zh-CN' }),
|
|
1096
|
+
)
|
|
1097
|
+
})
|
|
1098
|
+
|
|
1099
|
+
test('keeps the ingest draft defaults in both language tables', () => {
|
|
1100
|
+
const zh = scoutStrings('zh-CN')
|
|
1101
|
+
const en = scoutStrings('en')
|
|
1102
|
+
for (const table of [zh, en]) {
|
|
1103
|
+
assert.ok(table.ingest.autoConfidenceNote.length > 0)
|
|
1104
|
+
assert.ok(table.ingest.autoNextAction.length > 0)
|
|
1105
|
+
}
|
|
1106
|
+
// the zh-CN template keeps reproducing the pre-0.9.1 literal
|
|
1107
|
+
assert.match(zh.ingest.autoConfidenceNote, /\{sourceId\}/)
|
|
1108
|
+
assert.equal(
|
|
1109
|
+
formatScoutString(zh.ingest.autoConfidenceNote, { sourceId: 'src-1' }),
|
|
1110
|
+
'由采集项自动登记,来源 src-1',
|
|
1111
|
+
)
|
|
1112
|
+
assert.equal(zh.ingest.autoNextAction, '核验该主张并补充独立来源')
|
|
1113
|
+
// the English table carries no CJK and keeps the source placeholder
|
|
1114
|
+
assert.match(en.ingest.autoConfidenceNote, /\{sourceId\}/)
|
|
1115
|
+
assert.doesNotMatch(en.ingest.autoConfidenceNote, /[\u4e00-\u9fff]/)
|
|
1116
|
+
assert.doesNotMatch(en.ingest.autoNextAction, /[\u4e00-\u9fff]/)
|
|
1117
|
+
assert.equal(
|
|
1118
|
+
formatScoutString(en.ingest.autoConfidenceNote, { sourceId: 'src-1' }),
|
|
1119
|
+
'Auto-registered from an ingested item; source src-1',
|
|
1120
|
+
)
|
|
1121
|
+
|
|
1122
|
+
// the documented draft defaults keep an English report fully English
|
|
1123
|
+
let ingestCase = createCase({
|
|
1124
|
+
caseId: 'ingest-en', companyName: 'Example Co', roleTitle: 'HR Head', language: 'en',
|
|
1399
1125
|
})
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
sourceType: 'company_registry',
|
|
1414
|
-
title: 'Custom registry',
|
|
1415
|
-
url: 'https://registry.example-registry.example/rec/1',
|
|
1416
|
-
evidenceLevel: 'E3',
|
|
1417
|
-
}, { agent: { id: 'session-a' } })
|
|
1418
|
-
assert.match(result, /"evidenceLevel": "E3"/)
|
|
1419
|
-
// The default trust boundary still rejects the same origin without the override.
|
|
1420
|
-
assert.equal(isTrustedAuthorityUrl('https://registry.example-registry.example/rec/1'), false)
|
|
1126
|
+
ingestCase = addSource(ingestCase, {
|
|
1127
|
+
sourceId: 'src-1', type: 'job_posting', title: 'Report A', url: 'https://example.com/a',
|
|
1128
|
+
capturedAt: null, evidenceLevel: 'E1', status: 'captured',
|
|
1129
|
+
})
|
|
1130
|
+
ingestCase = addClaim(ingestCase, {
|
|
1131
|
+
claimId: 'claim-1', text: 'The role exists.', status: 'reported', evidenceLevel: 'E1',
|
|
1132
|
+
dimension: 'role_existence', impact: 'material', sourceIds: ['src-1'],
|
|
1133
|
+
confidenceNote: formatScoutString(en.ingest.autoConfidenceNote, { sourceId: 'src-1' }),
|
|
1134
|
+
nextAction: en.ingest.autoNextAction,
|
|
1135
|
+
})
|
|
1136
|
+
ingestCase = decideCase(ingestCase)
|
|
1137
|
+
assert.doesNotMatch(renderReport(ingestCase), /[\u4e00-\u9fff]/)
|
|
1138
|
+
assert.ok(generateInterviewQuestions(ingestCase).every(question => !/[\u4e00-\u9fff]/.test(question)))
|
|
1421
1139
|
})
|