Repository navigation
Expand file tree
/
Copy pathcontext.ts
More file actions
211 lines (197 loc) · 8.1 KB
/
Copy pathcontext.ts
File metadata and controls
211 lines (197 loc) · 8.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
import {
boundOutputDigests,
type DebateContextFeature,
type DebateContextOutputKind,
type DebateContextRequest,
type DebateContextSide,
type DebateOutputRef,
digestOutput,
MAX_CONTEXT_OUTPUTS,
MAX_CONTEXT_SOURCES,
selectDefaultOutputs,
} from '../../../supabase/functions/_shared/debate-context';
import { boundPromptSources } from '../../../supabase/functions/_shared/source-bounds';
import type { SavedItem, Source } from '@/lib/stores/session-store';
import type { SparringSession } from '@/lib/stores/sparring-store';
import { outputKey, type DebateContextOverride } from '@/lib/stores/debate-context-store';
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
/** What each feature may use, mirroring the server's per-feature policy. */
export interface DebateContextPolicy {
sources: boolean;
outputs: boolean;
instructions: boolean;
}
export function debateContextPolicy(feature: DebateContextFeature, options: { includeSources?: boolean } = {}): DebateContextPolicy {
// A live sparring round stays transcript-only.
if (feature === 'sparring') return { sources: false, outputs: false, instructions: true };
// Crossfire keeps its non-overridable transcript rules.
if (feature === 'crossfire') return { sources: options.includeSources !== false, outputs: true, instructions: false };
return { sources: options.includeSources !== false, outputs: true, instructions: true };
}
export interface ContextSourceItem {
id: string;
title: string;
active: boolean;
included: boolean;
/** Included but past the per-request source limit, so not sent. */
overLimit: boolean;
/** Sent but dropped or shortened to fit the prompt budget. */
omittedForSize: boolean;
shortened: boolean;
}
export interface ContextOutputItem {
ref: DebateOutputRef;
title: string;
side: DebateContextSide | null;
createdAt: number;
pinned: boolean;
included: boolean;
overLimit: boolean;
omittedForSize: boolean;
/** Nothing usable yet, such as an unjudged sparring round. */
empty: boolean;
}
export interface DebateContextPlan {
debateId: string;
feature: DebateContextFeature;
side: DebateContextSide;
policy: DebateContextPolicy;
sources: ContextSourceItem[];
outputs: ContextOutputItem[];
/** Every result in this debate, the open Ask chat included; pins on anything else no longer count. */
resultKeys: ReadonlySet<string>;
request: DebateContextRequest;
}
export interface DebateContextInputs {
sourceLibrary: Source[];
results: Partial<Record<Exclude<DebateContextOutputKind, 'sparring'>, SavedItem<unknown>[]>>;
sparringSessions: SparringSession[];
pins: DebateOutputRef[];
override?: DebateContextOverride;
/** The chat currently open in Ask is already the conversation itself. */
excludeChatId?: string | null;
}
function sideOf(value: unknown): DebateContextSide | null {
return value === 'pro' || value === 'con' || value === 'both' || value === 'neutral' ? value : null;
}
interface OutputCandidate {
ref: DebateOutputRef;
title: string;
side: DebateContextSide | null;
createdAt: number;
data: unknown;
}
function outputCandidates(debateId: string, inputs: DebateContextInputs): OutputCandidate[] {
const candidates: OutputCandidate[] = [];
for (const [kind, items] of Object.entries(inputs.results) as Array<[DebateContextOutputKind, SavedItem<unknown>[] | undefined]>) {
for (const item of items ?? []) {
if (item.debateId !== debateId || !UUID.test(item.id)) continue;
if (kind === 'chat' && item.id === inputs.excludeChatId) continue;
candidates.push({ ref: { kind, id: item.id }, title: item.title, side: sideOf(item.side), createdAt: item.createdAt, data: item.data });
}
}
for (const session of inputs.sparringSessions) {
if (session.debateId !== debateId || !UUID.test(session.id)) continue;
candidates.push({
ref: { kind: 'sparring', id: session.id },
title: session.motion,
side: sideOf(session.userSide),
createdAt: session.updatedAt,
data: { userSide: session.userSide, scorecard: session.scorecard },
});
}
return candidates.sort((a, b) => b.createdAt - a.createdAt);
}
/**
* The Current Debate material the next request in `feature` will send, with
* the student's Review choices applied. The server re-checks every ID and
* applies the same budgets, and its response reports what actually reached
* the model.
*/
export function planDebateContext(
feature: DebateContextFeature,
side: DebateContextSide,
debateId: string,
inputs: DebateContextInputs,
options: { includeSources?: boolean } = {},
): DebateContextPlan {
const policy = debateContextPolicy(feature, options);
const override = inputs.override?.debateId === debateId ? inputs.override : undefined;
const debateSources = policy.sources
? inputs.sourceLibrary.filter((source) => source.debateId === debateId && UUID.test(source.id))
: [];
const includedSources = debateSources.filter((source) => override?.sources[source.id] ?? source.isActive);
const sentSources = includedSources.slice(0, MAX_CONTEXT_SOURCES);
const boundedSources = boundPromptSources(sentSources);
const sourceState = new Map(sentSources.map((source, index) => {
const kept = boundedSources[index];
return [source.id, { omittedForSize: !kept, shortened: Boolean(kept && kept.content.length < source.content.length) }];
}));
const sources: ContextSourceItem[] = debateSources.map((source) => {
const included = includedSources.includes(source);
const state = sourceState.get(source.id);
return {
id: source.id,
title: source.title,
active: source.isActive,
included,
overLimit: included && !state,
omittedForSize: state?.omittedForSize ?? false,
shortened: state?.shortened ?? false,
};
});
const pinnedKeys = new Set(inputs.pins.map(outputKey));
const candidates = policy.outputs ? outputCandidates(debateId, inputs) : [];
// A pin on a result that isn't loaded here, because it was deleted or is
// older than local history keeps, is never shown or sent. It must not use up
// the limit, or the student could never free that slot.
const resultKeys = new Set(outputCandidates(debateId, { ...inputs, excludeChatId: null }).map((item) => outputKey(item.ref)));
const defaults = selectDefaultOutputs(
candidates.map((item) => ({ ...item.ref, side: item.side, createdAt: item.createdAt, pinned: pinnedKeys.has(outputKey(item.ref)) })),
feature,
side,
);
const defaultKeys = new Set(defaults.map(outputKey));
const isIncluded = (item: OutputCandidate) => override?.outputs[outputKey(item.ref)] ?? defaultKeys.has(outputKey(item.ref));
// Defaults keep their order (pins first); extra choices follow, newest first.
const byKey = new Map(candidates.map((item) => [outputKey(item.ref), item]));
const ordered = [
...defaults.map((ref) => byKey.get(outputKey(ref))!).filter(isIncluded),
...candidates.filter((item) => !defaultKeys.has(outputKey(item.ref)) && isIncluded(item)),
];
const sentOutputs = ordered.slice(0, MAX_CONTEXT_OUTPUTS);
const bounded = boundOutputDigests(sentOutputs.map((item) => ({ key: outputKey(item.ref), digest: digestOutput(item.ref.kind, item.data) })));
const tooLarge = new Set(bounded.omittedForSize.map((item) => item.key));
const empty = new Set(bounded.empty.map((item) => item.key));
const sentKeys = new Set(sentOutputs.map((item) => outputKey(item.ref)));
const outputs: ContextOutputItem[] = candidates.map((item) => {
const key = outputKey(item.ref);
const included = isIncluded(item);
return {
ref: item.ref,
title: item.title,
side: item.side,
createdAt: item.createdAt,
pinned: pinnedKeys.has(key),
included,
overLimit: included && !sentKeys.has(key),
omittedForSize: tooLarge.has(key),
empty: empty.has(key),
};
});
return {
debateId,
feature,
side,
policy,
sources,
outputs,
resultKeys,
request: {
debateId,
side,
sourceIds: sentSources.map((source) => source.id),
outputs: sentOutputs.filter((item) => !empty.has(outputKey(item.ref))).map((item) => item.ref),
},
};
}