dsh-scout 0.9.2 → 0.9.4
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 +76 -1
- package/README.en.md +140 -47
- package/README.md +140 -47
- package/dist/i18n.d.ts +157 -1
- package/dist/i18n.js +354 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +360 -88
- package/dist/model.d.ts +62 -9
- package/dist/model.js +655 -134
- package/docs/dsh-scout-product-contract.md +22 -1
- package/package.json +4 -1
- package/scripts/acceptance-report.mjs +622 -0
- package/scripts/check-release.mjs +228 -0
- package/scripts/profile-smoke.mjs +12 -15
- package/scripts/smoke-tool-call.mjs +43 -0
- package/tests/acceptance.test.mjs +118 -42
- package/tests/model.test.mjs +301 -69
- package/tests/package.test.mjs +572 -3
- package/tests/tools.test.mjs +382 -16
package/tests/model.test.mjs
CHANGED
|
@@ -91,12 +91,13 @@ test('renders a sourced claim and preserves a VERIFY decision', () => {
|
|
|
91
91
|
const report = renderReport(scoutCase)
|
|
92
92
|
|
|
93
93
|
assert.equal(scoutCase.decision, 'VERIFY')
|
|
94
|
-
assert.match(report,
|
|
94
|
+
assert.match(report, /^# Example Co$/m)
|
|
95
|
+
assert.match(report, /^> 岗位:HR Head/m)
|
|
95
96
|
assert.match(report, /job-posting/)
|
|
96
97
|
assert.match(report, /Confirm the mandate/)
|
|
97
|
-
assert.match(report,
|
|
98
|
-
assert.match(report,
|
|
99
|
-
assert.match(report,
|
|
98
|
+
assert.match(report, /## 3\. 线索与待确认/)
|
|
99
|
+
assert.match(report, /## 4\. 风险与红旗/)
|
|
100
|
+
assert.match(report, /## 5\. 面试问题/)
|
|
100
101
|
})
|
|
101
102
|
|
|
102
103
|
test('requires an E3 source to verify company identity', () => {
|
|
@@ -234,8 +235,8 @@ test('replays the Snapmaker fixture offline with a stable VERIFY result', () =>
|
|
|
234
235
|
|
|
235
236
|
const report = renderReport(scoutCase)
|
|
236
237
|
assert.equal(scoutCase.decision, fixture.expectedDecision)
|
|
237
|
-
assert.match(report,
|
|
238
|
-
assert.match(report,
|
|
238
|
+
assert.match(report, /公司主体还没有核验/)
|
|
239
|
+
assert.match(report, /## 5\. 面试问题/)
|
|
239
240
|
assert.equal(scoutCase.interviewQuestions.length, 5)
|
|
240
241
|
})
|
|
241
242
|
|
|
@@ -373,13 +374,11 @@ test('renders an evidence summary, source URLs, and a prioritized verification c
|
|
|
373
374
|
})
|
|
374
375
|
const report = renderReport(scoutCase)
|
|
375
376
|
|
|
376
|
-
assert.match(report,
|
|
377
|
+
assert.match(report, /## 3\. 线索与待确认/)
|
|
378
|
+
assert.match(report, /The role exists\./)
|
|
379
|
+
assert.match(report, /## 4\. 风险与红旗[\s\S]*A litigation risk is reported/)
|
|
377
380
|
assert.match(report, /https:\/\/example\.com\/job/)
|
|
378
|
-
assert.match(report, /##
|
|
379
|
-
const checklistStart = report.indexOf('## Verification checklist')
|
|
380
|
-
const checklist = report.slice(checklistStart, report.indexOf('## Claim ledger'))
|
|
381
|
-
assert.ok(checklist.indexOf('[blocking]') < checklist.indexOf('[material]'), 'blocking items rank before material')
|
|
382
|
-
assert.match(checklist, /→ 下一步:Confirm the mandate/)
|
|
381
|
+
assert.match(report, /## 5\. 面试问题/)
|
|
383
382
|
})
|
|
384
383
|
|
|
385
384
|
test('round-trips a case through the five-file export', () => {
|
|
@@ -418,7 +417,7 @@ test('round-trips a case through the five-file export', () => {
|
|
|
418
417
|
|
|
419
418
|
const files = exportCaseFiles(scoutCase)
|
|
420
419
|
assert.deepEqual(Object.keys(files).sort(), ['case.json', 'claims.json', 'events.jsonl', 'report.md', 'sources.json'])
|
|
421
|
-
assert.match(files['report.md'], /##
|
|
420
|
+
assert.match(files['report.md'], /## 5\. 面试问题/)
|
|
422
421
|
assert.match(files['events.jsonl'], /"type":"case_started"/)
|
|
423
422
|
|
|
424
423
|
const restored = importCaseFromFiles(files)
|
|
@@ -463,6 +462,7 @@ test('generates a deduplicated, prioritized interview question list', () => {
|
|
|
463
462
|
sourceIds: ['job-posting'],
|
|
464
463
|
confidenceNote: 'A source describes the role.',
|
|
465
464
|
nextAction: 'Confirm the mandate in the interview.',
|
|
465
|
+
interviewQuestion: 'Who owns the people roadmap in this role?',
|
|
466
466
|
})
|
|
467
467
|
scoutCase = addClaim(scoutCase, {
|
|
468
468
|
claimId: 'claim-reporting',
|
|
@@ -474,15 +474,20 @@ test('generates a deduplicated, prioritized interview question list', () => {
|
|
|
474
474
|
sourceIds: [],
|
|
475
475
|
confidenceNote: 'No source discloses the reporting line.',
|
|
476
476
|
nextAction: 'Ask who this role reports to.',
|
|
477
|
+
interviewQuestion: 'Who would this role report to on day one?',
|
|
477
478
|
})
|
|
478
479
|
|
|
479
480
|
const questions = generateInterviewQuestions(scoutCase)
|
|
480
|
-
|
|
481
|
-
|
|
481
|
+
// 0.9.4: only explicit interviewQuestion values feed the head; nextAction
|
|
482
|
+
// (a research instruction) must never become an interview question.
|
|
483
|
+
assert.ok(questions.includes('Who owns the people roadmap in this role?'))
|
|
484
|
+
assert.ok(questions.includes('Who would this role report to on day one?'))
|
|
485
|
+
assert.ok(!questions.includes('Confirm the mandate in the interview.'))
|
|
486
|
+
assert.ok(!questions.includes('Ask who this role reports to.'))
|
|
482
487
|
// missing verified mandate dimension gets its default question
|
|
483
488
|
const mandateQuestion = questions.findIndex(q => q.includes('最终决定权'))
|
|
484
489
|
assert.ok(mandateQuestion >= 0)
|
|
485
|
-
//
|
|
490
|
+
// untouched dimensions rank after the missing required ones
|
|
486
491
|
const untouchedQuestion = questions.findIndex(q => q.includes('法定名称'))
|
|
487
492
|
assert.ok(
|
|
488
493
|
untouchedQuestion > mandateQuestion,
|
|
@@ -718,63 +723,146 @@ function legacyCase() {
|
|
|
718
723
|
return decideCase(scoutCase)
|
|
719
724
|
}
|
|
720
725
|
|
|
721
|
-
// Captured
|
|
722
|
-
//
|
|
723
|
-
// change to the zh-CN report
|
|
724
|
-
|
|
725
|
-
|
|
726
|
+
// Captured from dsh-scout 0.9.3 plus the 0.9.4 report changes (verified
|
|
727
|
+
// subject block, source capture dates, derived interview questions). Any byte
|
|
728
|
+
// change to the zh-CN report must update this baseline deliberately, together
|
|
729
|
+
// with CHANGELOG.
|
|
730
|
+
const REPORT_GOLDEN = [
|
|
731
|
+
"# Legacy Co",
|
|
726
732
|
"",
|
|
727
|
-
"-
|
|
728
|
-
"
|
|
729
|
-
"
|
|
730
|
-
"
|
|
733
|
+
"> 岗位:HR Head | 地点:未指定 | 证据截至:2026-08-16 | 判断:继续核验(VERIFY)",
|
|
734
|
+
"> 目标:判断是否值得进入下一轮,并准备面试反问",
|
|
735
|
+
"> 阅读顺序:30 秒结论 → 主体 → 已确认事实 → 待确认 → 风险 → 面试问题 → 附录(证据台账)",
|
|
736
|
+
"> 可信度:已核实=多来源一致;有报道=单一来源;推断=由事实推出;未知=没查到;未找到证据=不代表不存在",
|
|
731
737
|
"",
|
|
732
|
-
"##
|
|
738
|
+
"## 30 秒结论",
|
|
733
739
|
"",
|
|
734
|
-
"
|
|
740
|
+
"**继续核验**:公司主体还没有核验,现在还不能决定是否推进。",
|
|
735
741
|
"",
|
|
736
|
-
"
|
|
742
|
+
"- 主体:Legacy Co——尚未核验。",
|
|
743
|
+
"- 业务:公开信息未形成可核实的产品描述。",
|
|
744
|
+
"- 资本:未找到公开融资信息。",
|
|
737
745
|
"",
|
|
738
|
-
"
|
|
746
|
+
"**最该先确认的 3 件事**",
|
|
739
747
|
"",
|
|
740
|
-
"
|
|
748
|
+
"1. 取得官方登记证据(营业执照或工商公示页),确认合同主体与信用代码",
|
|
749
|
+
"2. 确认岗位是否真实在招、汇报给谁、有多大授权(只能见面问)",
|
|
741
750
|
"",
|
|
742
|
-
"
|
|
751
|
+
"## 1. 公司与主体(已确认)",
|
|
743
752
|
"",
|
|
744
|
-
"
|
|
753
|
+
"| 项目 | 内容 |",
|
|
754
|
+
"|---|---|",
|
|
755
|
+
"| 法定主体 | 未知 |",
|
|
756
|
+
"| 统一社会信用代码 | 未知 |",
|
|
757
|
+
"| 注册地 | 未知 |",
|
|
758
|
+
"| 法定代表人 | 未知 |",
|
|
759
|
+
"| 品牌关系 | 未知 |",
|
|
760
|
+
"| 核验等级 | 未核验:尚无足够来源确认主体。 |",
|
|
745
761
|
"",
|
|
746
|
-
"
|
|
762
|
+
"**这意味着**:可以按该主体继续尽调与判断,但把它视为“未拿到官方盖章的事实”,关键动作(签约、背调、开票)前需复核。",
|
|
747
763
|
"",
|
|
748
|
-
"##
|
|
764
|
+
"## 2. 已确认的事实(阶段:可直接使用)",
|
|
749
765
|
"",
|
|
750
|
-
"-
|
|
766
|
+
"- 暂无已核实的事实",
|
|
751
767
|
"",
|
|
752
|
-
"##
|
|
768
|
+
"## 3. 线索与待确认(阶段:先别当事实)",
|
|
753
769
|
"",
|
|
754
|
-
"
|
|
770
|
+
"### 还能通过公开渠道再核实",
|
|
755
771
|
"",
|
|
756
|
-
"
|
|
772
|
+
"- The role may own the people roadmap.",
|
|
773
|
+
" - 转述 · 单方说法 · 下一步:Confirm the mandate in the interview.",
|
|
757
774
|
"",
|
|
775
|
+
"## 4. 风险与红旗(阶段:已识别)",
|
|
776
|
+
"",
|
|
777
|
+
"- 暂无已识别风险",
|
|
778
|
+
"",
|
|
779
|
+
"## 5. 面试问题(按主题,最多 12 条)",
|
|
780
|
+
"",
|
|
781
|
+
"### 岗位与团队",
|
|
782
|
+
"",
|
|
783
|
+
"- 该岗位当前是否真实在招?招聘状态与到岗时间如何?",
|
|
784
|
+
"- 该岗位直接向谁汇报?虚线/实线如何划分?",
|
|
785
|
+
"- 该岗位在哪些事项上拥有最终决定权?团队与预算规模如何?",
|
|
786
|
+
"- 创始团队背景与股权结构如何?关键决策人是谁?",
|
|
758
787
|
"- 这个岗位在前三个月最重要的业务结果是什么?",
|
|
759
788
|
"- 该岗位直接向谁汇报,在哪些事项上拥有最终决定权?",
|
|
760
789
|
"- 现有团队、预算和外部供应商资源分别是什么?",
|
|
761
790
|
"- 当前最影响业务增长的组织问题是什么?",
|
|
762
791
|
"- 六个月后,哪些可观察结果代表这个岗位取得成功?",
|
|
763
792
|
"",
|
|
793
|
+
"### 产品与市场",
|
|
794
|
+
"",
|
|
795
|
+
"- 核心产品当前处于什么阶段?技术壁垒与依赖是什么?",
|
|
796
|
+
"- 目标市场与主要竞争对手是谁?差异化优势如何维持?",
|
|
797
|
+
"",
|
|
798
|
+
"### 通用",
|
|
799
|
+
"",
|
|
800
|
+
"- 公司主体的法定名称、注册号与注册地是什么?如何与品牌对应?",
|
|
801
|
+
"",
|
|
802
|
+
"## 6. 距离下一步还差什么",
|
|
803
|
+
"",
|
|
804
|
+
"- **为什么现在还是「继续核验」**:公司主体还没有核验,现在还不能决定是否推进。",
|
|
805
|
+
"- **补齐后可以进入「可以推进」**:",
|
|
806
|
+
" - 主体:取得官方登记页/营业执照(或明确接受“多源交叉核验”的残余风险)",
|
|
807
|
+
" - 授权与职责:The role may own the people roadmap.",
|
|
808
|
+
"- **最小行动清单**:",
|
|
809
|
+
" 1. 取得官方登记证据(营业执照或工商公示页),确认合同主体与信用代码",
|
|
810
|
+
" 2. 确认岗位是否真实在招、汇报给谁、有多大授权(只能见面问)",
|
|
811
|
+
"",
|
|
812
|
+
"## 附录 A · 来源表",
|
|
813
|
+
"",
|
|
814
|
+
"| 编号 | 来源 | 类型 | 可信度 | 采集日期 |",
|
|
815
|
+
"|---|---|---|---|---|",
|
|
816
|
+
"| job-posting | Legacy job posting | 招聘平台 | 单方说法 | 2026-08-16 |",
|
|
817
|
+
"- job-posting: https://example.com/job",
|
|
818
|
+
"",
|
|
819
|
+
"## 附录 B · 主张台账(技术可追溯)",
|
|
820
|
+
"",
|
|
821
|
+
"| 结论摘要 | 阶段/证据 | 影响 | 来源 | 下一步 |",
|
|
822
|
+
"|---|---|---|---|---|",
|
|
823
|
+
"| The role may own the people roadmap. | 转述 · 单方说法 | 阻断级 | job-posting | Confirm the mandate in th… |",
|
|
824
|
+
"",
|
|
825
|
+
"## 附录 C · 调查覆盖度",
|
|
826
|
+
"",
|
|
827
|
+
"- 主体身份:未调查",
|
|
828
|
+
"- 岗位真实性:未调查",
|
|
829
|
+
"- 汇报关系:未调查",
|
|
830
|
+
"- 授权与职责:仅 1 条待确认线索",
|
|
831
|
+
"- 公司与创始人:未调查",
|
|
832
|
+
"- 产品与技术:未调查",
|
|
833
|
+
"- 市场与竞争:未调查",
|
|
834
|
+
"- 融资与商业化:未调查",
|
|
835
|
+
"- 组织与人力:未调查",
|
|
836
|
+
"- 风险:未调查",
|
|
837
|
+
"- 岗位匹配度:未调查",
|
|
838
|
+
"",
|
|
839
|
+
"## 附录 D · 术语表",
|
|
840
|
+
"",
|
|
841
|
+
"| 代码 | 面向读者的说法 | 含义 |",
|
|
842
|
+
"|---|---|---|",
|
|
843
|
+
"| E3 | 官方/权威来源 | 政府登记、监管、官方文件;可直接引用 |",
|
|
844
|
+
"| E2 | 独立来源 | 独立媒体、公开数据库、招聘平台;可交叉核对 |",
|
|
845
|
+
"| E1 | 单方说法 | 公司自述、招聘信息、匿名评价、用户提供 |",
|
|
846
|
+
"| E0 | 未找到证据 | 没找到证据,不等于不存在 |",
|
|
847
|
+
"| verified | 已核验 | 多来源一致 |",
|
|
848
|
+
"| reported | 转述 | 单一来源转述,未交叉 |",
|
|
849
|
+
"| inferred | 推断 | 由事实推出,不是直接证据 |",
|
|
850
|
+
"| contradicted | 矛盾 | 两个来源口径不一致 |",
|
|
851
|
+
"| unknown | 未知 | 没查到,需要补 |",
|
|
852
|
+
"| needs_verification | 待核验 | 有线索但未核实 |",
|
|
853
|
+
"",
|
|
764
854
|
"> 本报告只表达当前证据边界内的判断,不替代法律、投资或医疗意见。",
|
|
765
855
|
"",
|
|
766
856
|
].join('\n')
|
|
767
857
|
|
|
768
|
-
test('
|
|
858
|
+
test('matches the 0.9.4 zh-CN report baseline', () => {
|
|
769
859
|
const report = renderReport(legacyCase())
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
assert.
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
assert.equal(withoutCoverage, LEGACY_REPORT_GOLDEN)
|
|
777
|
-
assert.match(report, /## Coverage\n\n- identity/)
|
|
860
|
+
assert.equal(report, REPORT_GOLDEN)
|
|
861
|
+
// the blocks added in 0.9.4 are part of the baseline, not optional extras
|
|
862
|
+
assert.match(report, /## 1\. 公司与主体(已确认)/)
|
|
863
|
+
assert.match(report, /\| job-posting \| Legacy job posting \| 招聘平台 \| 单方说法 \| 2026-08-16 \|/)
|
|
864
|
+
assert.match(report, /## 5\. 面试问题(按主题,最多 12 条)/)
|
|
865
|
+
assert.match(report, /## 附录 D · 术语表/)
|
|
778
866
|
})
|
|
779
867
|
|
|
780
868
|
test('renders an English report and English questions, defaulting to the case language', () => {
|
|
@@ -796,24 +884,26 @@ test('renders an English report and English questions, defaulting to the case la
|
|
|
796
884
|
scoutCase = decideCase(scoutCase)
|
|
797
885
|
|
|
798
886
|
const report = renderReport(scoutCase)
|
|
799
|
-
assert.match(report,
|
|
800
|
-
assert.match(report,
|
|
801
|
-
assert.match(report,
|
|
802
|
-
assert.match(report,
|
|
803
|
-
assert.match(report,
|
|
804
|
-
assert.match(report, /
|
|
887
|
+
assert.match(report, /^> Role: CTO \| Location: unspecified \| Evidence as of: 2026-08-16 \| Decision: verify \(VERIFY\)$/m)
|
|
888
|
+
assert.match(report, /## 30-second verdict/)
|
|
889
|
+
assert.match(report, /## 1\. The company and its legal subject \(confirmed\)/)
|
|
890
|
+
assert.match(report, /## 4\. Risks and red flags/)
|
|
891
|
+
assert.match(report, /## 5\. Interview questions \(by theme, up to 12\)/)
|
|
892
|
+
assert.match(report, /reported · Single-party account/)
|
|
805
893
|
assert.doesNotMatch(report, /[\u4e00-\u9fff]/, 'the English report contains no CJK characters')
|
|
806
894
|
|
|
807
895
|
const questions = generateInterviewQuestions(scoutCase)
|
|
808
896
|
assert.equal(questions.length, 12)
|
|
809
|
-
|
|
810
|
-
|
|
897
|
+
// 0.9.4: nextAction no longer feeds questions; missing dimensions lead with
|
|
898
|
+
// their own default question (role existence first in REQUIRED order).
|
|
899
|
+
assert.equal(questions[0], 'Is this role actually open right now, and what are the hiring status and start date?')
|
|
900
|
+
assert.ok(!questions.includes('Confirm the mandate in the interview.'))
|
|
811
901
|
assert.ok(questions.every(question => !/[\u4e00-\u9fff]/.test(question)))
|
|
812
902
|
|
|
813
903
|
// an explicit option overrides the case language in either direction
|
|
814
904
|
const zhReport = renderReport(scoutCase, { language: 'zh-CN' })
|
|
815
|
-
assert.match(zhReport,
|
|
816
|
-
assert.doesNotMatch(zhReport,
|
|
905
|
+
assert.match(zhReport, /^> 岗位:CTO .*判断:继续核验(VERIFY)$/m)
|
|
906
|
+
assert.doesNotMatch(zhReport, /Decision:/)
|
|
817
907
|
const zhQuestions = generateInterviewQuestions(scoutCase, { language: 'zh-CN' })
|
|
818
908
|
assert.ok(zhQuestions.includes('公司主体的法定名称、注册号与注册地是什么?如何与品牌对应?'))
|
|
819
909
|
// the comparison report follows the same language resolution
|
|
@@ -864,14 +954,12 @@ test('computes coverage for all 11 dimensions, including untouched ones', () =>
|
|
|
864
954
|
})
|
|
865
955
|
|
|
866
956
|
const report = renderReport(scoutCase)
|
|
867
|
-
assert.match(report, /##
|
|
868
|
-
assert.ok(report.indexOf('##
|
|
869
|
-
assert.match(report, /-
|
|
870
|
-
assert.match(report, /-
|
|
871
|
-
assert.match(
|
|
872
|
-
|
|
873
|
-
/未调查维度(9):role_existence、reporting_line、mandate、company_and_founder、product_and_technology、market_and_competition、financing_and_commercialization、organization_and_hr、role_fit/,
|
|
874
|
-
)
|
|
957
|
+
assert.match(report, /## 附录 C · 调查覆盖度/)
|
|
958
|
+
assert.ok(report.indexOf('## 附录 C') < report.indexOf('## 附录 D'))
|
|
959
|
+
assert.match(report, /- 主体身份:1 条已核实/)
|
|
960
|
+
assert.match(report, /- 授权与职责:未调查/)
|
|
961
|
+
assert.match(report, /- 岗位真实性:未调查/)
|
|
962
|
+
assert.match(report, /- 市场与竞争:未调查/)
|
|
875
963
|
// coverage drives the question list
|
|
876
964
|
assert.ok(generateInterviewQuestions(scoutCase)
|
|
877
965
|
.includes('该岗位当前是否真实在招?招聘状态与到岗时间如何?'))
|
|
@@ -996,7 +1084,7 @@ test('imports a legacy five-file export without the new optional fields', () =>
|
|
|
996
1084
|
assert.equal(restored.decisionReasonCode, undefined)
|
|
997
1085
|
assert.equal('decisionReasonCode' in restored, false)
|
|
998
1086
|
assert.deepEqual(restored.interviewQuestions, ['第一个问题?'])
|
|
999
|
-
assert.match(renderReport(restored), /##
|
|
1087
|
+
assert.match(renderReport(restored), /## 附录 C · 调查覆盖度/)
|
|
1000
1088
|
|
|
1001
1089
|
// a payload that predates the language field still imports as zh-CN
|
|
1002
1090
|
const withoutLanguage = importCaseFromFiles({
|
|
@@ -1074,13 +1162,13 @@ test('renders the exported report.md in the requested language', () => {
|
|
|
1074
1162
|
const zhCase = legacyCase()
|
|
1075
1163
|
const zhFiles = exportCaseFiles(zhCase)
|
|
1076
1164
|
assert.equal(zhFiles['report.md'], renderReport(zhCase))
|
|
1077
|
-
assert.match(zhFiles['report.md'],
|
|
1165
|
+
assert.match(zhFiles['report.md'], /^> 岗位:HR Head .*判断:继续核验(VERIFY)$/m)
|
|
1078
1166
|
const enFiles = exportCaseFiles(zhCase, { language: 'en' })
|
|
1079
1167
|
assert.equal(enFiles['report.md'], renderReport(zhCase, { language: 'en' }))
|
|
1080
1168
|
// the framework text follows the requested language; stored case data
|
|
1081
1169
|
// (claim text, decisionReason, curated questions) is not translated
|
|
1082
|
-
assert.match(enFiles['report.md'],
|
|
1083
|
-
assert.match(enFiles['report.md'],
|
|
1170
|
+
assert.match(enFiles['report.md'], /^> Role: HR Head \| Location: unspecified \| Evidence as of: 2026-08-16 \| Decision: verify \(VERIFY\)$/m)
|
|
1171
|
+
assert.match(enFiles['report.md'], /## Appendix C - Investigation coverage/)
|
|
1084
1172
|
assert.doesNotMatch(enFiles['report.md'], /- 判断:/)
|
|
1085
1173
|
assert.equal(
|
|
1086
1174
|
JSON.parse(enFiles['case.json']).language,
|
|
@@ -1137,3 +1225,147 @@ test('keeps the ingest draft defaults in both language tables', () => {
|
|
|
1137
1225
|
assert.doesNotMatch(renderReport(ingestCase), /[\u4e00-\u9fff]/)
|
|
1138
1226
|
assert.ok(generateInterviewQuestions(ingestCase).every(question => !/[\u4e00-\u9fff]/.test(question)))
|
|
1139
1227
|
})
|
|
1228
|
+
|
|
1229
|
+
test('a corroborated identity still blocks PROCEED until an E3 registration source exists', () => {
|
|
1230
|
+
let scoutCase = createCase({ caseId: 'gate', companyName: 'Example Co', roleTitle: 'HR Head' })
|
|
1231
|
+
scoutCase = addSource(scoutCase, {
|
|
1232
|
+
sourceId: 'agg-a', type: 'public_database', title: 'Aggregator A',
|
|
1233
|
+
url: 'https://www.qixin.com/brand/gate', capturedAt: '2026-08-14T00:00:00.000Z',
|
|
1234
|
+
evidenceLevel: 'E2', status: 'captured',
|
|
1235
|
+
})
|
|
1236
|
+
scoutCase = addSource(scoutCase, {
|
|
1237
|
+
sourceId: 'agg-b', type: 'company_official', title: 'App Store listing',
|
|
1238
|
+
url: 'https://apps.apple.com/cn/app/gate/id1', capturedAt: '2026-08-14T00:00:00.000Z',
|
|
1239
|
+
evidenceLevel: 'E2', status: 'captured',
|
|
1240
|
+
})
|
|
1241
|
+
scoutCase = verifyIdentity(scoutCase, {
|
|
1242
|
+
legalEntity: 'Example Co Ltd',
|
|
1243
|
+
registrationNumber: 'REG-1',
|
|
1244
|
+
registeredRegion: 'Shenzhen',
|
|
1245
|
+
legalRepresentative: 'Example Person',
|
|
1246
|
+
brandRelationship: 'Operating entity',
|
|
1247
|
+
sourceIds: ['agg-a', 'agg-b'],
|
|
1248
|
+
fieldSources: {
|
|
1249
|
+
legalEntity: ['agg-a'],
|
|
1250
|
+
registrationNumber: ['agg-a', 'agg-b'],
|
|
1251
|
+
registeredRegion: ['agg-a'],
|
|
1252
|
+
legalRepresentative: ['agg-b'],
|
|
1253
|
+
brandRelationship: ['agg-b'],
|
|
1254
|
+
},
|
|
1255
|
+
})
|
|
1256
|
+
assert.equal(scoutCase.subject.identityStatus, 'corroborated')
|
|
1257
|
+
|
|
1258
|
+
// Cover every dimension with verified, informational claims so the only
|
|
1259
|
+
// remaining blocker is the identity tier itself.
|
|
1260
|
+
const dimensions = [
|
|
1261
|
+
'identity', 'role_existence', 'reporting_line', 'mandate', 'company_and_founder',
|
|
1262
|
+
'product_and_technology', 'market_and_competition', 'financing_and_commercialization',
|
|
1263
|
+
'organization_and_hr', 'risk', 'role_fit',
|
|
1264
|
+
]
|
|
1265
|
+
dimensions.forEach((dimension, index) => {
|
|
1266
|
+
scoutCase = addClaim(scoutCase, {
|
|
1267
|
+
claimId: `gate-${index}`,
|
|
1268
|
+
text: `Verified ${dimension} claim.`,
|
|
1269
|
+
status: 'verified',
|
|
1270
|
+
evidenceLevel: 'E2',
|
|
1271
|
+
dimension,
|
|
1272
|
+
impact: 'informational',
|
|
1273
|
+
sourceIds: ['agg-a'],
|
|
1274
|
+
confidenceNote: 'Two independent sources agree.',
|
|
1275
|
+
nextAction: 'None.',
|
|
1276
|
+
})
|
|
1277
|
+
})
|
|
1278
|
+
scoutCase = decideCase(scoutCase)
|
|
1279
|
+
assert.equal(scoutCase.decision, 'VERIFY')
|
|
1280
|
+
assert.equal(scoutCase.decisionReasonCode, 'identity')
|
|
1281
|
+
assert.match(scoutCase.decisionReason, /多源交叉核验/)
|
|
1282
|
+
assert.doesNotMatch(scoutCase.decisionReason, /尚有未核验结论/, 'nothing else is open in this case')
|
|
1283
|
+
})
|
|
1284
|
+
|
|
1285
|
+
test('new source-type hints and URL normalization behave as documented', () => {
|
|
1286
|
+
// 0.9.4 inference: first-party app stores and Chinese registry aggregators are
|
|
1287
|
+
// no longer lumped into `other`.
|
|
1288
|
+
assert.deepEqual(inferSourceType('https://apps.apple.com/cn/app/x/id1'), { type: 'company_official', inferred: true })
|
|
1289
|
+
assert.deepEqual(inferSourceType('https://play.google.com/store/apps/details?id=x'), { type: 'company_official', inferred: true })
|
|
1290
|
+
assert.deepEqual(inferSourceType('https://www.qixin.com/brand/x'), { type: 'public_database', inferred: true })
|
|
1291
|
+
assert.deepEqual(inferSourceType('https://m-harmony.qcc.com/pl/x.html'), { type: 'public_database', inferred: true })
|
|
1292
|
+
// Campaign parameters do not change source identity, content-selectors do.
|
|
1293
|
+
assert.equal(
|
|
1294
|
+
normalizeSourceUrl('https://example.com/a?utm_source=x&hmsr=y#frag'),
|
|
1295
|
+
'https://example.com/a',
|
|
1296
|
+
)
|
|
1297
|
+
assert.equal(
|
|
1298
|
+
normalizeSourceUrl('https://example.com/a?id=42&utm_medium=z'),
|
|
1299
|
+
'https://example.com/a?id=42',
|
|
1300
|
+
)
|
|
1301
|
+
})
|
|
1302
|
+
|
|
1303
|
+
test('verifyIdentity rejects field provenance that points outside sourceIds', () => {
|
|
1304
|
+
let scoutCase = createCase({ caseId: 'provenance', companyName: 'Example Co', roleTitle: 'HR Head' })
|
|
1305
|
+
scoutCase = addSource(scoutCase, {
|
|
1306
|
+
sourceId: 'a', type: 'public_database', title: 'A', url: 'https://www.qixin.com/brand/a',
|
|
1307
|
+
capturedAt: '2026-08-14T00:00:00.000Z', evidenceLevel: 'E2', status: 'captured',
|
|
1308
|
+
})
|
|
1309
|
+
scoutCase = addSource(scoutCase, {
|
|
1310
|
+
sourceId: 'b', type: 'company_official', title: 'B', url: 'https://apps.apple.com/cn/app/b/id1',
|
|
1311
|
+
capturedAt: '2026-08-14T00:00:00.000Z', evidenceLevel: 'E2', status: 'captured',
|
|
1312
|
+
})
|
|
1313
|
+
assert.throws(
|
|
1314
|
+
() => verifyIdentity(scoutCase, {
|
|
1315
|
+
legalEntity: 'Example Co Ltd',
|
|
1316
|
+
registrationNumber: 'REG-1',
|
|
1317
|
+
registeredRegion: 'Shenzhen',
|
|
1318
|
+
legalRepresentative: 'P',
|
|
1319
|
+
brandRelationship: 'brand',
|
|
1320
|
+
sourceIds: ['a', 'b'],
|
|
1321
|
+
fieldSources: { legalEntity: ['a'], registrationNumber: ['a', 'b'], registeredRegion: ['a'], legalRepresentative: ['b'], brandRelationship: ['missing'] },
|
|
1322
|
+
}),
|
|
1323
|
+
/Unknown source for identity field brandRelationship: missing/,
|
|
1324
|
+
)
|
|
1325
|
+
})
|
|
1326
|
+
|
|
1327
|
+
test('the reader-facing report body stays free of internal codes and keeps the stage order', () => {
|
|
1328
|
+
const report = renderReport(legacyCase())
|
|
1329
|
+
const appendixStart = report.indexOf('## 附录 A · 来源表')
|
|
1330
|
+
assert.ok(appendixStart > 0, 'the audit appendix exists')
|
|
1331
|
+
const body = report.slice(0, appendixStart)
|
|
1332
|
+
|
|
1333
|
+
// The readability contract: readers never see evidence codes, source ids, or
|
|
1334
|
+
// internal status/impact enum values in the narrative; those live in the
|
|
1335
|
+
// appendices with the glossary.
|
|
1336
|
+
assert.doesNotMatch(body, /\bE[0-3]\b/, 'no evidence codes in the reader-facing body')
|
|
1337
|
+
assert.doesNotMatch(body, /src-\d+/, 'no source ids in the reader-facing body')
|
|
1338
|
+
assert.doesNotMatch(
|
|
1339
|
+
body,
|
|
1340
|
+
/\b(verified|reported|inferred|contradicted|needs_verification|blocking|material|informational)\b/i,
|
|
1341
|
+
'no internal enum values in the reader-facing body',
|
|
1342
|
+
)
|
|
1343
|
+
assert.match(body, /已核验|有报道|推断|未知/)
|
|
1344
|
+
|
|
1345
|
+
// Stage order is the navigation contract: verdict -> subject -> confirmed ->
|
|
1346
|
+
// open -> risks -> questions -> gap -> appendices.
|
|
1347
|
+
const stageHeadings = [
|
|
1348
|
+
'## 30 秒结论',
|
|
1349
|
+
'## 1. 公司与主体(已确认)',
|
|
1350
|
+
'## 2. 已确认的事实(阶段:可直接使用)',
|
|
1351
|
+
'## 3. 线索与待确认(阶段:先别当事实)',
|
|
1352
|
+
'## 4. 风险与红旗(阶段:已识别)',
|
|
1353
|
+
'## 5. 面试问题(按主题,最多 12 条)',
|
|
1354
|
+
'## 6. 距离下一步还差什么',
|
|
1355
|
+
'## 附录 A · 来源表',
|
|
1356
|
+
]
|
|
1357
|
+
const positions = stageHeadings.map(heading => report.indexOf(heading))
|
|
1358
|
+
assert.ok(positions.every(position => position >= 0), 'every stage heading is present')
|
|
1359
|
+
assert.deepEqual([...positions].sort((left, right) => left - right), positions, 'stages render in order')
|
|
1360
|
+
|
|
1361
|
+
// The English framework keeps the same contract (case-authored prose is
|
|
1362
|
+
// deliberately not translated, so only heading/label checks apply here).
|
|
1363
|
+
const enReport = renderReport(createCase({
|
|
1364
|
+
caseId: 'readable-en', companyName: 'Readable Co', roleTitle: 'CTO', language: 'en',
|
|
1365
|
+
}))
|
|
1366
|
+
const enBody = enReport.slice(0, enReport.indexOf('## Appendix A - Sources'))
|
|
1367
|
+
assert.ok(enBody.length > 0, 'the English appendix boundary exists')
|
|
1368
|
+
assert.doesNotMatch(enBody, /\bE[0-3]\b/)
|
|
1369
|
+
assert.doesNotMatch(enBody, /src-\d+/)
|
|
1370
|
+
assert.match(enBody, /## 30-second verdict/)
|
|
1371
|
+
})
|