soulbridge 0.0.3 → 0.0.5
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 +34 -3
- package/adapters/dsh/SKILL.md +50 -0
- package/dist/packages/cli/src/cli.js +187 -36
- package/dist/packages/cli/src/llm-analyzer.js +82 -0
- package/dist/packages/core/src/affect/analyze.js +210 -0
- package/dist/packages/core/src/affect/directives.js +29 -0
- package/dist/packages/core/src/affect/index.js +4 -0
- package/dist/packages/core/src/affect/lexicon.js +62 -0
- package/dist/packages/core/src/affect/port.js +1 -0
- package/dist/packages/core/src/affect/rules.js +12 -0
- package/dist/packages/core/src/affect/seed-lexicon.js +123 -0
- package/dist/packages/core/src/context.js +24 -1
- package/dist/packages/core/src/index.js +52 -7
- package/dist/packages/core/src/sqlite.js +176 -37
- package/dist/packages/core/src/storage/config.js +83 -0
- package/dist/packages/core/src/storage/merge.js +21 -0
- package/dist/packages/core/src/storage/steering.js +83 -0
- package/dist/packages/core/src/storage/transcript.js +70 -0
- package/dist/packages/core/src/style/measure.js +28 -0
- package/dist/packages/core/src/types.js +13 -0
- package/package.json +2 -1
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { EMPTY_AFFECT } from '../types.js';
|
|
2
|
+
import { ABSTAIN_TERMS, DEGREE_DELTA, NEGATORS } from './lexicon.js';
|
|
3
|
+
import { SEED_LEXICON } from './seed-lexicon.js';
|
|
4
|
+
export const MAX_INPUT_CHARS = 32_000;
|
|
5
|
+
export const NEGATION_WINDOW = 3;
|
|
6
|
+
export const DEGREE_WINDOW = 2;
|
|
7
|
+
export class AffectBudgetError extends Error {
|
|
8
|
+
code = 'BUDGET_EXCEEDED';
|
|
9
|
+
constructor(length) {
|
|
10
|
+
super(`affect input too large: ${length} chars (max ${MAX_INPUT_CHARS})`);
|
|
11
|
+
this.name = 'AffectBudgetError';
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
const cache = new WeakMap();
|
|
15
|
+
function prepare(lexicon) {
|
|
16
|
+
const cached = cache.get(lexicon);
|
|
17
|
+
if (cached)
|
|
18
|
+
return cached;
|
|
19
|
+
const entries = new Map(Object.entries(lexicon.entries));
|
|
20
|
+
const fillers = new Set(lexicon.fillers);
|
|
21
|
+
const hedges = new Set(lexicon.hedges);
|
|
22
|
+
const vague = new Set(Object.keys(lexicon.vague));
|
|
23
|
+
const degree = new Map();
|
|
24
|
+
for (const level of Object.keys(DEGREE_DELTA)) {
|
|
25
|
+
for (const word of lexicon.degree[level])
|
|
26
|
+
degree.set(word, DEGREE_DELTA[level]);
|
|
27
|
+
}
|
|
28
|
+
const dict = new Set([
|
|
29
|
+
...entries.keys(),
|
|
30
|
+
...fillers,
|
|
31
|
+
...hedges,
|
|
32
|
+
...vague,
|
|
33
|
+
...degree.keys(),
|
|
34
|
+
...NEGATORS,
|
|
35
|
+
...ABSTAIN_TERMS,
|
|
36
|
+
]);
|
|
37
|
+
let maxLen = 1;
|
|
38
|
+
for (const word of dict)
|
|
39
|
+
maxLen = Math.max(maxLen, word.length);
|
|
40
|
+
const prepared = { entries, fillers, hedges, vague, degree, dict, maxLen };
|
|
41
|
+
cache.set(lexicon, prepared);
|
|
42
|
+
return prepared;
|
|
43
|
+
}
|
|
44
|
+
/** Full-width punctuation → ASCII, strip code fences and shell lines, drop emoji. */
|
|
45
|
+
export function normalizeForAffect(text) {
|
|
46
|
+
const withoutCode = text.replace(/```[\s\S]*?```/g, ' ');
|
|
47
|
+
const lines = withoutCode
|
|
48
|
+
.split(/\r?\n/)
|
|
49
|
+
.filter((line) => !/^\s*\$\s/.test(line))
|
|
50
|
+
.join('\n');
|
|
51
|
+
return lines
|
|
52
|
+
.replace(/[\uff01-\uff5e]/g, (ch) => String.fromCharCode(ch.charCodeAt(0) - 0xfee0))
|
|
53
|
+
.replace(/[\u{1f000}-\u{1faff}\u{2600}-\u{27bf}\u{fe0f}\u{200d}]/gu, ' ')
|
|
54
|
+
.replace(/[\u0000-\u001f\u007f]/g, ' ')
|
|
55
|
+
.toLowerCase();
|
|
56
|
+
}
|
|
57
|
+
/** Max forward match over the lexicon dictionary; punctuation and spaces are separators. */
|
|
58
|
+
export function tokenizeForAffect(text, dict, maxLen) {
|
|
59
|
+
const tokens = [];
|
|
60
|
+
let i = 0;
|
|
61
|
+
while (i < text.length) {
|
|
62
|
+
const ch = text[i];
|
|
63
|
+
if (/[\s\p{P}\p{S}]/u.test(ch)) {
|
|
64
|
+
i += 1;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
let matched = '';
|
|
68
|
+
for (let len = Math.min(maxLen, text.length - i); len >= 1; len -= 1) {
|
|
69
|
+
const candidate = text.slice(i, i + len);
|
|
70
|
+
if (dict.has(candidate)) {
|
|
71
|
+
matched = candidate;
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (matched) {
|
|
76
|
+
tokens.push(matched);
|
|
77
|
+
i += matched.length;
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
tokens.push(ch);
|
|
81
|
+
i += 1;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return tokens;
|
|
85
|
+
}
|
|
86
|
+
const clamp = (n, lo, hi) => Math.min(hi, Math.max(lo, n));
|
|
87
|
+
/**
|
|
88
|
+
* Negating a positive emotion turns it negative ("不开心"), but negating a
|
|
89
|
+
* negative one does not turn it positive ("不生气" is relief, not joy) — it
|
|
90
|
+
* becomes neutral. This is a deliberate, testable simplification.
|
|
91
|
+
*/
|
|
92
|
+
function flip(polarity) {
|
|
93
|
+
if (polarity === 'positive')
|
|
94
|
+
return 'negative';
|
|
95
|
+
return 'neutral';
|
|
96
|
+
}
|
|
97
|
+
export function analyzeAffect(text, options = {}) {
|
|
98
|
+
if (typeof text !== 'string')
|
|
99
|
+
throw new TypeError('text must be a string');
|
|
100
|
+
if (text.length > MAX_INPUT_CHARS)
|
|
101
|
+
throw new AffectBudgetError(text.length);
|
|
102
|
+
const lexicon = options.lexicon ?? SEED_LEXICON;
|
|
103
|
+
const prepared = prepare(lexicon);
|
|
104
|
+
const normalized = normalizeForAffect(text);
|
|
105
|
+
const tokens = tokenizeForAffect(normalized, prepared.dict, prepared.maxLen);
|
|
106
|
+
const hits = [];
|
|
107
|
+
for (let i = 0; i < tokens.length; i += 1) {
|
|
108
|
+
const entry = prepared.entries.get(tokens[i]);
|
|
109
|
+
if (!entry)
|
|
110
|
+
continue;
|
|
111
|
+
let negated = false;
|
|
112
|
+
for (let back = 1; back <= NEGATION_WINDOW && i - back >= 0; back += 1) {
|
|
113
|
+
const prev = tokens[i - back];
|
|
114
|
+
if (NEGATORS.includes(prev)) {
|
|
115
|
+
negated = true;
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
if (prepared.entries.has(prev))
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
let degree = 0;
|
|
122
|
+
for (let back = 1; back <= DEGREE_WINDOW && i - back >= 0; back += 1) {
|
|
123
|
+
const delta = prepared.degree.get(tokens[i - back]);
|
|
124
|
+
if (delta !== undefined) {
|
|
125
|
+
degree = delta;
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
let intensity = clamp(entry.intensity + degree, 1, 9);
|
|
130
|
+
let polarity = entry.polarity;
|
|
131
|
+
if (negated) {
|
|
132
|
+
polarity = flip(polarity);
|
|
133
|
+
intensity = Math.max(1, Math.round(intensity * 0.8));
|
|
134
|
+
}
|
|
135
|
+
hits.push({
|
|
136
|
+
term: tokens[i],
|
|
137
|
+
category: entry.category,
|
|
138
|
+
intensity,
|
|
139
|
+
polarity,
|
|
140
|
+
negated,
|
|
141
|
+
position: i,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
const byCategory = new Map();
|
|
145
|
+
let weighted = 0;
|
|
146
|
+
let total = 0;
|
|
147
|
+
for (const hit of hits) {
|
|
148
|
+
byCategory.set(hit.category, (byCategory.get(hit.category) ?? 0) + hit.intensity);
|
|
149
|
+
const sign = hit.polarity === 'positive' ? 1 : hit.polarity === 'negative' ? -1 : 0;
|
|
150
|
+
weighted += sign * hit.intensity;
|
|
151
|
+
total += hit.intensity;
|
|
152
|
+
}
|
|
153
|
+
let dominant = null;
|
|
154
|
+
let best = 0;
|
|
155
|
+
for (const [category, sum] of byCategory) {
|
|
156
|
+
if (sum > best) {
|
|
157
|
+
best = sum;
|
|
158
|
+
dominant = category;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const emphasis = (normalized.match(/[!?]{2,}|…{2,}/g) ?? []).length + (normalized.match(/(.)\1{2,}/gu) ?? []).length;
|
|
162
|
+
let intensity = hits.length > 0 ? Math.max(...hits.map((h) => h.intensity)) : 0;
|
|
163
|
+
if (hits.length > 0 && emphasis > 0)
|
|
164
|
+
intensity = clamp(intensity + Math.min(2, emphasis), 1, 9);
|
|
165
|
+
const fillers = tokens.filter((t) => prepared.fillers.has(t)).length;
|
|
166
|
+
const hedges = tokens.filter((t) => prepared.hedges.has(t)).length;
|
|
167
|
+
const vague = tokens.filter((t) => prepared.vague.has(t)).length;
|
|
168
|
+
const abstain = hits.length === 0 && ABSTAIN_TERMS.some((term) => normalized.includes(term));
|
|
169
|
+
// No hits means no judgement at all — length alone must never produce confidence.
|
|
170
|
+
let confidence = 0;
|
|
171
|
+
if (hits.length > 0) {
|
|
172
|
+
confidence =
|
|
173
|
+
0.4 * Math.min(1, hits.length / 2) +
|
|
174
|
+
0.2 * Math.min(1, tokens.length / 20) +
|
|
175
|
+
0.25 * (dominant ? 1 : 0) +
|
|
176
|
+
0.15 * (intensity / 9);
|
|
177
|
+
if (tokens.length <= 3)
|
|
178
|
+
confidence *= 0.5;
|
|
179
|
+
}
|
|
180
|
+
if (abstain)
|
|
181
|
+
confidence = Math.min(confidence, 0.5);
|
|
182
|
+
return {
|
|
183
|
+
analyzer: 'lexicon',
|
|
184
|
+
hits,
|
|
185
|
+
dominant,
|
|
186
|
+
intensity: Math.round(intensity * 10) / 10,
|
|
187
|
+
valence: total > 0 ? Math.round((weighted / total) * 100) / 100 : 0,
|
|
188
|
+
arousal: clamp(intensity / 9 + 0.1 * emphasis, 0, 1),
|
|
189
|
+
features: {
|
|
190
|
+
fillers,
|
|
191
|
+
hedges,
|
|
192
|
+
vague,
|
|
193
|
+
tokens: tokens.length,
|
|
194
|
+
density: tokens.length > 0 ? (tokens.length - fillers - hedges) / tokens.length : 1,
|
|
195
|
+
},
|
|
196
|
+
confidence: clamp(Math.round(confidence * 100) / 100, 0, 1),
|
|
197
|
+
abstain,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
/** Lexicon analyzer as a port implementation (ADR 0015). */
|
|
201
|
+
export const lexiconAnalyzer = {
|
|
202
|
+
id: 'lexicon',
|
|
203
|
+
analyze(text) {
|
|
204
|
+
return analyzeAffect(text);
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
/** Empty signal helper so callers do not reach for the shared constant by accident. */
|
|
208
|
+
export function emptyAffect(analyzer = 'none') {
|
|
209
|
+
return { ...EMPTY_AFFECT, analyzer, features: { ...EMPTY_AFFECT.features } };
|
|
210
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export const DIRECTIVE_CATALOG = [
|
|
2
|
+
{ id: 'negative_high', text: '对方情绪偏负面:先承接,再给方案;不要急着讲道理' },
|
|
3
|
+
{ id: 'positive_high', text: '对方情绪偏正面:回答可以更简短' },
|
|
4
|
+
{ id: 'hedging', text: '对方表达里犹豫词偏多:给结论,少加限定语' },
|
|
5
|
+
{ id: 'fragmented', text: '对方表达较碎片:先复述你的理解,再回答' },
|
|
6
|
+
{ id: 'vague_terms', text: '对方用词较笼统:必要时确认具体所指' },
|
|
7
|
+
];
|
|
8
|
+
const per100 = (count, tokens) => (tokens > 0 ? (count * 100) / tokens : 0);
|
|
9
|
+
/** Which catalog entries the signal justifies, in priority order, capped at `max`. */
|
|
10
|
+
export function selectDirectives(signal, max = 2) {
|
|
11
|
+
if (signal.hits.length === 0 || signal.confidence < 0.6)
|
|
12
|
+
return [];
|
|
13
|
+
const triggered = new Set();
|
|
14
|
+
if (signal.valence <= -0.4 && signal.intensity >= 6)
|
|
15
|
+
triggered.add('negative_high');
|
|
16
|
+
if (signal.valence >= 0.4 && signal.intensity >= 6)
|
|
17
|
+
triggered.add('positive_high');
|
|
18
|
+
// Absolute minimums stop a single "这个" in a ten-token sentence from
|
|
19
|
+
// reading as "fragmented" — rate alone is misleading on short inputs.
|
|
20
|
+
if (signal.features.hedges >= 2 && per100(signal.features.hedges, signal.features.tokens) >= 6) {
|
|
21
|
+
triggered.add('hedging');
|
|
22
|
+
}
|
|
23
|
+
if (signal.features.fillers >= 3 && per100(signal.features.fillers, signal.features.tokens) >= 8) {
|
|
24
|
+
triggered.add('fragmented');
|
|
25
|
+
}
|
|
26
|
+
if (signal.features.vague >= 3)
|
|
27
|
+
triggered.add('vague_terms');
|
|
28
|
+
return DIRECTIVE_CATALOG.filter((d) => triggered.has(d.id)).slice(0, max);
|
|
29
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { analyzeAffect, lexiconAnalyzer, emptyAffect, AffectBudgetError, normalizeForAffect, tokenizeForAffect, MAX_INPUT_CHARS, } from './analyze.js';
|
|
2
|
+
export { SEED_LEXICON } from './seed-lexicon.js';
|
|
3
|
+
export { validateLexicon, DEGREE_DELTA, NEGATORS, ABSTAIN_TERMS } from './lexicon.js';
|
|
4
|
+
export { DIRECTIVE_CATALOG, selectDirectives } from './directives.js';
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/** Multiplicative-ish adjustment applied to a hit's intensity. */
|
|
2
|
+
export const DEGREE_DELTA = {
|
|
3
|
+
weak: -1,
|
|
4
|
+
medium: 1,
|
|
5
|
+
strong: 2,
|
|
6
|
+
extreme: 3,
|
|
7
|
+
};
|
|
8
|
+
/** Words that flip polarity when they precede an emotion word. */
|
|
9
|
+
export const NEGATORS = ['不', '没', '别', '无', '未', '不是', '没有', '不再', '不用', '不太'];
|
|
10
|
+
/** Withdrawal phrases: no explicit emotion word, but the affect is negative and weak. */
|
|
11
|
+
export const ABSTAIN_TERMS = ['行吧', '随便', '算了', '随你', '都行', '无所谓', '你定', '看着办', '再说吧'];
|
|
12
|
+
const CATEGORIES = ['joy', 'anger', 'sadness', 'fear', 'disgust', 'surprise'];
|
|
13
|
+
const POLARITIES = ['positive', 'negative', 'neutral'];
|
|
14
|
+
/**
|
|
15
|
+
* Validate an externally supplied lexicon. Returns human-readable problems;
|
|
16
|
+
* an empty array means the lexicon is usable.
|
|
17
|
+
*/
|
|
18
|
+
export function validateLexicon(value) {
|
|
19
|
+
const errors = [];
|
|
20
|
+
if (typeof value !== 'object' || value === null)
|
|
21
|
+
return ['lexicon must be an object'];
|
|
22
|
+
const lex = value;
|
|
23
|
+
if (typeof lex.version !== 'string')
|
|
24
|
+
errors.push('version must be a string');
|
|
25
|
+
if (typeof lex.lang !== 'string')
|
|
26
|
+
errors.push('lang must be a string');
|
|
27
|
+
if (typeof lex.entries !== 'object' || lex.entries === null) {
|
|
28
|
+
errors.push('entries must be an object');
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
for (const [word, entry] of Object.entries(lex.entries)) {
|
|
32
|
+
if (!word.trim())
|
|
33
|
+
errors.push('entries: empty word');
|
|
34
|
+
const e = entry;
|
|
35
|
+
if (!CATEGORIES.includes(e.category)) {
|
|
36
|
+
errors.push(`entries["${word}"].category is invalid`);
|
|
37
|
+
}
|
|
38
|
+
if (typeof e.intensity !== 'number' || e.intensity < 1 || e.intensity > 9) {
|
|
39
|
+
errors.push(`entries["${word}"].intensity must be 1..9`);
|
|
40
|
+
}
|
|
41
|
+
if (!POLARITIES.includes(e.polarity)) {
|
|
42
|
+
errors.push(`entries["${word}"].polarity is invalid`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
for (const key of ['fillers', 'hedges']) {
|
|
47
|
+
if (!Array.isArray(lex[key]))
|
|
48
|
+
errors.push(`${key} must be an array`);
|
|
49
|
+
}
|
|
50
|
+
if (typeof lex.vague !== 'object' || lex.vague === null)
|
|
51
|
+
errors.push('vague must be an object');
|
|
52
|
+
if (typeof lex.degree !== 'object' || lex.degree === null) {
|
|
53
|
+
errors.push('degree must be an object');
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
for (const level of ['weak', 'medium', 'strong', 'extreme']) {
|
|
57
|
+
if (!Array.isArray(lex.degree[level]))
|
|
58
|
+
errors.push(`degree.${level} must be an array`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return errors;
|
|
62
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export const RULES = [
|
|
2
|
+
{ id: 'R1', name: 'lexicon-hit', summary: '字典命中给出类别与基础强度' },
|
|
3
|
+
{ id: 'R2', name: 'negation', summary: '前 3 个 token 内的否定词翻转极性/降强度' },
|
|
4
|
+
{ id: 'R3', name: 'degree', summary: '前 2 个 token 内的程度词调整强度' },
|
|
5
|
+
{ id: 'R4', name: 'punctuation', summary: '连续 !! ?? …… 提升 arousal' },
|
|
6
|
+
{ id: 'R5', name: 'repetition', summary: '重复字符提升强调与强度' },
|
|
7
|
+
{ id: 'R6', name: 'abstain', summary: '弃权短语标记为 abstain,置信度封顶 0.5,不注入' },
|
|
8
|
+
{ id: 'R7', name: 'code-fence', summary: '代码围栏与 shell 行跳过分析' },
|
|
9
|
+
{ id: 'R8', name: 'short-input', summary: '≤3 token 时置信度减半' },
|
|
10
|
+
];
|
|
11
|
+
export const RULE_BUDGET = 8;
|
|
12
|
+
export const SEED_ENTRY_BUDGET = 120;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
export const SEED_LEXICON = {
|
|
2
|
+
version: '1.0',
|
|
3
|
+
lang: 'zh',
|
|
4
|
+
entries: {
|
|
5
|
+
// joy
|
|
6
|
+
开心: { category: 'joy', intensity: 5, polarity: 'positive' },
|
|
7
|
+
高兴: { category: 'joy', intensity: 5, polarity: 'positive' },
|
|
8
|
+
快乐: { category: 'joy', intensity: 6, polarity: 'positive' },
|
|
9
|
+
愉快: { category: 'joy', intensity: 6, polarity: 'positive' },
|
|
10
|
+
欣喜: { category: 'joy', intensity: 7, polarity: 'positive' },
|
|
11
|
+
兴奋: { category: 'joy', intensity: 8, polarity: 'positive' },
|
|
12
|
+
雀跃: { category: 'joy', intensity: 8, polarity: 'positive' },
|
|
13
|
+
满足: { category: 'joy', intensity: 6, polarity: 'positive' },
|
|
14
|
+
欣慰: { category: 'joy', intensity: 6, polarity: 'positive' },
|
|
15
|
+
骄傲: { category: 'joy', intensity: 7, polarity: 'positive' },
|
|
16
|
+
自豪: { category: 'joy', intensity: 7, polarity: 'positive' },
|
|
17
|
+
踏实: { category: 'joy', intensity: 6, polarity: 'positive' },
|
|
18
|
+
安心: { category: 'joy', intensity: 5, polarity: 'positive' },
|
|
19
|
+
释然: { category: 'joy', intensity: 6, polarity: 'positive' },
|
|
20
|
+
轻松: { category: 'joy', intensity: 5, polarity: 'positive' },
|
|
21
|
+
幸福: { category: 'joy', intensity: 8, polarity: 'positive' },
|
|
22
|
+
喜欢: { category: 'joy', intensity: 6, polarity: 'positive' },
|
|
23
|
+
热爱: { category: 'joy', intensity: 8, polarity: 'positive' },
|
|
24
|
+
享受: { category: 'joy', intensity: 6, polarity: 'positive' },
|
|
25
|
+
舒畅: { category: 'joy', intensity: 6, polarity: 'positive' },
|
|
26
|
+
痛快: { category: 'joy', intensity: 7, polarity: 'positive' },
|
|
27
|
+
期待: { category: 'joy', intensity: 6, polarity: 'positive' },
|
|
28
|
+
// anger
|
|
29
|
+
生气: { category: 'anger', intensity: 5, polarity: 'negative' },
|
|
30
|
+
愤怒: { category: 'anger', intensity: 8, polarity: 'negative' },
|
|
31
|
+
恼火: { category: 'anger', intensity: 7, polarity: 'negative' },
|
|
32
|
+
烦躁: { category: 'anger', intensity: 6, polarity: 'negative' },
|
|
33
|
+
不爽: { category: 'anger', intensity: 5, polarity: 'negative' },
|
|
34
|
+
窝火: { category: 'anger', intensity: 7, polarity: 'negative' },
|
|
35
|
+
暴躁: { category: 'anger', intensity: 7, polarity: 'negative' },
|
|
36
|
+
火大: { category: 'anger', intensity: 7, polarity: 'negative' },
|
|
37
|
+
抓狂: { category: 'anger', intensity: 8, polarity: 'negative' },
|
|
38
|
+
崩溃: { category: 'anger', intensity: 9, polarity: 'negative' },
|
|
39
|
+
// sadness
|
|
40
|
+
难过: { category: 'sadness', intensity: 5, polarity: 'negative' },
|
|
41
|
+
伤心: { category: 'sadness', intensity: 6, polarity: 'negative' },
|
|
42
|
+
失落: { category: 'sadness', intensity: 6, polarity: 'negative' },
|
|
43
|
+
委屈: { category: 'sadness', intensity: 6, polarity: 'negative' },
|
|
44
|
+
沮丧: { category: 'sadness', intensity: 6, polarity: 'negative' },
|
|
45
|
+
低落: { category: 'sadness', intensity: 5, polarity: 'negative' },
|
|
46
|
+
心疼: { category: 'sadness', intensity: 7, polarity: 'negative' },
|
|
47
|
+
心累: { category: 'sadness', intensity: 7, polarity: 'negative' },
|
|
48
|
+
疲惫: { category: 'sadness', intensity: 6, polarity: 'negative' },
|
|
49
|
+
无力: { category: 'sadness', intensity: 6, polarity: 'negative' },
|
|
50
|
+
绝望: { category: 'sadness', intensity: 9, polarity: 'negative' },
|
|
51
|
+
郁闷: { category: 'sadness', intensity: 6, polarity: 'negative' },
|
|
52
|
+
烦闷: { category: 'sadness', intensity: 6, polarity: 'negative' },
|
|
53
|
+
压抑: { category: 'sadness', intensity: 7, polarity: 'negative' },
|
|
54
|
+
憋屈: { category: 'sadness', intensity: 7, polarity: 'negative' },
|
|
55
|
+
心酸: { category: 'sadness', intensity: 7, polarity: 'negative' },
|
|
56
|
+
落寞: { category: 'sadness', intensity: 7, polarity: 'negative' },
|
|
57
|
+
孤独: { category: 'sadness', intensity: 7, polarity: 'negative' },
|
|
58
|
+
// fear
|
|
59
|
+
害怕: { category: 'fear', intensity: 5, polarity: 'negative' },
|
|
60
|
+
恐惧: { category: 'fear', intensity: 8, polarity: 'negative' },
|
|
61
|
+
焦虑: { category: 'fear', intensity: 7, polarity: 'negative' },
|
|
62
|
+
紧张: { category: 'fear', intensity: 6, polarity: 'negative' },
|
|
63
|
+
担心: { category: 'fear', intensity: 6, polarity: 'negative' },
|
|
64
|
+
不安: { category: 'fear', intensity: 6, polarity: 'negative' },
|
|
65
|
+
慌张: { category: 'fear', intensity: 6, polarity: 'negative' },
|
|
66
|
+
忐忑: { category: 'fear', intensity: 6, polarity: 'negative' },
|
|
67
|
+
心慌: { category: 'fear', intensity: 6, polarity: 'negative' },
|
|
68
|
+
畏惧: { category: 'fear', intensity: 7, polarity: 'negative' },
|
|
69
|
+
惶恐: { category: 'fear', intensity: 7, polarity: 'negative' },
|
|
70
|
+
// disgust
|
|
71
|
+
讨厌: { category: 'disgust', intensity: 5, polarity: 'negative' },
|
|
72
|
+
厌恶: { category: 'disgust', intensity: 7, polarity: 'negative' },
|
|
73
|
+
反感: { category: 'disgust', intensity: 6, polarity: 'negative' },
|
|
74
|
+
恶心: { category: 'disgust', intensity: 7, polarity: 'negative' },
|
|
75
|
+
嫌弃: { category: 'disgust', intensity: 6, polarity: 'negative' },
|
|
76
|
+
鄙视: { category: 'disgust', intensity: 7, polarity: 'negative' },
|
|
77
|
+
受不了: { category: 'disgust', intensity: 6, polarity: 'negative' },
|
|
78
|
+
排斥: { category: 'disgust', intensity: 6, polarity: 'negative' },
|
|
79
|
+
// surprise
|
|
80
|
+
惊讶: { category: 'surprise', intensity: 6, polarity: 'neutral' },
|
|
81
|
+
震惊: { category: 'surprise', intensity: 8, polarity: 'neutral' },
|
|
82
|
+
吃惊: { category: 'surprise', intensity: 6, polarity: 'neutral' },
|
|
83
|
+
意外: { category: 'surprise', intensity: 5, polarity: 'neutral' },
|
|
84
|
+
诧异: { category: 'surprise', intensity: 6, polarity: 'neutral' },
|
|
85
|
+
目瞪口呆: { category: 'surprise', intensity: 8, polarity: 'neutral' },
|
|
86
|
+
},
|
|
87
|
+
fillers: [
|
|
88
|
+
'嗯', '啊', '呃', '额', '那个', '就是', '然后', '这个',
|
|
89
|
+
'对吧', '是吧', '反正', '基本上', '总之', '所以说', '其实', '说实话',
|
|
90
|
+
],
|
|
91
|
+
hedges: [
|
|
92
|
+
'可能', '也许', '大概', '应该', '我觉得', '好像', '似乎', '或许',
|
|
93
|
+
'不一定', '差不多', '算是', '感觉', '可能吧', '不确定', '大概率', '有可能',
|
|
94
|
+
],
|
|
95
|
+
vague: {
|
|
96
|
+
开心: ['欣喜', '雀跃', '兴奋'],
|
|
97
|
+
难过: ['心酸', '失落', '委屈'],
|
|
98
|
+
害怕: ['恐惧', '焦虑', '不安'],
|
|
99
|
+
生气: ['愤怒', '恼火', '气愤'],
|
|
100
|
+
不舒服: ['压抑', '烦躁', '疲惫'],
|
|
101
|
+
很好: ['出色', '精彩', '优秀'],
|
|
102
|
+
不好: ['糟糕', '恶劣', '拙劣'],
|
|
103
|
+
喜欢: ['热爱', '着迷', '钟爱'],
|
|
104
|
+
讨厌: ['厌恶', '反感', '排斥'],
|
|
105
|
+
问题: ['症结', '隐患', '瓶颈'],
|
|
106
|
+
东西: ['物件', '成果', '产出'],
|
|
107
|
+
方面: ['维度', '层面', '环节'],
|
|
108
|
+
觉得: ['认为', '判断', '确信'],
|
|
109
|
+
想: ['渴望', '期待', '向往'],
|
|
110
|
+
做: ['执行', '落实', '推进'],
|
|
111
|
+
看: ['观察', '审视'],
|
|
112
|
+
说: ['表达', '阐述'],
|
|
113
|
+
快: ['迅速', '飞速'],
|
|
114
|
+
大: ['巨大', '庞大'],
|
|
115
|
+
多: ['大量', '充裕'],
|
|
116
|
+
},
|
|
117
|
+
degree: {
|
|
118
|
+
weak: ['有点', '稍微', '略微', '些许', '稍稍'],
|
|
119
|
+
medium: ['比较', '挺', '相当', '蛮', '还算', '很'],
|
|
120
|
+
strong: ['非常', '特别', '十分', '格外', '越来越'],
|
|
121
|
+
extreme: ['极度', '极其', '无比', '超级', '太'],
|
|
122
|
+
},
|
|
123
|
+
};
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { rank } from './scoring.js';
|
|
2
|
+
import { selectDirectives } from './affect/directives.js';
|
|
2
3
|
export const DEFAULT_MAX_CHARS = 1200;
|
|
4
|
+
/** Characters reserved for the affect section; if it does not fit, it is dropped whole. */
|
|
5
|
+
export const AFFECT_BUDGET = 160;
|
|
6
|
+
/** Only the most recent user turn counts; older samples are for evaluation, not injection. */
|
|
7
|
+
export const AFFECT_WINDOW_MS = 30 * 60 * 1000;
|
|
3
8
|
/** Emotion → concrete behavioural instruction. Values are -2..+2. */
|
|
4
9
|
export function styleDirectives(emotion) {
|
|
5
10
|
const out = [];
|
|
@@ -33,6 +38,21 @@ export function buildContext(store, request, now = Date.now()) {
|
|
|
33
38
|
if (styles.length > 0) {
|
|
34
39
|
sections.push(`<style>\n${styles.map((s) => `- ${s}`).join('\n')}\n</style>`);
|
|
35
40
|
}
|
|
41
|
+
// Affect: newest sample only, and only when it is confident and recent enough.
|
|
42
|
+
let affectMeta;
|
|
43
|
+
const [latest] = store.recentAffect(request.soulId, request.userId, now - AFFECT_WINDOW_MS);
|
|
44
|
+
if (latest) {
|
|
45
|
+
const directives = selectDirectives(latest);
|
|
46
|
+
if (directives.length > 0) {
|
|
47
|
+
const section = `<affect confidence="${latest.confidence.toFixed(2)}" analyzer="${latest.analyzer}">\n` +
|
|
48
|
+
`${directives.map((d) => `- ${d.text}`).join('\n')}\n</affect>`;
|
|
49
|
+
const usedSoFar = sections.join('\n\n').length;
|
|
50
|
+
if (section.length <= AFFECT_BUDGET && usedSoFar + section.length + 2 <= maxChars) {
|
|
51
|
+
sections.push(section);
|
|
52
|
+
affectMeta = { confidence: latest.confidence, directives: directives.map((d) => d.id) };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
36
56
|
const candidates = store.query({ soulId: request.soulId, userId: request.userId });
|
|
37
57
|
const ranked = rank(candidates, request.query ?? '', now);
|
|
38
58
|
let chars = sections.join('\n\n').length;
|
|
@@ -55,5 +75,8 @@ export function buildContext(store, request, now = Date.now()) {
|
|
|
55
75
|
const text = sections.join('\n\n');
|
|
56
76
|
if (used.length > 0)
|
|
57
77
|
store.touch(used, now);
|
|
58
|
-
|
|
78
|
+
const result = { text, used, dropped, chars: text.length };
|
|
79
|
+
if (affectMeta)
|
|
80
|
+
result.affect = affectMeta;
|
|
81
|
+
return result;
|
|
59
82
|
}
|
|
@@ -5,23 +5,34 @@
|
|
|
5
5
|
* Anything a host needs beyond this belongs in an adapter.
|
|
6
6
|
*/
|
|
7
7
|
export * from './types.js';
|
|
8
|
-
export { openSqlite } from './sqlite.js';
|
|
9
|
-
export { buildContext, styleDirectives, DEFAULT_MAX_CHARS } from './context.js';
|
|
8
|
+
export { openSqlite, SCHEMA_VERSION, AFFECT_RETENTION_MS, DbNewerThanBinaryError } from './sqlite.js';
|
|
9
|
+
export { buildContext, styleDirectives, DEFAULT_MAX_CHARS, AFFECT_BUDGET, AFFECT_WINDOW_MS, } from './context.js';
|
|
10
10
|
export { reflect, ruleExtract, nextEmotion } from './reflect.js';
|
|
11
11
|
export { rank, scoreMemory, tokenize, relevance } from './scoring.js';
|
|
12
|
+
export * from './affect/index.js';
|
|
13
|
+
export { measureStyle } from './style/measure.js';
|
|
14
|
+
export * from './storage/config.js';
|
|
15
|
+
export * from './storage/merge.js';
|
|
16
|
+
export * from './storage/steering.js';
|
|
17
|
+
export * from './storage/transcript.js';
|
|
12
18
|
import { buildContext } from './context.js';
|
|
13
19
|
import { reflect, ruleExtract, nextEmotion } from './reflect.js';
|
|
14
20
|
import { rank, tokenize } from './scoring.js';
|
|
21
|
+
import { analyzeAffect, emptyAffect, lexiconAnalyzer } from './affect/analyze.js';
|
|
15
22
|
export class Soul {
|
|
16
23
|
store;
|
|
17
24
|
soulId;
|
|
18
25
|
userId;
|
|
19
26
|
name;
|
|
27
|
+
deviceId;
|
|
28
|
+
analyzer;
|
|
20
29
|
constructor(options) {
|
|
21
30
|
this.store = options.store;
|
|
22
31
|
this.soulId = options.soulId;
|
|
23
32
|
this.userId = options.userId;
|
|
24
33
|
this.name = options.name ?? '小灵';
|
|
34
|
+
this.deviceId = options.deviceId ?? 'dev_local';
|
|
35
|
+
this.analyzer = options.analyzer ?? lexiconAnalyzer;
|
|
25
36
|
if (!this.store.identity(this.soulId)) {
|
|
26
37
|
this.store.ensureSoul({
|
|
27
38
|
soulId: this.soulId,
|
|
@@ -46,9 +57,19 @@ export class Soul {
|
|
|
46
57
|
this.store.touch(ranked.map((r) => r.memory.id));
|
|
47
58
|
return ranked;
|
|
48
59
|
}
|
|
49
|
-
/**
|
|
50
|
-
|
|
60
|
+
/** Pure analysis, no storage. Async because the analyzer may call a model. */
|
|
61
|
+
async analyze(text) {
|
|
62
|
+
return this.analyzeTurn(text);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Verb 2: after each turn.
|
|
66
|
+
*
|
|
67
|
+
* The analyzer may be async (a model); any failure degrades to the lexicon
|
|
68
|
+
* analyzer. Emotion is computed from the turn only — never from affect.
|
|
69
|
+
*/
|
|
70
|
+
async observe(turn) {
|
|
51
71
|
const now = turn.at ?? Date.now();
|
|
72
|
+
const affect = turn.role === 'user' ? await this.analyzeTurn(turn.content) : emptyAffect();
|
|
52
73
|
const drafts = ruleExtract([turn]);
|
|
53
74
|
for (const draft of drafts) {
|
|
54
75
|
this.store.put({
|
|
@@ -62,14 +83,23 @@ export class Soul {
|
|
|
62
83
|
recallCount: 0,
|
|
63
84
|
status: 'active',
|
|
64
85
|
visibility: 'private',
|
|
65
|
-
keywords:
|
|
86
|
+
keywords: tokenize(draft.content),
|
|
66
87
|
createdAt: now,
|
|
88
|
+
updatedAt: now,
|
|
67
89
|
validFrom: now,
|
|
68
90
|
});
|
|
69
91
|
}
|
|
92
|
+
if (turn.role === 'user') {
|
|
93
|
+
try {
|
|
94
|
+
this.store.saveAffect(this.soulId, this.userId, affect, now, this.deviceId);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// Affect is an enhancement; storage trouble must not break the session.
|
|
98
|
+
}
|
|
99
|
+
}
|
|
70
100
|
const emotion = nextEmotion(this.store.emotion(this.soulId, this.userId), [turn]);
|
|
71
|
-
this.store.saveEmotion(this.soulId, this.userId, emotion);
|
|
72
|
-
return { written: drafts.length, emotion };
|
|
101
|
+
this.store.saveEmotion(this.soulId, this.userId, emotion, now);
|
|
102
|
+
return { written: drafts.length, emotion, affect };
|
|
73
103
|
}
|
|
74
104
|
/**
|
|
75
105
|
* Explicit form of `observe`: the host decided this is worth keeping.
|
|
@@ -91,6 +121,7 @@ export class Soul {
|
|
|
91
121
|
visibility: options.visibility ?? 'private',
|
|
92
122
|
keywords: tokenize(content),
|
|
93
123
|
createdAt: now,
|
|
124
|
+
updatedAt: now,
|
|
94
125
|
validFrom: now,
|
|
95
126
|
});
|
|
96
127
|
return id;
|
|
@@ -106,4 +137,18 @@ export class Soul {
|
|
|
106
137
|
emotion: this.store.emotion(this.soulId, this.userId),
|
|
107
138
|
};
|
|
108
139
|
}
|
|
140
|
+
async analyzeTurn(content) {
|
|
141
|
+
try {
|
|
142
|
+
const signal = await this.analyzer.analyze(content);
|
|
143
|
+
return { ...signal, analyzer: this.analyzer.id };
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
try {
|
|
147
|
+
return analyzeAffect(content);
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return emptyAffect();
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
109
154
|
}
|