dsh-quality-lens 0.1.0 → 0.1.1
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/README.md +4 -2
- package/lib/host.js +16 -3
- package/lib/scoring.js +37 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](LICENSE)
|
|
4
4
|
[](https://github.com/deepseek-ai/DeepSeek-Harness)
|
|
5
|
+
[](https://www.npmjs.com/package/dsh-quality-lens)
|
|
5
6
|
[](https://github.com/lishinho/dsh-quality-lens/actions/workflows/test.yml)
|
|
6
7
|
|
|
7
8
|
> 给每个 AI 回答做一次体检:动作行显示 **质量分 + 话题关联度**,点开是带证据的质检报告。
|
|
@@ -80,7 +81,7 @@
|
|
|
80
81
|
2. 来源选 **本地目录**,选择本仓库根目录(包含 `package.json`);
|
|
81
82
|
3. 重启 / 刷新 DSH。
|
|
82
83
|
|
|
83
|
-
**GitHub / npm
|
|
84
|
+
**GitHub / npm**
|
|
84
85
|
|
|
85
86
|
```sh
|
|
86
87
|
dsh plugin --profile web add github:lishinho/dsh-quality-lens
|
|
@@ -151,6 +152,7 @@ node scripts/calibrate.mjs --in calibration/samples.labeled.jsonl
|
|
|
151
152
|
- 默认引擎在本地完成,**不调用模型、不联网**;
|
|
152
153
|
- 报告只存本机 `$DSH_HOME/quality-lens/reports.jsonl`,删目录即清空;
|
|
153
154
|
- 无遥测;Jev 模式需显式配置 key,且仅发送当前问答;
|
|
155
|
+
- 报告、证据、提示词建议里的凭证样式字符串(`npm_` / `ghp_` / `sk-` 等)会自动打码;旧报告在加载时也会被清洗。
|
|
154
156
|
- 校准只读你自己的本机会话,不上传。
|
|
155
157
|
|
|
156
158
|
---
|
|
@@ -166,7 +168,7 @@ node scripts/calibrate.mjs --in calibration/samples.labeled.jsonl
|
|
|
166
168
|
## 开发
|
|
167
169
|
|
|
168
170
|
```sh
|
|
169
|
-
node --test test/scoring.test.mjs test/host.test.mjs test/client-bundle.test.mjs test/labels-jev.test.mjs #
|
|
171
|
+
node --test test/scoring.test.mjs test/host.test.mjs test/client-bundle.test.mjs test/labels-jev.test.mjs # 32 tests
|
|
170
172
|
node scripts/demo.mjs # 打印样本评分
|
|
171
173
|
node prototype/build.mjs # 重建自包含原型
|
|
172
174
|
```
|
package/lib/host.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import fs from 'node:fs'
|
|
11
11
|
import os from 'node:os'
|
|
12
12
|
import path from 'node:path'
|
|
13
|
-
import { scoreAnswer, scoreWithLabels, labelsToReport, isSubstantiveQuestion, RUBRIC_VERSION } from './scoring.js'
|
|
13
|
+
import { scoreAnswer, scoreWithLabels, labelsToReport, isSubstantiveQuestion, redactSecrets, RUBRIC_VERSION } from './scoring.js'
|
|
14
14
|
import { classifyWithJev, jevConfigured } from './jev.js'
|
|
15
15
|
|
|
16
16
|
export const name = 'dsh-quality-lens'
|
|
@@ -112,7 +112,7 @@ export function apply(ctx, config = {}) {
|
|
|
112
112
|
const appendLine = (file, value) => {
|
|
113
113
|
try {
|
|
114
114
|
fs.mkdirSync(dir, { recursive: true })
|
|
115
|
-
fs.appendFileSync(file, `${JSON.stringify(value)}\n`, 'utf8')
|
|
115
|
+
fs.appendFileSync(file, `${redactSecrets(JSON.stringify(value))}\n`, 'utf8')
|
|
116
116
|
} catch (error) {
|
|
117
117
|
ctx.logger?.warn?.(`[quality-lens] could not persist report: ${error?.message ?? error}`)
|
|
118
118
|
}
|
|
@@ -134,9 +134,14 @@ export function apply(ctx, config = {}) {
|
|
|
134
134
|
}
|
|
135
135
|
const cutoff = Date.now() - settings.retentionDays * 24 * 60 * 60 * 1000
|
|
136
136
|
const lines = text.split('\n').filter(Boolean).slice(-settings.maxReports)
|
|
137
|
+
let sanitized = false
|
|
138
|
+
const safeLines = []
|
|
137
139
|
for (const line of lines) {
|
|
138
140
|
try {
|
|
139
|
-
const
|
|
141
|
+
const safeLine = redactSecrets(line)
|
|
142
|
+
if (safeLine !== line) sanitized = true
|
|
143
|
+
safeLines.push(safeLine)
|
|
144
|
+
const record = JSON.parse(safeLine)
|
|
140
145
|
if (!record?.messageId || typeof record.score !== 'number') continue
|
|
141
146
|
if (record.at && Date.parse(record.at) < cutoff) continue
|
|
142
147
|
reports.set(record.messageId, record)
|
|
@@ -145,6 +150,14 @@ export function apply(ctx, config = {}) {
|
|
|
145
150
|
}
|
|
146
151
|
}
|
|
147
152
|
trim()
|
|
153
|
+
if (sanitized) {
|
|
154
|
+
// Old reports may contain a token the user pasted into a chat; rewrite them.
|
|
155
|
+
try {
|
|
156
|
+
fs.writeFileSync(reportsFile, `${safeLines.join('\n')}\n`, 'utf8')
|
|
157
|
+
} catch {
|
|
158
|
+
// Best effort; the in-memory copy is already redacted.
|
|
159
|
+
}
|
|
160
|
+
}
|
|
148
161
|
}
|
|
149
162
|
|
|
150
163
|
const registerRoute = (route) => {
|
package/lib/scoring.js
CHANGED
|
@@ -67,6 +67,29 @@ const NEGATIVE = {
|
|
|
67
67
|
|
|
68
68
|
const clamp = (n, lo = 0, hi = 100) => Math.max(lo, Math.min(hi, Math.round(n)))
|
|
69
69
|
|
|
70
|
+
/** Credential-shaped strings that must never appear in a report, a chip, or a share card. */
|
|
71
|
+
const SECRET_PATTERNS = [
|
|
72
|
+
/\bnpm_[A-Za-z0-9]{20,}\b/g,
|
|
73
|
+
/\bgh[pousr]_[A-Za-z0-9]{20,}\b/g,
|
|
74
|
+
/\bgithub_pat_[A-Za-z0-9_]{20,}\b/g,
|
|
75
|
+
/\bsk-[A-Za-z0-9_-]{16,}\b/g,
|
|
76
|
+
/\bAKIA[0-9A-Z]{16}\b/g,
|
|
77
|
+
/\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g,
|
|
78
|
+
/\b(?:Bearer\s+)[A-Za-z0-9._-]{20,}\b/gi,
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Redact credential-shaped substrings from anything user-facing or persisted.
|
|
83
|
+
* The report must never echo a token a user pasted into the chat.
|
|
84
|
+
* @param {string} text
|
|
85
|
+
* @returns {string}
|
|
86
|
+
*/
|
|
87
|
+
export function redactSecrets(text) {
|
|
88
|
+
let out = String(text ?? '')
|
|
89
|
+
for (const pattern of SECRET_PATTERNS) out = out.replace(pattern, '[REDACTED]')
|
|
90
|
+
return out
|
|
91
|
+
}
|
|
92
|
+
|
|
70
93
|
/** Small synonym expansion so Chinese paraphrases are not scored as off-topic. */
|
|
71
94
|
const TERM_SYNONYMS = [
|
|
72
95
|
['便宜', '价格', '费用', '费率', '更低', '贵', '成本'],
|
|
@@ -141,12 +164,18 @@ export function termFrequencies(text) {
|
|
|
141
164
|
export function isSubstantiveQuestion(text) {
|
|
142
165
|
const raw = String(text ?? '').trim()
|
|
143
166
|
if (!raw) return false
|
|
144
|
-
const
|
|
167
|
+
const redacted = redactSecrets(raw)
|
|
168
|
+
if (redacted !== raw) {
|
|
169
|
+
const residual = redacted.replace(/\[REDACTED\]/g, '').replace(/[\s。!?!?,,、~~::=]+/g, '')
|
|
170
|
+
// "token: npm_..." or a bare token is not a question.
|
|
171
|
+
if (residual.length < 8) return false
|
|
172
|
+
}
|
|
173
|
+
const compact = redacted.replace(/[\s。!?!?,,、~~…]+/g, '')
|
|
145
174
|
if (compact.length === 0) return false
|
|
146
175
|
const ack = /^(好的?|可以了?|行|嗯+|哦+|收到|明白了?|了解|谢谢|感谢|辛苦了?|继续|开始吧?|我试试|试一下|好滴|ok|okay|thanks?|thx)$/i
|
|
147
176
|
if (ack.test(compact)) return false
|
|
148
177
|
if (/^(好的?|可以了?|嗯+|哦+|行|收到|谢谢|继续)+$/.test(compact)) return false
|
|
149
|
-
const hasAsk = /[??]|怎么|如何|什么|为什么|为啥|哪|多少|是否|能否|可不可以|吗|呢|请教|请问|介绍|解释|对比|比较|建议|推荐|写|生成|实现|排查|安装|配置|设计|分析|评估|总结|整理/.test(
|
|
178
|
+
const hasAsk = /[??]|怎么|如何|什么|为什么|为啥|哪|多少|是否|能否|可不可以|吗|呢|请教|请问|介绍|解释|对比|比较|建议|推荐|写|生成|实现|排查|安装|配置|设计|分析|评估|总结|整理/.test(redacted)
|
|
150
179
|
if (!hasAsk && compact.length < 10) return false
|
|
151
180
|
return compact.length >= 2
|
|
152
181
|
}
|
|
@@ -275,7 +304,7 @@ function pickEvidence(answer, stats, topic) {
|
|
|
275
304
|
const evidence = []
|
|
276
305
|
const push = (dim, quote, why) => {
|
|
277
306
|
if (!quote || evidence.some((item) => item.quote === quote)) return
|
|
278
|
-
evidence.push({ dim, quote, why })
|
|
307
|
+
evidence.push({ dim, quote: redactSecrets(quote), why: redactSecrets(why) })
|
|
279
308
|
}
|
|
280
309
|
const sentences = stats.sentences
|
|
281
310
|
if (sentences[0]) push('relevance', sentences[0], '开篇直接回应问题')
|
|
@@ -605,8 +634,8 @@ function continuationScore(contextAnswer, answer) {
|
|
|
605
634
|
|
|
606
635
|
export function classifyLabels(input) {
|
|
607
636
|
const answer = String(input?.answer ?? '').trim()
|
|
608
|
-
const question = String(input?.question ?? '').trim() || '(未捕获到用户问题)'
|
|
609
|
-
const context = String(input?.context ?? '').trim()
|
|
637
|
+
const question = redactSecrets(String(input?.question ?? '').trim()) || '(未捕获到用户问题)'
|
|
638
|
+
const context = redactSecrets(String(input?.context ?? '').trim())
|
|
610
639
|
const contextAnswer = String(input?.contextAnswer ?? '').trim()
|
|
611
640
|
const stats = answerStats(answer)
|
|
612
641
|
let topic = topicCoverage(question, answer, new Set(tokenize(answer)))
|
|
@@ -713,7 +742,8 @@ function dimsFromLabels(labels) {
|
|
|
713
742
|
*/
|
|
714
743
|
export function labelsToReport(labels, extra = {}) {
|
|
715
744
|
const answer = String(extra.answer ?? '')
|
|
716
|
-
const question = String(extra.question ?? '')
|
|
745
|
+
const question = redactSecrets(String(extra.question ?? ''))
|
|
746
|
+
const context = redactSecrets(String(extra.context ?? ''))
|
|
717
747
|
const stats = labels.stats ?? answerStats(answer)
|
|
718
748
|
const topic = labels.topic
|
|
719
749
|
const dims = labels.dims ?? dimsFromLabels({ ...labels, answerText: answer })
|
|
@@ -732,7 +762,7 @@ export function labelsToReport(labels, extra = {}) {
|
|
|
732
762
|
const confidence = labels.source === 'jev' ? labels.confidence ?? 'medium' : stats.chars < 120 || topic.intents.length === 0 ? 'low' : 'medium'
|
|
733
763
|
const evidence = pickEvidence(answer, stats, topic)
|
|
734
764
|
const suggestions = buildSuggestions(topic.intents, dims, topic)
|
|
735
|
-
const tipQuestion = topic.contextUsed &&
|
|
765
|
+
const tipQuestion = topic.contextUsed && context ? context : question
|
|
736
766
|
const promptTips = buildPromptTips({ question: tipQuestion, dims, topic, labels })
|
|
737
767
|
|
|
738
768
|
return {
|