Refactor plan — deepening study-buddy
Refactor plan — deepening study-buddy
Bottom-up plan to deepen five shallow modules into three. Generated from an architecture review (2026-05-23). The five candidates collapse into three themes; order matters because each phase lands on a clean interface below it.
| Theme | Candidates | Why grouped |
|---|---|---|
| A · Settings | 3 (one schema) + 5 (header resolver) | 5 is the consumer side of 3 |
| B · Adapters | 1 (provider core) + 2 (search seam) | same pattern done twice |
| C · Orchestration | 4 (session-prep module) | calls A and B |
Recommended order: A → C → B (foundation first, so the orchestration in C lands on a clean settings interface).
Phase 0 · Precursor: a test runner (optional but it’s the payoff)
No test runner exists today (no test script, no vitest/jest). Without it, “testable” stays
hypothetical.
npm i -D vitest; add"test": "vitest"to scripts.- One smoke test so the harness is real before Phases 1–3 rely on it.
If skipped, each phase still stands as a depth win — you just can’t lock it with tests yet.
Phase 1 · Settings (candidates 3 + 5)
Goal: one module owns the settings schema; routes stop copying the header ritual and stop sharing a mutable global.
Interface
// utils/settings.ts
export const SettingsSchema = z.object({ /* all 12 fields */ });
export type AppSettings = z.infer<typeof SettingsSchema>;
export const DEFAULT_SETTINGS: AppSettings; // the ONE default set
export function loadSettings(): AppSettings; // env fallback (server)
export function resolveSettings(request: Request): AppSettings; // candidate 5
Steps
- Extend
AppSettingswith the four fields the UI already uses but the type never declared:contextSize,voiceGender,autoRead,sttProvider. Make it azodschema (zod is already a dependency). - Collapse the three default sets into one
DEFAULT_SETTINGS. Resolve the disagreement (app/settings/page.tsxreset usessearchEngine: "disabled"vs canonical"duckduckgo"). - Add
resolveSettings(request): parseX-StudyBuddy-Settings, validate with the schema, return a value (no global mutation). - Replace the duplicated ~8-line block in
getChat,getSources,summariseSources,getParsedSourceswithconst settings = resolveSettings(request). - Thread settings explicitly:
createProvider(settings)instead of reading the global. Removes theruntimeSettingsmodule-global — important becausegetChatruns on the edge runtime, where a shared global can leak between concurrent requests. - Replace raw
localStorage.getItem("studybuddy-settings")reads inapp/page.tsx(thecontextSizelookup) with a typed client helper.
Deletes
Local Settings interface in app/settings/page.tsx; the duplicated header block ×4; the
runtimeSettings global + updateSettings.
Test target
resolveSettings (valid / malformed / missing header → defaults) and schema validation. Pure,
no React, no HTTP.
Phase 2 · Session preparation (candidate 4)
Goal: pull the pipeline out of the React handler into one deep module, and fix the stale-prompt bug by construction.
Interface
// utils/session.ts
export type PreparedSession = {
sources: { name: string; url: string }[];
content: { fullContent: string }[]; // parsed + budget-fitted
processedNotes: string; // summarised if it was long
systemPrompt: string;
};
export async function prepareSession(
input: { topic; notes; settings; strategyId; nudge },
onStep: (steps: PrepStep[]) => void,
): Promise<PreparedSession>;
Steps
- Move
handleInitialChat’s pipeline (≈ lines 166–348) intoprepareSession: search → parse → budget-fit (summarise sources → chunk+summarise notes → drop sources) → build prompt. - Merge the two prompt builders into one. Today
buildSystemPrompt(line 131) uses rawcustomTextandbuildPromptWithNotes(line 331) usesprocessedCustomText. Keep one builder that always takes the processed content. - Return
processedNotes+content; store both on the page. Bug fix:handleStrategyChange/handleNudgeChangerebuild from the stored processed content via the same single builder, so a mid-session strategy change no longer reverts to un-summarised notes. onStepcallback drivessetPrepSteps— React stays in the page; the pipeline doesn’t.app/page.tsxshrinks to: phase state,messages, thehandleChatstreaming loop, render.
Test target
prepareSession with fetch stubbed — budget-fitting drops sources past the limit; prompt
contains summarised (not raw) notes. No browser.
Phase 3 · Adapters (candidates 1 + 2)
Goal: collapse the duplicated provider stream code onto one core, then mirror the seam for search.
3a · Provider core
// utils/providers/stream-core.ts
export function streamChatCompletion(opts: {
url; headers; body;
transport: "sse" | "lines"; // openai-family vs ollama/anthropic
readDelta: (json: any) => string; // the only real per-provider difference
}): Promise<ReadableStream>; // fetch + non-200 + {text} normalise
openai/together/groqbecome thin (OpenAI body + bearer header,readDelta = j => j.choices[0].delta?.content).openrouteralready reusesOpenAIProvider.anthropic/googlekeep their body transform + ownreadDelta.- Delete
utils/TogetherAIStream.ts(dead — never imported).
3b · Search seam
// utils/search/types.ts
export interface SearchProvider { search(question: string): Promise<Source[]>; }
// utils/search/index.ts → createSearchProvider(settings)
- One adapter per engine (
bing,serper,searxng,duckduckgo,brave). SearXNG’s rate-limit + retry lives inside its adapter. app/api/getSources/route.ts(465 lines) shrinks to:resolveSettings→createSearchProvider→search→ return;disabled→[]. Shared bits (exclusion list,what is …prefix) passed in.
Test target
The provider core (one normalise test, reused across adapters) and an in-memory SearchProvider
proving the seam.
The thread that ties them
Phase 1’s resolveSettings → createProvider(settings) is what lets Phase 3a drop the global, and
Phase 2 calls all three. Bottom-up saves rework: each phase lands on a clean interface below it.