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.
@@ -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
- assert.ok(questions.some(q => q.includes('最终决定权')))
634
- // defaults are appended and deduplicated
635
- assert.ok(questions.includes(scoutCase.interviewQuestions[0]))
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.ok(questions.length <= 12)
638
- })
639
-
640
- test('auto-persists each mutation when configured, and exports to the default dir', async () => {
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 missing-dimension questions + 5 defaults = 8
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
- assert.equal(generateInterviewQuestions(empty).length, 8)
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, 5)
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
- test('scout_compare tool compares cases and inference rejects invalid explicit enums', async () => {
1005
- // model-layer enum defense (review L3)
1006
- assert.deepEqual(inferSourceType('https://example.com/', 'not_a_type'), { type: 'other', inferred: true })
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
- const registered = []
1010
- apply({
1011
- get(name) { return this[name] },
1012
- tools: { register(tool) { registered.push(tool); return () => undefined } },
1013
- effect(execute) { Array.from(execute()) },
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
- const tools = new Map(registered.map(t => [t.name, t]))
1016
- for (const [caseId, company] of [['a', 'Company A'], ['b', 'Company B']]) {
1017
- await tools.get('scout_start').execute({
1018
- caseId, companyName: company, roleTitle: 'HR Head', location: '',
1019
- }, { agent: { id: 'session-a' } })
1020
- }
1021
- const result = await tools.get('scout_compare').execute(
1022
- { caseIds: 'a, b' },
1023
- { agent: { id: 'session-a' } },
1024
- )
1025
- assert.match(result, /公司\/岗位对比(2 个案例)/)
1026
- assert.match(result, /Company A/)
1027
- assert.match(result, /Company B/)
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('scout_compare deduplicates case ids and empty claims render cleanly', async () => {
1031
- const registered = []
1032
- apply({
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
- const tools = new Map(registered.map(t => [t.name, t]))
1038
- for (const [caseId, company] of [['a', 'Company A'], ['b', 'Company B']]) {
1039
- await tools.get('scout_start').execute({
1040
- caseId, companyName: company, roleTitle: 'HR Head', location: '',
1041
- }, { agent: { id: 'session-a' } })
1042
- }
1043
- const compare = tools.get('scout_compare')
1044
-
1045
- // duplicate ids are deduplicated: 'a,a,b' -> a, b
1046
- const deduped = await compare.execute({ caseIds: 'a, a, b' }, { agent: { id: 'session-a' } })
1047
- assert.match(deduped, /公司\/岗位对比(2 个案例)/)
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
- // only one distinct id -> explicit error
1050
- await assert.rejects(
1051
- compare.execute({ caseIds: 'a, a' }, { agent: { id: 'session-a' } }),
1052
- /at least two distinct case ids/,
1053
- )
1054
- // blank ids -> explicit error
1055
- await assert.rejects(
1056
- compare.execute({ caseIds: ' , ' }, { agent: { id: 'session-a' } }),
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
- // empty claims render without an empty parentheses suffix
1066
- const empty = renderComparison([
1067
- createCase({ caseId: 'x', companyName: 'X', roleTitle: 'R' }),
1068
- createCase({ caseId: 'y', companyName: 'Y', roleTitle: 'R' }),
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(empty, /主张 0 条\n/)
1071
- assert.doesNotMatch(empty, /主张 0 条()/)
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('scout_ingest drafts claims from items alongside sources', async () => {
1075
- // foreign registries classify as company_registry
1076
- assert.deepEqual(inferSourceType('https://w2.brreg.no/company/1'), { type: 'company_registry', inferred: true })
1077
- assert.deepEqual(inferSourceType('https://www.handelsregister.de/rp_web/mask.do'), { type: 'company_registry', inferred: true })
1078
- assert.deepEqual(inferSourceType('https://www1.hkexnews.hk/search/titlesearch.xhtml'), { type: 'company_registry', inferred: true })
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
- test('scout_ingest rejects invalid claim enums explicitly and keeps claim ids collision-safe', async () => {
1128
- const registered = []
1129
- apply({
1130
- get(name) { return this[name] },
1131
- tools: { register(tool) { registered.push(tool); return () => undefined } },
1132
- effect(execute) { Array.from(execute()) },
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
- test('covers registry boundaries, non-object claims, and verified-E0 rejection via claim drafts', async () => {
1174
- // registry classification boundaries
1175
- assert.deepEqual(inferSourceType('https://brreg.no/'), { type: 'company_registry', inferred: true })
1176
- assert.deepEqual(inferSourceType('https://www.sse.com.cn/'), { type: 'company_registry', inferred: true })
1177
- assert.deepEqual(inferSourceType('https://www.szse.cn/'), { type: 'company_registry', inferred: true })
1178
- assert.deepEqual(inferSourceType('https://brreg.no.evil.com/'), { type: 'other', inferred: true })
1179
-
1180
- const registered = []
1181
- const store = new Map()
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
- const tools = new Map(registered.map(t => [t.name, t]))
1193
- await tools.get('scout_start').execute({
1194
- caseId: 'edge', companyName: 'Example Co', roleTitle: 'HR Head', location: '',
1195
- }, { agent: { id: 'session-a' } })
1196
-
1197
- const result = JSON.parse(await tools.get('scout_ingest').execute({
1198
- caseId: 'edge',
1199
- itemsJson: JSON.stringify([
1200
- { url: 'https://m.liepin.com/job/1.shtml', claim: 'not-an-object' },
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
- const events = store.get('dsh-scout/edge/events.jsonl')
1220
- assert.ok(events.includes('"text":"event payload check"'))
1221
- assert.ok(events.includes('"sourceIds":["src-3"]'))
875
+ // coverage drives the question list
876
+ assert.ok(generateInterviewQuestions(scoutCase)
877
+ .includes('该岗位当前是否真实在招?招聘状态与到岗时间如何?'))
1222
878
  })
1223
879
 
1224
- test('scout_search registers provider results as sources and requires the web service', async () => {
1225
- const registered = []
1226
- const searched = []
1227
- apply({
1228
- get(name) { return this[name] },
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
- const tools = new Map(registered.map(t => [t.name, t]))
1247
- await tools.get('scout_start').execute({
1248
- caseId: 'search', companyName: 'Example Co', roleTitle: 'HR Head', location: '',
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
- const toolsNoWeb = new Map(noWeb.map(t => [t.name, t]))
1282
- await toolsNoWeb.get('scout_start').execute({
1283
- caseId: 'x', companyName: 'C', roleTitle: 'R', location: '',
1284
- }, { agent: { id: 'session-b' } })
1285
- await assert.rejects(
1286
- toolsNoWeb.get('scout_search').execute({ caseId: 'x', query: 'q' }, { agent: { id: 'session-b' } }),
1287
- /Web search service is unavailable/,
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('scout_search handles provider edge cases and limit bounds', async () => {
1292
- const registered = []
1293
- const providerCalls = []
1294
- let failSearch = false
1295
- apply({
1296
- get(name) { return this[name] },
1297
- tools: { register(tool) { registered.push(tool); return () => undefined } },
1298
- web: {
1299
- async search(request) {
1300
- if (failSearch) throw new Error('provider down')
1301
- providerCalls.push(request)
1302
- return {
1303
- sources: [
1304
- null,
1305
- { url: 123 },
1306
- { url: '' },
1307
- { url: 'https://m.liepin.com/job/1.shtml', publishedAt: '2026-08-16' },
1308
- ],
1309
- truncated: undefined,
1310
- }
1311
- },
1312
- },
1313
- effect(execute) { Array.from(execute()) },
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
- const tools = new Map(registered.map(t => [t.name, t]))
1316
- await tools.get('scout_start').execute({
1317
- caseId: 'edge', companyName: 'Example Co', roleTitle: 'HR Head', location: '',
1318
- }, { agent: { id: 'session-a' } })
1319
-
1320
- // limit: 0 / -1 normalize to default 5, 100 caps at 10
1321
- for (const [limit, expected] of [[0, 5], [-1, 5], [100, 10]]) {
1322
- await tools.get('scout_search').execute(
1323
- { caseId: 'edge', query: 'q', limit },
1324
- { agent: { id: 'session-a' } },
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.deepEqual(providerCalls.map(c => c.maxResults), [5, 5, 10])
1328
- // NaN is rejected by the tool-layer argument validation
1329
- await assert.rejects(
1330
- tools.get('scout_search').execute({ caseId: 'edge', query: 'q', limit: NaN }, { agent: { id: 'session-a' } }),
1331
- /finite JSON number/,
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
- // malformed sources are isolated; valid ones register with publishedAt
1335
- const result = JSON.parse(await tools.get('scout_search').execute(
1336
- { caseId: 'edge', query: 'q' },
1337
- { agent: { id: 'session-a' } },
1338
- ))
1339
- assert.equal(result.errors.length, 3)
1340
- assert.match(result.errors[0].error, /result 0 must be an object/)
1341
- assert.match(result.errors[1].error, /result 1 missing url/)
1342
- assert.match(result.errors[2].error, /result 2 missing url/)
1343
- assert.equal(result.added.length, 1)
1344
- assert.equal(result.added[0].type, 'job_posting')
1345
- assert.equal(result.added[0].publishedAt, '2026-08-16')
1346
- // truncated missing -> false, never dropped from the payload
1347
- assert.equal(result.truncated, false)
1348
-
1349
- // provider throwing -> tool rejects with the provider error
1350
- failSearch = true
1351
- await assert.rejects(
1352
- tools.get('scout_search').execute({ caseId: 'edge', query: 'q' }, { agent: { id: 'session-a' } }),
1353
- /provider down/,
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
- // non-array sources -> reported as an error, tool still succeeds
1357
- const noArray = []
1358
- apply({
1359
- get(name) { return this[name] },
1360
- tools: { register(tool) { noArray.push(tool); return () => undefined } },
1361
- web: { async search() { return { sources: 'nope' } } },
1362
- effect(execute) { Array.from(execute()) },
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
- const tools2 = new Map(noArray.map(t => [t.name, t]))
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('exports a schemastery Config with sensible defaults', () => {
1377
- const parsed = Config({})
1378
- assert.equal(parsed.scoutDir, 'dsh-scout')
1379
- assert.equal(parsed.autoPersist, false)
1380
- assert.ok(parsed.authorityHostSuffixes.includes('gov.cn'))
1381
- assert.ok(parsed.authorityHostSuffixes.includes('gsxt.gov.cn') === false)
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('custom authority host suffixes extend the E3 trust boundary', async () => {
1385
- const registered = []
1386
- apply({
1387
- get(name) { return this[name] },
1388
- tools: {
1389
- register(tool) {
1390
- registered.push(tool)
1391
- return () => undefined
1392
- },
1393
- },
1394
- effect(execute) {
1395
- Array.from(execute())
1396
- },
1397
- }, {
1398
- authorityHostSuffixes: ['example-registry.example'],
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
- const tools = new Map(registered.map(tool => [tool.name, tool]))
1401
- const start = tools.get('scout_start')
1402
- const addSource = tools.get('scout_add_source')
1403
- await start.execute({
1404
- caseId: 'custom-trust',
1405
- companyName: 'Custom Co',
1406
- roleTitle: 'Role',
1407
- location: '',
1408
- }, { agent: { id: 'session-a' } })
1409
- // The configured suffix makes this registry origin a valid E3 source.
1410
- const result = await addSource.execute({
1411
- caseId: 'custom-trust',
1412
- sourceId: 'registry',
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
  })