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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ env/

# Database
*.db
*.db-shm
*.db-wal
*.sqlite
*.sqlite3
data/config.json
Expand Down
372 changes: 212 additions & 160 deletions ARCHITECTURE.md

Large diffs are not rendered by default.

14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,24 @@ Work in progress is accumulated under `[Unreleased]`; on release, that section b

### Changed

### Fixed

### Removed

## 2026.8.9

### Added

### Changed

- **Updated UI** — light theme is now the default, with a **dark theme toggle** (sun/moon) in the navigation bar. Your choice is remembered and falls back to your system preference on first visit. The whole palette moved to a warm, restrained **«ember»** (orange/red) accent with better contrast throughout
- **Theory answer evaluation** — load `expected_points` rubric bullets from question banks, pass them through evaluation prompts with explicit candidate-only scoring rules, and use temperature 0 for structured LLM evaluation

### Fixed

- **Coding timer** — when a coding round timer expires, the round now submits automatically and the session advances even if you refresh the page
- **Whisper transcription** — more robust audio transcription (voice-activity detection disabled) with clearer audio-answer logging

### Removed

## 2026.7.14
Expand Down
27 changes: 2 additions & 25 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

[![Python 3.12+](https://img.shields.io/badge/python-3.12+-blue.svg)](https://www.python.org/downloads/)
[![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-yellow.svg)](https://opensource.org/licenses/Apache-2.0)
[![Version](https://img.shields.io/badge/version-2026.6.12-blue.svg)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-2026.8.9-blue.svg)](CHANGELOG.md)

Open-source AI technical interview trainer. Practice **theory Q&A**, **live coding**, or **both in one session** from curated YAML banks — with structured scoring, follow-ups, optional voice, and a local results history. Bring your own LLM (cloud or local).

Expand Down Expand Up @@ -34,30 +34,6 @@ A general chat assistant is flexible, but it does not run an **interview** for y
https://github.com/user-attachments/assets/25655f1e-89d3-472f-8c1f-3f154df622b2


**Dashboard** — recent sessions and quick start

<p align="center">
<img src="./assets/dashboard.png" alt="GrillKit dashboard" width="900" />
</p>

**Interview setup** — question-bank tracks, levels, topics, and session options

<p align="center">
<img src="./assets/interview-setup.png" alt="Interview setup" width="900" />
</p>

**Coding section** — Monaco editor, Run on public tests, Submit for AI evaluation

<p align="center">
<img src="./assets/coding.png" alt="Coding interview session" width="900" />
</p>

**Theory section** — real-time Q&A with AI scoring and final evaluation

<p align="center">
<img src="./assets/interview-session.png" alt="Completed interview with evaluation" width="900" />
</p>

## Features

### Session modes
Expand Down Expand Up @@ -85,6 +61,7 @@ Coding modes need a running [Judge0](https://github.com/judge0/judge0) instance
- **Known questions** — mark theory or coding bank items as **I know this** during an interview or on review pages; optionally exclude them on **New interview** setup; manage the list at `/known-questions/manage`
- **Dashboard** — recent sessions on the home page (completed sessions link to results)
- **Setup** — model catalog on `/config`, interview locale, Whisper/Piper downloads from the UI
- **Theme** — light theme by default with a **dark mode toggle** (sun/moon) in the navbar; your choice is remembered and follows your system preference on first visit
- **Deployment** — Docker Compose on port 8000 with `./data` volume for config, DB, and models

## Quick start
Expand Down
10 changes: 9 additions & 1 deletion app/ai/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,22 @@
including base classes, factory methods, and concrete implementations.
"""

from app.ai.base import AIProvider, GenerationResult, Message
from app.ai.base import (
AIProvider,
AudioCapableProvider,
GenerationResult,
Message,
StreamingProvider,
)
from app.ai.factory import ProviderFactory
from app.ai.openai_compatible import OpenAICompatibleProvider

__all__ = [
"AIProvider",
"AudioCapableProvider",
"GenerationResult",
"Message",
"OpenAICompatibleProvider",
"ProviderFactory",
"StreamingProvider",
]
21 changes: 16 additions & 5 deletions app/ai/audio_probe.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
"""Minimal WAV payloads for audio capability probes."""

import io
import math
import struct
import wave

from app.shared.infrastructure.audio_wav import CANONICAL_AUDIO_SAMPLE_RATE_HZ
Expand All @@ -13,23 +15,32 @@
def minimal_wav_bytes(
*,
sample_rate: int = CANONICAL_AUDIO_SAMPLE_RATE_HZ,
duration_sec: float = 0.1,
duration_sec: float = 0.5,
tone_freq_hz: float = 440.0,
) -> bytes:
"""Build a short silent mono PCM WAV for connection testing.
"""Build a short mono PCM WAV beep for connection testing.

A pure-silence clip is rejected by most multimodal audio models (they
require audible speech), so the probe sends a short audible tone instead.

Args:
sample_rate: Sample rate in Hz.
duration_sec: Duration of silence in seconds.
duration_sec: Duration of the tone in seconds.
tone_freq_hz: Frequency of the probe tone in Hz.

Returns:
WAV file bytes suitable for provider audio probes.
"""
frame_count = max(1, int(sample_rate * duration_sec))
pcm = b"\x00\x00" * frame_count
amplitude = 0.2 # moderate volume, well below clipping
pcm = bytearray()
for i in range(frame_count):
sample = amplitude * math.sin(2 * math.pi * tone_freq_hz * i / sample_rate)
pcm += struct.pack("<h", int(sample * 32767))
buffer = io.BytesIO()
with wave.open(buffer, "wb") as wav_file:
wav_file.setnchannels(1)
wav_file.setsampwidth(2)
wav_file.setframerate(sample_rate)
wav_file.writeframes(pcm)
wav_file.writeframes(bytes(pcm))
return buffer.getvalue()
111 changes: 37 additions & 74 deletions app/ai/base.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
# Copyright 2026 GrillKit Contributors
# SPDX-License-Identifier: Apache-2.0
"""Abstract base class for AI providers.
"""Abstract base class and capability protocols for AI providers.

This module defines the base abstractions and data models for AI providers,
including message structures, generation results, and the abstract provider interface.
including message structures, generation results, and capability-based
provider interfaces (ISP).
"""

from abc import ABC, abstractmethod
Expand Down Expand Up @@ -40,14 +41,10 @@ class GenerationResult:


class AIProvider(ABC):
"""Abstract base for all AI providers.
"""Base text-generation provider interface.

This class defines the interface that all AI providers must implement,
including generation, streaming, validation, and metadata.

Attributes:
model: The model name/identifier to use.
config: Additional provider-specific configuration.
All providers must implement at least text-based generation and lifecycle
methods. Streaming and audio are opt-in via separate capability protocols.
"""

def __init__(self, model: str, **kwargs: object) -> None:
Expand All @@ -64,29 +61,10 @@ def __init__(self, model: str, **kwargs: object) -> None:
@abstractmethod
def name(self) -> str:
"""Provider display name."""
pass

@abstractmethod
def supports_streaming(self) -> bool:
"""Check if provider supports streaming."""
pass

@abstractmethod
async def validate(self) -> bool:
"""Validate API key and connection."""
pass

@abstractmethod
async def probe_audio_input(self, audio_wav: bytes) -> bool:
"""Probe whether the endpoint accepts multimodal audio input.

Args:
audio_wav: Canonical WAV bytes (mono PCM).

Returns:
True when the provider accepts the audio probe request.
"""
pass

@abstractmethod
async def generate(
Expand All @@ -95,71 +73,56 @@ async def generate(
temperature: float = 0.7,
max_tokens: int = 2000,
) -> GenerationResult:
"""Generate a single response.
"""Generate a single response."""

Args:
messages: List of conversation messages.
temperature: Sampling temperature (0.0 to 2.0).
max_tokens: Maximum tokens to generate.
@abstractmethod
async def close(self) -> None:
"""Close the provider and release resources."""

Returns:
The generation result with content and metadata.

Raises:
ValueError: If the request fails or parameters are invalid.
"""
pass
class StreamingProvider(ABC):
"""Capability protocol for providers that support token streaming."""

@abstractmethod
async def generate_with_audio(
def supports_streaming(self) -> bool:
"""Check if provider supports streaming."""

@abstractmethod
def generate_stream(
self,
messages: list[Message],
audio_wav: bytes,
*,
user_text: str,
temperature: float = 0.7,
max_tokens: int = 2000,
) -> GenerationResult:
"""Generate a response from system messages, user text, and audio.

Args:
messages: System (and optional assistant) messages without user audio.
audio_wav: Canonical WAV bytes representing the user's spoken answer.
user_text: Text context for the user turn (question prompt, no answer text).
temperature: Sampling temperature (0.0 to 2.0).
max_tokens: Maximum tokens to generate.

Returns:
The generation result with content and metadata.
) -> AsyncIterator[str]:
"""Stream response tokens.

Raises:
ValueError: If the request fails or parameters are invalid.
Yields:
Chunks of generated text as they become available.
"""
pass

@abstractmethod
async def close(self) -> None:
"""Close the provider and release resources."""
pass

class AudioCapableProvider(ABC):
"""Capability protocol for providers that accept multimodal audio input."""

@abstractmethod
def generate_stream(
async def generate_with_audio(
self,
messages: list[Message],
audio_wav: bytes,
*,
user_text: str,
temperature: float = 0.7,
max_tokens: int = 2000,
) -> AsyncIterator[str]:
"""Stream response tokens.
) -> GenerationResult:
"""Generate a response from system messages, user text, and audio."""

Args:
messages: List of conversation messages.
temperature: Sampling temperature (0.0 to 2.0).
max_tokens: Maximum tokens to generate.
@abstractmethod
async def probe_audio_input(self, audio_wav: bytes) -> bool:
"""Probe whether the endpoint accepts multimodal audio input.

Yields:
Chunks of generated text as they become available.
Args:
audio_wav: Canonical WAV bytes (mono PCM).

Raises:
ValueError: If the request fails or parameters are invalid.
Returns:
True when the provider accepts the audio probe request.
"""
pass
2 changes: 1 addition & 1 deletion app/ai/faster_whisper_transcriber.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ def _transcribe() -> str:
result = "".join((segment.text or "") for segment in segment_list).strip()
logger.info(
"Whisper transcript: language=%s segments=%d result=%r",
info.language,
getattr(info, "language", language) if info is not None else language,
len(segment_list),
result,
)
Expand Down
Loading
Loading