@@ -857,3 +857,244 @@ def passthrough(d: dict) -> dict:
857857 assert "JSON object" in str (exc_info .value )
858858 assert exc_info .value .provider is LLMProvider .GOOGLE
859859 assert exc_info .value .model == "gemini-2.0-flash"
860+
861+
862+ # ---------------------------------------------------------------------------
863+ # execute_prompt_chain_step — protocol path (llm_provider injected)
864+ # ---------------------------------------------------------------------------
865+
866+
867+ from learning_commons_evaluators .schemas .llm_provider import LLMResponse # noqa: E402
868+
869+
870+ def _make_adapter (
871+ content : str ,
872+ model : str = "test-model" ,
873+ input_tokens : int | None = 10 ,
874+ output_tokens : int | None = 5 ,
875+ ) -> AsyncMock :
876+ """Minimal mock that satisfies LLMGeneratorProtocol.generate()."""
877+ adapter = AsyncMock ()
878+ adapter .generate = AsyncMock (
879+ return_value = LLMResponse (
880+ content = content , model = model , input_tokens = input_tokens , output_tokens = output_tokens
881+ )
882+ )
883+ return adapter
884+
885+
886+ _PROTO_SETTINGS = PromptSettings (
887+ provider_type = LLMProvider .ANTHROPIC ,
888+ model = "claude-opus-4-8" ,
889+ temperature = 0.0 ,
890+ )
891+
892+ _PROTO_TEMPLATE = ChatPromptTemplate .from_messages (
893+ [("system" , "You are a grader." ), ("human" , "{input}" )]
894+ )
895+
896+
897+ class TestExecutePromptChainStepProtocolPath :
898+ """Protocol path: llm_provider injected — LangChain provider is never called."""
899+
900+ def _ev (self , adapter : AsyncMock ) -> _StubEvaluator :
901+ return _StubEvaluator (create_config_no_telemetry (), llm_provider = adapter )
902+
903+ async def test_returns_raw_string_when_parser_type_is_none (self , evaluation_metadata ):
904+ ev = self ._ev (_make_adapter ("plain prose" ))
905+ out = await ev .execute_prompt_chain_step (
906+ step_name = "raw" ,
907+ prompt_settings = _PROTO_SETTINGS ,
908+ evaluation_metadata = evaluation_metadata ,
909+ template = _PROTO_TEMPLATE ,
910+ chain_inputs = {"input" : "Hello" },
911+ parser_output_type = None ,
912+ )
913+ assert out == "plain prose"
914+
915+ async def test_parses_clean_json (self , evaluation_metadata ):
916+ ev = self ._ev (_make_adapter (_CHAIN_JSON ))
917+ result = await ev .execute_prompt_chain_step (
918+ step_name = "main" ,
919+ prompt_settings = _PROTO_SETTINGS ,
920+ evaluation_metadata = evaluation_metadata ,
921+ template = _PROTO_TEMPLATE ,
922+ chain_inputs = {"input" : "Hello" },
923+ parser_output_type = _ChainOutput ,
924+ )
925+ assert isinstance (result , _ChainOutput )
926+ assert result .label == "ok"
927+ assert result .score == 7
928+
929+ async def test_strips_markdown_fences (self , evaluation_metadata ):
930+ fenced = f"```json\n { _CHAIN_JSON } \n ```"
931+ ev = self ._ev (_make_adapter (fenced ))
932+ result = await ev .execute_prompt_chain_step (
933+ step_name = "main" ,
934+ prompt_settings = _PROTO_SETTINGS ,
935+ evaluation_metadata = evaluation_metadata ,
936+ template = _PROTO_TEMPLATE ,
937+ chain_inputs = {"input" : "Hello" },
938+ parser_output_type = _ChainOutput ,
939+ )
940+ assert result .label == "ok"
941+
942+ async def test_strips_trailing_prose (self , evaluation_metadata ):
943+ with_prose = f"{ _CHAIN_JSON } \n \n Here is my reasoning for this score."
944+ ev = self ._ev (_make_adapter (with_prose ))
945+ result = await ev .execute_prompt_chain_step (
946+ step_name = "main" ,
947+ prompt_settings = _PROTO_SETTINGS ,
948+ evaluation_metadata = evaluation_metadata ,
949+ template = _PROTO_TEMPLATE ,
950+ chain_inputs = {"input" : "Hello" },
951+ parser_output_type = _ChainOutput ,
952+ )
953+ assert result .label == "ok"
954+
955+ async def test_strips_leading_prose (self , evaluation_metadata ):
956+ with_prefix = f"Here is the result:\n { _CHAIN_JSON } "
957+ ev = self ._ev (_make_adapter (with_prefix ))
958+ result = await ev .execute_prompt_chain_step (
959+ step_name = "main" ,
960+ prompt_settings = _PROTO_SETTINGS ,
961+ evaluation_metadata = evaluation_metadata ,
962+ template = _PROTO_TEMPLATE ,
963+ chain_inputs = {"input" : "Hello" },
964+ parser_output_type = _ChainOutput ,
965+ )
966+ assert result .label == "ok"
967+
968+ async def test_json_dict_normalizer_path (self , evaluation_metadata ):
969+ class _Out (BaseModel ):
970+ n : int
971+ doubled : int
972+
973+ ev = self ._ev (_make_adapter ('{"n": 3}' ))
974+ result = await ev .execute_prompt_chain_step (
975+ step_name = "main" ,
976+ prompt_settings = _PROTO_SETTINGS ,
977+ evaluation_metadata = evaluation_metadata ,
978+ template = _PROTO_TEMPLATE ,
979+ chain_inputs = {"input" : "Hello" },
980+ parser_output_type = _Out ,
981+ json_dict_normalizer = lambda d : {** d , "doubled" : d ["n" ] * 2 },
982+ )
983+ assert result .n == 3
984+ assert result .doubled == 6
985+
986+ async def test_non_dict_json_in_normalizer_path_raises_output_validation_error (
987+ self , evaluation_metadata
988+ ):
989+ class _Out (BaseModel ):
990+ n : int
991+
992+ ev = self ._ev (_make_adapter ('["not", "an", "object"]' ))
993+ with pytest .raises (OutputValidationError ) as exc_info :
994+ await ev .execute_prompt_chain_step (
995+ step_name = "main" ,
996+ prompt_settings = _PROTO_SETTINGS ,
997+ evaluation_metadata = evaluation_metadata ,
998+ template = _PROTO_TEMPLATE ,
999+ chain_inputs = {"input" : "Hello" },
1000+ parser_output_type = _Out ,
1001+ json_dict_normalizer = lambda d : d ,
1002+ )
1003+ assert "JSON object" in str (exc_info .value )
1004+
1005+ async def test_malformed_json_raises_output_validation_error (self , evaluation_metadata ):
1006+ ev = self ._ev (_make_adapter ("not json at all" ))
1007+ with pytest .raises (OutputValidationError ):
1008+ await ev .execute_prompt_chain_step (
1009+ step_name = "main" ,
1010+ prompt_settings = _PROTO_SETTINGS ,
1011+ evaluation_metadata = evaluation_metadata ,
1012+ template = _PROTO_TEMPLATE ,
1013+ chain_inputs = {"input" : "Hello" },
1014+ parser_output_type = _ChainOutput ,
1015+ )
1016+
1017+ async def test_schema_mismatch_raises_output_validation_error (self , evaluation_metadata ):
1018+ ev = self ._ev (_make_adapter ('{"label": "only"}' )) # missing required `score`
1019+ with pytest .raises (OutputValidationError ) as exc_info :
1020+ await ev .execute_prompt_chain_step (
1021+ step_name = "main" ,
1022+ prompt_settings = _PROTO_SETTINGS ,
1023+ evaluation_metadata = evaluation_metadata ,
1024+ template = _PROTO_TEMPLATE ,
1025+ chain_inputs = {"input" : "Hello" },
1026+ parser_output_type = _ChainOutput ,
1027+ )
1028+ assert isinstance (exc_info .value .__cause__ , PydanticValidationError )
1029+
1030+ async def test_token_usage_recorded_in_step_extras_and_total (self , evaluation_metadata ):
1031+ ev = self ._ev (
1032+ _make_adapter (_CHAIN_JSON , model = "claude-opus-4-8" , input_tokens = 42 , output_tokens = 17 )
1033+ )
1034+ await ev .execute_prompt_chain_step (
1035+ step_name = "main" ,
1036+ prompt_settings = _PROTO_SETTINGS ,
1037+ evaluation_metadata = evaluation_metadata ,
1038+ template = _PROTO_TEMPLATE ,
1039+ chain_inputs = {"input" : "Hello" },
1040+ parser_output_type = _ChainOutput ,
1041+ )
1042+ step = evaluation_metadata .step_details ["main" ]
1043+ assert step .extras [PROMPT_STEP_EXTRA_TOKEN_USAGE ]["input_tokens" ] == 42
1044+ assert step .extras [PROMPT_STEP_EXTRA_TOKEN_USAGE ]["output_tokens" ] == 17
1045+ assert evaluation_metadata .total_token_usage [LLMProvider .ANTHROPIC ].input_tokens == 42
1046+
1047+ async def test_token_usage_absent_when_llm_response_has_none_tokens (self , evaluation_metadata ):
1048+ ev = self ._ev (_make_adapter (_CHAIN_JSON , input_tokens = None , output_tokens = None ))
1049+ await ev .execute_prompt_chain_step (
1050+ step_name = "main" ,
1051+ prompt_settings = _PROTO_SETTINGS ,
1052+ evaluation_metadata = evaluation_metadata ,
1053+ template = _PROTO_TEMPLATE ,
1054+ chain_inputs = {"input" : "Hello" },
1055+ parser_output_type = _ChainOutput ,
1056+ )
1057+ assert not evaluation_metadata .total_token_usage
1058+
1059+ async def test_provider_error_wrapped_as_api_error (self , evaluation_metadata ):
1060+ adapter = AsyncMock ()
1061+ adapter .generate = AsyncMock (side_effect = RuntimeError ("network timeout" ))
1062+ ev = self ._ev (adapter )
1063+ with pytest .raises (APIError ) as exc_info :
1064+ await ev .execute_prompt_chain_step (
1065+ step_name = "main" ,
1066+ prompt_settings = _PROTO_SETTINGS ,
1067+ evaluation_metadata = evaluation_metadata ,
1068+ template = _PROTO_TEMPLATE ,
1069+ chain_inputs = {"input" : "Hello" },
1070+ parser_output_type = _ChainOutput ,
1071+ )
1072+ assert isinstance (exc_info .value .__cause__ , RuntimeError )
1073+
1074+ async def test_evaluator_error_from_provider_reraises_unchanged (self , evaluation_metadata ):
1075+ adapter = AsyncMock ()
1076+ adapter .generate = AsyncMock (side_effect = EvaluatorError ("already wrapped" ))
1077+ ev = self ._ev (adapter )
1078+ with pytest .raises (EvaluatorError , match = "already wrapped" ):
1079+ await ev .execute_prompt_chain_step (
1080+ step_name = "main" ,
1081+ prompt_settings = _PROTO_SETTINGS ,
1082+ evaluation_metadata = evaluation_metadata ,
1083+ template = _PROTO_TEMPLATE ,
1084+ chain_inputs = {"input" : "Hello" },
1085+ parser_output_type = _ChainOutput ,
1086+ )
1087+
1088+ async def test_keyboard_interrupt_from_provider_propagates (self , evaluation_metadata ):
1089+ adapter = AsyncMock ()
1090+ adapter .generate = AsyncMock (side_effect = KeyboardInterrupt )
1091+ ev = self ._ev (adapter )
1092+ with pytest .raises (KeyboardInterrupt ):
1093+ await ev .execute_prompt_chain_step (
1094+ step_name = "main" ,
1095+ prompt_settings = _PROTO_SETTINGS ,
1096+ evaluation_metadata = evaluation_metadata ,
1097+ template = _PROTO_TEMPLATE ,
1098+ chain_inputs = {"input" : "Hello" },
1099+ parser_output_type = _ChainOutput ,
1100+ )
0 commit comments