Skip to content

Commit 51c66e7

Browse files
Dage 16: response validation Schemas (#171)
* Minor fixes / improvements * Used simple data structures to perform validation of LLM responses in the LLM Service * Explored Pydantic, decided simple dict / lists / custom objetcts
1 parent d08bee8 commit 51c66e7

7 files changed

Lines changed: 258 additions & 74 deletions

File tree

Lines changed: 38 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
1+
import json
12
from json import JSONDecodeError
2-
from typing import List, Union
33

44
from langchain_core.language_models import BaseChatModel
55
from langchain_core.messages import HumanMessage, SystemMessage
6-
import json
6+
from src.model.query_response import LLMQueryResponse
7+
from src.model.score_response import LLMScoreResponse
78
import logging
89

910
from src.model.document import Document
10-
from typing import Union, List
1111

1212
log = logging.getLogger(__name__)
1313

@@ -16,7 +16,7 @@ class LLMService:
1616
def __init__(self, chat_model: BaseChatModel):
1717
self.chat_model = chat_model
1818

19-
def generate_queries(self, document: Document, num_queries_generate_per_doc: int) -> Union[List[str], str]:
19+
def generate_queries(self, document: Document, num_queries_generate_per_doc: int) -> LLMQueryResponse:
2020
"""
2121
Generate queries based on the given document and num_queries_generate_per_doc and
2222
Returns a list of generated queries or just a generated string in case of LLM hallucination
@@ -35,32 +35,35 @@ def generate_queries(self, document: Document, num_queries_generate_per_doc: int
3535
HumanMessage(content=f"Document:\n{doc_json}")
3636
]
3737

38-
raw = self.chat_model.invoke(messages).content.strip()
38+
# The response from invoke is an AIMessage object which contains all the needed info
39+
response = self.chat_model.invoke(messages)
3940

40-
# in case LLM hallucinates
4141
try:
42-
return json.loads(raw)
43-
except JSONDecodeError:
44-
log.warning("LLM hallucinated and its response: %s", raw)
45-
return raw
42+
output = LLMQueryResponse(response_content=response.content)
43+
except (KeyError, JSONDecodeError, ValueError) as e:
44+
log.warning(f"LLM unexpected response. Raw output: {response.content}")
45+
raise ValueError(f"Invalid LLM response: {e}")
4646

47-
def generate_score(self, document: Document, query: str, relevance_scale: str) -> int:
47+
return output
48+
49+
50+
def generate_score(self, document: Document, query: str, relevance_scale: str) -> LLMScoreResponse:
4851
"""
4952
Generates a relevance score for a given document-query pair using a specified relevance scale.
5053
"""
5154
if relevance_scale == "binary":
52-
scale = {0, 1}
53-
description = (" - 0: the query is NOT relevant to the given document"
54-
" - 1: the query is relevant to the given document")
55+
allowed = {0, 1}
56+
description = (" - 0: the query is NOT relevant to the given document\n"
57+
" - 1: the query is relevant to the given document")
5558
elif relevance_scale == "graded":
56-
scale = {0, 1, 2}
57-
description = (" - 0: the query is NOT relevant to the given document"
58-
" - 1: the query may be relevant to the given document"
59-
" - 2: the document proposed is the answer to the query")
59+
allowed = {0, 1, 2}
60+
description = (" - 0: the query is NOT relevant to the given document\n"
61+
" - 1: the query may be relevant to the given document\n"
62+
" - 2: the document proposed is the answer to the query")
6063
else:
61-
error_msg = "The relevance scale must be either 'binary' or 'graded'"
62-
log.error(error_msg)
63-
raise ValueError(error_msg)
64+
msg = f"Invalid relevance scale: {relevance_scale}"
65+
log.error(msg)
66+
raise ValueError(msg)
6467

6568
messages = [
6669
SystemMessage(
@@ -76,16 +79,19 @@ def generate_score(self, document: Document, query: str, relevance_scale: str) -
7679
)
7780
]
7881

79-
#response = self.chat_model.with_structured_output(method="json_mode").invoke(messages)
8082
raw = self.chat_model.invoke(messages).content.strip()
83+
8184
try:
82-
response = int(json.loads(raw)['score'])
83-
if response not in scale:
84-
error_msg = f"LLM hallucinated the value of the scale. Returned: {response}"
85-
log.warning(error_msg)
86-
raise ValueError(error_msg)
87-
return response
88-
except JSONDecodeError:
89-
error_msg = f"LLM hallucinated and its response: {raw}"
90-
log.warning(error_msg)
91-
raise ValueError(error_msg)
85+
score = json.loads(raw)['score']
86+
except (JSONDecodeError, KeyError) as e:
87+
log.debug(f"LLM unexpected response. Raw output: {raw}")
88+
raise ValueError(f"Invalid LLM response: {e}")
89+
90+
try:
91+
parsed = LLMScoreResponse(score=score, scale=relevance_scale)
92+
return parsed
93+
except ValueError as e:
94+
log.warning(f"Validation error for score '{score}' on scale '{relevance_scale}': {e}")
95+
raise e
96+
97+
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import json
2+
from typing import List
3+
4+
class LLMQueryResponse:
5+
"""
6+
Parses and validates a JSON response containing a list of queries.
7+
"""
8+
_queries: List[str]
9+
10+
def __init__(self, response_content: str):
11+
"""
12+
Initializes the object by parsing and validating the JSON string.
13+
14+
Args:
15+
response_content: A string containing a JSON-encoded list of strings.
16+
17+
Raises:
18+
ValueError: If the content is not a valid JSON list of non-empty strings.
19+
"""
20+
try:
21+
parsed_content = json.loads(response_content)
22+
except json.JSONDecodeError as e:
23+
raise ValueError(f"Invalid JSON in `response_content`: {e}")
24+
25+
if not isinstance(parsed_content, list):
26+
raise ValueError("`response_content` must be a JSON list.")
27+
28+
if not all(isinstance(item, str) for item in parsed_content):
29+
raise ValueError("All items in `response_content` must be strings.")
30+
31+
if any(not item.strip() for item in parsed_content):
32+
raise ValueError("Items in `response_content` must not be empty or only whitespace.")
33+
34+
self._queries = parsed_content
35+
36+
def get_queries(self) -> List[str]:
37+
"""
38+
Returns the validated list of queries.
39+
"""
40+
return self._queries
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
class LLMScoreResponse:
2+
"""
3+
Parses and validates an LLM score response.
4+
"""
5+
def __init__(self, score: int, scale: str = "graded"):
6+
"""
7+
Initializes the object by validating the score.
8+
9+
Args:
10+
score: The relevance score.
11+
scale: The relevance scale, either 'binary' (0-1) or 'graded' (0-2).
12+
13+
Raises:
14+
ValueError: If the score is not valid for the given scale.
15+
"""
16+
if scale not in ["binary", "graded"]:
17+
raise ValueError(f"Invalid scale: {scale}. Must be 'binary' or 'graded'.")
18+
19+
if scale == "binary" and score not in {0, 1}:
20+
raise ValueError(f"Score must be 0 or 1 for binary scale, got {score}")
21+
elif scale == "graded" and score not in {0, 1, 2}:
22+
raise ValueError(f"Score must be 0, 1, or 2 for graded scale, got {score}")
23+
24+
self.score = score
25+
26+
def get_score(self) -> int:
27+
"""
28+
Returns the validated score.
29+
30+
Returns:
31+
The validated score.
32+
"""
33+
return self.score

rre-dataset-generator/tests/unit/llm/__init__.py

Whitespace-only changes.
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import pytest
2+
from langchain_core.language_models.fake_chat_models import FakeListChatModel
3+
from src.llm.llm_service import LLMService
4+
from src.model.document import Document
5+
from src.model.query_response import LLMQueryResponse
6+
7+
8+
@pytest.fixture
9+
def example_doc():
10+
return Document(
11+
id="doc1",
12+
fields={
13+
"title": "Car of the Year",
14+
"description": "The Toyota Camry, the nation's most popular car has now been rated as its best new model."
15+
}
16+
)
17+
18+
19+
def test_llm_service_generate_queries_EXPECTED_valid(example_doc):
20+
fake_llm = FakeListChatModel(responses=['["Toyota", "Best Car"]'])
21+
service = LLMService(chat_model=fake_llm)
22+
response = service.generate_queries(example_doc, 2)
23+
24+
assert isinstance(response, LLMQueryResponse)
25+
assert response.get_queries() == ["Toyota", "Best Car"]
26+
27+
28+
def test_llm_service_generate_queries_EXPECTED_empty_list(example_doc):
29+
fake_llm = FakeListChatModel(responses=['[]'])
30+
service = LLMService(chat_model=fake_llm)
31+
response = service.generate_queries(example_doc, 0)
32+
assert response.get_queries() == []
33+
34+
35+
@pytest.mark.parametrize("invalid_response, expected_error", [
36+
('not a json', "Invalid JSON in `response_content`"),
37+
('["", " ", "Valid"]', "must not be empty or only whitespace"),
38+
('["Good", 123, null]', "must be strings"),
39+
])
40+
def test_llm_service_generate_queries_with_invalid_responses_EXPECTED_error(invalid_response, expected_error, example_doc):
41+
fake_llm = FakeListChatModel(responses=[invalid_response])
42+
service = LLMService(chat_model=fake_llm)
43+
with pytest.raises(ValueError, match=expected_error):
44+
service.generate_queries(example_doc, 3)
45+
46+
47+
def test_generate_queries_with_unicode_strings_EXPECTED_list_of_unicode_strings(example_doc):
48+
unicode_list = '["こんにちは", "你好", "¡Hola!"]'
49+
fake_llm = FakeListChatModel(responses=[unicode_list])
50+
service = LLMService(chat_model=fake_llm)
51+
response = service.generate_queries(example_doc, 3)
52+
assert response.get_queries() == ["こんにちは", "你好", "¡Hola!"]
53+
54+
55+
def test_generate_queries_with_leading_trailing_whitespace_EXPECTED_strings_preserved(example_doc):
56+
list_with_whitespace = '[" hello ", " world "]'
57+
fake_llm = FakeListChatModel(responses=[list_with_whitespace])
58+
service = LLMService(chat_model=fake_llm)
59+
response = service.generate_queries(example_doc, 2)
60+
assert response.get_queries() == [" hello ", " world "]
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import pytest
2+
import json
3+
from langchain_core.language_models.fake_chat_models import FakeListChatModel
4+
from src.llm.llm_service import LLMService
5+
from src.model.document import Document
6+
from src.model.score_response import LLMScoreResponse
7+
from src.model.query_response import LLMQueryResponse
8+
9+
10+
@pytest.fixture
11+
def example_doc():
12+
return Document(
13+
id="doc1",
14+
fields={
15+
"title": "Car of the Year",
16+
"description": "The Toyota Camry, the nation's most popular car has now been rated as its best new model."
17+
}
18+
)
19+
20+
21+
@pytest.mark.parametrize("scale, valid_score", [
22+
("binary", 0),
23+
("binary", 1),
24+
("graded", 0),
25+
("graded", 1),
26+
("graded", 2),
27+
])
28+
def test_generate_score_with_valid_scale_EXPECTED_integer_score(scale, valid_score, example_doc):
29+
fake_llm = FakeListChatModel(responses=[f'{{"score": {valid_score}}}'])
30+
service = LLMService(chat_model=fake_llm)
31+
query = "Is a Toyota the car of the year?"
32+
response = service.generate_score(example_doc, query, relevance_scale=scale)
33+
assert isinstance(response, LLMScoreResponse)
34+
assert response.get_score() == valid_score
35+
36+
37+
@pytest.mark.parametrize("scale, response_json, expected_error", [
38+
# Binary scale errors
39+
('binary', 'not a json', 'Invalid LLM response'),
40+
('binary', '{"not_score": 1}', 'Invalid LLM response'),
41+
('binary', '{"score": "one"}', 'Score must be 0 or 1 for binary scale, got one'),
42+
('binary', '{"score": 3}', 'Score must be 0 or 1 for binary scale, got 3'),
43+
# Graded scale errors
44+
('graded', '{"score": -1}', 'Score must be 0, 1, or 2 for graded scale, got -1'),
45+
('graded', '{"score": 1.5}', 'Score must be 0, 1, or 2 for graded scale, got 1.5'),
46+
('graded', '{"score": null}', 'Score must be 0, 1, or 2 for graded scale, got None'),
47+
])
48+
def test_generate_score_with_invalid_llm_responses_EXPECTED_value_error(scale, response_json, expected_error, example_doc):
49+
fake_llm = FakeListChatModel(responses=[response_json])
50+
service = LLMService(chat_model=fake_llm)
51+
query = "Is a Toyota the car of the year?"
52+
with pytest.raises(ValueError, match=expected_error):
53+
service.generate_score(example_doc, query, relevance_scale=scale)
54+
55+
56+
def test_generate_score_with_invalid_relevance_scale_EXPECTED_value_error(example_doc):
57+
fake_llm = FakeListChatModel(responses=['{"score": 1}'])
58+
service = LLMService(chat_model=fake_llm)
59+
query = "What car won?"
60+
with pytest.raises(ValueError, match="Invalid relevance scale"):
61+
service.generate_score(example_doc, query, relevance_scale='fuzzy')

0 commit comments

Comments
 (0)