Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
127 commits
Select commit Hold shift + click to select a range
84eadf5
codex generated changes, need to review and refactor
samuelvkwong Nov 3, 2025
f39195d
forgotten files to stage
samuelvkwong Nov 3, 2025
087e8b1
Adding downloads view
NumericalAdvantage Nov 6, 2025
a4a4cb2
Dont download report text
NumericalAdvantage Nov 7, 2025
ce4091c
adding tests
NumericalAdvantage Nov 7, 2025
395cad6
linter error
NumericalAdvantage Nov 7, 2025
5a85dd5
add test cases where there are no instances or extraction result has …
NumericalAdvantage Nov 7, 2025
96c500a
standardize boolean values to yes/no
NumericalAdvantage Nov 7, 2025
03e1841
button colrs
NumericalAdvantage Nov 7, 2025
7fbe664
Merge branch 'main' into subscription_extract
samuelvkwong Nov 10, 2025
c631098
Merge branch 'main' into subscription_extract
samuelvkwong Nov 10, 2025
9b2b368
change models
samuelvkwong Nov 10, 2025
5d0cc4e
remove previous migration files
samuelvkwong Nov 10, 2025
78b6c3b
fix lint errors
samuelvkwong Nov 10, 2025
204ff4c
form fixes
samuelvkwong Nov 10, 2025
b5add39
Adding Selection output type to the existing types for extracting output
NumericalAdvantage Nov 11, 2025
fdf5cde
fixed subscription tests with new models
samuelvkwong Nov 11, 2025
3b28ce4
Add tests for Selection form added via Alpine
NumericalAdvantage Nov 14, 2025
7a2dcdb
Merge branch 'main' into subscription_extract
samuelvkwong Nov 17, 2025
6292d1d
added unit test for subscriptions that use the LLM to filter/extract
samuelvkwong Nov 17, 2025
197faca
match font size of legend to other elements
samuelvkwong Nov 17, 2025
838cf79
fix filter question form validation
samuelvkwong Nov 17, 2025
b602c5f
fix dependency cycle
samuelvkwong Nov 17, 2025
7410f9d
fix files that use the new renamed output_fields
samuelvkwong Nov 17, 2025
df06a76
complete renaming of output field
samuelvkwong Nov 18, 2025
80b8293
missed one renaming, maybe use generic fk for
samuelvkwong Nov 18, 2025
def36f1
remove unnecessary iter
samuelvkwong Nov 18, 2025
da048fb
Allow middleware to detect locale from users browser (via Accept-Lang…
NumericalAdvantage Nov 19, 2025
eb08299
readd provider form field and use procrastinate_on_delete_sql
samuelvkwong Nov 19, 2025
018d8ae
small fixes
samuelvkwong Nov 19, 2025
e7e6c7c
stronger form validation
samuelvkwong Nov 19, 2025
a67fe07
fix filter logic
samuelvkwong Nov 19, 2025
ed99495
rework pydantic model generation
samuelvkwong Nov 19, 2025
362bfea
add more tests for subscription processor
samuelvkwong Nov 19, 2025
d839e58
remove redundant db query
samuelvkwong Nov 19, 2025
2fc97d7
readd provider validation
samuelvkwong Nov 19, 2025
d726ea9
display extraction results in subscription inbox
samuelvkwong Nov 20, 2025
1cbe27a
small refactors: remove has_changed, add logger, fix date for migration
samuelvkwong Nov 20, 2025
8ebae98
resolve n+1 query during processing, readd empty form validator
samuelvkwong Nov 20, 2025
e72da19
prefetch filterquestions
samuelvkwong Nov 20, 2025
6ef403a
early exit if expected_answer does not match
samuelvkwong Nov 20, 2025
d44453d
remove duplicate getter
samuelvkwong Nov 20, 2025
69712f0
Adding option to select "array type" for the Output Field.
NumericalAdvantage Nov 25, 2025
7b58df9
prevent accidental Enter from submitting the form
NumericalAdvantage Nov 25, 2025
db1aa5a
fixing ruff errors
NumericalAdvantage Nov 25, 2025
1f3b929
move expand button above extracted fields
samuelvkwong Nov 26, 2025
6b426a2
Disallow duplicate Selection Options, add test cases for the same and…
NumericalAdvantage Nov 26, 2025
bed99fa
Revert "Allow middleware to detect locale from users browser (via Acc…
NumericalAdvantage Nov 26, 2025
1c13a0a
pagination, sorting and filtering added to inbox view
samuelvkwong Nov 26, 2025
9ac799b
fix unused variables in test
samuelvkwong Nov 26, 2025
323e35a
resolve invariant type checker error
samuelvkwong Nov 27, 2025
2187090
linter error
NumericalAdvantage Nov 27, 2025
7f52438
remove the magic number 7
NumericalAdvantage Nov 27, 2025
1267257
add notification for new subscribed reports
samuelvkwong Nov 28, 2025
0fe6c3e
Merge branch 'main' into subscription_extract
samuelvkwong Dec 4, 2025
57b1cd9
reorder migrations from merge
samuelvkwong Dec 4, 2025
c4146a1
reorder subscription migrations
samuelvkwong Dec 4, 2025
14656e4
migration fix
samuelvkwong Dec 4, 2025
80a3ede
testing with original adit-radit-shared
samuelvkwong Dec 4, 2025
9a9500b
Add selection output type with array support
NumericalAdvantage Dec 5, 2025
a905914
handler based approach for starting subscription jobs
samuelvkwong Dec 8, 2025
b6765da
Merge branch 'main' into subscription_extract
samuelvkwong Dec 9, 2025
cddd408
Merge branch 'subscription_extract' into subscription_handler
samuelvkwong Dec 9, 2025
1c8db1c
move necessary imports inside register reports handler
samuelvkwong Dec 9, 2025
417cee1
generate query from extraction fields
samuelvkwong Dec 10, 2025
34a0cbc
generate query after form validation
samuelvkwong Dec 10, 2025
b712854
ask user for query if llm generation fails
samuelvkwong Dec 11, 2025
b367820
remove keyword fallback
samuelvkwong Dec 11, 2025
bca5fad
remove provider field from subscription detail template
samuelvkwong Dec 11, 2025
3fe6fd6
resolve n+1 queries for extractions and subscriptions
samuelvkwong Dec 11, 2025
5334420
narrow exception handling
samuelvkwong Dec 11, 2025
f9d04e0
Merge branch 'selectionOutputType' into subscription_extract
samuelvkwong Dec 11, 2025
3ad21a2
remove optional from outputfield
samuelvkwong Dec 12, 2025
645f64d
update extraction feature in subscriptions with new changes from ritwik
samuelvkwong Dec 12, 2025
879ac15
removed query from subscriptions form
samuelvkwong Dec 12, 2025
cde605b
Merge remote-tracking branch 'origin/extractions' into subscription_e…
samuelvkwong Dec 12, 2025
ea639a4
add extractions export to subscription feature
samuelvkwong Dec 12, 2025
7eb365f
Fixing form load without hidden variables error
NumericalAdvantage Dec 12, 2025
a228012
Merge remote-tracking branch 'origin/old-historySelectionOutput' into…
samuelvkwong Dec 14, 2025
ec413ad
Merge remote-tracking branch 'origin/subscription_handler' into subsc…
samuelvkwong Dec 14, 2025
e485427
Merge remote-tracking branch 'origin/query_generation' into subscript…
samuelvkwong Dec 14, 2025
8926d04
lint fix
samuelvkwong Dec 14, 2025
3d0bb32
fix attribute error for extractionjobfactory
samuelvkwong Dec 14, 2025
9235cd9
fix migration order and duplicate migration
samuelvkwong Dec 14, 2025
10c5ed0
fix remaining merge conflicts
samuelvkwong Dec 15, 2025
77208b3
constrain autobahn (daphne dependency) to <25.11.1
samuelvkwong Dec 15, 2025
335bb80
Merge remote-tracking branch 'origin/autobahn_version_constraint' int…
samuelvkwong Dec 15, 2025
9903b85
fix merge issues
samuelvkwong Dec 15, 2025
85fc8bd
fix syntax error
samuelvkwong Dec 15, 2025
0feb599
Merge branch 'subscription_extract' into query_generation
samuelvkwong Dec 15, 2025
775b9c2
swap search and extraction step in extractionjobwizard
samuelvkwong Dec 16, 2025
1f07595
fix migration order
samuelvkwong Dec 16, 2025
b378d9b
live update report count and search link
samuelvkwong Dec 16, 2025
edb7adc
move live update below query and fix get request for the live update
samuelvkwong Dec 21, 2025
e427b1c
Merge branch 'main' into subscription_demo
samuelvkwong Dec 21, 2025
0b77d3c
Merge branch 'main' into query_generation
samuelvkwong Dec 21, 2025
25c21b8
Merge branch 'query_generation' into subscription_demo
samuelvkwong Dec 21, 2025
61224b9
fix query generator and selection options tests
samuelvkwong Dec 22, 2025
91fa5ad
move selection options js/css from core to extractions. remove extrac…
samuelvkwong Dec 30, 2025
8e916fc
add error handling to chat client, remove optional from query field, …
samuelvkwong Dec 30, 2025
e453cad
use try finally for closing db connection in threads
samuelvkwong Dec 30, 2025
54a2273
lint fix
samuelvkwong Dec 30, 2025
0a4e8c8
remove error handling code smell, add transaction atomicity to subscr…
samuelvkwong Dec 30, 2025
86539a1
add auto escape to protect against xss attacks
samuelvkwong Dec 30, 2025
4e4d1ea
prefetch pks to avoid n+1 query during csv export
samuelvkwong Dec 30, 2025
d70204b
Merge branch 'main' into subscription_demo
samuelvkwong Dec 30, 2025
566924f
made query generation async
samuelvkwong Dec 30, 2025
bb70ad4
Remove duplicate OutputFieldForm from subscriptions, import from extr…
samuelvkwong Jan 5, 2026
42faad0
refactor search, extractions, subscriptions forms
samuelvkwong Jan 6, 2026
b7d126d
reduce code duplication in chat_client and in validating selection op…
samuelvkwong Jan 7, 2026
8e26e4c
Revert subscription handling from event-driven back to periodic cron
samuelvkwong Jan 18, 2026
a4d569d
Subscriptions run every hour with de-duplication
samuelvkwong Jan 19, 2026
4a926a5
Fix ChoiceField empty_label handling in create_language_field
samuelvkwong Jan 20, 2026
5f09a15
remove unused subscription handlers
samuelvkwong Jan 20, 2026
be01bd9
Let Django use the prefetched cache of the table by calling all() on …
NumericalAdvantage Jan 20, 2026
d493c6e
Fix code quality issues from PR review
samuelvkwong Feb 11, 2026
76d713f
Merge branch 'main' into subscription_demo
samuelvkwong Feb 11, 2026
d7a58db
update language form field factory function
samuelvkwong Feb 11, 2026
1dea313
Address code review feedback for PR #172
samuelvkwong Feb 12, 2026
b5e4948
Add missing Previous Step button to extraction wizard Step 2
samuelvkwong Feb 16, 2026
5a465b3
Fix Previous Step button validation in extraction wizard Step 2
samuelvkwong Feb 16, 2026
b267323
Merge branch 'main' into subscription_demo
samuelvkwong Feb 27, 2026
bc95cf5
Fix ThreadPoolExecutor error handling and subscription refresh timing
samuelvkwong Feb 27, 2026
21a9ba9
Merge remote-tracking branch 'origin/subscription_demo' into subscrip…
samuelvkwong Feb 27, 2026
827fcdb
Fix invalid query kwarg in subscription task test
samuelvkwong Feb 27, 2026
5c4e6d5
Fix incorrect assertions using `and` with `not in`
samuelvkwong Feb 27, 2026
9c3ab2d
Add Playwright acceptance tests for extractions and subscriptions
samuelvkwong Feb 27, 2026
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
166 changes: 142 additions & 24 deletions radis/chats/utils/chat_client.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import logging
from typing import Iterable
from typing import Iterable, NoReturn

import openai
from django.conf import settings
Expand All @@ -16,53 +16,171 @@ def _get_base_url() -> str:
return base_url


class AsyncChatClient:
def _validate_completion_response(completion) -> str:
"""
Validates that the LLM completion response contains valid content.

Args:
completion: The completion response from the LLM

Returns:
The message content as a string

Raises:
ValueError: If the response is empty or invalid
"""
if not completion.choices:
logger.error("LLM returned empty choices list")
raise ValueError("LLM returned no response choices")

answer = completion.choices[0].message.content
if answer is None:
logger.error("LLM returned None for message content")
raise ValueError("LLM returned empty response content")

return answer


def _validate_parsed_response(completion) -> BaseModel:
"""
Validates that the LLM completion response contains valid parsed data.

Args:
completion: The completion response from the LLM

Returns:
The parsed BaseModel instance

Raises:
ValueError: If the response is empty or invalid
"""
if not completion.choices:
logger.error("LLM returned empty choices list")
raise ValueError("LLM returned no response choices")

parsed = completion.choices[0].message.parsed
if parsed is None:
logger.error("LLM returned None for parsed message")
raise ValueError("LLM returned empty parsed response")

return parsed


def _handle_api_error(error: openai.APIError, operation: str) -> NoReturn:
"""
Logs and re-raises API errors with consistent error messages.

Args:
error: The API error from OpenAI
operation: Description of the operation that failed (e.g., "chat", "data extraction")

Raises:
RuntimeError: Always raises with a user-friendly error message
"""
logger.error(f"OpenAI API error during {operation}: {error}")
raise RuntimeError(f"Failed to communicate with LLM service: {error}") from error


class _BaseChatClient:
"""Base class containing shared chat client logic."""

def __init__(self):
base_url = _get_base_url()
api_key = settings.EXTERNAL_LLM_PROVIDER_API_KEY
self._client = openai.AsyncOpenAI(base_url=base_url, api_key=api_key)
self._base_url = _get_base_url()
self._api_key = settings.EXTERNAL_LLM_PROVIDER_API_KEY
self._model_name = settings.LLM_MODEL_NAME

async def chat(
def _build_chat_request(
self,
messages: Iterable[ChatCompletionMessageParam],
max_completion_tokens: int | None = None,
) -> str:
logger.debug(f"Sending messages to LLM for chat:\n{messages}")

) -> dict:
"""Build the request dictionary for chat completion."""
request = {
"model": self._model_name,
"messages": messages,
}
if max_completion_tokens is not None:
request["max_completion_tokens"] = max_completion_tokens
return request

completion = await self._client.chat.completions.create(**request)
answer = completion.choices[0].message.content
assert answer is not None
def _log_request(self, messages: Iterable[ChatCompletionMessageParam]) -> None:
"""Log the outgoing request."""
logger.debug(f"Sending messages to LLM for chat:\n{messages}")

def _log_response(self, answer: str) -> None:
"""Log the response from LLM."""
logger.debug("Received from LLM: %s", answer)


class AsyncChatClient(_BaseChatClient):
def __init__(self):
super().__init__()
self._client = openai.AsyncOpenAI(base_url=self._base_url, api_key=self._api_key)

async def chat(
self,
messages: Iterable[ChatCompletionMessageParam],
max_completion_tokens: int | None = None,
) -> str:
self._log_request(messages)
request = self._build_chat_request(messages, max_completion_tokens)

try:
completion = await self._client.chat.completions.create(**request)
except openai.APIError as e:
_handle_api_error(e, "chat")

answer = _validate_completion_response(completion)
self._log_response(answer)
return answer


class ChatClient:
class ChatClient(_BaseChatClient):
def __init__(self) -> None:
base_url = _get_base_url()
api_key = settings.EXTERNAL_LLM_PROVIDER_API_KEY
super().__init__()
self._client = openai.OpenAI(base_url=self._base_url, api_key=self._api_key)

def chat(
self,
messages: Iterable[ChatCompletionMessageParam],
max_completion_tokens: int | None = None,
) -> str:
"""
Send messages to LLM and return the response text.

self._client = openai.OpenAI(base_url=base_url, api_key=api_key)
self._llm_model_name = settings.LLM_MODEL_NAME
Args:
messages: List of message dictionaries with 'role' and 'content'
max_completion_tokens: Optional maximum tokens to generate

Returns:
The LLM's response as a string
"""
self._log_request(messages)
request = self._build_chat_request(messages, max_completion_tokens)

try:
completion = self._client.chat.completions.create(**request)
except openai.APIError as e:
_handle_api_error(e, "chat")

answer = _validate_completion_response(completion)
self._log_response(answer)
return answer
Comment thread
samuelvkwong marked this conversation as resolved.

def extract_data(self, prompt: str, schema: type[BaseModel]) -> BaseModel:
logger.debug("Sending prompt and schema to LLM to extract data.")
logger.debug("Prompt:\n%s", prompt)
logger.debug("Schema:\n%s", schema.model_json_schema())

completion = self._client.beta.chat.completions.parse(
model=self._llm_model_name,
messages=[{"role": "system", "content": prompt}],
response_format=schema,
)
event = completion.choices[0].message.parsed
assert event
try:
completion = self._client.beta.chat.completions.parse(
model=self._model_name,
messages=[{"role": "system", "content": prompt}],
response_format=schema,
)
except openai.APIError as e:
_handle_api_error(e, "data extraction")

event = _validate_parsed_response(completion)
logger.debug("Received from LLM: %s", event)
return event
4 changes: 4 additions & 0 deletions radis/core/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,7 @@
"de": "German",
"en": "English",
}

MIN_AGE = 0
MAX_AGE = 120
AGE_STEP = 10
179 changes: 179 additions & 0 deletions radis/core/form_fields.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
"""
Reusable form field factories for RADIS forms.

This module provides factory functions for commonly used form fields
to reduce duplication across the codebase.
"""

from typing import Literal, overload

from django import forms

from radis.core.constants import AGE_STEP, LANGUAGE_LABELS, MAX_AGE, MIN_AGE
from radis.reports.models import Language, Modality


@overload
def create_language_field(
required: bool = False,
empty_label: str | None = None,
use_pk: Literal[True] = True,
) -> forms.ModelChoiceField: ...


@overload
def create_language_field(
required: bool = False,
empty_label: str | None = None,
use_pk: Literal[False] = False,
) -> forms.ChoiceField: ...


def create_language_field(
required: bool = False,
empty_label: str | None = None,
use_pk: bool = True,
) -> forms.ModelChoiceField | forms.ChoiceField:
"""
Create a language choice field with consistent configuration.

Args:
required: Whether the field is required
empty_label: Label for empty option (None = no empty option)
use_pk: If True, returns ModelChoiceField with Language objects;
if False, returns ChoiceField with code strings

Returns:
ModelChoiceField (if use_pk=True) or ChoiceField (if use_pk=False)

Example:
# For extraction forms (uses ModelChoiceField, returns Language objects)
self.fields["language"] = create_language_field()

# For subscription forms (uses ModelChoiceField, allows "All")
self.fields["language"] = create_language_field(empty_label="All")

# For search forms (uses ChoiceField with codes)
self.fields["language"] = create_language_field(use_pk=False)
"""
languages = Language.objects.order_by("code")

if use_pk:
# Return ModelChoiceField - cleaned_data will contain Language objects
field = forms.ModelChoiceField(
queryset=languages,
required=required,
empty_label=empty_label,
)
field.label_from_instance = ( # type: ignore[method-assign]
lambda obj: LANGUAGE_LABELS.get(obj.code, obj.code)
)
return field
else:
# Return ChoiceField - cleaned_data will contain code strings
choices = [(lang.code, LANGUAGE_LABELS.get(lang.code, lang.code)) for lang in languages]
if empty_label is not None:
choices.insert(0, ("", empty_label))
field = forms.ChoiceField(required=required, choices=choices)
return field


@overload
def create_modality_field(
required: bool = False,
widget_size: int = 6,
use_pk: Literal[True] = True,
) -> forms.ModelMultipleChoiceField: ...


@overload
def create_modality_field(
required: bool = False,
widget_size: int = 6,
use_pk: Literal[False] = False,
) -> forms.MultipleChoiceField: ...


def create_modality_field(
required: bool = False,
widget_size: int = 6,
use_pk: bool = True,
) -> forms.ModelMultipleChoiceField | forms.MultipleChoiceField:
"""
Create a modality multiple choice field with consistent configuration.

Args:
required: Whether the field is required
widget_size: Height of the select widget
use_pk: If True, returns ModelMultipleChoiceField with Modality objects;
if False, returns MultipleChoiceField with code strings

Returns:
ModelMultipleChoiceField (if use_pk=True) or MultipleChoiceField (if use_pk=False)

Example:
# For extraction forms (uses ModelMultipleChoiceField, returns Modality objects)
self.fields["modalities"] = create_modality_field()

# For search forms (uses MultipleChoiceField with codes)
self.fields["modalities"] = create_modality_field(use_pk=False)
"""
modalities = Modality.objects.filter(filterable=True).order_by("code")

if use_pk:
# Return ModelMultipleChoiceField - cleaned_data will contain Modality objects
field = forms.ModelMultipleChoiceField(
queryset=modalities,
required=required,
)
# Display just the code for each modality
field.label_from_instance = lambda obj: obj.code
field.widget.attrs["size"] = widget_size
return field
else:
# Return MultipleChoiceField - cleaned_data will contain code strings
field = forms.MultipleChoiceField(required=required)
field.choices = [(mod.code, mod.code) for mod in modalities]
field.widget.attrs["size"] = widget_size
return field


def create_age_range_fields() -> tuple[forms.IntegerField, forms.IntegerField]:
"""
Create age_from and age_till fields with consistent configuration.

Returns:
Tuple of (age_from_field, age_till_field)

Example:
age_from, age_till = create_age_range_fields()
self.fields["age_from"] = age_from
self.fields["age_till"] = age_till
"""
age_from = forms.IntegerField(
required=False,
min_value=MIN_AGE,
max_value=MAX_AGE,
widget=forms.NumberInput(
attrs={
"type": "range",
"step": AGE_STEP,
"value": MIN_AGE,
}
),
)

age_till = forms.IntegerField(
required=False,
min_value=MIN_AGE,
max_value=MAX_AGE,
widget=forms.NumberInput(
attrs={
"type": "range",
"step": AGE_STEP,
"value": MAX_AGE,
}
),
)

return age_from, age_till
Loading
Loading