dsh-fact-check 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ADAPTING.md +85 -0
- package/CHANGELOG.md +100 -0
- package/CONTRIBUTING.md +59 -0
- package/CONTRIBUTING.zh-CN.md +55 -0
- package/FOR-AI.md +193 -0
- package/INSTRUCTIONS.md +273 -0
- package/LICENSE +21 -0
- package/MEASUREMENTS.md +143 -0
- package/README.md +191 -0
- package/README.zh-CN.md +176 -0
- package/SECURITY.md +74 -0
- package/SECURITY.zh-CN.md +55 -0
- package/SKILL.md +273 -0
- package/cases/README.md +57 -0
- package/cases/dispatch-blind-neutral.json +171 -0
- package/cases/dispatch-label-L1-neutral.json +207 -0
- package/cases/dispatch-label-L2-neutral.json +207 -0
- package/cases/dispatch-label-LLM-neutral.json +207 -0
- package/cases/injection-cases-neutral.json +131 -0
- package/cases/provenance-cases-neutral.json +203 -0
- package/cases/route-cases-neutral.json +164 -0
- package/cases/sufficient-cases-neutral.json +428 -0
- package/cases/support-cases-neutral.json +388 -0
- package/cases/usable-page-cases-neutral.json +143 -0
- package/cordis.patch.yml +16 -0
- package/lib/index.js +141 -0
- package/package.json +75 -0
- package/tools/doctor.mjs +318 -0
- package/tools/engine-routes.json +149 -0
- package/tools/jev-verdict.mjs +636 -0
- package/tools/local.example.json +30 -0
- package/tools/make-instructions.mjs +30 -0
- package/tools/route.mjs +410 -0
- package/tools/smoke-plugin.mjs +130 -0
- package/tools/source-matrix.json +117 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fact-check — DSH 技能提供者:把本包自带的 SKILL.md 挂到 `ctx.skills`。
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要这一层:DSH 插件市场只收「能用 `dsh plugin add` 装上的包」——包必须在
|
|
5
|
+
* package.json 里声明 `dsh.bundle`,并在包根放一份 cordis.patch.yml。而技能正文只是
|
|
6
|
+
* 一份 SKILL.md,它要进入会话的技能目录就需要一个 provider,本文件就是那层很薄的接线。
|
|
7
|
+
*
|
|
8
|
+
* 两条刻意的性质:
|
|
9
|
+
* · **不复制技能正文** —— 候选与正文都在调用时从 SKILL.md 现读。改 SKILL.md 不必动本文件,
|
|
10
|
+
* 也不会出现「描述与正文两份、各自漂移」。
|
|
11
|
+
* · **零依赖** —— 只 import `node:` 内置模块(与 tools/ 下两个脚本同一原则)。判定逻辑、
|
|
12
|
+
* 路由表、回归语料全留在 tools/ 与 cases/,与宿主无关。
|
|
13
|
+
*
|
|
14
|
+
* @module dsh-fact-check
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { readFile } from 'node:fs/promises'
|
|
18
|
+
import { fileURLToPath } from 'node:url'
|
|
19
|
+
|
|
20
|
+
/** 包根(lib/ 的上一级)。SKILL.md、tools/、cases/ 都在那里。 */
|
|
21
|
+
const ROOT_URL = new URL('..', import.meta.url)
|
|
22
|
+
const SKILL_URL = new URL('SKILL.md', ROOT_URL)
|
|
23
|
+
|
|
24
|
+
/** provider 名,在 `ctx.skills` 注册表里唯一。 */
|
|
25
|
+
const PROVIDER_NAME = 'fact-check'
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* bundled 档的秩。DSH 里 `BUNDLED_SKILL_RANK = 600`(本地 provider 的其余档位:
|
|
29
|
+
* project 100/200、custom 300、user-dsh 400、user-agents 500)。
|
|
30
|
+
*
|
|
31
|
+
* 这里写常量而不是 import 那个导出,是为了保持零依赖。秩只在**同名技能**上起作用,
|
|
32
|
+
* 而低秩胜出:装了本插件的机器若同时在 `~/.dsh/skills/fact-check` 放了一份本地特化版
|
|
33
|
+
* (user-dsh = 400),本地那份仍然优先——这是刻意的,插件不该覆盖用户自己的版本。
|
|
34
|
+
*/
|
|
35
|
+
const RANK = 600
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 读 SKILL.md 的 YAML frontmatter。只认本技能真正用到的四个键,因此不需要 YAML 依赖:
|
|
39
|
+
* `name` / `description` / `disable-model-invocation` / `user-invocable`。
|
|
40
|
+
*
|
|
41
|
+
* @param {string} text - SKILL.md 全文。
|
|
42
|
+
* @returns {{fm: Record<string, string>, body: string}} frontmatter 键值表与去掉它的正文。
|
|
43
|
+
*/
|
|
44
|
+
export function parseFrontmatter(text) {
|
|
45
|
+
// 去掉可选的 BOM 并把 CRLF 归一为 LF:编辑器编码与 Windows 检出不该改变解析结果。
|
|
46
|
+
const src = text.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n')
|
|
47
|
+
if (!src.startsWith('---')) return { fm: {}, body: text }
|
|
48
|
+
const end = src.indexOf('\n---', 3)
|
|
49
|
+
if (end === -1) return { fm: {}, body: text }
|
|
50
|
+
const fm = {}
|
|
51
|
+
for (const line of src.slice(3, end).split('\n')) {
|
|
52
|
+
const m = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/)
|
|
53
|
+
if (m) fm[m[1]] = m[2].trim().replace(/^["']|["']$/g, '')
|
|
54
|
+
}
|
|
55
|
+
return { fm, body: src.slice(end + 4).trimStart() }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 技能名必须匹配 DSH 的 kebab-case 语法,否则注册表会拒收这个候选。 */
|
|
59
|
+
export function isSkillName(name) {
|
|
60
|
+
return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(String(name))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** frontmatter 的调用开关:省略即两处都开。 */
|
|
64
|
+
function invocationOf(fm) {
|
|
65
|
+
return {
|
|
66
|
+
modelInvocable: String(fm['disable-model-invocation'] ?? '').toLowerCase() !== 'true',
|
|
67
|
+
userInvocable: String(fm['user-invocable'] ?? '').toLowerCase() !== 'false',
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 读候选:每次调用都重读 SKILL.md,所以正文改完立刻生效,不需要版本号或缓存失效。
|
|
73
|
+
*
|
|
74
|
+
* 读失败**不吞** —— 让 `list()` 拒绝比返回空数组诚实:注册表会把拒绝记成一次不完整的
|
|
75
|
+
* 观测并保留上一次的好目录,而返回空数组等于告诉模型「这个技能被删了」。
|
|
76
|
+
*
|
|
77
|
+
* @returns {Promise<object[]>} 本包只提供一条技能,因此是长度为 1 的数组。
|
|
78
|
+
*/
|
|
79
|
+
export async function list() {
|
|
80
|
+
const { fm } = parseFrontmatter(await readFile(SKILL_URL, 'utf8'))
|
|
81
|
+
if (!isSkillName(fm.name)) {
|
|
82
|
+
throw new Error(`SKILL.md 的 frontmatter name 不是 kebab-case:${JSON.stringify(fm.name)}`)
|
|
83
|
+
}
|
|
84
|
+
if (!fm.description) throw new Error('SKILL.md 的 frontmatter 缺少 description')
|
|
85
|
+
return [
|
|
86
|
+
{
|
|
87
|
+
name: fm.name,
|
|
88
|
+
description: fm.description,
|
|
89
|
+
invocation: invocationOf(fm),
|
|
90
|
+
provider: PROVIDER_NAME,
|
|
91
|
+
source: 'bundled',
|
|
92
|
+
// 资源基准指向包根:正文里写的 `tools/doctor.mjs`、`tools/route.mjs` 因此可解析,
|
|
93
|
+
// 换成 skills/ 子目录会让这些相对路径失效。
|
|
94
|
+
resourceBase: { kind: 'directory', path: fileURLToPath(ROOT_URL) },
|
|
95
|
+
rank: RANK,
|
|
96
|
+
locator: SKILL_URL,
|
|
97
|
+
},
|
|
98
|
+
]
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 读正文:frontmatter 已剥离,返回的就是指令正文。
|
|
103
|
+
*
|
|
104
|
+
* @param {object} candidate - `list()` 给出的候选。
|
|
105
|
+
* @returns {Promise<object>} 完整技能定义。
|
|
106
|
+
*/
|
|
107
|
+
export async function get(candidate) {
|
|
108
|
+
const { fm, body } = parseFrontmatter(await readFile(SKILL_URL, 'utf8'))
|
|
109
|
+
// 正文里的 name 与候选不一致说明两次读之间文件被换过 —— 抛出去让注册表重新发现,
|
|
110
|
+
// 而不是交出一份名不副实的定义。
|
|
111
|
+
if (fm.name !== candidate.name) {
|
|
112
|
+
throw new Error(`SKILL.md 的 name 已变为 ${JSON.stringify(fm.name)},与候选 ${JSON.stringify(candidate.name)} 不符`)
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
name: candidate.name,
|
|
116
|
+
description: fm.description || candidate.description,
|
|
117
|
+
invocation: candidate.invocation,
|
|
118
|
+
provider: PROVIDER_NAME,
|
|
119
|
+
source: 'bundled',
|
|
120
|
+
resourceBase: candidate.resourceBase,
|
|
121
|
+
content: body,
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** 本 provider 的实例(无内部状态:不缓存正文,全部现读)。 */
|
|
126
|
+
export const provider = { name: PROVIDER_NAME, list, get }
|
|
127
|
+
|
|
128
|
+
/** Cordis 插件名(与 cordis.patch.yml 的 `id` 一致)。 */
|
|
129
|
+
export const name = PROVIDER_NAME
|
|
130
|
+
/** 本插件依赖的服务:技能注册表。 */
|
|
131
|
+
export const inject = ['skills']
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* 挂载:把本 provider 注册到 `ctx.skills`。注册是同步的,发现与读正文都在 awaits 里。
|
|
135
|
+
*
|
|
136
|
+
* @param {{skills: {registerProvider: Function}}} ctx - Cordis 上下文。
|
|
137
|
+
* @returns {void}
|
|
138
|
+
*/
|
|
139
|
+
export function apply(ctx) {
|
|
140
|
+
ctx.skills.registerProvider(() => provider)
|
|
141
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-fact-check",
|
|
3
|
+
"version": "1.3.0",
|
|
4
|
+
"description": "Fact-checking skill for DeepSeek Harness: verifies claims against the open web instead of model memory, cross-checks independent sources across domestic and international search ecosystems, and can route three factual judgements (does the evidence support the claim / is this source primary / does this page carry usable text) to a TypeSafe Jev (System One) decision model when a key is configured, falling back to the skill's deterministic rules when it is not.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"deepseek-harness",
|
|
7
|
+
"dsh",
|
|
8
|
+
"dsh-plugin",
|
|
9
|
+
"cordis",
|
|
10
|
+
"skill",
|
|
11
|
+
"fact-check",
|
|
12
|
+
"verification",
|
|
13
|
+
"research",
|
|
14
|
+
"cross-check",
|
|
15
|
+
"jev",
|
|
16
|
+
"typesafe",
|
|
17
|
+
"provenance",
|
|
18
|
+
"prompt-injection"
|
|
19
|
+
],
|
|
20
|
+
"type": "module",
|
|
21
|
+
"main": "lib/index.js",
|
|
22
|
+
"exports": {
|
|
23
|
+
".": "./lib/index.js",
|
|
24
|
+
"./package.json": "./package.json"
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"selftest": "node tools/smoke-plugin.mjs && node tools/jev-verdict.mjs selftest && node tools/route.mjs selftest",
|
|
28
|
+
"doctor": "node tools/doctor.mjs",
|
|
29
|
+
"regress": "node tools/route.mjs regress --file cases/route-cases-neutral.json",
|
|
30
|
+
"arms": "node tools/route.mjs arms",
|
|
31
|
+
"instructions": "node tools/make-instructions.mjs"
|
|
32
|
+
},
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=18"
|
|
35
|
+
},
|
|
36
|
+
"dsh": {
|
|
37
|
+
"bundle": {
|
|
38
|
+
"patch": "./cordis.patch.yml"
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"files": [
|
|
42
|
+
"lib",
|
|
43
|
+
"tools",
|
|
44
|
+
"!tools/local.json",
|
|
45
|
+
"cases",
|
|
46
|
+
"SKILL.md",
|
|
47
|
+
"INSTRUCTIONS.md",
|
|
48
|
+
"FOR-AI.md",
|
|
49
|
+
"ADAPTING.md",
|
|
50
|
+
"MEASUREMENTS.md",
|
|
51
|
+
"CHANGELOG.md",
|
|
52
|
+
"cordis.patch.yml",
|
|
53
|
+
"README.md",
|
|
54
|
+
"README.zh-CN.md",
|
|
55
|
+
"CONTRIBUTING.md",
|
|
56
|
+
"CONTRIBUTING.zh-CN.md",
|
|
57
|
+
"SECURITY.md",
|
|
58
|
+
"SECURITY.zh-CN.md",
|
|
59
|
+
"LICENSE"
|
|
60
|
+
],
|
|
61
|
+
"license": "MIT",
|
|
62
|
+
"author": "7starsseeker",
|
|
63
|
+
"repository": {
|
|
64
|
+
"type": "git",
|
|
65
|
+
"url": "git+https://github.com/7starsseeker/dsh-fact-check.git"
|
|
66
|
+
},
|
|
67
|
+
"homepage": "https://github.com/7starsseeker/dsh-fact-check#readme",
|
|
68
|
+
"bugs": {
|
|
69
|
+
"url": "https://github.com/7starsseeker/dsh-fact-check/issues"
|
|
70
|
+
},
|
|
71
|
+
"publishConfig": {
|
|
72
|
+
"access": "public",
|
|
73
|
+
"registry": "https://registry.npmjs.org/"
|
|
74
|
+
}
|
|
75
|
+
}
|
package/tools/doctor.mjs
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* doctor — capability probe for THIS machine.
|
|
4
|
+
*
|
|
5
|
+
* The skill and its two tools are written to be environment-neutral: no host,
|
|
6
|
+
* path or endpoint is baked in. This script tells you what the current machine
|
|
7
|
+
* actually provides, what is missing, and what the skill will degrade to.
|
|
8
|
+
*
|
|
9
|
+
* node tools/doctor.mjs # offline checks only
|
|
10
|
+
* node tools/doctor.mjs --net # also probe the endpoint, the local search service, and the fetch chain
|
|
11
|
+
* node tools/doctor.mjs --write # write tools/local.json from detected values (won't overwrite)
|
|
12
|
+
* node tools/doctor.mjs --json # machine-readable
|
|
13
|
+
*
|
|
14
|
+
* It never prints a secret — only where a key was found.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'
|
|
18
|
+
import { dirname, join, isAbsolute, resolve } from 'node:path'
|
|
19
|
+
import { fileURLToPath } from 'node:url'
|
|
20
|
+
import { homedir, tmpdir } from 'node:os'
|
|
21
|
+
import { spawnSync } from 'node:child_process'
|
|
22
|
+
|
|
23
|
+
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
24
|
+
const ROOT = join(HERE, '..')
|
|
25
|
+
const argv = process.argv.slice(2)
|
|
26
|
+
const has = (f) => argv.includes(f)
|
|
27
|
+
|
|
28
|
+
const report = { ok: [], warn: [], fail: [], info: {}, willUse: {} }
|
|
29
|
+
|
|
30
|
+
function readJson(file) {
|
|
31
|
+
try {
|
|
32
|
+
return JSON.parse(readFileSync(file, 'utf8'))
|
|
33
|
+
} catch {
|
|
34
|
+
return null
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function localConfig() {
|
|
39
|
+
return readJson(join(HERE, 'local.json')) || {}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/* ---------------- runtime ---------------- */
|
|
43
|
+
|
|
44
|
+
const major = Number(process.versions.node.split('.')[0])
|
|
45
|
+
report.info.node = process.versions.node
|
|
46
|
+
report.info.platform = `${process.platform} ${process.arch}`
|
|
47
|
+
if (major >= 18) report.ok.push(`Node ${process.versions.node}(≥18,内置 fetch / AbortSignal.timeout 可用)`)
|
|
48
|
+
else report.fail.push(`Node ${process.versions.node} 过旧:需要 ≥18(内置 fetch)。判据层不可用,流程仍可按 SKILL.md 人工执行`)
|
|
49
|
+
|
|
50
|
+
/* ---------------- key ---------------- */
|
|
51
|
+
|
|
52
|
+
const cfg = localConfig()
|
|
53
|
+
const keySources = [
|
|
54
|
+
['环境变量 TYPESAFE_API_KEY', process.env.TYPESAFE_API_KEY ? '(set)' : null],
|
|
55
|
+
['环境变量 JEV_KEY_FILE', process.env.JEV_KEY_FILE || null],
|
|
56
|
+
['local.json keyFile', cfg.keyFile || null],
|
|
57
|
+
...(Array.isArray(cfg.keyFiles) ? cfg.keyFiles.map((f) => ['local.json keyFiles[]', f]) : []),
|
|
58
|
+
['包内 tools/secrets.json', existsSync(join(HERE, 'secrets.json')) ? join(HERE, 'secrets.json') : null],
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
function keyInFile(file) {
|
|
62
|
+
const j = readJson(isAbsolute(file) ? file : resolve(process.cwd(), file))
|
|
63
|
+
return !!(j && (j.TYPESAFE_API_KEY || j.apiKey || j.key))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let keyFoundFrom = null
|
|
67
|
+
for (const [label, value] of keySources) {
|
|
68
|
+
if (!value) continue
|
|
69
|
+
if (value === '(set)' || keyInFile(value)) {
|
|
70
|
+
keyFoundFrom = label
|
|
71
|
+
break
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (keyFoundFrom) {
|
|
75
|
+
report.ok.push(`密钥来源:${keyFoundFrom}(值不打印)`)
|
|
76
|
+
report.willUse.decisionModel = true
|
|
77
|
+
} else {
|
|
78
|
+
report.warn.push('未找到密钥:决策模型判据层不可用 —— 这不是错误,技能会按 SKILL.md 的确定性规则运行(判据三件事人工执行)')
|
|
79
|
+
report.willUse.decisionModel = false
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/* ---------------- endpoint / model ---------------- */
|
|
83
|
+
|
|
84
|
+
const endpoint = process.env.JEV_ENDPOINT || cfg.endpoint || 'https://api.typesafe.ai/v1/systemone'
|
|
85
|
+
const model = process.env.JEV_MODEL || cfg.model || 'jev-latest'
|
|
86
|
+
report.info.endpoint = endpoint
|
|
87
|
+
report.info.model = model
|
|
88
|
+
report.ok.push(`端点 ${endpoint} · 模型 ${model}(可用 JEV_ENDPOINT / JEV_MODEL / local.json 改)`)
|
|
89
|
+
|
|
90
|
+
/* ---------------- state dir ---------------- */
|
|
91
|
+
|
|
92
|
+
function stateDir() {
|
|
93
|
+
if (process.env.JEV_VERDICT_STATE_DIR) return process.env.JEV_VERDICT_STATE_DIR
|
|
94
|
+
if (cfg.stateDir) return cfg.stateDir
|
|
95
|
+
if (process.env.JEV_VERDICT_LOG) return dirname(process.env.JEV_VERDICT_LOG)
|
|
96
|
+
if (process.platform === 'win32' && process.env.LOCALAPPDATA) return join(process.env.LOCALAPPDATA, 'fact-check-jev')
|
|
97
|
+
if (process.env.XDG_STATE_HOME) return join(process.env.XDG_STATE_HOME, 'fact-check-jev')
|
|
98
|
+
return join(homedir(), '.local', 'state', 'fact-check-jev')
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const dir = stateDir()
|
|
102
|
+
let writable = false
|
|
103
|
+
try {
|
|
104
|
+
mkdirSync(dir, { recursive: true })
|
|
105
|
+
const probe = join(dir, '.doctor-probe')
|
|
106
|
+
writeFileSync(probe, 'ok')
|
|
107
|
+
rmSync(probe)
|
|
108
|
+
writable = true
|
|
109
|
+
} catch {
|
|
110
|
+
writable = false
|
|
111
|
+
}
|
|
112
|
+
report.info.stateDir = dir
|
|
113
|
+
if (writable) report.ok.push(`日志目录可写:${dir}`)
|
|
114
|
+
else report.warn.push(`日志目录不可写:${dir} —— 日志会退化为静默跳过(stderr 提示一次),判定不受影响;可用 JEV_VERDICT_LOG 指到可写路径`)
|
|
115
|
+
|
|
116
|
+
/* ---------------- placeholders in the route tables ---------------- */
|
|
117
|
+
|
|
118
|
+
const routes = readJson(join(HERE, 'engine-routes.json'))
|
|
119
|
+
const placeholders = new Set()
|
|
120
|
+
const walk = (v) => {
|
|
121
|
+
if (typeof v === 'string') for (const m of v.matchAll(/\$\{([A-Z0-9_]+)\}/g)) placeholders.add(m[1])
|
|
122
|
+
else if (Array.isArray(v)) v.forEach(walk)
|
|
123
|
+
else if (v && typeof v === 'object') Object.values(v).forEach(walk)
|
|
124
|
+
}
|
|
125
|
+
if (routes) walk(routes)
|
|
126
|
+
const ov = cfg.overrides || {}
|
|
127
|
+
const unset = []
|
|
128
|
+
const resolved = []
|
|
129
|
+
for (const name of placeholders) {
|
|
130
|
+
const v = process.env[`JEV_${name}`] || process.env[name] || ov[name]
|
|
131
|
+
;(v ? resolved : unset).push(name)
|
|
132
|
+
}
|
|
133
|
+
report.info.placeholders = { resolved, unset }
|
|
134
|
+
if (unset.length) {
|
|
135
|
+
report.warn.push(`渠道表里有 ${unset.length} 个占位符未填:${unset.join(', ')} —— 计划里会原样显示占位符并列入 localPlaceholdersUnset;填法见 local.example.json`)
|
|
136
|
+
} else if (placeholders.size) {
|
|
137
|
+
report.ok.push(`渠道表的 ${placeholders.size} 个占位符都已填(env 或 local.json)`)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/* ---------------- optional local tools ---------------- */
|
|
141
|
+
|
|
142
|
+
const OPTIONAL = [
|
|
143
|
+
['exiftool', '读取图片/视频元数据(相册溯源)', 'EXIFTOOL_CMD'],
|
|
144
|
+
['whois', '域名注册信息(系统 CLI,未装可用 python-whois 模块)', null],
|
|
145
|
+
['python3', '跑 python-whois / trafilatura 等脚本', null],
|
|
146
|
+
['curl', '抓取与探测的兜底手段', null],
|
|
147
|
+
['zip', '打包分发', null],
|
|
148
|
+
]
|
|
149
|
+
|
|
150
|
+
function whichAll(cmd) {
|
|
151
|
+
const probe = process.platform === 'win32' ? 'where' : 'which'
|
|
152
|
+
const r = spawnSync(probe, [cmd], { encoding: 'utf8' })
|
|
153
|
+
if (r.status !== 0 || !r.stdout.trim()) return []
|
|
154
|
+
// Windows 的 where 用 CRLF 分行;trim() 只削掉整串末尾的 \r\n,
|
|
155
|
+
// 第一行行尾的 \r 会留下来,在终端里回车覆盖行首(表现为"吞掉半行")。
|
|
156
|
+
return r.stdout.trim().split(/\r?\n/).map((s) => s.trim()).filter(Boolean)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function which(cmd) {
|
|
160
|
+
return whichAll(cmd)[0] || null
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
report.info.optionalTools = {}
|
|
164
|
+
for (const [cmd, why, envName] of OPTIONAL) {
|
|
165
|
+
const configured = envName ? (process.env[`JEV_${envName}`] || process.env[envName] || ov[envName]) : null
|
|
166
|
+
if (configured && existsSync(configured)) {
|
|
167
|
+
report.info.optionalTools[cmd] = configured
|
|
168
|
+
report.ok.push(`可选能力 ${cmd}:${configured}${why ? `(${why})` : ''}`)
|
|
169
|
+
continue
|
|
170
|
+
}
|
|
171
|
+
const found = whichAll(cmd)
|
|
172
|
+
report.info.optionalTools[cmd] = found[0] || null
|
|
173
|
+
if (!found.length) continue
|
|
174
|
+
report.ok.push(`可选能力 ${cmd}:${found[0]}${why ? `(${why})` : ''}`)
|
|
175
|
+
// 多路径命中时全部列出:Windows 上常见"Store 存根 + 真实安装"并存,存根往往不能用
|
|
176
|
+
if (found.length > 1) {
|
|
177
|
+
report.info.optionalTools[cmd] = found
|
|
178
|
+
report.warn.push(`${cmd} 有多条命中,上面列的是第一条,其余自行确认可用性:${found.slice(1).join(' / ')}`)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// python modules that matter
|
|
183
|
+
if (which('python3')) {
|
|
184
|
+
for (const [mod, why] of [['whois', '域名注册信息'], ['zstandard', '读压缩会话/日志']]) {
|
|
185
|
+
const r = spawnSync('python3', ['-c', `import ${mod}`], { encoding: 'utf8' })
|
|
186
|
+
if (r.status === 0) report.ok.push(`python 模块 ${mod} 可用(${why})`)
|
|
187
|
+
else report.warn.push(`python 模块 ${mod} 不可用(${why})—— 有替代就用替代`)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/* ---------------- network (opt-in) ---------------- */
|
|
192
|
+
|
|
193
|
+
if (has('--net')) {
|
|
194
|
+
/* ---- 正文抓取链自测:which(curl) 有结果 ≠ 抓取可用,所以真跑一次 ----
|
|
195
|
+
* 只自测本脚本能碰到的两条(node fetch / curl)。宿主自身的内置抓取能力
|
|
196
|
+
* 与搜索服务自带的正文抽取字段,探不到,交由使用者在真实核查中确认。 */
|
|
197
|
+
const CHAIN_URL = 'https://example.com'
|
|
198
|
+
const visibleLen = (html) =>
|
|
199
|
+
String(html)
|
|
200
|
+
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
|
201
|
+
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
|
202
|
+
.replace(/<[^>]+>/g, ' ')
|
|
203
|
+
.replace(/\s+/g, ' ')
|
|
204
|
+
.trim().length
|
|
205
|
+
let chainsOk = 0
|
|
206
|
+
try {
|
|
207
|
+
const res = await fetch(CHAIN_URL, { signal: AbortSignal.timeout(12000) })
|
|
208
|
+
const n = visibleLen(await res.text())
|
|
209
|
+
if (res.ok && n >= 80) {
|
|
210
|
+
chainsOk++
|
|
211
|
+
report.ok.push(`抓取链自测 · fetch 直连:可用(${n} 字符可见正文)`)
|
|
212
|
+
} else report.warn.push(`抓取链自测 · fetch 直连:HTTP ${res.status},可见正文仅 ${n} 字符`)
|
|
213
|
+
} catch (e) {
|
|
214
|
+
report.warn.push(`抓取链自测 · fetch 直连:失败(${e.name})`)
|
|
215
|
+
}
|
|
216
|
+
const cprobe = spawnSync('curl', ['-sL', '-m', '20', '-A', 'Mozilla/5.0', CHAIN_URL], { encoding: 'utf8' })
|
|
217
|
+
if (cprobe.status === 0 && cprobe.stdout) {
|
|
218
|
+
const n = visibleLen(cprobe.stdout)
|
|
219
|
+
if (n >= 80) {
|
|
220
|
+
chainsOk++
|
|
221
|
+
report.ok.push(`抓取链自测 · curl 直连:可用(${n} 字符可见正文)`)
|
|
222
|
+
} else report.warn.push(`抓取链自测 · curl 直连:拿到响应但可见正文仅 ${n} 字符`)
|
|
223
|
+
} else {
|
|
224
|
+
report.warn.push(`抓取链自测 · curl 直连:失败(exit ${cprobe.status === null ? 'spawn error' : cprobe.status})`)
|
|
225
|
+
}
|
|
226
|
+
if (!chainsOk) {
|
|
227
|
+
report.fail.push(
|
|
228
|
+
'两条可自测的正文抓取链都失败了。注意本脚本探不到宿主自身的抓取能力——先手动抓一个页面确认,' +
|
|
229
|
+
'三条链全失败才按 SKILL.md 判"无法核实"并如实写进报告的"本次未能核实到的"',
|
|
230
|
+
)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const probes = []
|
|
234
|
+
if (ov.SEARXNG_URL || process.env.JEV_SEARXNG_URL) {
|
|
235
|
+
const u = ov.SEARXNG_URL || process.env.JEV_SEARXNG_URL
|
|
236
|
+
probes.push(['自托管元搜索', `${u.replace(/\/$/, '')}/healthz`])
|
|
237
|
+
}
|
|
238
|
+
probes.push(['判据端点(GET 预期 405/404,说明路由存在且不接 GET)', endpoint])
|
|
239
|
+
for (const [label, url] of probes) {
|
|
240
|
+
try {
|
|
241
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(8000) })
|
|
242
|
+
const kind = res.ok ? 'ok' : res.status === 405 || res.status === 404 ? 'warn' : 'fail'
|
|
243
|
+
const line = `${label}:HTTP ${res.status}(${url})`
|
|
244
|
+
if (kind === 'ok') report.ok.push(line)
|
|
245
|
+
else if (kind === 'warn') report.warn.push(`${line} —— 端点存在,但不要用 GET 调用它`)
|
|
246
|
+
else report.fail.push(line)
|
|
247
|
+
} catch (e) {
|
|
248
|
+
report.warn.push(`${label}:不可达(${e.name})—— 检查网络/代理/地址(${url})`)
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
} else {
|
|
252
|
+
report.info.networkProbe = '已跳过(加 --net 可探测端点与自托管搜索服务)'
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/* ---------------- --write ---------------- */
|
|
256
|
+
|
|
257
|
+
if (has('--write')) {
|
|
258
|
+
const target = join(HERE, 'local.json')
|
|
259
|
+
if (existsSync(target)) {
|
|
260
|
+
report.warn.push(`local.json 已存在,未覆盖:${target}`)
|
|
261
|
+
} else {
|
|
262
|
+
const generated = {
|
|
263
|
+
_about: '由 doctor.mjs 生成;可按需修改。请勿提交或分享。',
|
|
264
|
+
keyFile: keyFoundFrom && keyFoundFrom.startsWith('local.json') ? cfg.keyFile : null,
|
|
265
|
+
stateDir: dir,
|
|
266
|
+
endpoint,
|
|
267
|
+
model,
|
|
268
|
+
overrides: {
|
|
269
|
+
SEARXNG_URL: process.env.JEV_SEARXNG_URL || ov.SEARXNG_URL || null,
|
|
270
|
+
HTTP_PROXY_URL: process.env.JEV_HTTP_PROXY_URL || ov.HTTP_PROXY_URL || null,
|
|
271
|
+
VIDEO_FETCH_CMD: process.env.JEV_VIDEO_FETCH_CMD || ov.VIDEO_FETCH_CMD || null,
|
|
272
|
+
EXIFTOOL_CMD: process.env.JEV_EXIFTOOL_CMD || ov.EXIFTOOL_CMD || report.info.optionalTools.exiftool || null,
|
|
273
|
+
},
|
|
274
|
+
}
|
|
275
|
+
writeFileSync(target, JSON.stringify(generated, null, 2) + '\n', 'utf8')
|
|
276
|
+
report.ok.push(`已生成 ${target} —— 请把 null 的项按你的环境补上`)
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/* ---------------- print ---------------- */
|
|
281
|
+
|
|
282
|
+
if (has('--json')) {
|
|
283
|
+
console.log(JSON.stringify(report, null, 1))
|
|
284
|
+
} else {
|
|
285
|
+
const line = (s) => console.log(s)
|
|
286
|
+
line('')
|
|
287
|
+
line('# fact-check skill · 环境自检')
|
|
288
|
+
line('')
|
|
289
|
+
line(`运行环境:Node ${report.info.node} · ${report.info.platform}`)
|
|
290
|
+
line(`判据端点:${report.info.endpoint} · 模型:${report.info.model}`)
|
|
291
|
+
line(`日志目录:${report.info.stateDir}${writable ? '(可写)' : '(不可写,会静默跳过)'}`)
|
|
292
|
+
line('')
|
|
293
|
+
const show = (title, arr, mark) => {
|
|
294
|
+
if (!arr.length) return
|
|
295
|
+
line(`${title}`)
|
|
296
|
+
for (const x of arr) line(` ${mark} ${x}`)
|
|
297
|
+
line('')
|
|
298
|
+
}
|
|
299
|
+
show('可用', report.ok, '✓')
|
|
300
|
+
show('注意', report.warn, '!')
|
|
301
|
+
show('阻塞', report.fail, '✗')
|
|
302
|
+
line('结论:')
|
|
303
|
+
line(` · 核心核查流程:${report.fail.length ? '受限(见上)' : '可运行'}(不依赖任何本机服务,只需要能上网的搜索能力)`)
|
|
304
|
+
line(` · 确定性路由 route.mjs:可运行(纯数据表,${unset.length ? `尚有 ${unset.length} 个占位符待填` : '占位符已填妥'})`)
|
|
305
|
+
line(` · 决策模型判据 jev-verdict.mjs:${report.willUse.decisionModel ? '可用' : '不可用(缺密钥)——会按 SKILL.md 的确定性规则执行'}`)
|
|
306
|
+
line('')
|
|
307
|
+
if (report.info.networkProbe) line(`提示:${report.info.networkProbe}`)
|
|
308
|
+
if (has('--net')) {
|
|
309
|
+
line(
|
|
310
|
+
'提示:抓取链只自测了 fetch / curl 两条;宿主内置抓取(web_extract / WebFetch 之类)与搜索服务自带的' +
|
|
311
|
+
'正文抽取字段探不到,请各自用一次真实抓取确认——这三条是"任一可用即可"的能力族。',
|
|
312
|
+
)
|
|
313
|
+
}
|
|
314
|
+
line('下一步:cp tools/local.example.json tools/local.json 并按上面提示填写;或 node tools/doctor.mjs --write 生成草稿。')
|
|
315
|
+
line('')
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
process.exit(0)
|