soulbridge 0.0.26 → 0.0.98
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/LICENSE +202 -202
- package/dist/packages/cli/src/cli.js +0 -0
- package/dist/packages/core/src/config/dimensions.d.ts +18 -0
- package/dist/packages/core/src/config/dimensions.js +33 -0
- package/dist/packages/core/src/config/domains.d.ts +70 -0
- package/dist/packages/core/src/config/domains.js +70 -0
- package/dist/packages/core/src/config/labels.d.ts +22 -0
- package/dist/packages/core/src/config/labels.js +21 -0
- package/dist/packages/core/src/config/status.d.ts +51 -0
- package/dist/packages/core/src/config/status.js +47 -0
- package/dist/packages/core/src/consolidate.d.ts +170 -0
- package/dist/packages/core/src/consolidate.js +311 -0
- package/dist/packages/core/src/context.d.ts +2 -0
- package/dist/packages/core/src/context.js +48 -17
- package/dist/packages/core/src/dimension-text.d.ts +6 -0
- package/dist/packages/core/src/dimension-text.js +24 -0
- package/dist/packages/core/src/dimensions.d.ts +28 -0
- package/dist/packages/core/src/dimensions.js +53 -0
- package/dist/packages/core/src/extract.d.ts +140 -0
- package/dist/packages/core/src/extract.js +611 -0
- package/dist/packages/core/src/index.d.ts +362 -10
- package/dist/packages/core/src/index.js +983 -127
- package/dist/packages/core/src/inject-text.d.ts +16 -0
- package/dist/packages/core/src/inject-text.js +24 -0
- package/dist/packages/core/src/profile-prompt.d.ts +18 -0
- package/dist/packages/core/src/profile-prompt.js +103 -0
- package/dist/packages/core/src/prompts.d.ts +122 -0
- package/dist/packages/core/src/prompts.generated.d.ts +9 -0
- package/dist/packages/core/src/prompts.generated.js +9 -0
- package/dist/packages/core/src/prompts.js +293 -0
- package/dist/packages/core/src/reflect.d.ts +7 -0
- package/dist/packages/core/src/reflect.js +25 -27
- package/dist/packages/core/src/retrieval.d.ts +39 -0
- package/dist/packages/core/src/retrieval.js +105 -0
- package/dist/packages/core/src/roles.d.ts +18 -0
- package/dist/packages/core/src/roles.js +17 -0
- package/dist/packages/core/src/rules.d.ts +69 -0
- package/dist/packages/core/src/rules.js +152 -0
- package/dist/packages/core/src/sqlite.d.ts +26 -5
- package/dist/packages/core/src/sqlite.js +944 -33
- package/dist/packages/core/src/store.d.ts +92 -3
- package/dist/packages/core/src/types.d.ts +442 -1
- package/dist/packages/core/src/types.js +1 -0
- package/package.json +5 -3
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
import { renderExtractionPrompt } from './prompts.js';
|
|
2
|
+
import { SLOT_ENTITY_MARKER } from './config/domains.js';
|
|
3
|
+
/**
|
|
4
|
+
* What a fact's confidence is when the model did not give one.
|
|
5
|
+
*
|
|
6
|
+
* The midpoint is the only honest value: 0 says "probably false" and 1 says
|
|
7
|
+
* "certain", and neither is what "no answer" means. Used by both the text and the
|
|
8
|
+
* JSON paths so the two shapes cannot disagree.
|
|
9
|
+
*/
|
|
10
|
+
export const UNKNOWN_CONFIDENCE = 0.6;
|
|
11
|
+
/** Relations a person is introduced with, longest first so 女同事 wins over 同事. */
|
|
12
|
+
const RELATIONS = [
|
|
13
|
+
'女朋友', '男朋友', '老婆', '老公', '对象', '同事', '朋友', '同学', '闺蜜', '室友',
|
|
14
|
+
'老板', '领导', '老师', '妈', '爸', '母亲', '父亲', '儿子', '女儿', '前男友', '前女友',
|
|
15
|
+
];
|
|
16
|
+
/** Verbs that carry a state change — the part of an event memory keeps. */
|
|
17
|
+
const VERBS = [
|
|
18
|
+
{ word: '裁员', passiveSlot: '被裁员者', activeSlot: '裁员方' },
|
|
19
|
+
{ word: '辞退', passiveSlot: '被辞退者', activeSlot: '辞退方' },
|
|
20
|
+
{ word: '离职', passiveSlot: '离职者', activeSlot: '原所属组织' },
|
|
21
|
+
{ word: '辞职', passiveSlot: '辞职者', activeSlot: '原所属组织' },
|
|
22
|
+
{ word: '升职', passiveSlot: '升职者', activeSlot: '所属组织' },
|
|
23
|
+
{ word: '生', passiveSlot: '生病者', activeSlot: '照顾者' },
|
|
24
|
+
{ word: '结婚', passiveSlot: '结婚方', activeSlot: '结婚对象' },
|
|
25
|
+
{ word: '分手', passiveSlot: '分手方', activeSlot: '分手对象' },
|
|
26
|
+
{ word: '去世', passiveSlot: '去世者', activeSlot: '亲属' },
|
|
27
|
+
{ word: '搬家', passiveSlot: '搬家者', activeSlot: '原住地' },
|
|
28
|
+
{ word: '吵架', passiveSlot: '当事人', activeSlot: '吵架对象' },
|
|
29
|
+
];
|
|
30
|
+
const TIME_WORDS = [
|
|
31
|
+
'昨天', '前天', '今天', '明天', '大前天', '后天',
|
|
32
|
+
'上周', '本周', '这周', '下周', '上个月', '这个月', '下个月',
|
|
33
|
+
'去年', '今年', '去年年底', '上周末', '这周末',
|
|
34
|
+
];
|
|
35
|
+
/** Rough offsets in days; good enough to sort, and `atText` keeps the original. */
|
|
36
|
+
const TIME_OFFSET_DAYS = {
|
|
37
|
+
昨天: -1, 前天: -2, 大前天: -3, 今天: 0,
|
|
38
|
+
明天: 1, 后天: 2, 上周: -7, 上周末: -5, 本周: 0, 这周: 0, 这周末: 2,
|
|
39
|
+
下周: 7, 上个月: -30, 这个月: 0, 下个月: 30, 去年: -365, 今年: 0,
|
|
40
|
+
};
|
|
41
|
+
const UNCERTAIN = /(不知道|不清楚|没消息|不知道她|不知道他|后来|怎么样了|是否|还在不在)/;
|
|
42
|
+
function splitSentences(text) {
|
|
43
|
+
return text
|
|
44
|
+
.split(/[。!?!?\n;;]/)
|
|
45
|
+
.map((s) => s.trim())
|
|
46
|
+
.filter((s) => s.length >= 2);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* A calendar date, or nothing.
|
|
50
|
+
*
|
|
51
|
+
* `Date.parse('2026-02-31T00:00:00Z')` does not fail: it rolls to 2026-03-03. A rolled
|
|
52
|
+
* date is worse than a missing one — it is confidently wrong and joins time retrieval
|
|
53
|
+
* as if it were exact. Round-tripping through Date is the only reliable check.
|
|
54
|
+
*/
|
|
55
|
+
export function calendarDate(year, month, day) {
|
|
56
|
+
if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day))
|
|
57
|
+
return undefined;
|
|
58
|
+
if (month < 1 || month > 12 || day < 1 || day > 31)
|
|
59
|
+
return undefined;
|
|
60
|
+
const ms = Date.UTC(year, month - 1, day);
|
|
61
|
+
const check = new Date(ms);
|
|
62
|
+
const same = check.getUTCFullYear() === year && check.getUTCMonth() === month - 1 && check.getUTCDate() === day;
|
|
63
|
+
return same ? ms : undefined;
|
|
64
|
+
}
|
|
65
|
+
/** Parse `YYYY-MM-DD`; anything that is not a real day on the calendar returns undefined. */
|
|
66
|
+
export function parseIsoDate(text) {
|
|
67
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(text ?? '').trim());
|
|
68
|
+
if (!match)
|
|
69
|
+
return undefined;
|
|
70
|
+
return calendarDate(Number(match[1]), Number(match[2]), Number(match[3]));
|
|
71
|
+
}
|
|
72
|
+
/** First time expression in the sentence, with a rough timestamp. */
|
|
73
|
+
function findTime(sentence, now) {
|
|
74
|
+
for (const word of TIME_WORDS) {
|
|
75
|
+
if (sentence.includes(word)) {
|
|
76
|
+
const days = TIME_OFFSET_DAYS[word];
|
|
77
|
+
return days === undefined ? { atText: word } : { atText: word, at: now + days * 86400000 };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const explicit = sentence.match(/(\d{1,2})月(\d{1,2})[日号]/);
|
|
81
|
+
if (explicit) {
|
|
82
|
+
const month = Number(explicit[1]);
|
|
83
|
+
const day = Number(explicit[2]);
|
|
84
|
+
const year = new Date(now).getUTCFullYear();
|
|
85
|
+
// "2月31日" is not a date; keep the words and let the precision stay unknown.
|
|
86
|
+
const at = calendarDate(year, month, day);
|
|
87
|
+
return at === undefined ? { atText: explicit[0] } : { atText: explicit[0], at };
|
|
88
|
+
}
|
|
89
|
+
const yearMonth = sentence.match(/(\d{4})年(\d{1,2})月/);
|
|
90
|
+
if (yearMonth) {
|
|
91
|
+
const at = calendarDate(Number(yearMonth[1]), Number(yearMonth[2]), 1);
|
|
92
|
+
return at === undefined ? { atText: yearMonth[0] } : { atText: yearMonth[0], at };
|
|
93
|
+
}
|
|
94
|
+
return {};
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* People in one sentence: `我同事小林` gives 同事 (relation) and 小林 (name); a bare
|
|
98
|
+
* `我妈` gives 妈 as both. No pronoun resolution — "她" is deliberately not a name.
|
|
99
|
+
*/
|
|
100
|
+
/** Characters that start a time word or a verb — a name never ends with one. */
|
|
101
|
+
const NAME_STOP_TAIL = new Set([...'上下今明昨前后本这去每被就已会在要想说很挺不没也还了着过的得'.split('')]);
|
|
102
|
+
function findPeople(sentence) {
|
|
103
|
+
const out = [];
|
|
104
|
+
for (const relation of RELATIONS) {
|
|
105
|
+
const index = sentence.indexOf(relation);
|
|
106
|
+
if (index < 0)
|
|
107
|
+
continue;
|
|
108
|
+
const after = sentence.slice(index + relation.length);
|
|
109
|
+
// Chinese names are 2-3 characters and are followed directly by the rest of
|
|
110
|
+
// the sentence ("小林上周"), so take the longest run and give back a trailing
|
|
111
|
+
// function character — otherwise 小林上周 becomes the name 小林上.
|
|
112
|
+
const run = after.match(/^([\u4e00-\u9fa5]{2,3})/);
|
|
113
|
+
let name = run?.[1];
|
|
114
|
+
if (name && name.length === 3 && NAME_STOP_TAIL.has(name[2]))
|
|
115
|
+
name = name.slice(0, 2);
|
|
116
|
+
if (name) {
|
|
117
|
+
out.push({ type: 'person', name, aliases: [], attrs: { relation } });
|
|
118
|
+
}
|
|
119
|
+
else if (!out.some((e) => e.name === relation)) {
|
|
120
|
+
out.push({ type: 'person', name: relation, aliases: [], attrs: { relation } });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
/** The verb that names what happened, if the lexicon knows it. */
|
|
126
|
+
function findVerb(sentence) {
|
|
127
|
+
for (const verb of VERBS) {
|
|
128
|
+
if (!sentence.includes(verb.word))
|
|
129
|
+
continue;
|
|
130
|
+
const passive = sentence.includes(`被${verb.word}`) || sentence.includes(`遭${verb.word}`);
|
|
131
|
+
return { word: verb.word, passive, slot: passive ? verb.passiveSlot : verb.activeSlot };
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
/** Trim a sentence into a statement: the memory's own line, not the whole paragraph. */
|
|
136
|
+
function toStatement(sentence) {
|
|
137
|
+
return sentence.replace(/\s+/g, ' ').trim().slice(0, 80);
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Rule extraction. Returns drafts for one turn's user text; may return an empty
|
|
141
|
+
* list, which is a valid answer (small talk is not a fact).
|
|
142
|
+
*
|
|
143
|
+
* Pronouns are resolved only inside the same turn, and only to the most recent
|
|
144
|
+
* explicit person: "我同事小林被裁了。不知道她后来怎么样" can bind 她→小林 because
|
|
145
|
+
* the turn itself said who she is. Across turns we do not guess — an unresolved
|
|
146
|
+
* "她" stays unresolved instead of being attached to the wrong person.
|
|
147
|
+
*/
|
|
148
|
+
export function extractByRules(text, at = Date.now()) {
|
|
149
|
+
const entities = [];
|
|
150
|
+
const facts = [];
|
|
151
|
+
const gaps = [];
|
|
152
|
+
let recentPerson = null;
|
|
153
|
+
for (const sentence of splitSentences(text)) {
|
|
154
|
+
const explicit = findPeople(sentence);
|
|
155
|
+
if (explicit.length > 0)
|
|
156
|
+
recentPerson = explicit[explicit.length - 1];
|
|
157
|
+
const pronoun = /(她|他|他们|她们)/.test(sentence);
|
|
158
|
+
// Same-turn binding only: with no explicit person in this sentence but a
|
|
159
|
+
// pronoun and a person earlier in the turn, the pronoun refers to them.
|
|
160
|
+
const people = explicit.length > 0 ? explicit : pronoun && recentPerson ? [recentPerson] : [];
|
|
161
|
+
const boundByPronoun = explicit.length === 0 && people.length > 0;
|
|
162
|
+
const verb = findVerb(sentence);
|
|
163
|
+
const time = findTime(sentence, at);
|
|
164
|
+
// A sentence with neither a person nor a known verb carries no structure;
|
|
165
|
+
// it may still be worth keeping as a fact with no slots (e.g. "今天下雨了").
|
|
166
|
+
const slots = [];
|
|
167
|
+
for (const person of people) {
|
|
168
|
+
entities.push(person);
|
|
169
|
+
slots.push({
|
|
170
|
+
slot: verb ? verb.slot : '当事人',
|
|
171
|
+
value: { kind: 'entity', entityName: person.name, entityType: person.type },
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
const hasStructure = people.length > 0 || verb !== null || time.at !== undefined || time.atText !== undefined;
|
|
175
|
+
if (!hasStructure)
|
|
176
|
+
continue;
|
|
177
|
+
facts.push({
|
|
178
|
+
statement: toStatement(sentence),
|
|
179
|
+
typeName: verb?.word,
|
|
180
|
+
at: time.at,
|
|
181
|
+
atText: time.atText,
|
|
182
|
+
// Precision follows what the rules actually resolved: a parsed date is `day`, a
|
|
183
|
+
// bare expression ("上次") is `relative`. Without this every rules fact claimed
|
|
184
|
+
// unknown and could not join time retrieval even when it had a date.
|
|
185
|
+
atPrecision: time.at !== undefined ? 'day' : time.atText !== undefined ? 'relative' : 'unknown',
|
|
186
|
+
source: boundByPronoun ? 'inferred' : 'told',
|
|
187
|
+
confidence: people.length > 0 ? (boundByPronoun ? 0.5 : 0.7) : 0.5,
|
|
188
|
+
slots,
|
|
189
|
+
});
|
|
190
|
+
// "不知道她后来怎么样了" opens a gap instead of asserting a fact.
|
|
191
|
+
if (UNCERTAIN.test(sentence) && people.length > 0) {
|
|
192
|
+
gaps.push({
|
|
193
|
+
topic: toStatement(sentence),
|
|
194
|
+
aboutEntityName: people[0].name,
|
|
195
|
+
tension: 0.5,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
// The same person appears once per sentence; the draft keeps one of them.
|
|
200
|
+
const unique = new Map();
|
|
201
|
+
for (const entity of entities)
|
|
202
|
+
unique.set(`${entity.type}:${entity.name}`, entity);
|
|
203
|
+
return { entities: [...unique.values()], facts, gaps, by: 'rules' };
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Model extraction: one call, strict JSON, validated afterwards.
|
|
207
|
+
*
|
|
208
|
+
* The model sees the entities and facts already stored, so it can reuse a name
|
|
209
|
+
* instead of inventing a duplicate and can supersede a fact that the new
|
|
210
|
+
* sentence contradicts. That merge decision is the difference between "a log of
|
|
211
|
+
* sentences" and memory (Mem0's ADD/UPDATE/DELETE, Graphiti's invalidation).
|
|
212
|
+
*
|
|
213
|
+
* The prompt itself is rendered from `prompts.ts`, not written here.
|
|
214
|
+
*/
|
|
215
|
+
export async function extractWithModel(text, llm, at = Date.now(), context, assistantText) {
|
|
216
|
+
const prompt = renderExtractionPrompt({
|
|
217
|
+
text,
|
|
218
|
+
now: at,
|
|
219
|
+
assistantText,
|
|
220
|
+
entities: context?.entities,
|
|
221
|
+
facts: context?.facts,
|
|
222
|
+
previousUserText: context?.previousUserText,
|
|
223
|
+
threads: context?.threads,
|
|
224
|
+
previousThreadId: context?.previousThreadId,
|
|
225
|
+
});
|
|
226
|
+
let raw;
|
|
227
|
+
try {
|
|
228
|
+
raw = await llm(prompt);
|
|
229
|
+
}
|
|
230
|
+
catch (error) {
|
|
231
|
+
return { draft: null, error: error instanceof Error ? error.message : String(error) };
|
|
232
|
+
}
|
|
233
|
+
// Lines first: one bad record is one bad record. JSON is the fallback for a
|
|
234
|
+
// model that ignores the template and answers in JSON anyway.
|
|
235
|
+
const threadRead = readThreadLine(raw);
|
|
236
|
+
const thread = threadRead.kind === 'continue' || threadRead.kind === 'new' ? threadRead : null;
|
|
237
|
+
const threadLine = threadRead.kind === 'continue' || threadRead.kind === 'new' ? 'present' : threadRead.kind;
|
|
238
|
+
const fromLines = parseLineDraft(raw);
|
|
239
|
+
if (fromLines) {
|
|
240
|
+
const result = validateDraft(fromLines, at, context);
|
|
241
|
+
if (result.draft) {
|
|
242
|
+
if (thread)
|
|
243
|
+
result.draft.thread = thread;
|
|
244
|
+
result.draft.threadLine = threadLine;
|
|
245
|
+
}
|
|
246
|
+
return result;
|
|
247
|
+
}
|
|
248
|
+
const json = extractJson(raw);
|
|
249
|
+
if (!json)
|
|
250
|
+
return { draft: null, error: `unparseable output: ${String(raw).slice(0, 60)}` };
|
|
251
|
+
const result = validateDraft(json, at, context);
|
|
252
|
+
if (result.draft) {
|
|
253
|
+
if (thread)
|
|
254
|
+
result.draft.thread = thread;
|
|
255
|
+
result.draft.threadLine = threadLine;
|
|
256
|
+
}
|
|
257
|
+
return result;
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Parse the attribution line.
|
|
261
|
+
*
|
|
262
|
+
* 线索: 继续 th_ab12
|
|
263
|
+
* 线索: 新 | 在聊 cookie 与认证
|
|
264
|
+
*/
|
|
265
|
+
export function parseThreadLine(raw) {
|
|
266
|
+
const read = readThreadLine(raw);
|
|
267
|
+
// `topic` is omitted rather than set to undefined: callers compare the attribution
|
|
268
|
+
// object, and an explicit undefined key makes two equivalent answers unequal.
|
|
269
|
+
if (read.kind === 'continue')
|
|
270
|
+
return read.topic ? { kind: 'continue', id: read.id, topic: read.topic } : { kind: 'continue', id: read.id };
|
|
271
|
+
if (read.kind === 'new')
|
|
272
|
+
return read.topic ? { kind: 'new', topic: read.topic } : { kind: 'new' };
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* What the model's thread line said, including whether it said anything.
|
|
277
|
+
*
|
|
278
|
+
* `parseThreadLine` collapses "no line" and "a line I cannot read" into null, which made
|
|
279
|
+
* the fallback rate unmeasurable — 75% of attributions arrived as `fallback-previous`
|
|
280
|
+
* with no way to tell whether the model was silent or unparseable. The kinds are kept
|
|
281
|
+
* apart here; only `continue` and `new` are usable attributions.
|
|
282
|
+
*/
|
|
283
|
+
export function readThreadLine(raw) {
|
|
284
|
+
let sawLine = false;
|
|
285
|
+
let unreadable = '';
|
|
286
|
+
for (const line of String(raw ?? '').split(/\r?\n/)) {
|
|
287
|
+
const match = /^线索\s*[::]\s*(.+)$/.exec(line.trim());
|
|
288
|
+
if (!match)
|
|
289
|
+
continue;
|
|
290
|
+
sawLine = true;
|
|
291
|
+
const body = match[1].trim();
|
|
292
|
+
if (/^(无|none|nothing)[。.]?$/i.test(body))
|
|
293
|
+
return { kind: 'absent' };
|
|
294
|
+
// `继续 <id>` with an optional new topic, for when the subject moved inside the
|
|
295
|
+
// same thread: the stored topic would otherwise stay at the first turn forever.
|
|
296
|
+
const continued = /^继续\s*[::]?\s*([A-Za-z0-9_-]+)\s*(?:[||]\s*主题\s*[=::]\s*(.*))?$/.exec(body);
|
|
297
|
+
if (continued) {
|
|
298
|
+
const topic = (continued[2] ?? '').trim();
|
|
299
|
+
return { kind: 'continue', id: continued[1], topic: topic || undefined };
|
|
300
|
+
}
|
|
301
|
+
const fresh = /^新\s*(?:[||::]\s*(.*))?$/.exec(body);
|
|
302
|
+
if (fresh)
|
|
303
|
+
return { kind: 'new', topic: (fresh[1] ?? '').trim() || undefined };
|
|
304
|
+
unreadable = `线索: ${body}`;
|
|
305
|
+
}
|
|
306
|
+
return sawLine && unreadable ? { kind: 'invalid', line: unreadable } : { kind: 'absent' };
|
|
307
|
+
}
|
|
308
|
+
/** Models wrap JSON in prose or fences; take the outermost object. */
|
|
309
|
+
function extractJson(raw) {
|
|
310
|
+
const start = raw.indexOf('{');
|
|
311
|
+
const end = raw.lastIndexOf('}');
|
|
312
|
+
if (start < 0 || end <= start)
|
|
313
|
+
return null;
|
|
314
|
+
try {
|
|
315
|
+
return JSON.parse(raw.slice(start, end + 1));
|
|
316
|
+
}
|
|
317
|
+
catch {
|
|
318
|
+
return null;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
const ENTITY_TYPES = new Set(['person', 'thing', 'place', 'self']);
|
|
322
|
+
const PRECISIONS = new Set(['day', 'range', 'relative', 'unknown']);
|
|
323
|
+
const SOURCES = new Set(['experienced', 'told', 'inferred']);
|
|
324
|
+
const OPS = new Set(['add', 'supersede', 'update', 'noop']);
|
|
325
|
+
/** One fact payload, shared by the legacy `facts` array and the operations. */
|
|
326
|
+
function parseFactDraft(obj) {
|
|
327
|
+
const statement = String(obj?.statement ?? '').trim();
|
|
328
|
+
if (!statement)
|
|
329
|
+
return null;
|
|
330
|
+
const slots = [];
|
|
331
|
+
for (const rawSlot of asArray(obj.slots)) {
|
|
332
|
+
const s = rawSlot;
|
|
333
|
+
const slot = String(s?.slot ?? '').trim();
|
|
334
|
+
if (!slot)
|
|
335
|
+
continue;
|
|
336
|
+
if (typeof s.entity === 'string' && s.entity.trim()) {
|
|
337
|
+
slots.push({ slot, value: { kind: 'entity', entityName: s.entity.trim(), entityType: 'person' } });
|
|
338
|
+
}
|
|
339
|
+
else if (typeof s.number === 'number' && Number.isFinite(s.number)) {
|
|
340
|
+
slots.push({ slot, value: { kind: 'number', value: s.number } });
|
|
341
|
+
}
|
|
342
|
+
else if (typeof s.text === 'string' && s.text.trim()) {
|
|
343
|
+
slots.push({ slot, value: { kind: 'text', value: s.text.trim() } });
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
const source = String(obj.source ?? 'told');
|
|
347
|
+
const holderRaw = typeof obj.holder === 'string' ? obj.holder.trim() : '';
|
|
348
|
+
const precisionRaw = typeof obj.at_precision === 'string' ? obj.at_precision.trim() : '';
|
|
349
|
+
const at = typeof obj.at === 'number' ? obj.at : undefined;
|
|
350
|
+
const atText = typeof obj.at_text === 'string' ? obj.at_text : undefined;
|
|
351
|
+
const precision = precisionRaw && PRECISIONS.has(precisionRaw)
|
|
352
|
+
? precisionRaw
|
|
353
|
+
: at !== undefined
|
|
354
|
+
? 'day'
|
|
355
|
+
: atText
|
|
356
|
+
? 'relative'
|
|
357
|
+
: 'unknown';
|
|
358
|
+
return {
|
|
359
|
+
statement,
|
|
360
|
+
// `world` unless the model named a mind; the commit gate resolves the name.
|
|
361
|
+
holder: holderRaw || 'world',
|
|
362
|
+
typeName: typeof obj.type_name === 'string' ? obj.type_name : undefined,
|
|
363
|
+
at,
|
|
364
|
+
atText,
|
|
365
|
+
atPrecision: precision,
|
|
366
|
+
source: (SOURCES.has(source) ? source : 'told'),
|
|
367
|
+
confidence: typeof obj.confidence === 'number' ? Math.min(1, Math.max(0, obj.confidence)) : UNKNOWN_CONFIDENCE,
|
|
368
|
+
// Only an explicit yes counts: the field is read by the promotion rules, and a
|
|
369
|
+
// default-yes would promote every observation to a lesson.
|
|
370
|
+
...(obj.principle === true ? { principle: true } : {}),
|
|
371
|
+
slots,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Validate and normalise a model answer. Anything malformed is dropped rather
|
|
376
|
+
* than repaired: a missing statement cannot be invented, and a bad entity type
|
|
377
|
+
* would poison retrieval later.
|
|
378
|
+
*
|
|
379
|
+
* `supersede` / `update` must point at a fact the model was actually shown —
|
|
380
|
+
* otherwise it could retire a memory it never read.
|
|
381
|
+
*/
|
|
382
|
+
export function validateDraft(json, at = Date.now(), context) {
|
|
383
|
+
if (!json || typeof json !== 'object')
|
|
384
|
+
return { draft: null, error: 'not an object' };
|
|
385
|
+
const input = json;
|
|
386
|
+
const knownFactIds = new Set((context?.facts ?? []).map((f) => f.id));
|
|
387
|
+
const entities = [];
|
|
388
|
+
for (const item of asArray(input.entities)) {
|
|
389
|
+
const name = String(item?.name ?? '').trim();
|
|
390
|
+
const type = String(item?.type ?? 'person');
|
|
391
|
+
if (!name || !ENTITY_TYPES.has(type))
|
|
392
|
+
continue;
|
|
393
|
+
const attrs = item?.attrs;
|
|
394
|
+
entities.push({
|
|
395
|
+
name,
|
|
396
|
+
type: type,
|
|
397
|
+
aliases: [],
|
|
398
|
+
attrs: attrs && typeof attrs === 'object' ? attrs : {},
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
const facts = [];
|
|
402
|
+
for (const item of asArray(input.facts)) {
|
|
403
|
+
const parsed = parseFactDraft(item);
|
|
404
|
+
if (parsed)
|
|
405
|
+
facts.push(parsed);
|
|
406
|
+
}
|
|
407
|
+
const operations = [];
|
|
408
|
+
for (const item of asArray(input.operations)) {
|
|
409
|
+
const obj = item;
|
|
410
|
+
const op = String(obj?.op ?? '').trim();
|
|
411
|
+
if (!OPS.has(op))
|
|
412
|
+
continue;
|
|
413
|
+
const parsed = parseFactDraft((obj.fact ?? {}));
|
|
414
|
+
const targetId = typeof obj.target === 'string' ? obj.target.trim() : undefined;
|
|
415
|
+
if (op === 'noop') {
|
|
416
|
+
// A noop carries no fact; keep it so the trace can show "nothing to store".
|
|
417
|
+
operations.push({ op: 'noop', targetId, fact: parsed ?? { statement: '', source: 'told', confidence: 0, slots: [] } });
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
if (!parsed)
|
|
421
|
+
continue;
|
|
422
|
+
// Target validation belongs to the writer (commitDrafts), which can see every row and
|
|
423
|
+
// record a reason. Demoting here as well would give two places that decide whether a
|
|
424
|
+
// supersede is real — and would hide the model's intent from the skip report.
|
|
425
|
+
operations.push({ op: op, targetId, fact: parsed });
|
|
426
|
+
// Keep `facts` as a flat view of the adds: older callers and the commit
|
|
427
|
+
// gate's skip report read it, and operations are the source of truth.
|
|
428
|
+
if (op === 'add')
|
|
429
|
+
facts.push(parsed);
|
|
430
|
+
}
|
|
431
|
+
const gaps = [];
|
|
432
|
+
for (const item of asArray(input.gaps)) {
|
|
433
|
+
const obj = item;
|
|
434
|
+
const topic = String(obj?.topic ?? '').trim();
|
|
435
|
+
if (!topic)
|
|
436
|
+
continue;
|
|
437
|
+
gaps.push({
|
|
438
|
+
topic,
|
|
439
|
+
aboutEntityName: typeof obj.about === 'string' ? obj.about.trim() : undefined,
|
|
440
|
+
tension: typeof obj.tension === 'number' ? Math.min(1, Math.max(0, obj.tension)) : 0.5,
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
const hasOps = operations.some((o) => o.op !== 'noop');
|
|
444
|
+
// An explicit noop is an answer, not an empty one: the model looked and found
|
|
445
|
+
// nothing worth storing. Without this the caller falls back to rules and
|
|
446
|
+
// reports a failure that never happened.
|
|
447
|
+
const hasNoop = operations.some((o) => o.op === 'noop');
|
|
448
|
+
if (!hasOps && !hasNoop && facts.length === 0 && entities.length === 0 && gaps.length === 0) {
|
|
449
|
+
return { draft: null, error: 'model returned nothing usable' };
|
|
450
|
+
}
|
|
451
|
+
void at;
|
|
452
|
+
return { draft: { entities, facts, gaps, operations, by: 'llm' }, error: undefined };
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Parse the line template the model is asked to fill in.
|
|
456
|
+
*
|
|
457
|
+
* Requiring valid JSON makes the whole extraction hinge on one character:
|
|
458
|
+
* a missing brace turns a correct answer into "model-failed". A line template
|
|
459
|
+
* fails per line instead, so one malformed record costs one record. The shape it
|
|
460
|
+
* builds is the same object `validateDraft` already checks, so there is one gate,
|
|
461
|
+
* not two.
|
|
462
|
+
*
|
|
463
|
+
* 实体: 小林 | person | 同事
|
|
464
|
+
* 事实: 陈述 | 类型名 | 2026-10-24 | 上周 | told | 0.8 | 被裁员者=小林
|
|
465
|
+
* 缺口: 怎么了 | 0.6 | 小林
|
|
466
|
+
* 无
|
|
467
|
+
*/
|
|
468
|
+
export function parseLineDraft(raw) {
|
|
469
|
+
const entities = [];
|
|
470
|
+
const operations = [];
|
|
471
|
+
const gaps = [];
|
|
472
|
+
let sawRecord = false;
|
|
473
|
+
for (const line of String(raw ?? '').split(/\r?\n/)) {
|
|
474
|
+
const trimmed = line.trim();
|
|
475
|
+
if (!trimmed)
|
|
476
|
+
continue;
|
|
477
|
+
if (/^(无|none|noop|无记录|没有)[。.]?$/i.test(trimmed)) {
|
|
478
|
+
operations.push({ op: 'noop' });
|
|
479
|
+
sawRecord = true;
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
const match = /^(实体|事实|缺口)\s*[::]\s*(.+)$/.exec(trimmed);
|
|
483
|
+
if (!match)
|
|
484
|
+
continue;
|
|
485
|
+
const [, kind, body] = match;
|
|
486
|
+
const parts = body.split('|').map((part) => part.trim());
|
|
487
|
+
if (kind === '实体') {
|
|
488
|
+
const [name, type, relation] = parts;
|
|
489
|
+
if (!name)
|
|
490
|
+
continue;
|
|
491
|
+
entities.push({
|
|
492
|
+
name,
|
|
493
|
+
type: ENTITY_TYPES.has(type ?? '') ? type : 'person',
|
|
494
|
+
attrs: relation ? { relation } : {},
|
|
495
|
+
});
|
|
496
|
+
sawRecord = true;
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
if (kind === '事实') {
|
|
500
|
+
const [statement, typeName, at, atText, source, confidence, ...rest] = parts;
|
|
501
|
+
if (!statement)
|
|
502
|
+
continue;
|
|
503
|
+
const fact = { statement };
|
|
504
|
+
if (typeName)
|
|
505
|
+
fact.type_name = typeName;
|
|
506
|
+
// A date that is not a real day on the calendar is dropped, not rolled:
|
|
507
|
+
// '2026-02-31' used to become 03-03 and still claim precision=day.
|
|
508
|
+
const parsedAt = at ? parseIsoDate(at) : undefined;
|
|
509
|
+
if (parsedAt !== undefined)
|
|
510
|
+
fact.at = parsedAt;
|
|
511
|
+
if (atText)
|
|
512
|
+
fact.at_text = atText;
|
|
513
|
+
if (source)
|
|
514
|
+
fact.source = source;
|
|
515
|
+
// An empty field is "the model did not say", not "the model said zero".
|
|
516
|
+
// `Number('')` is 0, so an empty confidence used to rank the fact last — the
|
|
517
|
+
// opposite of an honest unknown. An explicit number (including 0) is honoured.
|
|
518
|
+
if (confidence !== undefined) {
|
|
519
|
+
const parsed = confidence.trim() === '' ? Number.NaN : Number(confidence);
|
|
520
|
+
fact.confidence = Number.isFinite(parsed) ? Math.min(1, Math.max(0, parsed)) : UNKNOWN_CONFIDENCE;
|
|
521
|
+
}
|
|
522
|
+
// 持有= / 精度= / 原则候选= / 操作= are keyed so the positional fields stay
|
|
523
|
+
// backward compatible; anything else in the tail is the slot list.
|
|
524
|
+
let slotText;
|
|
525
|
+
let supersedes = '';
|
|
526
|
+
let wantsSupersede = false;
|
|
527
|
+
for (const part of rest) {
|
|
528
|
+
const keyed = /^(持有|精度|原则候选|槽位|操作)\s*[==]\s*(.*)$/.exec(part);
|
|
529
|
+
if (keyed) {
|
|
530
|
+
if (keyed[1] === '持有')
|
|
531
|
+
fact.holder = keyed[2].trim();
|
|
532
|
+
else if (keyed[1] === '精度')
|
|
533
|
+
fact.at_precision = keyed[2].trim();
|
|
534
|
+
else if (keyed[1] === '原则候选') {
|
|
535
|
+
// Only 是 promotes. "否" and anything else is the safe default.
|
|
536
|
+
if (/^(是|yes|true)$/i.test(keyed[2].trim()))
|
|
537
|
+
fact.principle = true;
|
|
538
|
+
}
|
|
539
|
+
else if (keyed[1] === '操作') {
|
|
540
|
+
// `操作=取代 <id>` replaces an old fact. The id is *not* trusted here: the
|
|
541
|
+
// gate verifies it exists before anything is retired, so a hallucinated id
|
|
542
|
+
// degrades to an add instead of silently claiming a revision.
|
|
543
|
+
if (/^取代/.test(keyed[2].trim())) {
|
|
544
|
+
wantsSupersede = true;
|
|
545
|
+
supersedes = /^取代\s*[::]?\s*([A-Za-z0-9_-]+)?/.exec(keyed[2].trim())?.[1] ?? '';
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
else
|
|
549
|
+
slotText = keyed[2].trim();
|
|
550
|
+
continue;
|
|
551
|
+
}
|
|
552
|
+
if (part && slotText === undefined)
|
|
553
|
+
slotText = part;
|
|
554
|
+
}
|
|
555
|
+
const slots = [];
|
|
556
|
+
for (const pair of (slotText ?? '').split(/[;;]/).map((item) => item.trim()).filter(Boolean)) {
|
|
557
|
+
const [slot, value] = pair.split(/[==]/).map((item) => item.trim());
|
|
558
|
+
if (!slot || !value)
|
|
559
|
+
continue;
|
|
560
|
+
// Syntax does not imply semantics (ADR 0027, health-check §12). A slot value is
|
|
561
|
+
// text unless the model marks it as a reference with SLOT_ENTITY_MARKER; the old
|
|
562
|
+
// rule "anything that is not a number is a name" turned descriptions into
|
|
563
|
+
// entity claims and made the gate clean up after the parser.
|
|
564
|
+
if (/^-?\d+(\.\d+)?$/.test(value)) {
|
|
565
|
+
slots.push({ slot, number: Number(value) });
|
|
566
|
+
}
|
|
567
|
+
else if (value.startsWith(SLOT_ENTITY_MARKER) && value.slice(SLOT_ENTITY_MARKER.length).trim()) {
|
|
568
|
+
slots.push({ slot, entity: value.slice(SLOT_ENTITY_MARKER.length).trim() });
|
|
569
|
+
}
|
|
570
|
+
else {
|
|
571
|
+
slots.push({ slot, text: value });
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
if (slots.length > 0)
|
|
575
|
+
fact.slots = slots;
|
|
576
|
+
// Precision must agree with what survived: a model that wrote 精度=day for a date
|
|
577
|
+
// we rejected would otherwise claim exactness it does not have.
|
|
578
|
+
const precision = typeof fact.at_precision === 'string' ? fact.at_precision : '';
|
|
579
|
+
if (fact.at === undefined && precision === 'day') {
|
|
580
|
+
fact.at_precision = fact.at_text ? 'relative' : 'unknown';
|
|
581
|
+
}
|
|
582
|
+
else if (fact.at !== undefined && !precision) {
|
|
583
|
+
fact.at_precision = 'day';
|
|
584
|
+
}
|
|
585
|
+
// A supersede without a target is still recorded as an operation: the gate decides
|
|
586
|
+
// whether the target exists, and a missing one becomes a skipped reason instead of
|
|
587
|
+
// a silent add (the fact itself is never lost).
|
|
588
|
+
operations.push(wantsSupersede ? { op: 'supersede', target: supersedes, fact } : { op: 'add', fact });
|
|
589
|
+
sawRecord = true;
|
|
590
|
+
continue;
|
|
591
|
+
}
|
|
592
|
+
// 缺口
|
|
593
|
+
const [topic, tension, about] = parts;
|
|
594
|
+
if (!topic)
|
|
595
|
+
continue;
|
|
596
|
+
const gap = { topic };
|
|
597
|
+
const value = Number(tension);
|
|
598
|
+
if (Number.isFinite(value))
|
|
599
|
+
gap.tension = value;
|
|
600
|
+
if (about)
|
|
601
|
+
gap.about = about;
|
|
602
|
+
gaps.push(gap);
|
|
603
|
+
sawRecord = true;
|
|
604
|
+
}
|
|
605
|
+
if (!sawRecord)
|
|
606
|
+
return null;
|
|
607
|
+
return { entities, operations, gaps };
|
|
608
|
+
}
|
|
609
|
+
function asArray(value) {
|
|
610
|
+
return Array.isArray(value) ? value : [];
|
|
611
|
+
}
|