soulbridge 0.0.22 → 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 +380 -11
- package/dist/packages/core/src/index.js +1015 -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 +27 -0
- package/dist/packages/core/src/reflect.js +87 -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/scoring.d.ts +26 -1
- package/dist/packages/core/src/scoring.js +65 -5
- package/dist/packages/core/src/sqlite.d.ts +26 -5
- package/dist/packages/core/src/sqlite.js +1013 -17
- package/dist/packages/core/src/store.d.ts +98 -1
- package/dist/packages/core/src/types.d.ts +472 -1
- package/dist/packages/core/src/types.js +1 -0
- package/package.json +5 -3
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The layer names a lesson can be classified into — declared once.
|
|
3
|
+
*
|
|
4
|
+
* These labels travel three ways: the classifier is told to emit them, the parser
|
|
5
|
+
* matches them, and the store writes them as `type_name`. A copy anywhere else means
|
|
6
|
+
* a rename turns into "one stage writes a label the next stage does not read".
|
|
7
|
+
*/
|
|
8
|
+
export const CLASSIFY_LABELS = {
|
|
9
|
+
rule: '规则',
|
|
10
|
+
profile: '画像',
|
|
11
|
+
pending: '待修',
|
|
12
|
+
};
|
|
13
|
+
/** The judgment a conclusion is stored as. */
|
|
14
|
+
export const CONCLUSION_TYPE = '结论';
|
|
15
|
+
/**
|
|
16
|
+
* The fact type names that are injected as always-on anchors.
|
|
17
|
+
*
|
|
18
|
+
* Kept here because the set is shared by the context builder and by profile/rule
|
|
19
|
+
* readers; retyping it in a second filter is how a layer silently drops out.
|
|
20
|
+
*/
|
|
21
|
+
export const ANCHOR_TYPES = ['结论', '教训', '原则', '性格'];
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fact lifecycle vocabulary — declared once, imported everywhere.
|
|
3
|
+
*
|
|
4
|
+
* "superseded" used to be typed out in three files, so renaming it would have left
|
|
5
|
+
* one filter silently admitting retired facts. Config modules hold data only; the
|
|
6
|
+
* decoding (SQL binding, filters, prompts) lives in the code that imports this.
|
|
7
|
+
*
|
|
8
|
+
* Values match the column defaults in sqlite.ts; changing one is a schema migration,
|
|
9
|
+
* not a rename.
|
|
10
|
+
*/
|
|
11
|
+
export declare const FACT_STATUS: {
|
|
12
|
+
/** Written, no lifecycle decision yet. */
|
|
13
|
+
readonly none: 'none';
|
|
14
|
+
/** Being worked on (promises, plans). */
|
|
15
|
+
readonly open: 'open';
|
|
16
|
+
/** Held. */
|
|
17
|
+
readonly kept: 'kept';
|
|
18
|
+
/** Broken (a promise that did not happen). */
|
|
19
|
+
readonly broken: 'broken';
|
|
20
|
+
/** Replaced by another fact; kept for the record, never injected. */
|
|
21
|
+
readonly superseded: 'superseded';
|
|
22
|
+
};
|
|
23
|
+
export type FactStatus = (typeof FACT_STATUS)[keyof typeof FACT_STATUS];
|
|
24
|
+
/** Where a fact came from, in the trust sense (not a type). */
|
|
25
|
+
export declare const FACT_SOURCE: {
|
|
26
|
+
readonly experienced: 'experienced';
|
|
27
|
+
readonly told: 'told';
|
|
28
|
+
readonly inferred: 'inferred';
|
|
29
|
+
};
|
|
30
|
+
export type FactSource = (typeof FACT_SOURCE)[keyof typeof FACT_SOURCE];
|
|
31
|
+
/** How precise a time is. `unknown` is an honest answer, not a failure. */
|
|
32
|
+
export declare const AT_PRECISION: {
|
|
33
|
+
readonly day: 'day';
|
|
34
|
+
readonly range: 'range';
|
|
35
|
+
readonly relative: 'relative';
|
|
36
|
+
readonly unknown: 'unknown';
|
|
37
|
+
};
|
|
38
|
+
export type AtPrecision = (typeof AT_PRECISION)[keyof typeof AT_PRECISION];
|
|
39
|
+
/**
|
|
40
|
+
* The legacy `memories` layer's lifecycle.
|
|
41
|
+
*
|
|
42
|
+
* It shares the word "superseded" with facts on purpose: one lifecycle concept, one
|
|
43
|
+
* set of values, one declaration. The table retires (see retirements.json), the
|
|
44
|
+
* vocabulary does not fork before it does.
|
|
45
|
+
*/
|
|
46
|
+
export declare const MEMORY_STATUS: {
|
|
47
|
+
readonly active: 'active';
|
|
48
|
+
readonly superseded: 'superseded';
|
|
49
|
+
readonly archived: 'archived';
|
|
50
|
+
};
|
|
51
|
+
export type MemoryStatus = (typeof MEMORY_STATUS)[keyof typeof MEMORY_STATUS];
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fact lifecycle vocabulary — declared once, imported everywhere.
|
|
3
|
+
*
|
|
4
|
+
* "superseded" used to be typed out in three files, so renaming it would have left
|
|
5
|
+
* one filter silently admitting retired facts. Config modules hold data only; the
|
|
6
|
+
* decoding (SQL binding, filters, prompts) lives in the code that imports this.
|
|
7
|
+
*
|
|
8
|
+
* Values match the column defaults in sqlite.ts; changing one is a schema migration,
|
|
9
|
+
* not a rename.
|
|
10
|
+
*/
|
|
11
|
+
export const FACT_STATUS = {
|
|
12
|
+
/** Written, no lifecycle decision yet. */
|
|
13
|
+
none: 'none',
|
|
14
|
+
/** Being worked on (promises, plans). */
|
|
15
|
+
open: 'open',
|
|
16
|
+
/** Held. */
|
|
17
|
+
kept: 'kept',
|
|
18
|
+
/** Broken (a promise that did not happen). */
|
|
19
|
+
broken: 'broken',
|
|
20
|
+
/** Replaced by another fact; kept for the record, never injected. */
|
|
21
|
+
superseded: 'superseded',
|
|
22
|
+
};
|
|
23
|
+
/** Where a fact came from, in the trust sense (not a type). */
|
|
24
|
+
export const FACT_SOURCE = {
|
|
25
|
+
experienced: 'experienced',
|
|
26
|
+
told: 'told',
|
|
27
|
+
inferred: 'inferred',
|
|
28
|
+
};
|
|
29
|
+
/** How precise a time is. `unknown` is an honest answer, not a failure. */
|
|
30
|
+
export const AT_PRECISION = {
|
|
31
|
+
day: 'day',
|
|
32
|
+
range: 'range',
|
|
33
|
+
relative: 'relative',
|
|
34
|
+
unknown: 'unknown',
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* The legacy `memories` layer's lifecycle.
|
|
38
|
+
*
|
|
39
|
+
* It shares the word "superseded" with facts on purpose: one lifecycle concept, one
|
|
40
|
+
* set of values, one declaration. The table retires (see retirements.json), the
|
|
41
|
+
* vocabulary does not fork before it does.
|
|
42
|
+
*/
|
|
43
|
+
export const MEMORY_STATUS = {
|
|
44
|
+
active: 'active',
|
|
45
|
+
superseded: 'superseded',
|
|
46
|
+
archived: 'archived',
|
|
47
|
+
};
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Consolidation: several facts become one conclusion, with the evidence kept.
|
|
3
|
+
*
|
|
4
|
+
* Two ideas are borrowed rather than invented here:
|
|
5
|
+
*
|
|
6
|
+
* - AIS / attribution (Rashkin et al. 2021): every sentence must be
|
|
7
|
+
* attributable to identified sources. A conclusion cites fact ids, and one
|
|
8
|
+
* citation is not enough — that is a fact, not a conclusion.
|
|
9
|
+
* - Chain-of-Verification (Dhuliawala et al. 2023): verifying a draft against
|
|
10
|
+
* its sources works better when the check is independent of the draft. The
|
|
11
|
+
* model writes conclusions in one call and answers "do these facts directly
|
|
12
|
+
* support it?" in a second, without the first answer in view.
|
|
13
|
+
*
|
|
14
|
+
* The gate below is deliberately deterministic: evidence count, evidence
|
|
15
|
+
* existence, statement shape, and over-generalisation are all checkable without
|
|
16
|
+
* a model. The model only does what code cannot — compress meaning.
|
|
17
|
+
*/
|
|
18
|
+
import type { ConclusionDraft, Fact, SlotValue } from './types.ts';
|
|
19
|
+
export interface ConclusionGateInput {
|
|
20
|
+
draft: ConclusionDraft;
|
|
21
|
+
/** Facts the model was shown; evidence must come from here. */
|
|
22
|
+
allowed: Set<string>;
|
|
23
|
+
/** Existing conclusion the draft claims to replace, if any. */
|
|
24
|
+
existing?: Fact | null;
|
|
25
|
+
}
|
|
26
|
+
export type ConclusionGateResult = {
|
|
27
|
+
ok: true;
|
|
28
|
+
statement: string;
|
|
29
|
+
evidence: string[];
|
|
30
|
+
confidence: number;
|
|
31
|
+
} | {
|
|
32
|
+
ok: false;
|
|
33
|
+
reason: string;
|
|
34
|
+
};
|
|
35
|
+
export declare function gateConclusion(input: ConclusionGateInput): ConclusionGateResult;
|
|
36
|
+
/**
|
|
37
|
+
* The fact a conclusion becomes.
|
|
38
|
+
*
|
|
39
|
+
* No new table: a conclusion is a fact whose type is 结论, whose holder is the
|
|
40
|
+
* soul itself, and whose evidence is a `fact` slot per citation. That keeps
|
|
41
|
+
* retrieval, export and supersede working unchanged.
|
|
42
|
+
*
|
|
43
|
+
* A conclusion also carries the entities its evidence mentions, under `关于`.
|
|
44
|
+
* Without that it cannot be recalled when the conversation turns to that person —
|
|
45
|
+
* the one question the whole layer exists to answer.
|
|
46
|
+
*/
|
|
47
|
+
export declare function conclusionSlots(evidence: string[], entities?: string[]): {
|
|
48
|
+
slot: string;
|
|
49
|
+
value: SlotValue;
|
|
50
|
+
}[];
|
|
51
|
+
/**
|
|
52
|
+
* Parse the draft answer: one conclusion per line.
|
|
53
|
+
*
|
|
54
|
+
* 结论: 用户讨厌同一件事反复交代 | 证据=fc_a1,fc_a2 | 置信=0.8 | 取代=fc_c1
|
|
55
|
+
* 无
|
|
56
|
+
*/
|
|
57
|
+
export declare function parseConclusionDraft(raw: string): ConclusionDraft | null;
|
|
58
|
+
export interface VerificationDraft {
|
|
59
|
+
verdict: 'supported' | 'partial' | 'unsupported';
|
|
60
|
+
reason?: string;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Parse the verification answer:
|
|
64
|
+
*
|
|
65
|
+
* 判定: 支持 | 依据=fc_a1,fc_a2 | 理由=两条事实都提到"反复交代"
|
|
66
|
+
* 判定: 不支持 | 理由=只有 fc_a1 提到,fc_a2 说的是工具切换
|
|
67
|
+
*/
|
|
68
|
+
export declare function parseVerificationDraft(raw: string): VerificationDraft | null;
|
|
69
|
+
/**
|
|
70
|
+
* Normalise a statement for the one check a rule can make safely.
|
|
71
|
+
*
|
|
72
|
+
* This is deliberately narrow: it only catches text that is *the same text*, so a
|
|
73
|
+
* hit cannot be wrong. Everything about meaning is the model's call, because
|
|
74
|
+
* "same topic, different words" is exactly where string matching lies.
|
|
75
|
+
*/
|
|
76
|
+
export declare function normalizeStatement(statement: string): string;
|
|
77
|
+
export interface ConclusionRelation {
|
|
78
|
+
kind: 'duplicate' | 'refine' | 'conflict' | 'unrelated';
|
|
79
|
+
target?: string;
|
|
80
|
+
reason?: string;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Parse the relation check:
|
|
84
|
+
*
|
|
85
|
+
* 关系: 重复 fc_abc | 理由=说的是同一件事,只是措辞不同
|
|
86
|
+
* 关系: 细化 fc_abc | 理由=新结论多了"只在紧急时"
|
|
87
|
+
* 关系: 矛盾 fc_abc | 理由=一个说喜欢重复讲,一个说讨厌
|
|
88
|
+
* 关系: 无关
|
|
89
|
+
*
|
|
90
|
+
* A named id that we do not hold is not an error — the caller treats it as
|
|
91
|
+
* unrelated, which only ever writes the new conclusion and never deletes one.
|
|
92
|
+
*/
|
|
93
|
+
export declare function parseRelationDraft(raw: string): ConclusionRelation | null;
|
|
94
|
+
/**
|
|
95
|
+
* Read a verifier's answer, keeping "we could not read it" separate from "no".
|
|
96
|
+
*
|
|
97
|
+
* An empty or unparseable reply is not evidence against a conclusion — it is the
|
|
98
|
+
* documented stream-gone failure. Collapsing it to `unsupported` silently discards
|
|
99
|
+
* a draft that was never actually checked (and before this, it also lost the
|
|
100
|
+
* thread). Only a parsed verdict may drop a conclusion.
|
|
101
|
+
*/
|
|
102
|
+
export declare function readVerification(raw: string): {
|
|
103
|
+
kind: 'verdict';
|
|
104
|
+
verdict: VerificationDraft;
|
|
105
|
+
} | {
|
|
106
|
+
kind: 'retry';
|
|
107
|
+
reason: string;
|
|
108
|
+
};
|
|
109
|
+
export interface ProfileLine {
|
|
110
|
+
statement: string;
|
|
111
|
+
sources: string[];
|
|
112
|
+
dimension: ProfileDimension;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* The dimensions a profile line can belong to.
|
|
116
|
+
*
|
|
117
|
+
* Declared in dimensions.ts so prompts render it instead of retyping the names —
|
|
118
|
+
* a second copy is how "add an eighth dimension" becomes a silent drift.
|
|
119
|
+
*/
|
|
120
|
+
export { PROFILE_DIMENSIONS } from './config/dimensions.ts';
|
|
121
|
+
export type { ProfileDimension } from './config/dimensions.ts';
|
|
122
|
+
import type { ProfileDimension } from './config/dimensions.ts';
|
|
123
|
+
/**
|
|
124
|
+
* Parse the merged profile.
|
|
125
|
+
*
|
|
126
|
+
* Three things are rejected by code rather than hoped for: a line with no source is
|
|
127
|
+
* an assumption, a hedged or absolute line is a failed merge, and a fragment is not
|
|
128
|
+
* a judgment. Near-duplicates are folded, because "the same thing said twice" is the
|
|
129
|
+
* exact failure the merge exists to fix.
|
|
130
|
+
*
|
|
131
|
+
* What code cannot check is direction ("先" becoming "后"). That needs the separate
|
|
132
|
+
* verification pass, and it is why this parser is not the last gate.
|
|
133
|
+
*/
|
|
134
|
+
export declare function parseProfileMerge(raw: string, allowed: Set<string>, expected?: string): ProfileLine[];
|
|
135
|
+
/**
|
|
136
|
+
* A merged line is derived: it exists because other lines do.
|
|
137
|
+
*
|
|
138
|
+
* Derivedness used to be a slot written by exactly one code path. Any line created
|
|
139
|
+
* another way was therefore treated as an original and could be merged again, and
|
|
140
|
+
* re-compressing a compression produces lines thinner than what they came from.
|
|
141
|
+
* Citing a source is what makes a line derived; the marker is only a cache of that,
|
|
142
|
+
* so both are read — a line that names its sources can never be merge input.
|
|
143
|
+
*/
|
|
144
|
+
export declare function isDerivedProfileLine(fact: {
|
|
145
|
+
slots?: {
|
|
146
|
+
slot: string;
|
|
147
|
+
}[];
|
|
148
|
+
}): boolean;
|
|
149
|
+
export interface ProfileCandidate {
|
|
150
|
+
id: string;
|
|
151
|
+
statement: string;
|
|
152
|
+
dimension?: string;
|
|
153
|
+
}
|
|
154
|
+
export interface ProfileGroup {
|
|
155
|
+
/** Empty for candidates that have no dimension yet; they are not merge input. */
|
|
156
|
+
dimension: string;
|
|
157
|
+
items: ProfileCandidate[];
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Cut the merge input along dimensions before any model sees it.
|
|
161
|
+
*
|
|
162
|
+
* A single call across every dimension welded unrelated judgments into one sentence
|
|
163
|
+
* ("以目的实现为准,大胆假设、小心求证"). Which judgment belongs to which lens is a
|
|
164
|
+
* semantic call, but *keeping batches apart* is structure, so code does it: the
|
|
165
|
+
* model only ever compresses lines that are already about the same thing.
|
|
166
|
+
*
|
|
167
|
+
* Unlabelled candidates are their own group. Guessing a dimension to fill a batch
|
|
168
|
+
* would be invention, and a wrong dimension is worse than a missing line.
|
|
169
|
+
*/
|
|
170
|
+
export declare function groupProfileCandidates(items: ProfileCandidate[]): ProfileGroup[];
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/** Words that turn a specific observation into a rule about everything. */
|
|
2
|
+
const ABSOLUTE = /一切|所有|总是|从不|永远|从来|任何人|任何事/g;
|
|
3
|
+
export function gateConclusion(input) {
|
|
4
|
+
const statement = String(input.draft.statement ?? '').trim();
|
|
5
|
+
const evidence = [...new Set((input.draft.evidence ?? []).map((id) => String(id).trim()).filter(Boolean))];
|
|
6
|
+
if (statement.length < 8)
|
|
7
|
+
return { ok: false, reason: 'statement too short' };
|
|
8
|
+
if (statement.length > 80)
|
|
9
|
+
return { ok: false, reason: 'statement too long' };
|
|
10
|
+
// One citation is an observation. Two is the smallest thing that deserves the
|
|
11
|
+
// word "conclusion", and it is the rule this whole stage is built on.
|
|
12
|
+
if (evidence.length < 2)
|
|
13
|
+
return { ok: false, reason: `needs >=2 evidence (got ${evidence.length})` };
|
|
14
|
+
const foreign = evidence.filter((id) => !input.allowed.has(id));
|
|
15
|
+
if (foreign.length > 0)
|
|
16
|
+
return { ok: false, reason: `evidence not in batch: ${foreign.join(',')}` };
|
|
17
|
+
// Over-generalisation: an absolute word that also appears in the evidence is
|
|
18
|
+
// quoting the user; one that appears only in the conclusion is the model
|
|
19
|
+
// inflating two observations into a rule.
|
|
20
|
+
const absolute = statement.match(ABSOLUTE);
|
|
21
|
+
if (absolute) {
|
|
22
|
+
const quoted = new Set(absolute.filter((word) => input.allowed.size > 0 && input.draft.evidenceText?.includes(word)));
|
|
23
|
+
const invented = absolute.filter((word) => !quoted.has(word));
|
|
24
|
+
if (invented.length > 0)
|
|
25
|
+
return { ok: false, reason: `over-generalised: ${invented.join('、')}` };
|
|
26
|
+
}
|
|
27
|
+
const confidence = Math.min(1, Math.max(0, Number(input.draft.confidence ?? 0.6)));
|
|
28
|
+
return { ok: true, statement, evidence, confidence };
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The fact a conclusion becomes.
|
|
32
|
+
*
|
|
33
|
+
* No new table: a conclusion is a fact whose type is 结论, whose holder is the
|
|
34
|
+
* soul itself, and whose evidence is a `fact` slot per citation. That keeps
|
|
35
|
+
* retrieval, export and supersede working unchanged.
|
|
36
|
+
*
|
|
37
|
+
* A conclusion also carries the entities its evidence mentions, under `关于`.
|
|
38
|
+
* Without that it cannot be recalled when the conversation turns to that person —
|
|
39
|
+
* the one question the whole layer exists to answer.
|
|
40
|
+
*/
|
|
41
|
+
export function conclusionSlots(evidence, entities = []) {
|
|
42
|
+
return [
|
|
43
|
+
...evidence.map((factId) => ({ slot: '证据', value: { kind: 'fact', factId } })),
|
|
44
|
+
...entities.map((entityId) => ({ slot: '关于', value: { kind: 'entity', entityId } })),
|
|
45
|
+
];
|
|
46
|
+
}
|
|
47
|
+
const RECORD = /^结论\s*[::]\s*(.+)$/;
|
|
48
|
+
const VERDICT = /^判定\s*[::]\s*(.+)$/;
|
|
49
|
+
/**
|
|
50
|
+
* Parse the draft answer: one conclusion per line.
|
|
51
|
+
*
|
|
52
|
+
* 结论: 用户讨厌同一件事反复交代 | 证据=fc_a1,fc_a2 | 置信=0.8 | 取代=fc_c1
|
|
53
|
+
* 无
|
|
54
|
+
*/
|
|
55
|
+
export function parseConclusionDraft(raw) {
|
|
56
|
+
for (const line of String(raw ?? '').split(/\r?\n/)) {
|
|
57
|
+
const trimmed = line.trim();
|
|
58
|
+
if (!trimmed)
|
|
59
|
+
continue;
|
|
60
|
+
if (/^(无|none|nothing)[。.]?$/i.test(trimmed))
|
|
61
|
+
return null;
|
|
62
|
+
const match = RECORD.exec(trimmed);
|
|
63
|
+
if (!match)
|
|
64
|
+
continue;
|
|
65
|
+
const parts = match[1].split('|').map((part) => part.trim());
|
|
66
|
+
const statement = parts[0] ?? '';
|
|
67
|
+
const field = (name) => {
|
|
68
|
+
const hit = parts.slice(1).find((part) => part.startsWith(`${name}=`) || part.startsWith(`${name}:`));
|
|
69
|
+
return hit?.slice(name.length + 1).trim();
|
|
70
|
+
};
|
|
71
|
+
const evidence = (field('证据') ?? '')
|
|
72
|
+
.split(/[,,、;;\s]+/)
|
|
73
|
+
.map((id) => id.trim())
|
|
74
|
+
.filter(Boolean);
|
|
75
|
+
const confidence = Number(field('置信') ?? field('confidence') ?? '0.6');
|
|
76
|
+
return {
|
|
77
|
+
statement,
|
|
78
|
+
evidence,
|
|
79
|
+
confidence: Number.isFinite(confidence) ? confidence : 0.6,
|
|
80
|
+
supersedes: field('取代') || undefined,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Parse the verification answer:
|
|
87
|
+
*
|
|
88
|
+
* 判定: 支持 | 依据=fc_a1,fc_a2 | 理由=两条事实都提到"反复交代"
|
|
89
|
+
* 判定: 不支持 | 理由=只有 fc_a1 提到,fc_a2 说的是工具切换
|
|
90
|
+
*/
|
|
91
|
+
export function parseVerificationDraft(raw) {
|
|
92
|
+
for (const line of String(raw ?? '').split(/\r?\n/)) {
|
|
93
|
+
const trimmed = line.trim();
|
|
94
|
+
const match = VERDICT.exec(trimmed);
|
|
95
|
+
if (!match)
|
|
96
|
+
continue;
|
|
97
|
+
const body = match[1];
|
|
98
|
+
const reason = /理由\s*[::=]\s*(.+)$/.exec(body)?.[1]?.trim();
|
|
99
|
+
const head = body.split('|')[0] ?? '';
|
|
100
|
+
if (/不支持|unsupported|否/.test(head))
|
|
101
|
+
return { verdict: 'unsupported', reason };
|
|
102
|
+
if (/部分|partial/.test(head))
|
|
103
|
+
return { verdict: 'partial', reason };
|
|
104
|
+
if (/支持|supported|是/.test(head))
|
|
105
|
+
return { verdict: 'supported', reason };
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
/* ------------------------------------------------------------------ *
|
|
110
|
+
* Duplicate handling: the new conclusion vs the conclusions we hold
|
|
111
|
+
* ------------------------------------------------------------------ */
|
|
112
|
+
/**
|
|
113
|
+
* Normalise a statement for the one check a rule can make safely.
|
|
114
|
+
*
|
|
115
|
+
* This is deliberately narrow: it only catches text that is *the same text*, so a
|
|
116
|
+
* hit cannot be wrong. Everything about meaning is the model's call, because
|
|
117
|
+
* "same topic, different words" is exactly where string matching lies.
|
|
118
|
+
*/
|
|
119
|
+
export function normalizeStatement(statement) {
|
|
120
|
+
return String(statement ?? '')
|
|
121
|
+
.normalize('NFKC')
|
|
122
|
+
.toLowerCase()
|
|
123
|
+
.replace(/[\s,。、;:!?,.;:!?"'“”‘’()()【】\[\]…—–\-\/\\]+/g, '');
|
|
124
|
+
}
|
|
125
|
+
const RELATION = /^关系\s*[::]\s*(.+)$/;
|
|
126
|
+
const FACT_ID = /(fc_[a-z0-9_]+)/i;
|
|
127
|
+
/**
|
|
128
|
+
* Parse the relation check:
|
|
129
|
+
*
|
|
130
|
+
* 关系: 重复 fc_abc | 理由=说的是同一件事,只是措辞不同
|
|
131
|
+
* 关系: 细化 fc_abc | 理由=新结论多了"只在紧急时"
|
|
132
|
+
* 关系: 矛盾 fc_abc | 理由=一个说喜欢重复讲,一个说讨厌
|
|
133
|
+
* 关系: 无关
|
|
134
|
+
*
|
|
135
|
+
* A named id that we do not hold is not an error — the caller treats it as
|
|
136
|
+
* unrelated, which only ever writes the new conclusion and never deletes one.
|
|
137
|
+
*/
|
|
138
|
+
export function parseRelationDraft(raw) {
|
|
139
|
+
for (const line of String(raw ?? '').split(/\r?\n/)) {
|
|
140
|
+
const match = RELATION.exec(line.trim());
|
|
141
|
+
if (!match)
|
|
142
|
+
continue;
|
|
143
|
+
const body = match[1];
|
|
144
|
+
const head = body.split('|')[0] ?? '';
|
|
145
|
+
const target = FACT_ID.exec(body)?.[1];
|
|
146
|
+
const reason = /理由\s*[::=]\s*(.+)$/.exec(body)?.[1]?.trim();
|
|
147
|
+
if (/重复|duplicate/i.test(head))
|
|
148
|
+
return { kind: 'duplicate', target, reason };
|
|
149
|
+
if (/细化|refine/i.test(head))
|
|
150
|
+
return { kind: 'refine', target, reason };
|
|
151
|
+
if (/矛盾|conflict/i.test(head))
|
|
152
|
+
return { kind: 'conflict', target, reason };
|
|
153
|
+
if (/无关|unrelated/i.test(head))
|
|
154
|
+
return { kind: 'unrelated', reason };
|
|
155
|
+
}
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Read a verifier's answer, keeping "we could not read it" separate from "no".
|
|
160
|
+
*
|
|
161
|
+
* An empty or unparseable reply is not evidence against a conclusion — it is the
|
|
162
|
+
* documented stream-gone failure. Collapsing it to `unsupported` silently discards
|
|
163
|
+
* a draft that was never actually checked (and before this, it also lost the
|
|
164
|
+
* thread). Only a parsed verdict may drop a conclusion.
|
|
165
|
+
*/
|
|
166
|
+
export function readVerification(raw) {
|
|
167
|
+
const text = String(raw ?? '').trim();
|
|
168
|
+
if (!text)
|
|
169
|
+
return { kind: 'retry', reason: 'empty reply' };
|
|
170
|
+
const verdict = parseVerificationDraft(text);
|
|
171
|
+
if (!verdict)
|
|
172
|
+
return { kind: 'retry', reason: 'unparseable verdict' };
|
|
173
|
+
return { kind: 'verdict', verdict };
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* The dimensions a profile line can belong to.
|
|
177
|
+
*
|
|
178
|
+
* Declared in dimensions.ts so prompts render it instead of retyping the names —
|
|
179
|
+
* a second copy is how "add an eighth dimension" becomes a silent drift.
|
|
180
|
+
*/
|
|
181
|
+
export { PROFILE_DIMENSIONS } from './config/dimensions.js';
|
|
182
|
+
import { PROFILE_DIMENSIONS } from './config/dimensions.js';
|
|
183
|
+
const PROFILE_LINE = /^画像\s*[::]\s*(.+)$/;
|
|
184
|
+
/** Hedged lines describe a mood, not a person; the merge has not happened yet. */
|
|
185
|
+
const PROFILE_HEDGE = /有时候|有时|通常|大部分时候|一般来说|一般说/;
|
|
186
|
+
/** Over-generalisation: no single set of observations supports an "always". */
|
|
187
|
+
const PROFILE_ABSOLUTE = /一切|所有|总是|从不/;
|
|
188
|
+
/** A fragment has no subject; completing it would be invention. */
|
|
189
|
+
const PROFILE_FRAGMENT = /^(每次|因为|然后|所以|但是|而且|并且)/;
|
|
190
|
+
/** One judgment per line. A semicolon is how three unrelated points get welded together. */
|
|
191
|
+
const PROFILE_MULTI_CLAUSE = /[;;]/;
|
|
192
|
+
/** Negation flips the meaning while sharing nearly every bigram. */
|
|
193
|
+
const PROFILE_NEGATION = /[不没别勿非无]/;
|
|
194
|
+
function profileBigrams(text) {
|
|
195
|
+
const clean = text.replace(/[\s,。、;:!?,.;:!?"'()()]/g, '');
|
|
196
|
+
const out = new Set();
|
|
197
|
+
for (let i = 0; i < clean.length - 1; i += 1)
|
|
198
|
+
out.add(clean.slice(i, i + 2));
|
|
199
|
+
return out;
|
|
200
|
+
}
|
|
201
|
+
function profileSimilarity(a, b) {
|
|
202
|
+
const left = profileBigrams(a);
|
|
203
|
+
const right = profileBigrams(b);
|
|
204
|
+
if (left.size === 0 || right.size === 0)
|
|
205
|
+
return 0;
|
|
206
|
+
let shared = 0;
|
|
207
|
+
for (const gram of left)
|
|
208
|
+
if (right.has(gram))
|
|
209
|
+
shared += 1;
|
|
210
|
+
return shared / (left.size + right.size - shared);
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Parse the merged profile.
|
|
214
|
+
*
|
|
215
|
+
* Three things are rejected by code rather than hoped for: a line with no source is
|
|
216
|
+
* an assumption, a hedged or absolute line is a failed merge, and a fragment is not
|
|
217
|
+
* a judgment. Near-duplicates are folded, because "the same thing said twice" is the
|
|
218
|
+
* exact failure the merge exists to fix.
|
|
219
|
+
*
|
|
220
|
+
* What code cannot check is direction ("先" becoming "后"). That needs the separate
|
|
221
|
+
* verification pass, and it is why this parser is not the last gate.
|
|
222
|
+
*/
|
|
223
|
+
export function parseProfileMerge(raw, allowed, expected) {
|
|
224
|
+
const out = [];
|
|
225
|
+
for (const line of String(raw ?? '').split(/\r?\n/)) {
|
|
226
|
+
const match = PROFILE_LINE.exec(line.trim());
|
|
227
|
+
if (!match)
|
|
228
|
+
continue;
|
|
229
|
+
const [statementPart, ...fields] = match[1].split('|').map((part) => part.trim());
|
|
230
|
+
const statement = (statementPart ?? '').trim();
|
|
231
|
+
if (statement.length < 6 || statement.length > 32)
|
|
232
|
+
continue;
|
|
233
|
+
if (PROFILE_MULTI_CLAUSE.test(statement))
|
|
234
|
+
continue;
|
|
235
|
+
if (PROFILE_HEDGE.test(statement) || PROFILE_ABSOLUTE.test(statement))
|
|
236
|
+
continue;
|
|
237
|
+
if (PROFILE_FRAGMENT.test(statement))
|
|
238
|
+
continue;
|
|
239
|
+
const dimension = fields
|
|
240
|
+
.find((field) => field.startsWith('维度'))
|
|
241
|
+
?.replace(/^维度\s*[==]\s*/, '');
|
|
242
|
+
if (!dimension || !PROFILE_DIMENSIONS.includes(dimension))
|
|
243
|
+
continue;
|
|
244
|
+
// The batch is one dimension by construction; a line that leaves it means the
|
|
245
|
+
// model mixed two lenses back together, which is the welding the split prevents.
|
|
246
|
+
if (expected && dimension !== expected)
|
|
247
|
+
continue;
|
|
248
|
+
const sourceField = fields.find((field) => field.startsWith('来源'));
|
|
249
|
+
const sources = (sourceField?.replace(/^来源\s*[==]\s*/, '') ?? '')
|
|
250
|
+
.split(/[,,、;;\s]+/)
|
|
251
|
+
.map((id) => id.trim())
|
|
252
|
+
.filter((id) => allowed.has(id));
|
|
253
|
+
if (sources.length === 0)
|
|
254
|
+
continue;
|
|
255
|
+
const duplicate = out.some((kept) => {
|
|
256
|
+
if (profileSimilarity(kept.statement, statement) < 0.7)
|
|
257
|
+
return false;
|
|
258
|
+
// "喜欢爬山" and "不喜欢爬山" share ~0.86 of their bigrams, and folding them
|
|
259
|
+
// keeps whichever came first — writing the person's preference backwards.
|
|
260
|
+
// Opposite polarity is never a duplicate: the prompt says a flipped direction
|
|
261
|
+
// is worse than a missing line.
|
|
262
|
+
return PROFILE_NEGATION.test(kept.statement) === PROFILE_NEGATION.test(statement);
|
|
263
|
+
});
|
|
264
|
+
if (duplicate)
|
|
265
|
+
continue;
|
|
266
|
+
out.push({ statement, sources, dimension: dimension });
|
|
267
|
+
}
|
|
268
|
+
return out;
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* A merged line is derived: it exists because other lines do.
|
|
272
|
+
*
|
|
273
|
+
* Derivedness used to be a slot written by exactly one code path. Any line created
|
|
274
|
+
* another way was therefore treated as an original and could be merged again, and
|
|
275
|
+
* re-compressing a compression produces lines thinner than what they came from.
|
|
276
|
+
* Citing a source is what makes a line derived; the marker is only a cache of that,
|
|
277
|
+
* so both are read — a line that names its sources can never be merge input.
|
|
278
|
+
*/
|
|
279
|
+
export function isDerivedProfileLine(fact) {
|
|
280
|
+
return (fact.slots ?? []).some((slot) => slot.slot === '来源' || slot.slot === '合并');
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Cut the merge input along dimensions before any model sees it.
|
|
284
|
+
*
|
|
285
|
+
* A single call across every dimension welded unrelated judgments into one sentence
|
|
286
|
+
* ("以目的实现为准,大胆假设、小心求证"). Which judgment belongs to which lens is a
|
|
287
|
+
* semantic call, but *keeping batches apart* is structure, so code does it: the
|
|
288
|
+
* model only ever compresses lines that are already about the same thing.
|
|
289
|
+
*
|
|
290
|
+
* Unlabelled candidates are their own group. Guessing a dimension to fill a batch
|
|
291
|
+
* would be invention, and a wrong dimension is worse than a missing line.
|
|
292
|
+
*/
|
|
293
|
+
export function groupProfileCandidates(items) {
|
|
294
|
+
const order = [];
|
|
295
|
+
const groups = new Map();
|
|
296
|
+
for (const item of items) {
|
|
297
|
+
const known = typeof item.dimension === 'string' && PROFILE_DIMENSIONS.includes(item.dimension);
|
|
298
|
+
const dimension = known ? item.dimension : '';
|
|
299
|
+
let bucket = groups.get(dimension);
|
|
300
|
+
if (!bucket) {
|
|
301
|
+
bucket = [];
|
|
302
|
+
groups.set(dimension, bucket);
|
|
303
|
+
order.push(dimension);
|
|
304
|
+
}
|
|
305
|
+
bucket.push(item);
|
|
306
|
+
}
|
|
307
|
+
// Biggest group first: it is the one with the most repetition to collapse.
|
|
308
|
+
return order
|
|
309
|
+
.map((dimension) => ({ dimension, items: groups.get(dimension) ?? [] }))
|
|
310
|
+
.sort((a, b) => b.items.length - a.items.length);
|
|
311
|
+
}
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* - every injected memory id is reported in `used`, so injection is auditable
|
|
7
7
|
* - the affect section is opt-in by confidence, capped in size, and only ever
|
|
8
8
|
* contains whitelisted sentences (ADR 0014)
|
|
9
|
+
* - every dynamic value is escaped before it joins a block: a statement containing
|
|
10
|
+
* `</memories>` otherwise ends the envelope and can forge a `<rules>` block
|
|
9
11
|
*/
|
|
10
12
|
import type { ContextRequest, ContextResult, Emotion } from './types.ts';
|
|
11
13
|
import type { SoulStore } from './store.ts';
|