@nightpainters/dsh-ling 1.5.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/ACCESS-DESIGN.md +119 -0
- package/CHANGELOG.md +627 -0
- package/DESIGN.md +461 -0
- package/LICENSE +201 -0
- package/NOTICE +7 -0
- package/README.md +236 -0
- package/cordis.patch.yml +16 -0
- package/lib/audit/access-log.js +533 -0
- package/lib/audit/index.js +92 -0
- package/lib/audit/patch-spec.js +195 -0
- package/lib/client.js +5069 -0
- package/lib/host/api.js +2584 -0
- package/lib/host/archive.js +194 -0
- package/lib/host/autotree.js +131 -0
- package/lib/host/backfill.js +267 -0
- package/lib/host/classify.js +10 -0
- package/lib/host/clock-skill.js +141 -0
- package/lib/host/clock.js +132 -0
- package/lib/host/corpus-suggest.js +139 -0
- package/lib/host/deepsummary.js +338 -0
- package/lib/host/dsweb-summary.js +225 -0
- package/lib/host/feedback.js +119 -0
- package/lib/host/freeze.js +144 -0
- package/lib/host/genesis.js +176 -0
- package/lib/host/guard.js +184 -0
- package/lib/host/habit-gen.js +273 -0
- package/lib/host/import-file.js +291 -0
- package/lib/host/import-log.js +129 -0
- package/lib/host/inject-common.js +25 -0
- package/lib/host/inject.js +285 -0
- package/lib/host/l1.js +282 -0
- package/lib/host/lifecycle.js +367 -0
- package/lib/host/memory.js +2389 -0
- package/lib/host/mode.js +101 -0
- package/lib/host/persona.js +214 -0
- package/lib/host/retitle.js +214 -0
- package/lib/host/rules.js +195 -0
- package/lib/host/scan-dsweb.js +111 -0
- package/lib/host/seal.js +17 -0
- package/lib/host/settings-file.js +49 -0
- package/lib/host/snapshot.js +168 -0
- package/lib/host/suggestions.js +47 -0
- package/lib/host/summarizer.js +103 -0
- package/lib/host/tone-advice.js +112 -0
- package/lib/host/tools.js +1067 -0
- package/lib/host/tree-ops.js +330 -0
- package/lib/host/util.js +165 -0
- package/lib/host/zstd.js +63 -0
- package/lib/index.js +263 -0
- package/package.json +69 -0
- package/tools/apply-access-log-patch.mjs +295 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// dsh-ling host — clock skill:精确时间按需查询(2026-09-23 用户定)。
|
|
2
|
+
//
|
|
3
|
+
// 为什么做成 skill 而不常驻注入:
|
|
4
|
+
// 时间锚搬迁之后(见内部时间锚设计笔记),system 侧只留 [今天](日期+星期,一天变一次),
|
|
5
|
+
// context 侧给 [现在](时段档 + 间隔档)。**精确到分钟的时间刻意不常驻** —— 它每 60 秒变一次,
|
|
6
|
+
// 会把整段 system 前缀连同工具定义一起作废(实测 system 侧 1440 次/天 → 0)。
|
|
7
|
+
// 需要精确时间时主动查,像看手表,而不是让表一直对着人报时。
|
|
8
|
+
//
|
|
9
|
+
// 注册形态按平台契约:`ctx.skills.register({...})` 注册**内存中的 skill**(嵌入式),
|
|
10
|
+
// 注册表补入默认调用策略与 provider 标签,返回的 disposer 归入本插件 effect 链。
|
|
11
|
+
// 取服务沿用 tools 的三级兜底(直取 → ctx.inject 子 ctx → 定时重试):apply 可能早于 skills 挂载。
|
|
12
|
+
|
|
13
|
+
/** skill 名(kebab-case,平台按此寻址)。 */
|
|
14
|
+
export const CLOCK_SKILL_NAME = 'clock';
|
|
15
|
+
|
|
16
|
+
/** 面向发现层的路由描述。 */
|
|
17
|
+
export const CLOCK_SKILL_DESCRIPTION =
|
|
18
|
+
'查询当前的精确时间(年月日 · 时分秒 · 星期)。需要报时间、算时长、判断"过了多久"、生成带时间戳的名字时使用。';
|
|
19
|
+
|
|
20
|
+
/** 补充路由指引 —— 顺带把"什么时候不该用它"写在这里,避免为了问时间而白调一次。 */
|
|
21
|
+
export const CLOCK_SKILL_WHEN_TO_USE =
|
|
22
|
+
'用户问"现在几点""今天几号",或你需要精确到分钟/秒的时间做判断(倒计时、间隔计算、时间戳)时。' +
|
|
23
|
+
'日常判断"是接着聊还是隔了很久"不需要它 —— 读上下文里的 [现在]。';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 指令正文(frontmatter 由注册字段承载,不进正文)。
|
|
27
|
+
* ⚠️ 正文会随 skill 加载进入模型请求 ⇒ **不得含相邻的 `{{`**(宿主 renderPrompt 对未注册变量直接抛异常,
|
|
28
|
+
* 见 1.3.1 的注入面加固);`tests/clock-skill.test.mjs` 对此有断言。
|
|
29
|
+
*/
|
|
30
|
+
export const CLOCK_SKILL_CONTENT = `# clock — 精确时间按需查询
|
|
31
|
+
|
|
32
|
+
## 为什么需要它
|
|
33
|
+
|
|
34
|
+
上下文里的时间信息只有**两个档**,都不含精确分钟:
|
|
35
|
+
|
|
36
|
+
| 载体 | 内容 | 刷新频率 |
|
|
37
|
+
|---|---|---|
|
|
38
|
+
| system 侧 \`[今天]\` | 年月日 + 星期 | 一天一次 |
|
|
39
|
+
| context 侧 \`[现在]\` | 时段(清晨/上午/中午/下午/傍晚/夜里/深夜)+ 间隔档(刚刚/今天早些时候/昨天/N 天前) | 每条消息 |
|
|
40
|
+
|
|
41
|
+
精确到分钟的时间**刻意不常驻** —— 它每 60 秒变一次,会让整段 system 前缀连同工具定义一起失效。
|
|
42
|
+
所以:**需要精确时间时主动查。像看手表,而不是让表一直报时。**
|
|
43
|
+
|
|
44
|
+
## 怎么查
|
|
45
|
+
|
|
46
|
+
\`\`\`powershell
|
|
47
|
+
# 完整:日期 + 时间 + 星期
|
|
48
|
+
pwsh -NoProfile -Command "Get-Date -Format 'yyyy-MM-dd HH:mm:ss dddd'"
|
|
49
|
+
# → 2026-09-23 22:14:37 星期三
|
|
50
|
+
|
|
51
|
+
# 只要日期
|
|
52
|
+
pwsh -NoProfile -Command "Get-Date -Format 'yyyy-MM-dd dddd'"
|
|
53
|
+
|
|
54
|
+
# 只要时间
|
|
55
|
+
pwsh -NoProfile -Command "Get-Date -Format 'HH:mm'"
|
|
56
|
+
|
|
57
|
+
# 带时区偏移(跨时区判断时)
|
|
58
|
+
pwsh -NoProfile -Command "Get-Date -Format 'yyyy-MM-dd HH:mm:ss zzz'"
|
|
59
|
+
# → 2026-09-23 22:14:37 +08:00
|
|
60
|
+
\`\`\`
|
|
61
|
+
|
|
62
|
+
本机时区:China Standard Time(**UTC+8**),无夏令时。
|
|
63
|
+
|
|
64
|
+
## 何时该用
|
|
65
|
+
|
|
66
|
+
| 场景 | 用不用 |
|
|
67
|
+
|---|---|
|
|
68
|
+
| 用户问"现在几点 / 今天几号" | ✅ 用 |
|
|
69
|
+
| 算时长、"过了多久"、倒计时 | ✅ 用(先查再算) |
|
|
70
|
+
| 生成带时间戳的文件名 / 日志行 | ✅ 用 |
|
|
71
|
+
| 判断"接着聊还是隔了很久" | ❌ 不用 —— 读 \`[现在]\` 的间隔档 |
|
|
72
|
+
| 判断语气(深夜该收尾 / 中午可以继续) | ❌ 不用 —— 时段档已在 \`[现在]\` 里 |
|
|
73
|
+
| 往文档里写日期 | ⚠️ 用 \`[今天]\` 即可,除非明确要精确到分钟 |
|
|
74
|
+
|
|
75
|
+
## 三条边界
|
|
76
|
+
|
|
77
|
+
1. **不要把精确时间写进长期内容**(persona / settings / 记忆)—— 那会让 system 前缀每次组装都变,
|
|
78
|
+
把这次优化的收益原样还回去。
|
|
79
|
+
2. **\`[今天]\` 与 \`[现在]\` 优先** —— 它们覆盖绝大多数判断;本 skill 只用于真的需要分钟级时。
|
|
80
|
+
3. **跨零点注意**:\`[今天]\` 由 system 侧承载,跨天后的第一条消息**可能仍是昨天的日期** ——
|
|
81
|
+
若结论依赖"绝对今天",先查本 skill。
|
|
82
|
+
|
|
83
|
+
## 来源
|
|
84
|
+
|
|
85
|
+
dsh-ling 内置(器灵包)。设计依据:本插件内部的时间锚设计笔记。
|
|
86
|
+
`;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 注册 clock skill,带与 tools 同款的三级兜底。
|
|
90
|
+
* @param {object} ctx cordis 上下文。
|
|
91
|
+
* @returns {Array<Function>} 需要随插件一起释放的 disposer 列表(含定时器与 skill 注销)。
|
|
92
|
+
*/
|
|
93
|
+
export function registerClockSkill(ctx) {
|
|
94
|
+
const disposers = [];
|
|
95
|
+
let mounted = false;
|
|
96
|
+
|
|
97
|
+
const mount = (c) => {
|
|
98
|
+
if (mounted) return true;
|
|
99
|
+
try {
|
|
100
|
+
const skills = c?.skills;
|
|
101
|
+
if (!skills || typeof skills.register !== 'function') return false;
|
|
102
|
+
const d = skills.register({
|
|
103
|
+
name: CLOCK_SKILL_NAME,
|
|
104
|
+
description: CLOCK_SKILL_DESCRIPTION,
|
|
105
|
+
whenToUse: CLOCK_SKILL_WHEN_TO_USE,
|
|
106
|
+
content: CLOCK_SKILL_CONTENT,
|
|
107
|
+
source: 'bundled',
|
|
108
|
+
});
|
|
109
|
+
if (typeof d === 'function') disposers.push(d);
|
|
110
|
+
mounted = true;
|
|
111
|
+
console.info('[dsh-ling] clock skill registered');
|
|
112
|
+
return true;
|
|
113
|
+
} catch (e) {
|
|
114
|
+
console.debug('[dsh-ling] clock skill registration failed', e);
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
if (!mount(ctx)) {
|
|
120
|
+
// 服务从回调参数取,不能走闭包 ctx(否则报 without inject)
|
|
121
|
+
try {
|
|
122
|
+
ctx.inject?.(['skills'], (child) => {
|
|
123
|
+
mount(child);
|
|
124
|
+
});
|
|
125
|
+
} catch (e) {
|
|
126
|
+
console.debug('[dsh-ling] ctx.inject skills failed', e);
|
|
127
|
+
}
|
|
128
|
+
let tries = 0;
|
|
129
|
+
const timer = setInterval(() => {
|
|
130
|
+
tries += 1;
|
|
131
|
+
if (mounted || tries >= 20) {
|
|
132
|
+
clearInterval(timer);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
mount(ctx);
|
|
136
|
+
}, 1500);
|
|
137
|
+
disposers.push(() => clearInterval(timer));
|
|
138
|
+
console.info('[dsh-ling] skills service not ready at apply; retrying (clock)');
|
|
139
|
+
}
|
|
140
|
+
return disposers;
|
|
141
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// dsh-ling host — 被动时间锚(上下文时间):每次模型步组装时动态生成的"现在"行。
|
|
2
|
+
// 设计(2026-09-11 作者拍板):
|
|
3
|
+
// - 属记忆与人格层的"被动时间感知":让器灵知道今天几号、距上次对话多久、是否纪念日;
|
|
4
|
+
// - **不进快照**(snapshotVersion 不含它),由注入面逐次拼接,避免跨天快照过期;
|
|
5
|
+
// - 称呼**跟随用户自己设定的 userTitle**(不硬编码);未设置时用中性措辞;
|
|
6
|
+
// - 本版本不做主动打扰:她只在你开口时出现(时间锚是"开口时的感知",不触发任何主动消息)。
|
|
7
|
+
import { userTitleForMode } from './persona.js';
|
|
8
|
+
|
|
9
|
+
export const WEEK_CN = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
|
|
10
|
+
|
|
11
|
+
/** 内置纪念日(月份从 1 起):本项目(器灵)的诞生纪念日。可在 settings.dates 追加/覆盖自定义条目。 */
|
|
12
|
+
export const FESTIVALS = [
|
|
13
|
+
{ m: 9, d: 7, label: '器灵的新生纪念日', greeting: 'HELLO PARTNER' },
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
export function periodOf(hour) {
|
|
17
|
+
if (hour < 5) return '深夜';
|
|
18
|
+
if (hour < 8) return '清晨';
|
|
19
|
+
if (hour < 11) return '上午';
|
|
20
|
+
if (hour < 13) return '中午';
|
|
21
|
+
if (hour < 17) return '下午';
|
|
22
|
+
if (hour < 19) return '傍晚';
|
|
23
|
+
if (hour < 23) return '夜里';
|
|
24
|
+
return '深夜';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function formatGap(ms) {
|
|
28
|
+
if (!Number.isFinite(ms) || ms < 0) return '';
|
|
29
|
+
const min = Math.floor(ms / 60000);
|
|
30
|
+
if (min < 1) return '刚刚';
|
|
31
|
+
if (min < 60) return min + ' 分钟前';
|
|
32
|
+
const hr = Math.floor(min / 60);
|
|
33
|
+
if (hr < 24) return hr + ' 小时前';
|
|
34
|
+
const day = Math.floor(hr / 24);
|
|
35
|
+
if (day < 30) return day + ' 天前';
|
|
36
|
+
const mon = Math.floor(day / 30);
|
|
37
|
+
if (mon < 12) return '约 ' + mon + ' 个月前';
|
|
38
|
+
return '约 ' + Math.floor(mon / 12) + ' 年前';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 档位化间隔(context 侧专用,2026-09-23)。
|
|
43
|
+
*
|
|
44
|
+
* 与 formatGap 的差别是**精度降到档**:分钟级梯度("3 分钟前/38 分钟前")对判断没有价值,
|
|
45
|
+
* 却会让挂在末条消息上的 context 文本每分钟改一次 ⇒ 上下文缓慢膨胀。
|
|
46
|
+
* 本函数按**日历天**分档,一天最多变一次("刚刚"→"今天早些时候"跨 2 小时边界)。
|
|
47
|
+
*/
|
|
48
|
+
export function formatGapCoarse(last, now = new Date()) {
|
|
49
|
+
const t = last instanceof Date ? last : new Date(last);
|
|
50
|
+
if (!Number.isFinite(t.getTime())) return '';
|
|
51
|
+
const min = Math.floor((now.getTime() - t.getTime()) / 60000);
|
|
52
|
+
if (min < 0) return '';
|
|
53
|
+
if (min < 120) return '刚刚'; // < 2 小时:含跨午夜(23:50→01:00 仍算刚聊过)
|
|
54
|
+
const d0 = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
55
|
+
const d1 = new Date(t.getFullYear(), t.getMonth(), t.getDate());
|
|
56
|
+
const days = Math.round((d0.getTime() - d1.getTime()) / 864e5);
|
|
57
|
+
if (days <= 0) return '今天早些时候';
|
|
58
|
+
if (days === 1) return '昨天';
|
|
59
|
+
if (days < 30) return days + ' 天前';
|
|
60
|
+
const mon = Math.floor(days / 30);
|
|
61
|
+
if (mon < 12) return '约 ' + mon + ' 个月前';
|
|
62
|
+
return '约 ' + Math.floor(mon / 12) + ' 年前';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function pad2(n) { return (n < 10 ? '0' : '') + n; }
|
|
66
|
+
|
|
67
|
+
/** 命中当天的纪念日(内置 + settings.dates),返回 {label, greeting} 或 null。 */
|
|
68
|
+
export function festivalToday(settings, now = new Date()) {
|
|
69
|
+
const list = [];
|
|
70
|
+
const custom = settings?.dates;
|
|
71
|
+
if (Array.isArray(custom)) list.push(...custom);
|
|
72
|
+
list.push(...FESTIVALS);
|
|
73
|
+
const m = now.getMonth() + 1;
|
|
74
|
+
const d = now.getDate();
|
|
75
|
+
for (const f of list) {
|
|
76
|
+
if (!f || Number(f.m) !== m || Number(f.d) !== d) continue;
|
|
77
|
+
return { label: String(f.label || '纪念日'), greeting: f.greeting ? String(f.greeting) : '' };
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* system 侧时间锚:**日期 + 星期**(+ 纪念日)。一天只变一次。
|
|
84
|
+
*
|
|
85
|
+
* 为什么时分/时段不在这里(2026-09-23 搬迁):system 是**会话前缀**,改一个字整段前缀
|
|
86
|
+
* 缓存作废。实测回合内断裂 174 次 = 未缓存 token 43.96%,是全项目最大单点。
|
|
87
|
+
* 分钟级信息移到 context 侧({@link timeAnchorLive})——那里是"末条消息追加",
|
|
88
|
+
* 平台只在本轮真正不同时产出新快照。
|
|
89
|
+
* @param {object} _memory 未用:保留签名与 timeAnchorLive 对称,便于调用方切换
|
|
90
|
+
* @param {object} settings 传入 SettingsFile 或已解包的 settings 对象
|
|
91
|
+
* @param {Date} now 便于测试注入
|
|
92
|
+
*/
|
|
93
|
+
export function timeAnchorDate(_memory, settings, { now = new Date() } = {}) {
|
|
94
|
+
const s = settings && typeof settings.get === 'function' ? settings.get() : (settings || {});
|
|
95
|
+
const mode = s?.mode?.lastMode || 'life';
|
|
96
|
+
const callName = userTitleForMode(s?.persona?.userTitle, mode); // 称呼跟随用户设定;空则中性措辞
|
|
97
|
+
const lines = [];
|
|
98
|
+
lines.push(`[今天] ${now.getFullYear()}-${pad2(now.getMonth() + 1)}-${pad2(now.getDate())} ${WEEK_CN[now.getDay()]}`);
|
|
99
|
+
const fest = festivalToday(s, now);
|
|
100
|
+
if (fest) {
|
|
101
|
+
const greet = fest.greeting ? ` —— 开场第一句${callName ? `先对${callName}说` : '先说'}:${fest.greeting}` : '';
|
|
102
|
+
lines.push(`今天是 ${fest.label}(${now.getMonth() + 1}-${now.getDate()})${greet}。`);
|
|
103
|
+
}
|
|
104
|
+
return lines.join('\n');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* context 侧时间锚:**时段 + 间隔(档位制)**。每轮实时重算,挂在末条消息上。
|
|
109
|
+
*
|
|
110
|
+
* 精度刻意降到"档":分钟级梯度对判断没有价值,却会让尾部文本每分钟变一次。
|
|
111
|
+
* 两侧合起来才是完整时间感知 —— system 给"今天几号",本函数给"什么时段、隔了多久"。
|
|
112
|
+
*
|
|
113
|
+
* 调用方把本函数的文本注册进 `systemPrompt.context()`,不是 `section()`:
|
|
114
|
+
* context 走末条消息追加 + 平台差异比对,section 改一个字就作废整个前缀。
|
|
115
|
+
*/
|
|
116
|
+
export function timeAnchorLive(memory, settings, { now = new Date() } = {}) {
|
|
117
|
+
const s = settings && typeof settings.get === 'function' ? settings.get() : (settings || {});
|
|
118
|
+
const mode = s?.mode?.lastMode || 'life';
|
|
119
|
+
const callName = userTitleForMode(s?.persona?.userTitle, mode);
|
|
120
|
+
const head = `[现在] ${periodOf(now.getHours())}`;
|
|
121
|
+
try {
|
|
122
|
+
const last = memory && typeof memory.lastUserTurnAt === 'function' ? memory.lastUserTurnAt() : null;
|
|
123
|
+
if (last) {
|
|
124
|
+
const t = Date.parse(last);
|
|
125
|
+
if (Number.isFinite(t)) {
|
|
126
|
+
const gap = formatGapCoarse(new Date(t), now);
|
|
127
|
+
if (gap) return callName ? `${head} · 距上次与${callName}对话:${gap}` : `${head} · 距上次对话:${gap}`;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
} catch {}
|
|
131
|
+
return head;
|
|
132
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// dsh-ling host — 语料人格建议识别器 v2(零模型,证据驱动)。
|
|
2
|
+
// 称呼类建议采用**白名单人称词库 + 句首/句尾双端匹配**(避免把"不错/当然可以/服务器繁忙"
|
|
3
|
+
// 之类功能性开头当称呼);纠正类仍用模式桶。全部阈值保守,证据附带原文摘录。
|
|
4
|
+
|
|
5
|
+
// 示例词库(启发式):可按你自己的历史语料增删。
|
|
6
|
+
// AI 自称词库:识别"助手在句中如何自称"的候选;
|
|
7
|
+
// 用户称呼词库:识别"助手如何称呼用户"的候选(其中也含 AI 自身名字,以免把自称误判成对用户的称呼)。
|
|
8
|
+
const AI_NAME_LEX = ['小灵', '灵灵', '小灵儿', '阿灵', '小语', '语语'];
|
|
9
|
+
const USER_TITLE_LEX = ['主人', '朋友', '兄弟', '老哥', '老师', '同学', '伙伴', '老板', '师傅', '亲', '阁下', '君', '先生', '女士', '道友', '掌柜', '大王', '小灵', '灵灵', '小语', '你老'];
|
|
10
|
+
|
|
11
|
+
export const CORRECTION_BUCKETS = [
|
|
12
|
+
{
|
|
13
|
+
key: 'classical',
|
|
14
|
+
rule: '不要为文言而文言;用户要求直白清楚时用现代口语。',
|
|
15
|
+
re: /太文言|文言文|文言了|晦涩|诘屈|为文言|咬文嚼字|拽文|之乎者也/,
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
key: 'verbose',
|
|
19
|
+
rule: '回答先给结论,控制篇幅,别绕。',
|
|
20
|
+
re: /太啰嗦|太冗长|废话|太长不看|精简|精炼|说重点|直接给结论|别绕|说人话|简洁一点|能不能短点|缩短|废话太多|回复太长|回答太长|写太长|车轱辘|重复啰嗦/,
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
key: 'vague',
|
|
24
|
+
rule: '不要空话套话;给可执行的具体内容。',
|
|
25
|
+
re: /太虚|假大空|空话|套话|不接地气|太抽象|太空洞|虚的|来点实际的|干货|务实点|别整虚的/,
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
key: 'emotion',
|
|
29
|
+
rule: '语气有温度一点,别太冷/太官方。',
|
|
30
|
+
re: /太冷|冷漠|冷冰冰|温柔一点|温暖一点|太官方|太正式|轻松点|活泼点|俏皮|幽默点|有人情味|太机械|太客套/,
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
key: 'format',
|
|
34
|
+
rule: '多用结构化呈现(分点/表格/列表),便于阅读。',
|
|
35
|
+
re: /用表格|分点|分条|列个表|做成表格|排版|结构化|条理|编号列出|要点列出来|表格形式|按点回答/,
|
|
36
|
+
},
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
const escRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
40
|
+
|
|
41
|
+
function excerptAround(text, idx, span = 44) {
|
|
42
|
+
const start = Math.max(0, idx - span);
|
|
43
|
+
const seg = text.slice(start, idx + span).replace(/\s+/g, ' ');
|
|
44
|
+
return (start > 0 ? '…' : '') + seg + (start + seg.length < text.length ? '…' : '');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 白名单人称匹配:词出现在句首(词+标点)或句尾(标点+词)才计为"称呼"。
|
|
49
|
+
* 附带 anyTotal(全文提及次数,作旁证展示)。
|
|
50
|
+
*/
|
|
51
|
+
function analyzeLexMatches(docs, lexicon, { minCount = 3 }) {
|
|
52
|
+
const out = [];
|
|
53
|
+
for (const w of lexicon) {
|
|
54
|
+
const startRe = new RegExp('^' + escRe(w) + '[,,。!!??::]');
|
|
55
|
+
const tailRe = new RegExp('[,,。!!??::]' + escRe(w) + '[。!!??]?$');
|
|
56
|
+
let n = 0;
|
|
57
|
+
let anyTotal = 0;
|
|
58
|
+
let last = '';
|
|
59
|
+
const samples = [];
|
|
60
|
+
for (const doc of docs) {
|
|
61
|
+
const text = doc.text || '';
|
|
62
|
+
if (text.length > 20000) continue;
|
|
63
|
+
if (text.includes(w)) anyTotal += 1;
|
|
64
|
+
let idx = -1;
|
|
65
|
+
let m = text.match(startRe);
|
|
66
|
+
if (m) {
|
|
67
|
+
idx = 0;
|
|
68
|
+
} else {
|
|
69
|
+
m = text.match(tailRe);
|
|
70
|
+
if (m) idx = text.lastIndexOf(w);
|
|
71
|
+
}
|
|
72
|
+
if (idx < 0) continue;
|
|
73
|
+
n += 1;
|
|
74
|
+
if (!last || (doc.date || '') > last) last = doc.date || '';
|
|
75
|
+
if (samples.length < 3) {
|
|
76
|
+
samples.push({ date: doc.date || '', ref: doc.ref || '', excerpt: excerptAround(text, Math.max(0, idx)) });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (n >= minCount) {
|
|
80
|
+
out.push({ chunk: w, n, last, samples, anyTotal });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return out.sort((a, b) => b.n - a.n).slice(0, 3);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function analyzeCorrections(docs, { minCount = 3 }) {
|
|
87
|
+
const buckets = CORRECTION_BUCKETS.map((b) => ({ ...b, n: 0, last: '', samples: [] }));
|
|
88
|
+
for (const doc of docs) {
|
|
89
|
+
const text = doc.text || '';
|
|
90
|
+
if (text.length > 20000) continue;
|
|
91
|
+
for (const b of buckets) {
|
|
92
|
+
const m = text.match(b.re);
|
|
93
|
+
if (m) {
|
|
94
|
+
b.n += 1;
|
|
95
|
+
if (!b.last || (doc.date || '') > b.last) b.last = doc.date || '';
|
|
96
|
+
if (b.samples.length < 3) {
|
|
97
|
+
b.samples.push({ date: doc.date || '', ref: doc.ref || '', excerpt: excerptAround(text, m.index) });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return buckets.filter((b) => b.n >= minCount);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* @param {object} opts { userDocs, assistantDocs }
|
|
107
|
+
*/
|
|
108
|
+
export function analyzeCorpus({ userDocs = [], assistantDocs = [] } = {}) {
|
|
109
|
+
const items = [];
|
|
110
|
+
const aiNames = analyzeLexMatches(userDocs, AI_NAME_LEX, { minCount: 3 });
|
|
111
|
+
if (aiNames.length) {
|
|
112
|
+
const top = aiNames[0];
|
|
113
|
+
items.push({
|
|
114
|
+
kind: 'aiName',
|
|
115
|
+
value: top.chunk,
|
|
116
|
+
note: `历史语料里你有 ${top.n} 次在句首/句尾以「${top.chunk}」唤我(全文提及 ${top.anyTotal} 次,最近 ${top.last || '?'})。建议 AI 自称设为「${top.chunk}」。`,
|
|
117
|
+
evidence: { count: top.n, recent: top.last, samples: top.samples.slice(0, 3), anyTotal: top.anyTotal },
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
const userTitles = analyzeLexMatches(assistantDocs, USER_TITLE_LEX, { minCount: 3 });
|
|
121
|
+
if (userTitles.length) {
|
|
122
|
+
const top = userTitles[0];
|
|
123
|
+
items.push({
|
|
124
|
+
kind: 'userTitle',
|
|
125
|
+
value: top.chunk,
|
|
126
|
+
note: `过去我的回复里有 ${top.n} 次以「${top.chunk}」称呼你(全文 ${top.anyTotal} 次,最近 ${top.last || '?'})。建议沿用为对你的称呼。`,
|
|
127
|
+
evidence: { count: top.n, recent: top.last, samples: top.samples.slice(0, 3), anyTotal: top.anyTotal },
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
for (const b of analyzeCorrections(userDocs, { minCount: 3 })) {
|
|
131
|
+
items.push({
|
|
132
|
+
kind: 'hardrule',
|
|
133
|
+
value: b.rule,
|
|
134
|
+
note: `你在 ${b.n} 条消息里表达过这类诉求(最近 ${b.last || '?'}),建议采纳为习惯:「${b.rule}」`,
|
|
135
|
+
evidence: { count: b.n, recent: b.last, samples: b.samples.slice(0, 3), bucket: b.key },
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
return { items, stats: { userDocs: userDocs.length, assistantDocs: assistantDocs.length } };
|
|
139
|
+
}
|