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
3 changes: 2 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ DEPARTMENT_GENERATION_MODEL=gpt-5.4-nano
LECTURE_GENERATION_MODEL=claude-sonnet-4-6
LECTURE_SUMMARY_MODEL=gpt-5.4-nano
PROFESSOR_GENERATION_MODEL=gpt-5.4-nano
TOPICS_GENERATION_MODEL=gemini-3.5-flash
TOPICS_GENERATION_MODEL=gemini-3.6-flash
IMAGE_GENERATION_MODEL=gemini-3.1-flash-lite-image
TTS_VOICE_MODEL=eleven_flash_v2_5

Expand All @@ -45,6 +45,7 @@ TTS_VOICE_MODEL=eleven_flash_v2_5
# claude-sonnet-4-6
# claude-haiku-4-5
# gemini-3.1-pro-preview
# gemini-3.6-flash
# gemini-3.5-flash
# gpt-5.5
# gpt-5.4-mini
Expand Down
2 changes: 1 addition & 1 deletion artificial_u/api/routers/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ def _coerce_int(value: Any) -> Optional[int]:
return None


def _job_link_path(
def _job_link_path( # noqa: C901
payload: Any, result: Any, factory=None, cache: Optional[dict] = None
) -> Optional[str]:
"""
Expand Down
4 changes: 2 additions & 2 deletions artificial_u/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ class Settings(BaseSettings):
# Professor generation model
PROFESSOR_GENERATION_MODEL: str = "gpt-5.4-nano"
# Topics generation model
TOPICS_GENERATION_MODEL: str = "gemini-3.5-flash"
TOPICS_GENERATION_MODEL: str = "gemini-3.6-flash"
# Course tags generation model
TAGS_GENERATION_MODEL: str = "gpt-5.4-nano"
# Image generation model
Expand Down Expand Up @@ -220,7 +220,7 @@ def set_default_model(cls, v, info):
if backend == "openai":
return "gpt-5.4-nano"
elif backend == "gemini":
return "gemini-3.5-flash"
return "gemini-3.6-flash"
elif backend == "anthropic":
return "claude-sonnet-4-6"
else:
Expand Down
43 changes: 34 additions & 9 deletions artificial_u/services/content_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,27 @@ def _is_gemini_3_plus(model: str) -> bool:
return bool(match) and int(match.group(1)) >= 3


def _deprecates_gemini_sampling_params(model: str) -> bool:
"""Check if a Gemini model deprecates sampling parameters (temperature, top_p, top_k).

Starting with Gemini 3.6 Flash and Gemini 3.5 Flash-Lite, sampling parameters
are deprecated and ignored (and will return a 400 error in future model generations).
"""
if not model or not model.startswith("gemini-"):
return False

if "gemini-3.5-flash-lite" in model:
return True

match = re.match(r"gemini-(\d+)(?:\.(\d+))?", model)
if match:
major = int(match.group(1))
minor = int(match.group(2)) if match.group(2) else 0
return (major, minor) >= (3, 6)

return False


class ContentService:
"""
Service for generating text content using various AI models/backends.
Expand Down Expand Up @@ -628,19 +649,23 @@ async def _generate_gemini(
# which in turn burns the output budget and truncates the response).
is_gemini_3_plus = _is_gemini_3_plus(model)

if temperature is not None:
effective_temperature = temperature
elif is_gemini_3_plus:
effective_temperature = 1.0
else:
effective_temperature = DEFAULT_TEMPERATURE

# Create generation config with all parameters including system_instruction
# Create generation config with max_output_tokens
config_params = {
"temperature": effective_temperature,
"max_output_tokens": max_tokens if max_tokens is not None else DEFAULT_MAX_TOKENS,
}

# Sampling parameters (temperature, top_p, top_k) are deprecated starting
# with Gemini 3.6 Flash and Gemini 3.5 Flash-Lite. Omit temperature for models
# that deprecate it to avoid errors or ignored parameter warnings.
if not _deprecates_gemini_sampling_params(model):
if temperature is not None:
effective_temperature = temperature
elif is_gemini_3_plus:
effective_temperature = 1.0
else:
effective_temperature = DEFAULT_TEMPERATURE
config_params["temperature"] = effective_temperature

# Control reasoning effort on models that support it (Gemini 3+). Using a
# lower thinking level reserves more of the output budget for the visible
# response and reduces the risk of MAX_TOKENS truncation.
Expand Down
2 changes: 1 addition & 1 deletion cdk/cdk/cdk_stack.py
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,7 @@ def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None:
"STORAGE_LECTURES_BUCKET": lectures_bucket.bucket_name,
"STORAGE_REGION": self.region,
"STORAGE_TYPE": "s3",
"TOPICS_GENERATION_MODEL": "gemini-3.5-flash",
"TOPICS_GENERATION_MODEL": "gemini-3.6-flash",
"TTS_VOICE_MODEL": "eleven_flash_v2_5",
# Database connection pool settings (conservative for db.t4g.small ~110 max_connections)
# These ensure the app uses a shared connection pool and doesn't exhaust RDS connections
Expand Down
4 changes: 2 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ LECTURE_GENERATION_MODEL=claude-sonnet-4-6
PROFESSOR_GENERATION_MODEL=gpt-5.4-nano

# Topics generation model
TOPICS_GENERATION_MODEL=gemini-3.5-flash
TOPICS_GENERATION_MODEL=gemini-3.6-flash

# Image generation model (gemini-3.1-flash-lite-image, gemini-3.1-flash-image, or gemini-3-pro-image)
IMAGE_GENERATION_MODEL=gemini-3.1-flash-lite-image
Expand Down Expand Up @@ -312,7 +312,7 @@ TESTING=true
| `DEPARTMENT_GENERATION_MODEL` | Model for department generation | `gpt-5.4-nano` | No |
| `LECTURE_GENERATION_MODEL` | Model for lecture generation | `claude-sonnet-4-6` | No |
| `LECTURE_SUMMARY_MODEL` | Model for lecture summary generation | `gpt-5.4-nano` | No |
| `TOPICS_GENERATION_MODEL` | Model for topics generation | `gemini-3.5-flash` | No |
| `TOPICS_GENERATION_MODEL` | Model for topics generation | `gemini-3.6-flash` | No |
| `PROFESSOR_GENERATION_MODEL` | Model for professor generation | `gpt-5.4-nano` | No |
| `IMAGE_GENERATION_MODEL` | Model for image generation (`gemini-3.1-flash-lite-image`, `gemini-3.1-flash-image`, `gemini-3-pro-image`) | `gemini-3.1-flash-lite-image` | No |
| `TTS_VOICE_MODEL` | Model for text-to-speech voice | `eleven_flash_v2_5` | No |
Expand Down
2 changes: 1 addition & 1 deletion mise.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[tools]
pnpm = "11.15.1"
node = "24"
pnpm = "latest"
python = "latest"
uv = "latest"
97 changes: 96 additions & 1 deletion tests/unit/test_content_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@

import pytest

from artificial_u.services.content_service import ContentService
from artificial_u.services.content_service import (
ContentService,
_deprecates_gemini_sampling_params,
)


def _build_service():
Expand Down Expand Up @@ -175,3 +178,95 @@ async def test_generate_anthropic_uses_sampling_params_for_sonnet_4_6(monkeypatc
call_kwargs = mock_client.messages.create.await_args.kwargs
assert "thinking" not in call_kwargs
assert call_kwargs["temperature"] == pytest.approx(0.3)


@pytest.mark.parametrize(
"model,expected",
[
("gemini-3.6-flash", True),
("gemini-3.5-flash-lite", True),
("gemini-3.5-flash", False),
("gemini-3.1-pro-preview", False),
("gemini-3.1-flash-lite", False),
("gemini-4.0-flash", True),
("claude-sonnet-4-6", False),
("", False),
],
)
def test_deprecates_gemini_sampling_params(model, expected):
assert _deprecates_gemini_sampling_params(model) is expected


@pytest.mark.asyncio
async def test_generate_gemini_omits_temperature_for_gemini_3_6_flash(monkeypatch):
service = _build_service()

part_mock = MagicMock()
part_mock.text = "hello from gemini"
part_mock.thought = False

candidate_mock = MagicMock()
candidate_mock.content.parts = [part_mock]

response_mock = MagicMock()
response_mock.candidates = [candidate_mock]

mock_client = MagicMock()
mock_client.aio.models.generate_content = AsyncMock(return_value=response_mock)
monkeypatch.setattr("artificial_u.services.content_service.gemini_client", mock_client)

result = await service._generate_gemini(
prompt="Test prompt",
model="gemini-3.6-flash",
system_prompt="Test system",
temperature=0.3,
max_tokens=2048,
prefill=None,
thinking_level="low",
)

assert result == "hello from gemini"
call_kwargs = mock_client.aio.models.generate_content.await_args.kwargs
config = call_kwargs["config"]
# Config should not have temperature set because gemini-3.6-flash deprecates sampling params
assert not hasattr(config, "temperature") or getattr(config, "temperature", None) is None
assert config.max_output_tokens == 2048
assert (
getattr(
config.thinking_config.thinking_level, "value", config.thinking_config.thinking_level
).lower()
== "low"
)


@pytest.mark.asyncio
async def test_generate_gemini_includes_temperature_for_gemini_3_5_flash(monkeypatch):
service = _build_service()

part_mock = MagicMock()
part_mock.text = "hello from gemini 3.5"
part_mock.thought = False

candidate_mock = MagicMock()
candidate_mock.content.parts = [part_mock]

response_mock = MagicMock()
response_mock.candidates = [candidate_mock]

mock_client = MagicMock()
mock_client.aio.models.generate_content = AsyncMock(return_value=response_mock)
monkeypatch.setattr("artificial_u.services.content_service.gemini_client", mock_client)

result = await service._generate_gemini(
prompt="Test prompt",
model="gemini-3.5-flash",
system_prompt="Test system",
temperature=None,
max_tokens=2048,
prefill=None,
)

assert result == "hello from gemini 3.5"
call_kwargs = mock_client.aio.models.generate_content.await_args.kwargs
config = call_kwargs["config"]
assert config.temperature == 1.0
Loading
Loading