Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 3 additions & 6 deletions api/services/inference.py
Original file line number Diff line number Diff line change
Expand Up @@ -559,12 +559,9 @@ async def generate_stream_explanation(topic: str, level: str, model: str | None
telemetry_sink=stream_telemetry,
):
socratic_chunks.append(chunk)
yield chunk # yield immediately - do not buffer

# NOTE: Socratic constraint enforcement (question capping) is now
# the caller's responsibility. The full response is available in
# the router's accumulated `full_content` after streaming ends.
# This trade-off is intentional: streaming health > post-processing.
constrained_response = _enforce_socratic_response_constraints("".join(socratic_chunks))
for index in range(0, len(constrained_response), 400):
yield constrained_response[index : index + 400]
else:
async for chunk in stream_chat_completion(
model=alias,
Expand Down
31 changes: 31 additions & 0 deletions api/tests/test_inference.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,40 @@ async def fake_stream(*_args, **_kwargs):

combined = "".join(streamed)
assert combined.count("?") <= 3
assert combined.count("How does it change in this process?") == 1
assert "Share your answer, and I will guide the next step." in combined


@pytest.mark.asyncio
async def test_generate_stream_explanation_socratic_dedupes_and_caps_questions(monkeypatch):
async def fake_stream(*_args, **_kwargs):
chunks = [
"What is entropy? ",
"How does entropy change here? ",
"How does entropy change here? ",
"Why does entropy matter? ",
"How would you measure entropy in practice?",
]
for chunk in chunks:
yield chunk

monkeypatch.setattr(inference_module, "stream_chat_completion", fake_stream)

streamed = []
async for chunk in inference_module.generate_stream_explanation(
"entropy",
"eli15",
mode="socratic",
):
streamed.append(chunk)

combined = "".join(streamed)
assert combined.count("?") == 3
assert combined.count("How does entropy change here?") == 1
assert "How would you measure entropy in practice?" not in combined
assert combined.endswith("Share your answer, and I will guide the next step.")


@pytest.mark.asyncio
async def test_technical_mode_handler_uses_safe_defaults_when_classification_fails(monkeypatch):
captured = {}
Expand Down
2 changes: 1 addition & 1 deletion src/components/chat/DepthDropdown.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useEffect, useId, useMemo, useRef, useState } from "react";
import { Bolt, ChevronDown } from "lucide-react";
import type { DepthLevel } from "../../stores/useChatStore";
import type { DepthLevel } from "../../lib/chatStoreUtils";

interface DepthOption {
id: DepthLevel;
Expand Down
2 changes: 1 addition & 1 deletion src/components/chat/WelcomeEmptyState.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Workspace } from "../../stores/useChatStore";
import type { Workspace } from "../../lib/chatStoreUtils";
import {
WORKSPACE_CONTENT,
WORKSPACE_ICONS,
Expand Down
2 changes: 1 addition & 1 deletion src/components/chat/WorkspaceInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { ArrowRight, Paperclip } from "lucide-react";
import DepthDropdown from "./DepthDropdown";
import { useChatStore } from "../../stores/useChatStore";
import type { DepthLevel, Workspace } from "../../stores/useChatStore";
import type { DepthLevel, Workspace } from "../../lib/chatStoreUtils";
import type { ChatMode, PromptMode } from "../../types/chat";

interface WorkspaceInputProps {
Expand Down
2 changes: 1 addition & 1 deletion src/components/chat/WorkspaceSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
Trash2,
} from "lucide-react";
import type { Conversation } from "../../types/chat";
import type { Workspace } from "../../stores/useChatStore";
import type { Workspace } from "../../lib/chatStoreUtils";

interface WorkspaceSidebarProps {
workspace: Workspace;
Expand Down
293 changes: 293 additions & 0 deletions src/lib/chatStoreUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,293 @@
import type { Level } from "../types";
import type { ChatMode, Conversation, Message, PromptMode } from "../types/chat";
import { CHAT_PREMIUM_MODES, isPromptMode, resolveChatMode } from "../lib/chatModes";

export type Workspace = "learn" | "socratic" | "technical";
export type ThemeMode = "dark" | "light";
export const DEPTH_LEVELS = [
"eli5",
"eli10",
"eli12",
"eli15",
"meme",
] as const;
export type DepthLevel = (typeof DEPTH_LEVELS)[number];
export type StoreLevel = Level;
export type StoreConversation = Conversation;
export const CHAT_STORE_PREMIUM_MODES = CHAT_PREMIUM_MODES;

export const THEME_STORAGE_KEY = "kb_theme_v1";
export const DEFAULT_WORKSPACE: Workspace = "learn";
export const DEFAULT_DEPTH_LEVEL: DepthLevel = "eli12";
export const PENDING_SYNC_KEY = "kb_pending_sync_v1";

export const supabaseConfigured =
Boolean(import.meta.env.VITE_SUPABASE_URL) &&
Boolean(import.meta.env.VITE_SUPABASE_ANON_KEY);

const defaultIsProEnv = import.meta.env.VITE_DEFAULT_IS_PRO;
export const defaultIsPro = defaultIsProEnv ? defaultIsProEnv === "true" : false;
export const API_URL = import.meta.env.VITE_API_URL || "";

export const createUuid = () => {
const webCrypto: Crypto | undefined =
typeof globalThis !== "undefined" ? globalThis.crypto : undefined;

if (webCrypto?.randomUUID) {
return webCrypto.randomUUID();
}

const getRandomValues = webCrypto?.getRandomValues
? webCrypto.getRandomValues.bind(webCrypto)
: null;
const rnd = (size: number) => {
if (getRandomValues) {
const arr = new Uint8Array(size);
getRandomValues(arr);
return arr;
}
return Uint8Array.from({ length: size }, () =>
Math.floor(Math.random() * 256),
);
};
const bytes = rnd(16);
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0"));
return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex
.slice(6, 8)
.join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10, 16).join("")}`;
};

export const makeLocalId = () => `local-${createUuid()}`;

export const makeClientId = () => createUuid();

export const truncateTitle = (content: string) => {
const trimmed = content.trim().replace(/\s+/g, " ");
if (trimmed.length <= 64) return trimmed;
return `${trimmed.slice(0, 61)}...`;
};

export const isDepthLevel = (mode: string | null | undefined): mode is DepthLevel => {
return DEPTH_LEVELS.includes(mode as DepthLevel);
};

export const resolveDepthLevel = (
mode: string | null | undefined,
fallback: DepthLevel = DEFAULT_DEPTH_LEVEL,
): DepthLevel => {
if (isDepthLevel(mode)) return mode;
return fallback;
};

export const resolveWorkspaceFromMode = (mode: ChatMode): Workspace => {
if (mode === "socratic") return "socratic";
if (mode === "technical") return "technical";
return "learn";
};

export const resolveWorkspaceState = (
mode: string | null | undefined,
promptMode: string | null | undefined,
fallbackDepth: DepthLevel,
) => {
const resolvedMode = resolveChatMode(mode);

if (resolvedMode === "socratic") {
return {
workspace: "socratic" as Workspace,
mode: "socratic" as ChatMode,
promptMode: resolveDepthLevel(promptMode, fallbackDepth) as PromptMode,
depthLevel: resolveDepthLevel(promptMode, fallbackDepth),
};
}

if (resolvedMode === "technical") {
return {
workspace: "technical" as Workspace,
mode: "technical" as ChatMode,
promptMode: resolveDepthLevel(promptMode, fallbackDepth) as PromptMode,
depthLevel: resolveDepthLevel(promptMode, fallbackDepth),
};
}

const nextDepth = resolveDepthLevel(
promptMode || (isPromptMode(resolvedMode) ? resolvedMode : undefined),
fallbackDepth,
);

return {
workspace: "learn" as Workspace,
mode: "learning" as ChatMode,
promptMode: nextDepth as PromptMode,
depthLevel: nextDepth,
};
};

export const loadTheme = (): ThemeMode => {
if (typeof window === "undefined") return "light";

const cachedTheme = window.localStorage.getItem(THEME_STORAGE_KEY);
if (cachedTheme === "light" || cachedTheme === "dark") {
return cachedTheme;
}

return window.matchMedia?.("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
};

export const applyThemeClass = (theme: ThemeMode) => {
if (typeof document === "undefined") return;
document.documentElement.classList.toggle("dark", theme === "dark");
};

export const persistTheme = (theme: ThemeMode) => {
if (typeof window === "undefined") return;
window.localStorage.setItem(THEME_STORAGE_KEY, theme);
};

export const getModeForWorkspace = (workspace: Workspace): ChatMode => {
if (workspace === "socratic") return "socratic";
if (workspace === "technical") return "technical";
return "learning";
};

export const asString = (value: unknown): string | undefined => {
return typeof value === "string" ? value : undefined;
};

export const isAbortError = (error: unknown): boolean => {
return (
typeof error === "object" &&
error !== null &&
"name" in error &&
(error as { name?: string }).name === "AbortError"
);
};

export const getErrorMessage = (error: unknown, fallback: string): string => {
if (error instanceof Error && error.message) return error.message;
return fallback;
};

export const notifyError = (message: string) => {
console.error(message);
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent("kb-toast", { detail: { type: "error", message } }),
);
}
};

export const resolveMessageKey = (message: Message) => {
return (
message.clientGeneratedId ||
message.metadata?.assistant_client_id ||
message.metadata?.client_id ||
message.serverMessageId ||
message.id
);
};

export const messagesMatch = (existing: Message, incoming: Message) => {
if (existing.id === incoming.id) return true;
if (
existing.clientGeneratedId &&
incoming.clientGeneratedId &&
existing.clientGeneratedId === incoming.clientGeneratedId
) {
return true;
}
if (
incoming.metadata?.assistant_client_id &&
existing.clientGeneratedId === incoming.metadata.assistant_client_id
) {
return true;
}
if (
existing.metadata?.assistant_client_id &&
incoming.clientGeneratedId &&
existing.metadata.assistant_client_id === incoming.clientGeneratedId
) {
return true;
}
if (
existing.serverMessageId &&
incoming.id &&
existing.serverMessageId === incoming.id
)
return true;
if (
incoming.serverMessageId &&
existing.id &&
incoming.serverMessageId === existing.id
)
return true;
if (
incoming.metadata?.client_id &&
existing.id === incoming.metadata.client_id
)
return true;
if (
existing.metadata?.client_id &&
existing.metadata.client_id === incoming.id
)
return true;
if (
incoming.metadata?.client_id &&
existing.clientGeneratedId === incoming.metadata.client_id
)
return true;
if (
existing.metadata?.client_id &&
incoming.clientGeneratedId &&
existing.metadata.client_id === incoming.clientGeneratedId
) {
return true;
}
if (
incoming.metadata?.client_id &&
existing.metadata?.client_id &&
existing.metadata.client_id === incoming.metadata.client_id
) {
return true;
}
return false;
};

export const findExistingMessageKey = (
state: { messagesById: Record<string, Message>; messageIds: string[] },
incoming: Message,
) => {
for (const messageKey of state.messageIds) {
const existing = state.messagesById[messageKey];
if (!existing) continue;
if (messagesMatch(existing, incoming)) {
return messageKey;
}
}
return null;
};

export const buildMessageRegistry = (messages: Message[]) => {
const messagesById: Record<string, Message> = {};
const messageIds: string[] = [];

for (const message of messages) {
const key = resolveMessageKey(message);
if (!key) {
console.warn("Message has no identifiable key, skipping:", message);
continue;
}
if (messagesById[key]) {
messagesById[key] = { ...messagesById[key], ...message };
continue;
}
messagesById[key] = message;
messageIds.push(key);
}

return { messagesById, messageIds };
};
2 changes: 1 addition & 1 deletion src/pages/ChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { getHealth } from "../api";
import { useConversations } from "../hooks/useConversations";
import { useMessages } from "../hooks/useMessages";
import { useChatStore } from "../stores/useChatStore";
import type { Workspace } from "../stores/useChatStore";
import type { Workspace } from "../lib/chatStoreUtils";

const WORKSPACE_LABELS: Record<Workspace, string> = {
learn: "Learn",
Expand Down
Loading
Loading