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
|
@@ -0,0 +1,622 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 0.9.4 acceptance run: drive a complete due-diligence case through the real
|
|
4
|
+
* plugin surface and assert that the exported artifact is a *complete* research
|
|
5
|
+
* report.
|
|
6
|
+
*
|
|
7
|
+
* What this exercises that the unit suite cannot:
|
|
8
|
+
* - the host's confined-filesystem seam: every write must carry a per-call
|
|
9
|
+
* sandbox policy whose workspace root is the caller session's cwd (the P0
|
|
10
|
+
* desktop export failure);
|
|
11
|
+
* - the end-to-end agent workflow shape: search -> ingest -> verify identity ->
|
|
12
|
+
* verify claims -> report -> export -> cold rehydration;
|
|
13
|
+
* - report completeness as a product claim, not just a schema claim.
|
|
14
|
+
*
|
|
15
|
+
* The evidence fed through the tools is the real 深圳市流数科技有限公司 research
|
|
16
|
+
* collected on 2026-11-07 (`notes/market-research-liushu.md`); the scripted
|
|
17
|
+
* calls stand in for the model's tool choices, which is the same trade-off the
|
|
18
|
+
* repo's existing `profile-smoke` gate documents.
|
|
19
|
+
*
|
|
20
|
+
* Usage: node scripts/acceptance-report.mjs [--keep]
|
|
21
|
+
*/
|
|
22
|
+
import assert from 'node:assert/strict'
|
|
23
|
+
import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'
|
|
24
|
+
import { tmpdir } from 'node:os'
|
|
25
|
+
import { dirname, join, resolve as resolvePath } from 'node:path'
|
|
26
|
+
import { fileURLToPath } from 'node:url'
|
|
27
|
+
import { apply } from '../dist/index.js'
|
|
28
|
+
|
|
29
|
+
const REPO_ROOT = resolvePath(dirname(fileURLToPath(import.meta.url)), '..')
|
|
30
|
+
const OUT_ROOT = join(REPO_ROOT, 'notes/acceptance-0.9.4')
|
|
31
|
+
const CASE_ID = 'liushu-tech-2026-11'
|
|
32
|
+
const EXPORT_DIR = join(OUT_ROOT, CASE_ID)
|
|
33
|
+
const KEEP = process.argv.includes('--keep')
|
|
34
|
+
|
|
35
|
+
const checks = []
|
|
36
|
+
const check = (name, fn) => {
|
|
37
|
+
try {
|
|
38
|
+
fn()
|
|
39
|
+
checks.push({ name, ok: true })
|
|
40
|
+
} catch (error) {
|
|
41
|
+
checks.push({ name, ok: false, error: error instanceof Error ? error.message : String(error) })
|
|
42
|
+
throw error
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const log = (message) => console.log(`• ${message}`)
|
|
46
|
+
|
|
47
|
+
/* ------------------------------------------------------------------ *
|
|
48
|
+
* Host doubles: a confined filesystem (same contract as
|
|
49
|
+
* @deepseek-ai/dsh-fs-sandbox) and a per-session sandbox policy.
|
|
50
|
+
* ------------------------------------------------------------------ */
|
|
51
|
+
|
|
52
|
+
const session = { header: { cwd: REPO_ROOT } }
|
|
53
|
+
const sandboxPolicy = {
|
|
54
|
+
resolve(request) {
|
|
55
|
+
return {
|
|
56
|
+
mode: 'workspace-write',
|
|
57
|
+
workspaceRoot: request?.session?.header?.cwd ?? '/nonexistent-deployment-default',
|
|
58
|
+
...(request?.session ? { sessionId: 'acceptance' } : {}),
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
const writes = []
|
|
63
|
+
const confinedFs = {
|
|
64
|
+
async resolve(path, opts = {}) {
|
|
65
|
+
const displayPath = resolvePath(opts.cwd ?? REPO_ROOT, path)
|
|
66
|
+
return { targetKey: displayPath, displayPath }
|
|
67
|
+
},
|
|
68
|
+
async readText(target) {
|
|
69
|
+
return readFile(target.displayPath, 'utf8')
|
|
70
|
+
},
|
|
71
|
+
async writeText(target, content, intent, signal, policy) {
|
|
72
|
+
assert.ok(policy, `write denied: no per-call sandbox policy for ${target.displayPath}`)
|
|
73
|
+
const writable = target.displayPath.startsWith(policy.workspaceRoot) || target.displayPath.startsWith(tmpdir())
|
|
74
|
+
assert.ok(writable, `write denied: ${target.displayPath} is outside ${policy.workspaceRoot}`)
|
|
75
|
+
await mkdir(dirname(target.displayPath), { recursive: true })
|
|
76
|
+
await writeFile(target.displayPath, content, 'utf8')
|
|
77
|
+
writes.push(target.displayPath)
|
|
78
|
+
},
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The plugin's tool surface, mounted against a minimal Cordis-like context. */
|
|
82
|
+
function mount() {
|
|
83
|
+
const registered = []
|
|
84
|
+
const cases = []
|
|
85
|
+
const ctx = {
|
|
86
|
+
get(serviceName) {
|
|
87
|
+
return this[serviceName]
|
|
88
|
+
},
|
|
89
|
+
tools: {
|
|
90
|
+
register(tool) {
|
|
91
|
+
registered.push(tool)
|
|
92
|
+
return () => undefined
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
effect(execute) {
|
|
96
|
+
const result = execute()
|
|
97
|
+
if (result && typeof result[Symbol.iterator] === 'function') cases.push(...result)
|
|
98
|
+
else if (typeof result === 'function') cases.push(result)
|
|
99
|
+
},
|
|
100
|
+
inject(_dependencies, callback) {
|
|
101
|
+
callback({ get() { return undefined }, effect() {} })
|
|
102
|
+
},
|
|
103
|
+
fs: confinedFs,
|
|
104
|
+
sandboxPolicy,
|
|
105
|
+
web: {
|
|
106
|
+
async search() {
|
|
107
|
+
return { sources: [], truncated: false }
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
}
|
|
111
|
+
apply(ctx, { scoutDir: OUT_ROOT, language: 'zh-CN' })
|
|
112
|
+
return new Map(registered.map(tool => [tool.name, tool]))
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const exec = { agent: { id: 'acceptance', session }, signal: undefined }
|
|
116
|
+
const call = async (tools, name, args) => {
|
|
117
|
+
const tool = tools.get(name)
|
|
118
|
+
assert.ok(tool, `tool not registered: ${name}`)
|
|
119
|
+
return tool.execute(args, exec)
|
|
120
|
+
}
|
|
121
|
+
const rendered = (tools, name, value) =>
|
|
122
|
+
(tools.get(name).output.render({}, value) ?? []).filter(block => block.type === 'text').map(block => block.text).join('')
|
|
123
|
+
|
|
124
|
+
/* ------------------------------------------------------------------ *
|
|
125
|
+
* The case: real evidence collected for 深圳市流数科技有限公司.
|
|
126
|
+
* ------------------------------------------------------------------ */
|
|
127
|
+
|
|
128
|
+
const SEARCH_FIXTURE = [
|
|
129
|
+
{
|
|
130
|
+
url: 'https://www.qixin.com/brand/00yu1x9y/0dbb8bea-98b6-4891-9e6b-e1389badae60',
|
|
131
|
+
title: '流数科技_深圳市流数科技有限公司 - 启信宝',
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
url: 'https://apps.apple.com/cn/app/difluid-cafe/id1634782098',
|
|
135
|
+
title: 'DiFluid Cafe App - App Store(开发者:深圳市流数科技有限公司)',
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
url: 'https://www.patentguru.com/cn/assignee/%E6%B7%B1%E5%9C%B3%E5%B8%82%E6%B5%81%E6%95%B0%E7%A7%91%E6%8A%80%E6%9C%89%E9%99%90%E5%85%AC%E5%8F%B8',
|
|
139
|
+
title: '深圳市流数科技有限公司专利 - PatentGuru',
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
url: 'https://www.chinaventure.com.cn/news/80/20221213/372612.html',
|
|
143
|
+
title: '投中网:高瓴又投了一个天使轮 流数DiFluid智能硬件',
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
url: 'https://www.qichamao.com/orgcompany/searchitemdtl/d1181d5087cd1243c4b838c832d7ae61.html',
|
|
147
|
+
title: '深圳市流数科技有限公司 - 企查猫工商信息(注册资本变更)',
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
url: 'https://www.szns.gov.cn/xxgk/qzfxxgkml/jryw/content/post_11320707.html',
|
|
151
|
+
title: '南山区咖啡及茶饮产业座谈会(深圳南山区政府官网)',
|
|
152
|
+
},
|
|
153
|
+
]
|
|
154
|
+
|
|
155
|
+
const INGEST_ITEMS = [
|
|
156
|
+
{
|
|
157
|
+
url: 'https://www.digitizefluid.com/',
|
|
158
|
+
title: 'DiFluid 官网(咖啡与液体检测智能仪器)',
|
|
159
|
+
sourceType: 'company_official',
|
|
160
|
+
evidenceLevel: 'E2',
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
url: 'http://epub.cnipa.gov.cn/patent/CN223742286U',
|
|
164
|
+
title: 'CNIPA 专利页:一种刮粉器(深圳市流数科技有限公司)',
|
|
165
|
+
sourceType: 'regulator',
|
|
166
|
+
evidenceLevel: 'E2',
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
url: 'https://www.zhaopin.com/jobdetail/CCL1324056630J40737272808.htm',
|
|
170
|
+
title: '智联招聘:深圳市流数科技有限公司 平面设计师',
|
|
171
|
+
sourceType: 'job_posting',
|
|
172
|
+
evidenceLevel: 'E2',
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
url: 'https://www.tianyancha.com/annualReport/4347904209/2025',
|
|
176
|
+
title: '深圳市流数科技有限公司 2025 年报 - 天眼查',
|
|
177
|
+
sourceType: 'public_database',
|
|
178
|
+
evidenceLevel: 'E2',
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
url: 'http://www.innocom.gov.cn/gqrdw/c101466/202501/8ec772ca005544dca37fd1956cfb3eb3/files/10f2d1702096481db34cf90e372c22da.pdf',
|
|
182
|
+
title: '国家高新技术企业备案名单(innocom.gov.cn,GR202444200701)',
|
|
183
|
+
sourceType: 'regulator',
|
|
184
|
+
evidenceLevel: 'E2',
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
url: 'https://kjxb.org/question/32425',
|
|
188
|
+
title: '匿名面经:深圳市流数科技有限公司(kjxb.org)',
|
|
189
|
+
sourceType: 'other',
|
|
190
|
+
evidenceLevel: 'E1',
|
|
191
|
+
},
|
|
192
|
+
]
|
|
193
|
+
|
|
194
|
+
const CLAIMS = [
|
|
195
|
+
{
|
|
196
|
+
url: SEARCH_FIXTURE[0].url,
|
|
197
|
+
claim: {
|
|
198
|
+
text: '深圳市流数科技有限公司统一社会信用代码 91440300MA5GMX9G9D,法定代表人彭倜,2021-03-15 成立,注册地深圳市龙华区大浪街道陶元社区陶吓锦华大厦 1602,经营状态存续。',
|
|
199
|
+
dimension: 'identity',
|
|
200
|
+
impact: 'material',
|
|
201
|
+
status: 'reported',
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
url: SEARCH_FIXTURE[1].url,
|
|
206
|
+
claim: {
|
|
207
|
+
text: '品牌「流数科技 / DiFluid(滴叶)」对应深圳市流数科技有限公司:App Store 开发者信息、官网 digitizefluid.com、政府报道三处一致。',
|
|
208
|
+
dimension: 'identity',
|
|
209
|
+
impact: 'material',
|
|
210
|
+
status: 'reported',
|
|
211
|
+
interviewQuestion: '对外签约主体、品牌名与开票主体是否为同一家?',
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
url: INGEST_ITEMS[0].url,
|
|
216
|
+
claim: {
|
|
217
|
+
text: '产品为咖啡/液体检测智能仪器:RS/R1/R2 Extract 咖啡浓度仪、Omix Plus 多功能检测仪、OmniFlux 烘焙色度仪、Microbalance Ti 冲煮秤与 CoffeeOS / DiFluid Café App;官网在营,美国/欧洲仓发货。',
|
|
218
|
+
dimension: 'product_and_technology',
|
|
219
|
+
impact: 'material',
|
|
220
|
+
status: 'reported',
|
|
221
|
+
interviewQuestion: '目前收入主要来自哪几条产品线,硬件与软件订阅的占比是多少?',
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
url: INGEST_ITEMS[1].url,
|
|
226
|
+
claim: {
|
|
227
|
+
text: '该公司名下有多项专利:CN115236034B(同时测量液体折射率和浊度)、CN223742286U(刮粉器)、CN309905675S(咖啡布豆器);CNIPA 页载明专利权人与注册地址。',
|
|
228
|
+
dimension: 'product_and_technology',
|
|
229
|
+
impact: 'material',
|
|
230
|
+
status: 'reported',
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
url: SEARCH_FIXTURE[3].url,
|
|
235
|
+
claim: {
|
|
236
|
+
text: '2021-12-29 完成天使轮融资,投资方高瓴创投,金额未披露;2022 年报道其咖啡浓度仪+智能秤组合定价 1599 元,新品预购 12 小时近 500 单。',
|
|
237
|
+
dimension: 'financing_and_commercialization',
|
|
238
|
+
impact: 'material',
|
|
239
|
+
status: 'reported',
|
|
240
|
+
interviewQuestion: '目前融资到哪一轮、金额与投资方是谁,现金跑道还有多久?',
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
url: SEARCH_FIXTURE[4].url,
|
|
245
|
+
claim: {
|
|
246
|
+
text: '2026-08-27 工商变更:注册资本由 121.794872 万元增至 139.104 万元,同日新增 4 家机构股东(深圳普创、厦门弘行、合创萤火、厦门中南弘新);无公开融资公告,属新一轮融资的间接证据。',
|
|
247
|
+
dimension: 'financing_and_commercialization',
|
|
248
|
+
impact: 'material',
|
|
249
|
+
status: 'inferred',
|
|
250
|
+
interviewQuestion: '2026 年 8 月的注册资本与股东变更对应的融资轮次、金额和用途是什么?',
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
url: INGEST_ITEMS[2].url,
|
|
255
|
+
claim: {
|
|
256
|
+
text: '参保人数 2024 年报 51 人、2025 年报 46 人;招聘平台公司卡标注 20-99 人、研发人员占比较高、A 轮。',
|
|
257
|
+
dimension: 'organization_and_hr',
|
|
258
|
+
impact: 'material',
|
|
259
|
+
status: 'reported',
|
|
260
|
+
interviewQuestion: '团队目前多少人、研发占比多少,过去一年离职率如何?',
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
url: INGEST_ITEMS[4].url,
|
|
265
|
+
claim: {
|
|
266
|
+
text: '国家高新技术企业,证书编号 GR202444200701(深圳市 2024 年第一批备案,2025-01-16 公告)。',
|
|
267
|
+
dimension: 'organization_and_hr',
|
|
268
|
+
impact: 'informational',
|
|
269
|
+
status: 'reported',
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
{
|
|
273
|
+
url: INGEST_ITEMS[5].url,
|
|
274
|
+
claim: {
|
|
275
|
+
text: '匿名面经称该公司办公位置离地铁约 20 分钟、面试流程冗长、较看重英语口语且未告知薪资结构;内容为匿名自述、无法独立核实。',
|
|
276
|
+
dimension: 'risk',
|
|
277
|
+
impact: 'material',
|
|
278
|
+
status: 'reported',
|
|
279
|
+
interviewQuestion: '面试流程通常有几轮、英语口语在什么场景下使用、薪资结构如何构成?',
|
|
280
|
+
},
|
|
281
|
+
},
|
|
282
|
+
{
|
|
283
|
+
url: SEARCH_FIXTURE[4].url,
|
|
284
|
+
claim: {
|
|
285
|
+
text: '公开检索未发现失信被执行人、被执行人、经营异常或行政处罚记录(负向证据,不等于官方清零证明)。',
|
|
286
|
+
dimension: 'risk',
|
|
287
|
+
impact: 'informational',
|
|
288
|
+
status: 'reported',
|
|
289
|
+
},
|
|
290
|
+
},
|
|
291
|
+
{
|
|
292
|
+
url: SEARCH_FIXTURE[0].url,
|
|
293
|
+
claim: {
|
|
294
|
+
text: '创始人彭倜(CEO)2016-2019 年任网龙网络算法研究员、2020-2021 年任南方科技大学研究助理;股东含彭倜 34.26%、洪小平 28.74%、乘数一号 15%、上海高瓴辰钧 13.33%。',
|
|
295
|
+
dimension: 'company_and_founder',
|
|
296
|
+
impact: 'material',
|
|
297
|
+
status: 'reported',
|
|
298
|
+
interviewQuestion: '创始团队的决策分工与股权结构在下一轮融资后会如何变化?',
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
]
|
|
302
|
+
|
|
303
|
+
const CLAIM_NOTES = [
|
|
304
|
+
[/^2026-08-27 工商变更/, {
|
|
305
|
+
confidenceNote: '同源工商变更记录,未见融资公告,按间接证据处理。',
|
|
306
|
+
nextAction: '向 HR/创始人确认该次变更对应的融资轮次、金额与用途。',
|
|
307
|
+
}],
|
|
308
|
+
[/^匿名面经/, {
|
|
309
|
+
confidenceNote: '匿名自述,无法独立核实,仅作面谈准备参考。',
|
|
310
|
+
nextAction: '面试时确认流程轮次、英语使用场景与薪资结构。',
|
|
311
|
+
}],
|
|
312
|
+
[/^创始人彭倜/, {
|
|
313
|
+
confidenceNote: '启信宝品牌页记载,尚未与官方披露核对。',
|
|
314
|
+
nextAction: '下一轮融资后核对股权结构变化与管理层分工。',
|
|
315
|
+
}],
|
|
316
|
+
[/^国家高新技术企业/, {
|
|
317
|
+
confidenceNote: '备案名单 PDF 索引命中,未见证书原件。',
|
|
318
|
+
nextAction: '核对证书有效期与复审安排。',
|
|
319
|
+
}],
|
|
320
|
+
[/^公开检索未发现失信/, {
|
|
321
|
+
confidenceNote: '第三方聚合站栏目为空,不等于官方清零。',
|
|
322
|
+
nextAction: '签约前在官方信用平台再核一次。',
|
|
323
|
+
}],
|
|
324
|
+
]
|
|
325
|
+
const claimsWithNotes = CLAIMS.map(item => {
|
|
326
|
+
const match = CLAIM_NOTES.find(([pattern]) => pattern.test(item.claim.text))
|
|
327
|
+
return match ? { ...item, claim: { ...item.claim, ...match[1] } } : item
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
const ROLE_CLAIMS = [
|
|
331
|
+
{
|
|
332
|
+
claimId: 'claim-role-existence',
|
|
333
|
+
text: '用户关注的岗位未指定;公司在招「平面设计师」等岗位,说明存在招聘行为,但无法确认目标岗位是否真实在招。',
|
|
334
|
+
dimension: 'role_existence',
|
|
335
|
+
impact: 'material',
|
|
336
|
+
status: 'unknown',
|
|
337
|
+
evidenceLevel: 'E0',
|
|
338
|
+
sourceIds: '',
|
|
339
|
+
confidenceNote: '用户未指定岗位,属于输入缺失而非检索失败。',
|
|
340
|
+
nextAction: '补岗位链接/JD,并通过 HR 确认编制与到岗时间。',
|
|
341
|
+
interviewQuestion: '该岗位当前是否真实在招、编制是否已批、预计何时到岗?',
|
|
342
|
+
},
|
|
343
|
+
{
|
|
344
|
+
claimId: 'claim-reporting',
|
|
345
|
+
text: '汇报关系未知:公开信息无法确定该岗位的汇报线与团队规模。',
|
|
346
|
+
dimension: 'reporting_line',
|
|
347
|
+
impact: 'material',
|
|
348
|
+
status: 'unknown',
|
|
349
|
+
evidenceLevel: 'E0',
|
|
350
|
+
sourceIds: '',
|
|
351
|
+
confidenceNote: '公开渠道无组织与汇报线信息。',
|
|
352
|
+
nextAction: '面试第一轮确认汇报对象与实线/虚线划分。',
|
|
353
|
+
interviewQuestion: '这个岗位向谁汇报、汇报对象的决策权范围有多大?',
|
|
354
|
+
},
|
|
355
|
+
{
|
|
356
|
+
claimId: 'claim-mandate',
|
|
357
|
+
text: '岗位授权与职责边界未知:无法判断预算权、用人权与最终决定权。',
|
|
358
|
+
dimension: 'mandate',
|
|
359
|
+
impact: 'material',
|
|
360
|
+
status: 'unknown',
|
|
361
|
+
evidenceLevel: 'E0',
|
|
362
|
+
sourceIds: '',
|
|
363
|
+
confidenceNote: '无 JD、无公开授权信息。',
|
|
364
|
+
nextAction: '面试时确认预算权、用人权与最终决定权。',
|
|
365
|
+
interviewQuestion: '这个岗位能独立决定哪些事,预算和团队编制各是多少?',
|
|
366
|
+
},
|
|
367
|
+
{
|
|
368
|
+
claimId: 'claim-role-fit',
|
|
369
|
+
text: '岗位匹配度未知:本次未指定岗位,无法评估技能与经验匹配。',
|
|
370
|
+
dimension: 'role_fit',
|
|
371
|
+
impact: 'material',
|
|
372
|
+
status: 'unknown',
|
|
373
|
+
evidenceLevel: 'E0',
|
|
374
|
+
sourceIds: '',
|
|
375
|
+
confidenceNote: '岗位未定,无法评估匹配度。',
|
|
376
|
+
nextAction: '补充 JD 后再评估技能与经验匹配。',
|
|
377
|
+
},
|
|
378
|
+
{
|
|
379
|
+
claimId: 'claim-market',
|
|
380
|
+
text: '市场与竞争格局未调查:咖啡数字化测量硬件的市场规模、竞品份额与公司相对位置均无证据。',
|
|
381
|
+
dimension: 'market_and_competition',
|
|
382
|
+
impact: 'material',
|
|
383
|
+
status: 'unknown',
|
|
384
|
+
evidenceLevel: 'E0',
|
|
385
|
+
sourceIds: '',
|
|
386
|
+
confidenceNote: '仅确认处于咖啡数字化测量赛道,未检索市场规模与份额。',
|
|
387
|
+
nextAction: '补充竞品对比与市场规模数据。',
|
|
388
|
+
interviewQuestion: '主要竞争对手是谁,公司的差异化与增长来源是什么?',
|
|
389
|
+
},
|
|
390
|
+
]
|
|
391
|
+
|
|
392
|
+
async function main() {
|
|
393
|
+
await rm(EXPORT_DIR, { recursive: true, force: true })
|
|
394
|
+
await mkdir(OUT_ROOT, { recursive: true })
|
|
395
|
+
const tools = mount()
|
|
396
|
+
|
|
397
|
+
// 1. Start the case with the full legal name.
|
|
398
|
+
const started = await call(tools, 'scout_start', {
|
|
399
|
+
caseId: CASE_ID,
|
|
400
|
+
companyName: '深圳市流数科技有限公司',
|
|
401
|
+
roleTitle: '待定(本次以公司主体尽调为主)',
|
|
402
|
+
location: '深圳',
|
|
403
|
+
})
|
|
404
|
+
check('scout_start creates a VERIFY case with a next action', () => {
|
|
405
|
+
assert.equal(started.decision, 'VERIFY')
|
|
406
|
+
assert.ok(started.nextActions.length >= 1, 'a new case still tells the model what to do next')
|
|
407
|
+
})
|
|
408
|
+
log(`started ${CASE_ID}: ${rendered(tools, 'scout_start', started).split('\n')[0]}`)
|
|
409
|
+
|
|
410
|
+
// 2. Register the collected pages. The compact summary must say what
|
|
411
|
+
// was found (titles), not only which ids were written.
|
|
412
|
+
// Register the fixture sources through the real ingest path so ids and
|
|
413
|
+
// capturedAt are produced by the plugin itself.
|
|
414
|
+
const searchIngest = await call(tools, 'scout_ingest', {
|
|
415
|
+
caseId: CASE_ID,
|
|
416
|
+
itemsJson: JSON.stringify(SEARCH_FIXTURE.map(item => ({ ...item }))),
|
|
417
|
+
})
|
|
418
|
+
check('registered sources are summarised by title in the compact render', () => {
|
|
419
|
+
assert.equal(searchIngest.counts.sources, SEARCH_FIXTURE.length)
|
|
420
|
+
const text = rendered(tools, 'scout_ingest', searchIngest)
|
|
421
|
+
assert.match(text, /changed: sources_added/, 'the compact summary still names the change')
|
|
422
|
+
assert.match(text, /启信宝/, 'new source titles are visible to the model')
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
// 3. Ingest the remaining evidence and draft the claims.
|
|
426
|
+
const ingested = await call(tools, 'scout_ingest', {
|
|
427
|
+
caseId: CASE_ID,
|
|
428
|
+
itemsJson: JSON.stringify([...INGEST_ITEMS, ...claimsWithNotes]),
|
|
429
|
+
})
|
|
430
|
+
const totalClaims = claimsWithNotes.length
|
|
431
|
+
check('ingest registers sources + claims and reports both counts', () => {
|
|
432
|
+
assert.equal(ingested.counts.claims, totalClaims)
|
|
433
|
+
assert.equal(ingested.counts.sources, SEARCH_FIXTURE.length + INGEST_ITEMS.length)
|
|
434
|
+
const text = rendered(tools, 'scout_ingest', ingested)
|
|
435
|
+
assert.match(text, /\(\+\d+ sources, \+\d+ claims/, 'source and claim additions are split')
|
|
436
|
+
})
|
|
437
|
+
log(`ingested: ${ingested.counts.sources} sources / ${ingested.counts.claims} claims`)
|
|
438
|
+
|
|
439
|
+
// 4. Identity: two independent registries support the credit number, an E3
|
|
440
|
+
// government page only supports the legal name -> corroborated, never
|
|
441
|
+
// presented as official verification.
|
|
442
|
+
const verifiedIdentity = await call(tools, 'scout_verify_identity', {
|
|
443
|
+
caseId: CASE_ID,
|
|
444
|
+
legalEntity: '深圳市流数科技有限公司',
|
|
445
|
+
registrationNumber: '91440300MA5GMX9G9D',
|
|
446
|
+
registeredRegion: '广东省深圳市龙华区',
|
|
447
|
+
legalRepresentative: '彭倜',
|
|
448
|
+
brandRelationship: '品牌「流数科技 / DiFluid(滴叶)」的注册主体',
|
|
449
|
+
sourceIds: 'src-1,src-2,src-5,src-6,src-7,src-8',
|
|
450
|
+
fieldSources: JSON.stringify({
|
|
451
|
+
legalEntity: 'src-1,src-6',
|
|
452
|
+
registrationNumber: 'src-1,src-2,src-5',
|
|
453
|
+
registeredRegion: 'src-1,src-5',
|
|
454
|
+
legalRepresentative: 'src-1,src-5',
|
|
455
|
+
brandRelationship: 'src-2,src-7',
|
|
456
|
+
}),
|
|
457
|
+
})
|
|
458
|
+
check('identity is corroborated with per-field provenance (no E3 registration source)', () => {
|
|
459
|
+
assert.equal(verifiedIdentity.identity, 'corroborated')
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
// 5. Verify the six claims that have two independent sources.
|
|
463
|
+
const verifyPlan = [
|
|
464
|
+
['claim-1', 'src-1,src-2,src-5'],
|
|
465
|
+
['claim-2', 'src-1,src-2,src-6,src-7'],
|
|
466
|
+
['claim-3', 'src-7,src-3'],
|
|
467
|
+
['claim-4', 'src-3,src-8'],
|
|
468
|
+
['claim-5', 'src-2,src-4,src-5'],
|
|
469
|
+
['claim-7', 'src-9,src-10'],
|
|
470
|
+
]
|
|
471
|
+
for (const [claimId, sourceIds] of verifyPlan) {
|
|
472
|
+
await call(tools, 'scout_verify_claim', {
|
|
473
|
+
caseId: CASE_ID,
|
|
474
|
+
claimId,
|
|
475
|
+
evidenceLevel: 'E2',
|
|
476
|
+
sourceIds,
|
|
477
|
+
confidenceNote: '两处以上独立公开来源交叉一致(工商聚合 + 官网/媒体/官方页)。',
|
|
478
|
+
nextAction: '进入下一轮前以合同主体与最新工商页面复核。',
|
|
479
|
+
})
|
|
480
|
+
}
|
|
481
|
+
log(`verified ${verifyPlan.length} claims against independent sources`)
|
|
482
|
+
|
|
483
|
+
// 6. Role/market claims the research cannot answer before the interview.
|
|
484
|
+
let finalEnvelope = ingested
|
|
485
|
+
for (const item of ROLE_CLAIMS) {
|
|
486
|
+
finalEnvelope = await call(tools, 'scout_add_claim', { caseId: CASE_ID, ...item })
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// 7. The report must be complete.
|
|
490
|
+
const report = await call(tools, 'scout_report', { caseId: CASE_ID })
|
|
491
|
+
const text = report.report
|
|
492
|
+
check('report opens with a 30-second verdict and a readable subject table', () => {
|
|
493
|
+
assert.match(text, /^# 深圳市流数科技有限公司$/m)
|
|
494
|
+
assert.match(text, /## 30 秒结论\n\n\*\*继续核验\*\*/)
|
|
495
|
+
assert.match(text, /- 主体:深圳市流数科技有限公司(91440300MA5GMX9G9D)/)
|
|
496
|
+
assert.match(text, /- 业务:/)
|
|
497
|
+
assert.match(text, /\*\*最该先确认的 3 件事\*\*/)
|
|
498
|
+
assert.match(text, /## 1\. 公司与主体(已确认)\n\n\| 项目 \| 内容 \|/)
|
|
499
|
+
assert.match(text, /\| 核验等级 \| 已交叉核验:/)
|
|
500
|
+
})
|
|
501
|
+
check('the stage sections exist and cover all 11 dimensions', () => {
|
|
502
|
+
assert.equal(report.coverage.length, 11)
|
|
503
|
+
for (const heading of [
|
|
504
|
+
'## 2. 已确认的事实(阶段:可直接使用)',
|
|
505
|
+
'## 3. 线索与待确认(阶段:先别当事实)',
|
|
506
|
+
'## 4. 风险与红旗(阶段:已识别)',
|
|
507
|
+
'## 5. 面试问题(按主题,最多 12 条)',
|
|
508
|
+
'## 6. 距离下一步还差什么',
|
|
509
|
+
'## 附录 A · 来源表',
|
|
510
|
+
'## 附录 B · 主张台账(技术可追溯)',
|
|
511
|
+
'## 附录 C · 调查覆盖度',
|
|
512
|
+
'## 附录 D · 术语表',
|
|
513
|
+
]) {
|
|
514
|
+
assert.ok(text.includes(heading), `report is missing ${heading}`)
|
|
515
|
+
}
|
|
516
|
+
const coverage = text.slice(text.indexOf('## 附录 C · 调查覆盖度'), text.indexOf('## 附录 D · 术语表'))
|
|
517
|
+
for (const label of ['主体身份', '岗位真实性', '汇报关系', '授权与职责', '公司与创始人', '产品与技术', '市场与竞争', '融资与商业化', '组织与人力', '风险', '岗位匹配度']) {
|
|
518
|
+
assert.ok(coverage.includes(`- ${label}`), `coverage is missing ${label}`)
|
|
519
|
+
}
|
|
520
|
+
})
|
|
521
|
+
check('the body speaks human labels: no E-codes, no source ids, no internal statuses', () => {
|
|
522
|
+
const body = text.slice(0, text.indexOf('## 附录 A · 来源表'))
|
|
523
|
+
assert.doesNotMatch(body, /\bE[0-3]\b/, 'the reader-facing body must not expose evidence codes')
|
|
524
|
+
assert.doesNotMatch(body, /src-\d+/, 'the reader-facing body must not expose source ids')
|
|
525
|
+
assert.doesNotMatch(body, /\b(verified|reported|inferred|contradicted|needs_verification|blocking|material|informational)\b/i,
|
|
526
|
+
'the reader-facing body must not expose internal enum values')
|
|
527
|
+
assert.match(body, /已核验|有报道|推断|未知/)
|
|
528
|
+
assert.match(text, /\| E3 \| 官方\/权威来源 \|/)
|
|
529
|
+
})
|
|
530
|
+
check('sources and ledger appendices keep the audit trail', () => {
|
|
531
|
+
assert.match(text, /- src-1: https:\/\/www\.qixin\.com/)
|
|
532
|
+
assert.match(text, /\| src-1 \| .* \| 公开数据库 \| 独立来源 \| 2026-11-07 \|/)
|
|
533
|
+
assert.match(text, /\| src-6 \| .* \| 政府\/监管 \| 官方\/权威来源 \| 2026-11-07 \|/)
|
|
534
|
+
assert.match(text, /## 附录 B · 主张台账[\s\S]*已核验 · 独立来源/)
|
|
535
|
+
assert.match(text, /统一社会信用代码=src-1, src-2, src-5/)
|
|
536
|
+
})
|
|
537
|
+
check('interview questions are real questions, grouped, capped, and never research actions', () => {
|
|
538
|
+
const block = text.slice(text.indexOf('## 5. 面试问题'), text.indexOf('## 6. 距离下一步还差什么'))
|
|
539
|
+
const questions = block.split('\n').filter(line => line.startsWith('- '))
|
|
540
|
+
assert.equal(questions.length, 12)
|
|
541
|
+
assert.ok(questions.every(q => q.trim().length > 8))
|
|
542
|
+
assert.ok(!block.includes('核验该主张并补充独立来源'))
|
|
543
|
+
assert.ok(!block.includes('下一步'))
|
|
544
|
+
assert.match(block, /### 岗位与团队/)
|
|
545
|
+
assert.match(block, /### 资本与现金/)
|
|
546
|
+
assert.match(block, /- 这个岗位在前三个月最重要的业务结果是什么?/)
|
|
547
|
+
assert.match(block, /- 主要竞争对手是谁,公司的差异化与增长来源是什么?/)
|
|
548
|
+
})
|
|
549
|
+
log(`report: ${report.decision}, ${report.coverage.length} dimensions, complete sections`)
|
|
550
|
+
|
|
551
|
+
// 8. Export into the session workspace (the P0 path) and check the artifact.
|
|
552
|
+
const exported = await call(tools, 'scout_export', { caseId: CASE_ID })
|
|
553
|
+
check('export writes six files into the workspace under the session policy', () => {
|
|
554
|
+
assert.equal(exported.persisted, true, exported.error ?? 'persist failed')
|
|
555
|
+
assert.equal(exported.complete, true)
|
|
556
|
+
assert.equal(exported.targetDir, EXPORT_DIR)
|
|
557
|
+
})
|
|
558
|
+
const files = await readdir(EXPORT_DIR)
|
|
559
|
+
check('export directory contains the five data files plus the marker', () => {
|
|
560
|
+
for (const name of ['case.json', 'sources.json', 'claims.json', 'events.jsonl', 'report.md', 'export.complete.json']) {
|
|
561
|
+
assert.ok(files.includes(name), `missing ${name}`)
|
|
562
|
+
}
|
|
563
|
+
})
|
|
564
|
+
const marker = JSON.parse(await readFile(join(EXPORT_DIR, 'export.complete.json'), 'utf8'))
|
|
565
|
+
const events = (await readFile(join(EXPORT_DIR, 'events.jsonl'), 'utf8')).trim().split('\n')
|
|
566
|
+
const exportedReport = await readFile(join(EXPORT_DIR, 'report.md'), 'utf8')
|
|
567
|
+
check('marker is complete and its eventCount matches events.jsonl', () => {
|
|
568
|
+
assert.equal(marker.complete, true)
|
|
569
|
+
assert.equal(marker.eventCount, events.length)
|
|
570
|
+
})
|
|
571
|
+
check('exported report.md is byte-equal to scout_report output', () => {
|
|
572
|
+
assert.equal(exportedReport, text, 'report.md must equal the scout_report artifact')
|
|
573
|
+
})
|
|
574
|
+
check('every export write carried the session sandbox policy', () => {
|
|
575
|
+
assert.ok(writes.length >= 7, 'marker false + 5 files + marker true')
|
|
576
|
+
assert.ok(writes.every(path => path.startsWith(EXPORT_DIR)), 'all writes stayed inside the export dir')
|
|
577
|
+
})
|
|
578
|
+
|
|
579
|
+
// 9. Cold rehydration: a fresh mount must restore the case from disk.
|
|
580
|
+
const coldTools = mount()
|
|
581
|
+
const coldReport = await call(coldTools, 'scout_report', { caseId: CASE_ID })
|
|
582
|
+
check('cold start rehydrates the case from the workspace export', () => {
|
|
583
|
+
assert.equal(coldReport.decision, report.decision)
|
|
584
|
+
assert.equal(coldReport.report, text)
|
|
585
|
+
})
|
|
586
|
+
// A fresh mount that has NOT touched the case would silently replace the
|
|
587
|
+
// on-disk export before 0.9.4; now it refuses unless overwrite is explicit.
|
|
588
|
+
const guardTools = mount()
|
|
589
|
+
await assert.rejects(
|
|
590
|
+
() => call(guardTools, 'scout_start', {
|
|
591
|
+
caseId: CASE_ID, companyName: 'X', roleTitle: 'Y', location: '',
|
|
592
|
+
}),
|
|
593
|
+
/completed export already exists/,
|
|
594
|
+
)
|
|
595
|
+
log('cold rehydration works; same-name start is guarded on disk')
|
|
596
|
+
|
|
597
|
+
// 10. Summary artifact.
|
|
598
|
+
const summary = [
|
|
599
|
+
'# 0.9.4 验收运行(acceptance-report.mjs)',
|
|
600
|
+
'',
|
|
601
|
+
`- 运行时间:${new Date().toISOString()}`,
|
|
602
|
+
`- case:${CASE_ID}(深圳市流数科技有限公司,岗位未指定)`,
|
|
603
|
+
`- 决策:${report.decision}`,
|
|
604
|
+
`- 来源 ${finalEnvelope.counts.sources} 个 / 主张 ${finalEnvelope.counts.claims} 条(其中 ${finalEnvelope.counts.verified} 条 verified)`,
|
|
605
|
+
`- 导出目录:${EXPORT_DIR}(marker complete: true,events ${events.length} 行)`,
|
|
606
|
+
'',
|
|
607
|
+
'## 断言结果',
|
|
608
|
+
'',
|
|
609
|
+
...checks.map(item => `- ${item.ok ? 'PASS' : 'FAIL'} — ${item.name}`),
|
|
610
|
+
'',
|
|
611
|
+
].join('\n')
|
|
612
|
+
await writeFile(join(OUT_ROOT, 'summary.md'), summary, 'utf8')
|
|
613
|
+
|
|
614
|
+
console.log('\n' + summary)
|
|
615
|
+
console.log(`全部 ${checks.length} 项断言通过;导出的报告:${join(EXPORT_DIR, 'report.md')}`)
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
main().catch(async error => {
|
|
619
|
+
console.error('\nACCEPTANCE FAILED:', error)
|
|
620
|
+
console.error(`已完成 ${checks.filter(item => item.ok).length} 项断言,最后失败:${checks.at(-1)?.name}`)
|
|
621
|
+
if (!KEEP) process.exitCode = 1
|
|
622
|
+
})
|