@lssm/example.locale-jurisdiction-gate 0.0.0-canary-20251213172311
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/.turbo/turbo-build.log +26 -0
- package/CHANGELOG.md +9 -0
- package/README.md +31 -0
- package/dist/contracts/assistant.js +1 -0
- package/dist/contracts/index.js +1 -0
- package/dist/docs/index.js +1 -0
- package/dist/docs/locale-jurisdiction-gate.docblock.js +19 -0
- package/dist/entities/index.js +1 -0
- package/dist/entities/models.js +1 -0
- package/dist/events.js +1 -0
- package/dist/example.js +1 -0
- package/dist/feature.js +1 -0
- package/dist/handlers/demo.handlers.js +1 -0
- package/dist/handlers/index.js +1 -0
- package/dist/index.js +1 -0
- package/dist/policy/guard.js +2 -0
- package/dist/policy/index.js +1 -0
- package/dist/policy/types.js +0 -0
- package/example.ts +1 -0
- package/package.json +70 -0
- package/src/contracts/assistant.ts +96 -0
- package/src/contracts/index.ts +3 -0
- package/src/docs/index.ts +3 -0
- package/src/docs/locale-jurisdiction-gate.docblock.ts +48 -0
- package/src/entities/index.ts +3 -0
- package/src/entities/models.ts +101 -0
- package/src/events.ts +61 -0
- package/src/example.ts +29 -0
- package/src/feature.ts +31 -0
- package/src/handlers/demo.handlers.test.ts +54 -0
- package/src/handlers/demo.handlers.ts +154 -0
- package/src/handlers/index.ts +3 -0
- package/src/index.ts +17 -0
- package/src/policy/guard.test.ts +27 -0
- package/src/policy/guard.ts +96 -0
- package/src/policy/index.ts +4 -0
- package/src/policy/types.ts +17 -0
- package/tsconfig.json +9 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/tsdown.config.js +9 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import { createDemoAssistantHandlers } from './demo.handlers';
|
|
4
|
+
|
|
5
|
+
describe('@lssm/example.locale-jurisdiction-gate demo handlers', () => {
|
|
6
|
+
it('blocks when locale is missing', async () => {
|
|
7
|
+
const handlers = createDemoAssistantHandlers();
|
|
8
|
+
const result = await handlers.answer({
|
|
9
|
+
envelope: {
|
|
10
|
+
traceId: 't1',
|
|
11
|
+
locale: '',
|
|
12
|
+
kbSnapshotId: 'snap_1',
|
|
13
|
+
allowedScope: 'education_only',
|
|
14
|
+
regulatoryContext: { jurisdiction: 'EU' },
|
|
15
|
+
},
|
|
16
|
+
question: 'What is a snapshot?',
|
|
17
|
+
});
|
|
18
|
+
expect(result.refused).toBeTrue();
|
|
19
|
+
expect(result.refusalReason).toBe('LOCALE_REQUIRED');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('blocks when kbSnapshotId is missing', async () => {
|
|
23
|
+
const handlers = createDemoAssistantHandlers();
|
|
24
|
+
const result = await handlers.answer({
|
|
25
|
+
envelope: {
|
|
26
|
+
traceId: 't2',
|
|
27
|
+
locale: 'en-GB',
|
|
28
|
+
kbSnapshotId: '',
|
|
29
|
+
allowedScope: 'education_only',
|
|
30
|
+
regulatoryContext: { jurisdiction: 'EU' },
|
|
31
|
+
},
|
|
32
|
+
question: 'What is a snapshot?',
|
|
33
|
+
});
|
|
34
|
+
expect(result.refused).toBeTrue();
|
|
35
|
+
expect(result.refusalReason).toBe('KB_SNAPSHOT_REQUIRED');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('blocks education_only answers that include buy/sell language', async () => {
|
|
39
|
+
const handlers = createDemoAssistantHandlers();
|
|
40
|
+
const result = await handlers.answer({
|
|
41
|
+
envelope: {
|
|
42
|
+
traceId: 't3',
|
|
43
|
+
locale: 'en-GB',
|
|
44
|
+
kbSnapshotId: 'snap_1',
|
|
45
|
+
allowedScope: 'education_only',
|
|
46
|
+
regulatoryContext: { jurisdiction: 'EU' },
|
|
47
|
+
},
|
|
48
|
+
question: 'Should I buy now?',
|
|
49
|
+
});
|
|
50
|
+
// demo handler echoes question; question includes forbidden phrase \"buy\"
|
|
51
|
+
expect(result.refused).toBeTrue();
|
|
52
|
+
expect(result.refusalReason).toBe('SCOPE_VIOLATION');
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { enforceAllowedScope, enforceCitations, validateEnvelope } from '../policy/guard';
|
|
2
|
+
|
|
3
|
+
type AllowedScope = 'education_only' | 'generic_info' | 'escalation_required';
|
|
4
|
+
|
|
5
|
+
type AssistantAnswerIR = {
|
|
6
|
+
locale: string;
|
|
7
|
+
jurisdiction: string;
|
|
8
|
+
allowedScope: AllowedScope;
|
|
9
|
+
sections: Array<{ heading: string; body: string }>;
|
|
10
|
+
citations: Array<{
|
|
11
|
+
kbSnapshotId: string;
|
|
12
|
+
sourceType: string;
|
|
13
|
+
sourceId: string;
|
|
14
|
+
title?: string;
|
|
15
|
+
excerpt?: string;
|
|
16
|
+
}>;
|
|
17
|
+
disclaimers?: string[];
|
|
18
|
+
riskFlags?: string[];
|
|
19
|
+
refused?: boolean;
|
|
20
|
+
refusalReason?: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export interface DemoAssistantHandlers {
|
|
24
|
+
answer(input: {
|
|
25
|
+
envelope: {
|
|
26
|
+
traceId: string;
|
|
27
|
+
locale: string;
|
|
28
|
+
kbSnapshotId: string;
|
|
29
|
+
allowedScope: AllowedScope;
|
|
30
|
+
regulatoryContext: { jurisdiction: string };
|
|
31
|
+
};
|
|
32
|
+
question: string;
|
|
33
|
+
}): Promise<AssistantAnswerIR>;
|
|
34
|
+
|
|
35
|
+
explainConcept(input: {
|
|
36
|
+
envelope: {
|
|
37
|
+
traceId: string;
|
|
38
|
+
locale: string;
|
|
39
|
+
kbSnapshotId: string;
|
|
40
|
+
allowedScope: AllowedScope;
|
|
41
|
+
regulatoryContext: { jurisdiction: string };
|
|
42
|
+
};
|
|
43
|
+
conceptKey: string;
|
|
44
|
+
}): Promise<AssistantAnswerIR>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Deterministic demo assistant handlers (no LLM).
|
|
49
|
+
*
|
|
50
|
+
* - Validates envelope
|
|
51
|
+
* - Requires citations
|
|
52
|
+
* - Enforces allowedScope (education_only blocks actionable language)
|
|
53
|
+
*/
|
|
54
|
+
export function createDemoAssistantHandlers(): DemoAssistantHandlers {
|
|
55
|
+
async function answer(input: {
|
|
56
|
+
envelope: DemoAssistantHandlers['answer'] extends (a: infer A) => any
|
|
57
|
+
? A extends { envelope: infer E }
|
|
58
|
+
? E
|
|
59
|
+
: never
|
|
60
|
+
: never;
|
|
61
|
+
question: string;
|
|
62
|
+
}): Promise<AssistantAnswerIR> {
|
|
63
|
+
const env = validateEnvelope(input.envelope);
|
|
64
|
+
if (!env.ok) {
|
|
65
|
+
return {
|
|
66
|
+
locale: input.envelope.locale ?? 'en-US',
|
|
67
|
+
jurisdiction: input.envelope.regulatoryContext?.jurisdiction ?? 'UNKNOWN',
|
|
68
|
+
allowedScope: input.envelope.allowedScope ?? 'education_only',
|
|
69
|
+
sections: [
|
|
70
|
+
{
|
|
71
|
+
heading: 'Request blocked',
|
|
72
|
+
body: env.error.message,
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
citations: [],
|
|
76
|
+
disclaimers: ['This system refuses to answer without a valid envelope.'],
|
|
77
|
+
riskFlags: [env.error.code],
|
|
78
|
+
refused: true,
|
|
79
|
+
refusalReason: env.error.code,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const draft: AssistantAnswerIR = {
|
|
84
|
+
locale: env.value.locale,
|
|
85
|
+
jurisdiction: env.value.regulatoryContext!.jurisdiction!,
|
|
86
|
+
allowedScope: env.value.allowedScope!,
|
|
87
|
+
sections: [
|
|
88
|
+
{
|
|
89
|
+
heading: 'Answer (demo)',
|
|
90
|
+
body: `You asked: "${input.question}". This demo answer is derived from the KB snapshot only.`,
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
citations: [
|
|
94
|
+
{
|
|
95
|
+
kbSnapshotId: env.value.kbSnapshotId!,
|
|
96
|
+
sourceType: 'ruleVersion',
|
|
97
|
+
sourceId: 'rv_demo',
|
|
98
|
+
title: 'Demo rule version',
|
|
99
|
+
excerpt: 'Demo excerpt',
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
disclaimers: ['Educational demo only.'],
|
|
103
|
+
riskFlags: [],
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const scope = enforceAllowedScope(env.value.allowedScope, draft);
|
|
107
|
+
if (!scope.ok) {
|
|
108
|
+
return {
|
|
109
|
+
...draft,
|
|
110
|
+
sections: [
|
|
111
|
+
{ heading: 'Escalation required', body: scope.error.message },
|
|
112
|
+
],
|
|
113
|
+
citations: draft.citations,
|
|
114
|
+
refused: true,
|
|
115
|
+
refusalReason: scope.error.code,
|
|
116
|
+
riskFlags: [...(draft.riskFlags ?? []), scope.error.code],
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const cited = enforceCitations(draft);
|
|
121
|
+
if (!cited.ok) {
|
|
122
|
+
return {
|
|
123
|
+
...draft,
|
|
124
|
+
sections: [
|
|
125
|
+
{ heading: 'Request blocked', body: cited.error.message },
|
|
126
|
+
],
|
|
127
|
+
citations: [],
|
|
128
|
+
refused: true,
|
|
129
|
+
refusalReason: cited.error.code,
|
|
130
|
+
riskFlags: [...(draft.riskFlags ?? []), cited.error.code],
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return draft;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function explainConcept(input: {
|
|
138
|
+
envelope: DemoAssistantHandlers['explainConcept'] extends (a: infer A) => any
|
|
139
|
+
? A extends { envelope: infer E }
|
|
140
|
+
? E
|
|
141
|
+
: never
|
|
142
|
+
: never;
|
|
143
|
+
conceptKey: string;
|
|
144
|
+
}): Promise<AssistantAnswerIR> {
|
|
145
|
+
return await answer({
|
|
146
|
+
envelope: input.envelope,
|
|
147
|
+
question: `Explain concept: ${input.conceptKey}`,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return { answer, explainConcept };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locale/Jurisdiction Gate Example
|
|
3
|
+
*
|
|
4
|
+
* Fail-closed gating for assistant calls: locale + jurisdiction + kbSnapshotId +
|
|
5
|
+
* allowedScope must be explicit, and answers must cite a KB snapshot.
|
|
6
|
+
*/
|
|
7
|
+
export * from './entities';
|
|
8
|
+
export * from './contracts';
|
|
9
|
+
export * from './events';
|
|
10
|
+
export * from './policy';
|
|
11
|
+
export * from './handlers';
|
|
12
|
+
export * from './feature';
|
|
13
|
+
export { default as example } from './example';
|
|
14
|
+
|
|
15
|
+
import './docs';
|
|
16
|
+
|
|
17
|
+
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import { enforceCitations, validateEnvelope } from './guard';
|
|
4
|
+
|
|
5
|
+
describe('locale/jurisdiction gate policy', () => {
|
|
6
|
+
it('blocks unsupported locale', () => {
|
|
7
|
+
const result = validateEnvelope({
|
|
8
|
+
locale: 'es-ES',
|
|
9
|
+
kbSnapshotId: 'snap_1',
|
|
10
|
+
allowedScope: 'education_only',
|
|
11
|
+
regulatoryContext: { jurisdiction: 'EU' },
|
|
12
|
+
});
|
|
13
|
+
expect(result.ok).toBeFalse();
|
|
14
|
+
if (!result.ok) expect(result.error.code).toBe('LOCALE_REQUIRED');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('blocks answer without citations', () => {
|
|
18
|
+
const result = enforceCitations({
|
|
19
|
+
sections: [{ heading: 'A', body: 'B' }],
|
|
20
|
+
citations: [],
|
|
21
|
+
});
|
|
22
|
+
expect(result.ok).toBeFalse();
|
|
23
|
+
if (!result.ok) expect(result.error.code).toBe('CITATIONS_REQUIRED');
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { GateError, GateResult } from './types';
|
|
2
|
+
|
|
3
|
+
type EnvelopeLike = {
|
|
4
|
+
locale?: string;
|
|
5
|
+
kbSnapshotId?: string;
|
|
6
|
+
allowedScope?: 'education_only' | 'generic_info' | 'escalation_required';
|
|
7
|
+
regulatoryContext?: { jurisdiction?: string };
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
type AnswerLike = {
|
|
11
|
+
citations?: unknown[];
|
|
12
|
+
sections?: Array<{ heading: string; body: string }>;
|
|
13
|
+
refused?: boolean;
|
|
14
|
+
refusalReason?: string;
|
|
15
|
+
allowedScope?: 'education_only' | 'generic_info' | 'escalation_required';
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const SUPPORTED_LOCALES = new Set<string>(['en-US', 'en-GB', 'fr-FR']);
|
|
19
|
+
|
|
20
|
+
function err(code: GateError['code'], message: string): GateError {
|
|
21
|
+
return { code, message };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function validateEnvelope(
|
|
25
|
+
envelope: EnvelopeLike
|
|
26
|
+
): GateResult<Required<EnvelopeLike>> {
|
|
27
|
+
if (!envelope.locale || !SUPPORTED_LOCALES.has(envelope.locale)) {
|
|
28
|
+
return {
|
|
29
|
+
ok: false,
|
|
30
|
+
error: err('LOCALE_REQUIRED', 'locale is required and must be supported'),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
if (!envelope.regulatoryContext?.jurisdiction) {
|
|
34
|
+
return { ok: false, error: err('JURISDICTION_REQUIRED', 'jurisdiction is required') };
|
|
35
|
+
}
|
|
36
|
+
if (!envelope.kbSnapshotId) {
|
|
37
|
+
return {
|
|
38
|
+
ok: false,
|
|
39
|
+
error: err('KB_SNAPSHOT_REQUIRED', 'kbSnapshotId is required'),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
if (!envelope.allowedScope) {
|
|
43
|
+
return {
|
|
44
|
+
ok: false,
|
|
45
|
+
error: err('SCOPE_VIOLATION', 'allowedScope is required'),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
return { ok: true, value: envelope as Required<EnvelopeLike> };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function enforceCitations(answer: AnswerLike): GateResult<AnswerLike> {
|
|
52
|
+
const citations = answer.citations ?? [];
|
|
53
|
+
if (!Array.isArray(citations) || citations.length === 0) {
|
|
54
|
+
return {
|
|
55
|
+
ok: false,
|
|
56
|
+
error: err('CITATIONS_REQUIRED', 'answers must include at least one citation'),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
return { ok: true, value: answer };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const EDUCATION_ONLY_FORBIDDEN_PATTERNS: RegExp[] = [
|
|
63
|
+
/\b(buy|sell)\b/i,
|
|
64
|
+
/\b(should\s+buy|should\s+sell)\b/i,
|
|
65
|
+
/\b(guarantee(d)?|promise(d)?)\b/i,
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
export function enforceAllowedScope(
|
|
69
|
+
allowedScope: EnvelopeLike['allowedScope'],
|
|
70
|
+
answer: AnswerLike
|
|
71
|
+
): GateResult<AnswerLike> {
|
|
72
|
+
if (!allowedScope) {
|
|
73
|
+
return {
|
|
74
|
+
ok: false,
|
|
75
|
+
error: err('SCOPE_VIOLATION', 'allowedScope is required'),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
if (allowedScope !== 'education_only') {
|
|
79
|
+
return { ok: true, value: answer };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const bodies = (answer.sections ?? []).map((s) => s.body).join('\n');
|
|
83
|
+
const violations = EDUCATION_ONLY_FORBIDDEN_PATTERNS.some((re) => re.test(bodies));
|
|
84
|
+
if (violations) {
|
|
85
|
+
return {
|
|
86
|
+
ok: false,
|
|
87
|
+
error: err(
|
|
88
|
+
'SCOPE_VIOLATION',
|
|
89
|
+
'answer violates education_only scope (contains actionable or promotional language)'
|
|
90
|
+
),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
return { ok: true, value: answer };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type AllowedScope = 'education_only' | 'generic_info' | 'escalation_required';
|
|
2
|
+
|
|
3
|
+
export interface GateError {
|
|
4
|
+
code:
|
|
5
|
+
| 'LOCALE_REQUIRED'
|
|
6
|
+
| 'JURISDICTION_REQUIRED'
|
|
7
|
+
| 'KB_SNAPSHOT_REQUIRED'
|
|
8
|
+
| 'CITATIONS_REQUIRED'
|
|
9
|
+
| 'SCOPE_VIOLATION';
|
|
10
|
+
message: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type GateResult<T> =
|
|
14
|
+
| { ok: true; value: T }
|
|
15
|
+
| { ok: false; error: GateError };
|
|
16
|
+
|
|
17
|
+
|