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
6 changes: 6 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ on:
- main
- dev

permissions:
contents: read

jobs:
frontend:
runs-on: ubuntu-24.04
Expand All @@ -20,6 +23,8 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
persist-credentials: false

- name: Set up Node.js
uses: actions/setup-node@v6
Expand Down Expand Up @@ -48,6 +53,7 @@ jobs:
uses: actions/checkout@v6
with:
fetch-depth: 0
persist-credentials: false

- name: Set up Temurin JDK
uses: actions/setup-java@v5
Expand Down
7 changes: 6 additions & 1 deletion frontend/src/lib/components/LearnView.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -609,7 +609,12 @@
<!-- Lesson Header - full width with inner constraint -->
<div class="lesson-header">
<div class="lesson-header-inner">
<button type="button" class="back-btn" onclick={goBack}>
<button
type="button"
class="back-btn"
aria-label="All Lessons"
onclick={goBack}
>
<svg
viewBox="0 0 20 20"
fill="currentColor"
Expand Down
19 changes: 19 additions & 0 deletions frontend/src/lib/components/LearnView.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,25 @@ describe("LearnView guided chat streaming stability", () => {
vi.unstubAllGlobals();
});

it("keeps the All Lessons back control named when its visual label is hidden on mobile", async () => {
fetchTocMock.mockResolvedValue([
{ slug: "intro", title: "Test Lesson", summary: "Lesson summary", keywords: [] },
]);

streamLessonContentMock.mockImplementation(async (_slug, callbacks) => {
callbacks.onChunk("# Lesson");
});
fetchGuidedLessonCitationsMock.mockResolvedValue({ success: true, citations: [] });

const { findByRole } = await renderLearnView();
const lessonButton = await findByRole("button", { name: /test lesson/i });
await fireEvent.click(lessonButton);

const allLessonsButton = await findByRole("button", { name: "All Lessons" });
expect(allLessonsButton).toHaveAttribute("aria-label", "All Lessons");
expect(allLessonsButton).toHaveTextContent("All Lessons");
});

it("keeps the guided assistant message DOM node stable when the stream completes", async () => {
fetchTocMock.mockResolvedValue([
{ slug: "intro", title: "Test Lesson", summary: "Lesson summary", keywords: [] },
Expand Down
35 changes: 32 additions & 3 deletions frontend/src/lib/services/javaLanguageDetection.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/** Keywords indicating Java code for auto-detection. */
const JAVA_KEYWORDS = [
const JAVA_KEYWORDS = new Set<string>([
"public",
"private",
"class",
Expand All @@ -8,14 +8,43 @@ const JAVA_KEYWORDS = [
"String",
"int",
"boolean",
] as const;
]);

/** Java's identifier-ignorable ISO control character ranges. */
const JAVA_IDENTIFIER_IGNORABLE_CONTROL_RANGES = "\\u0000-\\u0008\\u000E-\\u001B\\u007F-\\u009F";

/** Matches Java identifier parts, including identifier-ignorable characters. */
const JAVA_IDENTIFIER_PART_PATTERN = new RegExp(
`[\\p{ID_Continue}\\p{Sc}\\p{Cf}${JAVA_IDENTIFIER_IGNORABLE_CONTROL_RANGES}]`,
"u",
);

/** CSS class applied to detected Java code blocks for syntax highlighting. */
const JAVA_LANGUAGE_CLASS = "language-java";

/** Selector for unmarked code blocks eligible for language detection. */
const UNMARKED_CODE_SELECTOR = "pre > code:not([class])";

/** Determines whether the code text contains a standalone Java keyword token. */
function containsJavaKeyword(codeText: string): boolean {
let javaIdentifier = "";

for (const codeCharacter of codeText) {
if (JAVA_IDENTIFIER_PART_PATTERN.test(codeCharacter)) {
javaIdentifier += codeCharacter;
continue;
}

if (JAVA_KEYWORDS.has(javaIdentifier)) {
return true;
}

javaIdentifier = "";
}

return JAVA_KEYWORDS.has(javaIdentifier);
}
Comment thread
WilliamAGH marked this conversation as resolved.

/** Adds Java highlighting metadata to unmarked code blocks that contain Java keywords. */
export function applyJavaLanguageDetection(container: HTMLElement | null | undefined): void {
if (!container || typeof container.querySelectorAll !== "function") {
Expand All @@ -31,7 +60,7 @@ export function applyJavaLanguageDetection(container: HTMLElement | null | undef
const codeBlocks = container.querySelectorAll(UNMARKED_CODE_SELECTOR);
codeBlocks.forEach((codeBlock) => {
const codeText = codeBlock.textContent ?? "";
if (JAVA_KEYWORDS.some((javaKeyword) => codeText.includes(javaKeyword))) {
if (containsJavaKeyword(codeText)) {
codeBlock.className = JAVA_LANGUAGE_CLASS;
}
});
Expand Down
42 changes: 30 additions & 12 deletions frontend/src/lib/services/markdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,23 +189,17 @@ describe("applyJavaLanguageDetection", () => {
expect(code.className).toBe("");
});

it("detects various Java keywords", () => {
const javaKeywords = [
"public",
"private",
"class",
"import",
"void",
"String",
"int",
"boolean",
it("detects Java keywords at identifier boundaries", () => {
const javaKeywordBoundarySnippets = [
"int[] greetingCounts = { 1 };",
'String.valueOf("hello");',
];

for (const keyword of javaKeywords) {
for (const javaKeywordBoundarySnippet of javaKeywordBoundarySnippets) {
const container = document.createElement("div");
const pre = document.createElement("pre");
const code = document.createElement("code");
code.textContent = `${keyword} something`;
code.textContent = javaKeywordBoundarySnippet;
pre.appendChild(code);
container.appendChild(pre);

Expand All @@ -214,6 +208,30 @@ describe("applyJavaLanguageDetection", () => {
expect(code.className).toBe("language-java");
}
});

it("does not classify keyword substrings or identifier continuations as Java", () => {
const nonJavaSnippets = [
'print("hello")',
"const point = { horizontal: 1 };",
'const hint = "tip";',
'const publication = "article";',
'const republic = "state";',
'const public\uFEFFation = "article";',
];

for (const nonJavaSnippet of nonJavaSnippets) {
const container = document.createElement("div");
const pre = document.createElement("pre");
const code = document.createElement("code");
code.textContent = nonJavaSnippet;
pre.appendChild(code);
container.appendChild(pre);

applyJavaLanguageDetection(container);

expect(code.className).toBe("");
}
});
});

describe("escapeHtml", () => {
Expand Down
40 changes: 40 additions & 0 deletions frontend/src/lib/services/streamRecovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,46 @@ describe("streamRecovery", () => {
expect(retryDecision).toBe(true);
});

it("retries a generic terminal stream error when the backend marks it retryable", () => {
const retryDecision = shouldRetryStreamRequest(
new Error("Streaming error"),
{
message: "Streaming error",
code: "stream.provider.retryable-error",
retryable: true,
stage: "stream",
},
null,
false,
0,
1,
);

expect(retryDecision).toBe(true);
});

it("refuses retry when a terminal error overrides an earlier retryable status", () => {
const retryDecision = shouldRetryStreamRequest(
new Error("Streaming error"),
{
message: "Streaming error",
code: "stream.provider.fatal-error",
retryable: false,
stage: "stream",
},
{
message: "Provider fallback succeeded",
retryable: true,
stage: "stream",
},
false,
0,
1,
);

expect(retryDecision).toBe(false);
});

it("preserves stream error details in thrown stream failure exception", () => {
const streamFailureException = toStreamFailureException(new Error("Transport failed"), {
message: "OverflowException",
Expand Down
6 changes: 5 additions & 1 deletion frontend/src/lib/services/streamRecovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@ export const MAX_STREAM_RECOVERY_RETRIES = resolveStreamRecoveryRetryCount(
* The retry gate is intentionally strict:
* - bounded retries via MAX_STREAM_RECOVERY_RETRIES,
* - only before any assistant chunk has been rendered,
* - only for known malformed/overflow stream signatures.
* - terminal backend metadata takes precedence over earlier status events,
* - unstructured failures require a known recoverable signature.
*/
export function shouldRetryStreamRequest(
streamFailure: unknown,
Expand All @@ -72,6 +73,9 @@ export function shouldRetryStreamRequest(
if (attemptedRetries >= maxRecoveryRetries) {
return false;
}
if (typeof streamErrorEvent?.retryable === "boolean") {
return streamErrorEvent.retryable;
Comment thread
WilliamAGH marked this conversation as resolved.
}
if (latestStreamStatus?.stage === "stream" && latestStreamStatus.retryable === false) {
return false;
}
Expand Down
23 changes: 1 addition & 22 deletions frontend/src/styles/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
*/

@font-face {
font-family: "Fraunces";
font-family: Fraunces;
src: url("/fonts/Fraunces-Variable.ttf") format("truetype");
font-weight: 100 900;
font-display: swap;
Expand Down Expand Up @@ -294,27 +294,6 @@ body {
}
}

@keyframes slide-in-right {
from {
opacity: 0;
transform: translateX(-12px);
}
to {
opacity: 1;
transform: translateX(0);
}
}

@keyframes pulse-subtle {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.6;
}
}

@keyframes typing-cursor {
0%,
100% {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,13 @@
public class SystemPromptConfig {

private static final String JDK_VERSION_PLACEHOLDER = "__JDK_VERSION__";
private static final String CORE_PROMPT_TEMPLATE = """
static final String MARKER_PROSE_LINE_CLAUSE = "Put each enrichment marker only on its own prose line.";
static final String MARKER_CODE_BOUNDARY_CLAUSE =
"Never place an enrichment marker inside inline code or a fenced code block; put it before or after the fence.";
static final String JAVA_FENCE_VALIDITY_CLAUSE =
"A fenced `java` block contains syntactically valid Java that compiles with its stated imports and context. Use real APIs appropriate to the response context; never invent API names, method signatures, or type arguments.";
private static final String CORE_PROMPT_TEMPLATE =
"""
You are a Java learning assistant focused on Java __JDK_VERSION__ and current stable JDK releases.

## Default Environment
Expand Down Expand Up @@ -42,14 +48,19 @@ If the user asks about a feature, answer for Java __JDK_VERSION__ (preview disab
- Prefer official docs and stable releases over previews or early-access content

## Learning Enhancement Markers
CRITICAL: Embed learning insights directly in your response using these markers. EACH marker MUST be on its own line.
Embed learning insights directly in prose using these markers:
- {{hint:Text here}} for helpful tips and best practices
- {{reminder:Text here}} for important things to remember
- {{background:Text here}} for conceptual explanations
- {{example:code here}} for inline code examples
- {{example:code here}} for concise Java examples
- {{warning:Text here}} for common pitfalls to avoid

Integrate these markers naturally throughout your explanation. Don't group them at the end.
### Marker and Code Boundaries
- %s
- %s
- %s

Integrate these markers naturally throughout your prose. Don't group them at the end.

## Citation Handling
Do NOT include footnote references like [1], [2] or citation/reference sections in your response.
Expand All @@ -63,7 +74,7 @@ Simply reference sources naturally in prose when relevant (e.g., "the JDK docume
- If a feature became final before the active Java version context, treat it as a standard language feature without version caveats
- The active Java version context is the user-stated version when provided; otherwise use the default (__JDK_VERSION__)
- If the user explicitly states an older Java version, apply version-appropriate warnings (e.g., preview features in that version)
""";
""".formatted(MARKER_PROSE_LINE_CLAUSE, MARKER_CODE_BOUNDARY_CLAUSE, JAVA_FENCE_VALIDITY_CLAUSE);

@Value("${DOCS_JDK_VERSION:25}")
private String jdkVersion;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -165,14 +165,23 @@ public Mono<StreamingResult> streamResponse(StructuredPrompt structuredPrompt, d
}

OpenAiProviderCandidate initialProvider = availableProviders.get(0);
// Routing selects two candidates, so one pre-text fallback signal is sufficient.
Sinks.Many<StreamingNotice> noticeSink =
Sinks.many().multicast().onBackpressureBuffer();
Sinks.many().replay().limit(1);
Sinks.One<RateLimitService.ApiProvider> providerChangeSink = Sinks.one();
StreamingAttemptContext attemptContext =
StreamingAttemptContext.first(availableProviders, noticeSink);
StreamingAttemptContext.first(availableProviders, noticeSink, providerChangeSink);
Flux<String> contentFlux = executeStreamingWithPreTextRetry(
structuredPrompt, temperature, attemptContext)
.doFinally(ignoredSignal -> noticeSink.tryEmitComplete());
return Mono.just(new StreamingResult(contentFlux, initialProvider.provider(), noticeSink.asFlux()));
.doFinally(ignoredSignalType -> {
noticeSink.tryEmitComplete();
providerChangeSink.tryEmitEmpty();
});
return Mono.just(new StreamingResult(
contentFlux,
initialProvider.provider(),
providerChangeSink.asMono().flux(),
noticeSink.asFlux()));
})
.subscribeOn(Schedulers.boundedElastic());
}
Expand Down Expand Up @@ -370,6 +379,7 @@ private Flux<String> executeStreamingWithPreTextRetry(
nextAttempt.maxAttempts(),
streamingFailure.getClass().getSimpleName());

emitProviderChange(attemptContext.providerChangeSink(), retryProvider.provider());
emitStreamingNotice(
attemptContext.noticeSink(),
StreamingNotice.builder(
Expand Down Expand Up @@ -485,4 +495,15 @@ private void emitStreamingNotice(Sinks.Many<StreamingNotice> noticeSink, Streami
log.debug("Failed to emit streaming notice (emitResult={})", emitResult);
}
}

private void emitProviderChange(
Sinks.One<RateLimitService.ApiProvider> providerChangeSink, RateLimitService.ApiProvider fallbackProvider) {
Sinks.EmitResult emitResult = providerChangeSink.tryEmitValue(fallbackProvider);
if (emitResult.isFailure()) {
log.debug(
"Failed to emit fallback provider change (providerId={}, emitResult={})",
fallbackProvider.ordinal(),
emitResult);
}
}
Comment thread
WilliamAGH marked this conversation as resolved.
}
Loading