@contractspec/example.locale-jurisdiction-gate 0.0.0-canary-20260113162409
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$colon$bundle.log +56 -0
- package/.turbo/turbo-build.log +57 -0
- package/CHANGELOG.md +302 -0
- package/LICENSE +21 -0
- package/README.md +42 -0
- package/dist/docs/index.d.ts +1 -0
- package/dist/docs/index.js +1 -0
- package/dist/docs/locale-jurisdiction-gate.docblock.d.ts +1 -0
- package/dist/docs/locale-jurisdiction-gate.docblock.js +53 -0
- package/dist/docs/locale-jurisdiction-gate.docblock.js.map +1 -0
- package/dist/entities/index.d.ts +2 -0
- package/dist/entities/index.js +3 -0
- package/dist/entities/models.d.ts +186 -0
- package/dist/entities/models.d.ts.map +1 -0
- package/dist/entities/models.js +168 -0
- package/dist/entities/models.js.map +1 -0
- package/dist/events.d.ts +69 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +123 -0
- package/dist/events.js.map +1 -0
- package/dist/example.d.ts +7 -0
- package/dist/example.d.ts.map +1 -0
- package/dist/example.js +47 -0
- package/dist/example.js.map +1 -0
- package/dist/handlers/demo.handlers.d.ts +59 -0
- package/dist/handlers/demo.handlers.d.ts.map +1 -0
- package/dist/handlers/demo.handlers.js +86 -0
- package/dist/handlers/demo.handlers.js.map +1 -0
- package/dist/handlers/index.d.ts +2 -0
- package/dist/handlers/index.js +3 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +12 -0
- package/dist/locale-jurisdiction-gate.feature.d.ts +7 -0
- package/dist/locale-jurisdiction-gate.feature.d.ts.map +1 -0
- package/dist/locale-jurisdiction-gate.feature.js +53 -0
- package/dist/locale-jurisdiction-gate.feature.js.map +1 -0
- package/dist/operations/assistant.d.ts +245 -0
- package/dist/operations/assistant.d.ts.map +1 -0
- package/dist/operations/assistant.js +115 -0
- package/dist/operations/assistant.js.map +1 -0
- package/dist/operations/index.d.ts +2 -0
- package/dist/operations/index.js +3 -0
- package/dist/policy/guard.d.ts +27 -0
- package/dist/policy/guard.d.ts.map +1 -0
- package/dist/policy/guard.js +73 -0
- package/dist/policy/guard.js.map +1 -0
- package/dist/policy/index.d.ts +3 -0
- package/dist/policy/index.js +3 -0
- package/dist/policy/types.d.ts +16 -0
- package/dist/policy/types.d.ts.map +1 -0
- package/dist/policy/types.js +0 -0
- package/example.ts +1 -0
- package/package.json +77 -0
- package/src/docs/index.ts +1 -0
- package/src/docs/locale-jurisdiction-gate.docblock.ts +46 -0
- package/src/entities/index.ts +1 -0
- package/src/entities/models.ts +110 -0
- package/src/events.ts +74 -0
- package/src/example.ts +34 -0
- package/src/handlers/demo.handlers.test.ts +54 -0
- package/src/handlers/demo.handlers.ts +160 -0
- package/src/handlers/index.ts +1 -0
- package/src/index.ts +15 -0
- package/src/locale-jurisdiction-gate.feature.ts +30 -0
- package/src/operations/assistant.ts +98 -0
- package/src/operations/index.ts +1 -0
- package/src/policy/guard.test.ts +25 -0
- package/src/policy/guard.ts +102 -0
- package/src/policy/index.ts +2 -0
- package/src/policy/types.ts +18 -0
- package/tsconfig.json +17 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/tsdown.config.js +17 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import {
|
|
2
|
+
enforceAllowedScope,
|
|
3
|
+
enforceCitations,
|
|
4
|
+
validateEnvelope,
|
|
5
|
+
} from '../policy/guard';
|
|
6
|
+
|
|
7
|
+
type AllowedScope = 'education_only' | 'generic_info' | 'escalation_required';
|
|
8
|
+
|
|
9
|
+
interface AssistantAnswerIR {
|
|
10
|
+
locale: string;
|
|
11
|
+
jurisdiction: string;
|
|
12
|
+
allowedScope: AllowedScope;
|
|
13
|
+
sections: { heading: string; body: string }[];
|
|
14
|
+
citations: {
|
|
15
|
+
kbSnapshotId: string;
|
|
16
|
+
sourceType: string;
|
|
17
|
+
sourceId: string;
|
|
18
|
+
title?: string;
|
|
19
|
+
excerpt?: string;
|
|
20
|
+
}[];
|
|
21
|
+
disclaimers?: string[];
|
|
22
|
+
riskFlags?: string[];
|
|
23
|
+
refused?: boolean;
|
|
24
|
+
refusalReason?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface DemoAssistantHandlers {
|
|
28
|
+
answer(input: {
|
|
29
|
+
envelope: {
|
|
30
|
+
traceId: string;
|
|
31
|
+
locale: string;
|
|
32
|
+
kbSnapshotId: string;
|
|
33
|
+
allowedScope: AllowedScope;
|
|
34
|
+
regulatoryContext: { jurisdiction: string };
|
|
35
|
+
};
|
|
36
|
+
question: string;
|
|
37
|
+
}): Promise<AssistantAnswerIR>;
|
|
38
|
+
|
|
39
|
+
explainConcept(input: {
|
|
40
|
+
envelope: {
|
|
41
|
+
traceId: string;
|
|
42
|
+
locale: string;
|
|
43
|
+
kbSnapshotId: string;
|
|
44
|
+
allowedScope: AllowedScope;
|
|
45
|
+
regulatoryContext: { jurisdiction: string };
|
|
46
|
+
};
|
|
47
|
+
conceptKey: string;
|
|
48
|
+
}): Promise<AssistantAnswerIR>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Deterministic demo assistant handlers (no LLM).
|
|
53
|
+
*
|
|
54
|
+
* - Validates envelope
|
|
55
|
+
* - Requires citations
|
|
56
|
+
* - Enforces allowedScope (education_only blocks actionable language)
|
|
57
|
+
*/
|
|
58
|
+
export function createDemoAssistantHandlers(): DemoAssistantHandlers {
|
|
59
|
+
async function answer(input: {
|
|
60
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
61
|
+
envelope: DemoAssistantHandlers['answer'] extends (a: infer A) => any
|
|
62
|
+
? A extends { envelope: infer E }
|
|
63
|
+
? E
|
|
64
|
+
: never
|
|
65
|
+
: never;
|
|
66
|
+
question: string;
|
|
67
|
+
}): Promise<AssistantAnswerIR> {
|
|
68
|
+
const env = validateEnvelope(input.envelope);
|
|
69
|
+
if (!env.ok) {
|
|
70
|
+
return {
|
|
71
|
+
locale: input.envelope.locale ?? 'en-US',
|
|
72
|
+
jurisdiction:
|
|
73
|
+
input.envelope.regulatoryContext?.jurisdiction ?? 'UNKNOWN',
|
|
74
|
+
allowedScope: input.envelope.allowedScope ?? 'education_only',
|
|
75
|
+
sections: [
|
|
76
|
+
{
|
|
77
|
+
heading: 'Request blocked',
|
|
78
|
+
body: env.error.message,
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
citations: [],
|
|
82
|
+
disclaimers: [
|
|
83
|
+
'This system refuses to answer without a valid envelope.',
|
|
84
|
+
],
|
|
85
|
+
riskFlags: [env.error.code],
|
|
86
|
+
refused: true,
|
|
87
|
+
refusalReason: env.error.code,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const draft: AssistantAnswerIR = {
|
|
92
|
+
locale: env.value.locale,
|
|
93
|
+
jurisdiction: env.value.regulatoryContext?.jurisdiction ?? 'UNKNOWN',
|
|
94
|
+
allowedScope: env.value.allowedScope ?? 'education_only',
|
|
95
|
+
sections: [
|
|
96
|
+
{
|
|
97
|
+
heading: 'Answer (demo)',
|
|
98
|
+
body: `You asked: "${input.question}". This demo answer is derived from the KB snapshot only.`,
|
|
99
|
+
},
|
|
100
|
+
],
|
|
101
|
+
citations: [
|
|
102
|
+
{
|
|
103
|
+
kbSnapshotId: env.value.kbSnapshotId ?? 'unknown',
|
|
104
|
+
sourceType: 'ruleVersion',
|
|
105
|
+
sourceId: 'rv_demo',
|
|
106
|
+
title: 'Demo rule version',
|
|
107
|
+
excerpt: 'Demo excerpt',
|
|
108
|
+
},
|
|
109
|
+
],
|
|
110
|
+
disclaimers: ['Educational demo only.'],
|
|
111
|
+
riskFlags: [],
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const scope = enforceAllowedScope(env.value.allowedScope, draft);
|
|
115
|
+
if (!scope.ok) {
|
|
116
|
+
return {
|
|
117
|
+
...draft,
|
|
118
|
+
sections: [
|
|
119
|
+
{ heading: 'Escalation required', body: scope.error.message },
|
|
120
|
+
],
|
|
121
|
+
citations: draft.citations,
|
|
122
|
+
refused: true,
|
|
123
|
+
refusalReason: scope.error.code,
|
|
124
|
+
riskFlags: [...(draft.riskFlags ?? []), scope.error.code],
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const cited = enforceCitations(draft);
|
|
129
|
+
if (!cited.ok) {
|
|
130
|
+
return {
|
|
131
|
+
...draft,
|
|
132
|
+
sections: [{ heading: 'Request blocked', body: cited.error.message }],
|
|
133
|
+
citations: [],
|
|
134
|
+
refused: true,
|
|
135
|
+
refusalReason: cited.error.code,
|
|
136
|
+
riskFlags: [...(draft.riskFlags ?? []), cited.error.code],
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return draft;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function explainConcept(input: {
|
|
144
|
+
envelope: DemoAssistantHandlers['explainConcept'] extends (
|
|
145
|
+
a: infer A
|
|
146
|
+
) => any // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
147
|
+
? A extends { envelope: infer E }
|
|
148
|
+
? E
|
|
149
|
+
: never
|
|
150
|
+
: never;
|
|
151
|
+
conceptKey: string;
|
|
152
|
+
}): Promise<AssistantAnswerIR> {
|
|
153
|
+
return await answer({
|
|
154
|
+
envelope: input.envelope,
|
|
155
|
+
question: `Explain concept: ${input.conceptKey}`,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return { answer, explainConcept };
|
|
160
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './demo.handlers';
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
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 './operations';
|
|
9
|
+
export * from './events';
|
|
10
|
+
export * from './policy';
|
|
11
|
+
export * from './handlers';
|
|
12
|
+
export * from './locale-jurisdiction-gate.feature';
|
|
13
|
+
export { default as example } from './example';
|
|
14
|
+
|
|
15
|
+
import './docs';
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { defineFeature } from '@contractspec/lib.contracts';
|
|
2
|
+
|
|
3
|
+
export const LocaleJurisdictionGateFeature = defineFeature({
|
|
4
|
+
meta: {
|
|
5
|
+
key: 'locale-jurisdiction-gate',
|
|
6
|
+
version: '1.0.0',
|
|
7
|
+
title: 'Locale + Jurisdiction Gate',
|
|
8
|
+
description:
|
|
9
|
+
'Fail-closed gating for assistant calls requiring locale/jurisdiction/snapshot/scope and citations.',
|
|
10
|
+
domain: 'knowledge',
|
|
11
|
+
owners: ['@examples'],
|
|
12
|
+
tags: ['assistant', 'policy', 'locale', 'jurisdiction', 'knowledge'],
|
|
13
|
+
stability: 'experimental',
|
|
14
|
+
},
|
|
15
|
+
operations: [
|
|
16
|
+
{ key: 'assistant.answer', version: '1.0.0' },
|
|
17
|
+
{ key: 'assistant.explainConcept', version: '1.0.0' },
|
|
18
|
+
],
|
|
19
|
+
events: [
|
|
20
|
+
{ key: 'assistant.answer.requested', version: '1.0.0' },
|
|
21
|
+
{ key: 'assistant.answer.blocked', version: '1.0.0' },
|
|
22
|
+
{ key: 'assistant.answer.delivered', version: '1.0.0' },
|
|
23
|
+
],
|
|
24
|
+
presentations: [],
|
|
25
|
+
opToPresentation: [],
|
|
26
|
+
presentationsTargets: [],
|
|
27
|
+
capabilities: {
|
|
28
|
+
requires: [{ key: 'knowledge', version: '1.0.0' }],
|
|
29
|
+
},
|
|
30
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { defineCommand } from '@contractspec/lib.contracts';
|
|
2
|
+
import { ScalarTypeEnum, defineSchemaModel } from '@contractspec/lib.schema';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
AssistantAnswerIRModel,
|
|
6
|
+
LLMCallEnvelopeModel,
|
|
7
|
+
} from '../entities/models';
|
|
8
|
+
|
|
9
|
+
const AssistantQuestionInput = defineSchemaModel({
|
|
10
|
+
name: 'AssistantQuestionInput',
|
|
11
|
+
description: 'Input for assistant calls with mandatory envelope.',
|
|
12
|
+
fields: {
|
|
13
|
+
envelope: { type: LLMCallEnvelopeModel, isOptional: false },
|
|
14
|
+
question: { type: ScalarTypeEnum.String_unsecure(), isOptional: false },
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const AssistantConceptInput = defineSchemaModel({
|
|
19
|
+
name: 'AssistantConceptInput',
|
|
20
|
+
description: 'Input for explaining a concept with mandatory envelope.',
|
|
21
|
+
fields: {
|
|
22
|
+
envelope: { type: LLMCallEnvelopeModel, isOptional: false },
|
|
23
|
+
conceptKey: { type: ScalarTypeEnum.String_unsecure(), isOptional: false },
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export const AssistantAnswerContract = defineCommand({
|
|
28
|
+
meta: {
|
|
29
|
+
key: 'assistant.answer',
|
|
30
|
+
version: '1.0.0',
|
|
31
|
+
stability: 'experimental',
|
|
32
|
+
owners: ['@examples'],
|
|
33
|
+
tags: ['assistant', 'policy', 'locale', 'jurisdiction', 'knowledge'],
|
|
34
|
+
description:
|
|
35
|
+
'Answer a user question using a KB snapshot with strict locale/jurisdiction gating.',
|
|
36
|
+
goal: 'Provide policy-safe answers that cite a KB snapshot or refuse.',
|
|
37
|
+
context:
|
|
38
|
+
'Called by UI or workflows; must fail-closed if envelope is invalid or citations are missing.',
|
|
39
|
+
},
|
|
40
|
+
io: {
|
|
41
|
+
input: AssistantQuestionInput,
|
|
42
|
+
output: AssistantAnswerIRModel,
|
|
43
|
+
errors: {
|
|
44
|
+
LOCALE_REQUIRED: {
|
|
45
|
+
description: 'Locale is required and must be supported',
|
|
46
|
+
http: 400,
|
|
47
|
+
gqlCode: 'LOCALE_REQUIRED',
|
|
48
|
+
when: 'locale is missing or unsupported',
|
|
49
|
+
},
|
|
50
|
+
JURISDICTION_REQUIRED: {
|
|
51
|
+
description: 'Jurisdiction is required',
|
|
52
|
+
http: 400,
|
|
53
|
+
gqlCode: 'JURISDICTION_REQUIRED',
|
|
54
|
+
when: 'jurisdiction is missing',
|
|
55
|
+
},
|
|
56
|
+
KB_SNAPSHOT_REQUIRED: {
|
|
57
|
+
description: 'KB snapshot id is required',
|
|
58
|
+
http: 400,
|
|
59
|
+
gqlCode: 'KB_SNAPSHOT_REQUIRED',
|
|
60
|
+
when: 'kbSnapshotId is missing',
|
|
61
|
+
},
|
|
62
|
+
CITATIONS_REQUIRED: {
|
|
63
|
+
description: 'Answers must include citations to a KB snapshot',
|
|
64
|
+
http: 422,
|
|
65
|
+
gqlCode: 'CITATIONS_REQUIRED',
|
|
66
|
+
when: 'answer has no citations',
|
|
67
|
+
},
|
|
68
|
+
SCOPE_VIOLATION: {
|
|
69
|
+
description:
|
|
70
|
+
'Answer violates allowed scope and must be refused/escalated',
|
|
71
|
+
http: 403,
|
|
72
|
+
gqlCode: 'SCOPE_VIOLATION',
|
|
73
|
+
when: 'output includes forbidden content under the given allowedScope',
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
policy: { auth: 'user' },
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
export const AssistantExplainConceptContract = defineCommand({
|
|
81
|
+
meta: {
|
|
82
|
+
key: 'assistant.explainConcept',
|
|
83
|
+
version: '1.0.0',
|
|
84
|
+
stability: 'experimental',
|
|
85
|
+
owners: ['@examples'],
|
|
86
|
+
tags: ['assistant', 'policy', 'knowledge', 'concepts'],
|
|
87
|
+
description:
|
|
88
|
+
'Explain a concept using a KB snapshot with strict locale/jurisdiction gating.',
|
|
89
|
+
goal: 'Explain concepts with citations or refuse.',
|
|
90
|
+
context: 'Same constraints as assistant.answer.',
|
|
91
|
+
},
|
|
92
|
+
io: {
|
|
93
|
+
input: AssistantConceptInput,
|
|
94
|
+
output: AssistantAnswerIRModel,
|
|
95
|
+
errors: AssistantAnswerContract.io.errors,
|
|
96
|
+
},
|
|
97
|
+
policy: { auth: 'user' },
|
|
98
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './assistant';
|
|
@@ -0,0 +1,25 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { GateError, GateResult } from './types';
|
|
2
|
+
|
|
3
|
+
interface EnvelopeLike {
|
|
4
|
+
locale?: string;
|
|
5
|
+
kbSnapshotId?: string;
|
|
6
|
+
allowedScope?: 'education_only' | 'generic_info' | 'escalation_required';
|
|
7
|
+
regulatoryContext?: { jurisdiction?: string };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface AnswerLike {
|
|
11
|
+
citations?: unknown[];
|
|
12
|
+
sections?: { 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 {
|
|
35
|
+
ok: false,
|
|
36
|
+
error: err('JURISDICTION_REQUIRED', 'jurisdiction is required'),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
if (!envelope.kbSnapshotId) {
|
|
40
|
+
return {
|
|
41
|
+
ok: false,
|
|
42
|
+
error: err('KB_SNAPSHOT_REQUIRED', 'kbSnapshotId is required'),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
if (!envelope.allowedScope) {
|
|
46
|
+
return {
|
|
47
|
+
ok: false,
|
|
48
|
+
error: err('SCOPE_VIOLATION', 'allowedScope is required'),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
return { ok: true, value: envelope as Required<EnvelopeLike> };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function enforceCitations(answer: AnswerLike): GateResult<AnswerLike> {
|
|
55
|
+
const citations = answer.citations ?? [];
|
|
56
|
+
if (!Array.isArray(citations) || citations.length === 0) {
|
|
57
|
+
return {
|
|
58
|
+
ok: false,
|
|
59
|
+
error: err(
|
|
60
|
+
'CITATIONS_REQUIRED',
|
|
61
|
+
'answers must include at least one citation'
|
|
62
|
+
),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
return { ok: true, value: answer };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const EDUCATION_ONLY_FORBIDDEN_PATTERNS: RegExp[] = [
|
|
69
|
+
/\b(buy|sell)\b/i,
|
|
70
|
+
/\b(should\s+buy|should\s+sell)\b/i,
|
|
71
|
+
/\b(guarantee(d)?|promise(d)?)\b/i,
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
export function enforceAllowedScope(
|
|
75
|
+
allowedScope: EnvelopeLike['allowedScope'],
|
|
76
|
+
answer: AnswerLike
|
|
77
|
+
): GateResult<AnswerLike> {
|
|
78
|
+
if (!allowedScope) {
|
|
79
|
+
return {
|
|
80
|
+
ok: false,
|
|
81
|
+
error: err('SCOPE_VIOLATION', 'allowedScope is required'),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
if (allowedScope !== 'education_only') {
|
|
85
|
+
return { ok: true, value: answer };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const bodies = (answer.sections ?? []).map((s) => s.body).join('\n');
|
|
89
|
+
const violations = EDUCATION_ONLY_FORBIDDEN_PATTERNS.some((re) =>
|
|
90
|
+
re.test(bodies)
|
|
91
|
+
);
|
|
92
|
+
if (violations) {
|
|
93
|
+
return {
|
|
94
|
+
ok: false,
|
|
95
|
+
error: err(
|
|
96
|
+
'SCOPE_VIOLATION',
|
|
97
|
+
'answer violates education_only scope (contains actionable or promotional language)'
|
|
98
|
+
),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
return { ok: true, value: answer };
|
|
102
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export type AllowedScope =
|
|
2
|
+
| 'education_only'
|
|
3
|
+
| 'generic_info'
|
|
4
|
+
| 'escalation_required';
|
|
5
|
+
|
|
6
|
+
export interface GateError {
|
|
7
|
+
code:
|
|
8
|
+
| 'LOCALE_REQUIRED'
|
|
9
|
+
| 'JURISDICTION_REQUIRED'
|
|
10
|
+
| 'KB_SNAPSHOT_REQUIRED'
|
|
11
|
+
| 'CITATIONS_REQUIRED'
|
|
12
|
+
| 'SCOPE_VIOLATION';
|
|
13
|
+
message: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type GateResult<T> =
|
|
17
|
+
| { ok: true; value: T }
|
|
18
|
+
| { ok: false; error: GateError };
|
package/tsconfig.json
ADDED