Repository: stanfordnlp/dspy
Pull request #7980 (2025-03-18 21:05:26)

Support Pydantic field constraint in DSPy
We are supporting field constraint in this PR, basically the following code will be valid:

```
import dspy

dspy.settings.configure(lm=dspy.LM("openai/gpt-4o-mini"))


class MySignature(dspy.Signature):
    text: str = dspy.InputField(description="The text to judge sentiment")
    sentiment: int = dspy.OutputField(description="The sentiment of the text, the higher the positive", ge=0, le=10)
    reason: str = dspy.OutputField(description="The reason for the sentiment", max_length=200)


predict = dspy.Predict(MySignature)
print(predict(text="I am thrilled!"))
```

You can check the LM history to get what's really being sent to LLM:

```
dspy.inspect_history(n=1)
```
then you will get something like:

```
[2025-03-18T14:03:46.099868]

System message:

Your input fields are:
1. `text` (str): The text to judge sentiment

Your output fields are:
1. `sentiment` (int): The sentiment of the text, the higher the positive
Constraints: greater than or equal to: 0, less than or equal to: 10
2. `reason` (str): The reason for the sentiment
Constraints: maximum length: 200

All interactions will be structured in the following way, with the appropriate values filled in.

[[ ## text ## ]]
{text}

[[ ## sentiment ## ]]
{sentiment}        # note: the value you produce must be a single int value

[[ ## reason ## ]]
{reason}

[[ ## completed ## ]]

In adhering to this structure, your objective is: 
        Given the fields `text`, produce the fields `sentiment`, `reason`.


User message:

[[ ## text ## ]]
I am thrilled!

Respond with the corresponding output fields, starting with the field `[[ ## sentiment ## ]]` (must be formatted as a valid Python int), then `[[ ## reason ## ]]`, and then ending with the marker for `[[ ## completed ## ]]`.


Response:

[[ ## sentiment ## ]]
10

[[ ## reason ## ]]
The text expresses a strong positive emotion of excitement, indicating a very high level of happiness.

[[ ## completed ## ]]
```

From the system message section, you can find the constraint message.



Patch:
diff --git a/dspy/adapters/chat_adapter.py b/dspy/adapters/chat_adapter.py
index c011819b..edadc4ed 100644
--- a/dspy/adapters/chat_adapter.py
+++ b/dspy/adapters/chat_adapter.py
@@ -257,7 +257,9 @@ def enumerate_fields(fields: dict) -> str:
         parts.append(f"{idx + 1}. `{k}`")
         parts[-1] += f" ({get_annotation_name(v.annotation)})"
         parts[-1] += f": {v.json_schema_extra['desc']}" if v.json_schema_extra["desc"] != f"${{{k}}}" else ""
-
+        parts[-1] += (
+            f"\nConstraints: {v.json_schema_extra['constraints']}" if v.json_schema_extra.get("constraints") else ""
+        )
     return "\n".join(parts).strip()
 
 
diff --git a/dspy/adapters/json_adapter.py b/dspy/adapters/json_adapter.py
index f5ea79a6..ed364b06 100644
--- a/dspy/adapters/json_adapter.py
+++ b/dspy/adapters/json_adapter.py
@@ -252,6 +252,9 @@ def enumerate_fields(fields):
         parts.append(f"{idx+1}. `{k}`")
         parts[-1] += f" ({get_annotation_name(v.annotation)})"
         parts[-1] += f": {v.json_schema_extra['desc']}" if v.json_schema_extra["desc"] != f"${{{k}}}" else ""
+        parts[-1] += (
+            f"\nConstraints: {v.json_schema_extra['constraints']}" if v.json_schema_extra.get("constraints") else ""
+        )
 
     return "\n".join(parts).strip()
 
diff --git a/dspy/signatures/field.py b/dspy/signatures/field.py
index 13d17626..1d62c4f8 100644
--- a/dspy/signatures/field.py
+++ b/dspy/signatures/field.py
@@ -1,9 +1,21 @@
 import pydantic
+
 # The following arguments can be used in DSPy InputField and OutputField in addition
 # to the standard pydantic.Field arguments. We just hope pydanitc doesn't add these,
 # as it would give a name clash.
 DSPY_FIELD_ARG_NAMES = ["desc", "prefix", "format", "parser", "__dspy_field_type"]
 
+PYDANTIC_CONSTRAINT_MAP = {
+    "gt": "greater than: ",
+    "ge": "greater than or equal to: ",
+    "lt": "less than: ",
+    "le": "less than or equal to: ",
+    "min_length": "minimum length: ",
+    "max_length": "maximum length: ",
+    "multiple_of": "a multiple of the given number: ",
+    "allow_inf_nan": "allow 'inf', '-inf', 'nan' values: ",
+}
+
 
 def move_kwargs(**kwargs):
     # Pydantic doesn't allow arbitrary arguments to be given to fields,
@@ -21,10 +33,24 @@ def move_kwargs(**kwargs):
     # Also copy over the pydantic "description" if no dspy "desc" is given.
     if "description" in kwargs and "desc" not in json_schema_extra:
         json_schema_extra["desc"] = kwargs["description"]
+    constraints = _translate_pydantic_field_constraints(**kwargs)
+    if constraints:
+        json_schema_extra["constraints"] = constraints
     pydantic_kwargs["json_schema_extra"] = json_schema_extra
     return pydantic_kwargs
 
 
+def _translate_pydantic_field_constraints(**kwargs):
+    """Extracts Pydantic constraints and translates them into human-readable format."""
+
+    constraints = []
+    for key, value in kwargs.items():
+        if key in PYDANTIC_CONSTRAINT_MAP:
+            constraints.append(f"{PYDANTIC_CONSTRAINT_MAP[key]}{value}")
+
+    return ", ".join(constraints)
+
+
 def InputField(**kwargs):
     return pydantic.Field(**move_kwargs(**kwargs, __dspy_field_type="input"))
 


Test patch:
diff --git a/tests/predict/test_predict.py b/tests/predict/test_predict.py
index c734f621..3085e255 100644
--- a/tests/predict/test_predict.py
+++ b/tests/predict/test_predict.py
@@ -36,7 +36,7 @@ def test_reset_method():
 def test_lm_after_dump_and_load_state():
     predict_instance = Predict("input -> output")
     lm = dspy.LM(
-        model="openai/gpt-4o-mini", 
+        model="openai/gpt-4o-mini",
         model_type="chat",
         temperature=1,
         max_tokens=100,
@@ -229,11 +229,12 @@ def test_signature_fields_after_dump_and_load_state(tmp_path):
     new_instance.load(file_path)
     assert new_instance.signature.dump_state() == original_instance.signature.dump_state()
 
+
 @pytest.mark.parametrize("filename", ["model.json", "model.pkl"])
 def test_lm_field_after_dump_and_load_state(tmp_path, filename):
     file_path = tmp_path / filename
     lm = dspy.LM(
-        model="openai/gpt-4o-mini", 
+        model="openai/gpt-4o-mini",
         model_type="chat",
         temperature=1,
         max_tokens=100,
@@ -487,3 +488,53 @@ def test_call_predict_with_chat_history(adapter_type):
     assert "what's the capital of france?" in messages[1]["content"]
     assert "paris" in messages[2]["content"]
     assert "are you sure that's correct" in messages[3]["content"]
+
+
+@pytest.mark.parametrize("adapter_type", ["chat", "json"])
+def test_field_constraints(adapter_type):
+    class SpyLM(dspy.LM):
+        def __init__(self, *args, return_json=False, **kwargs):
+            super().__init__(*args, **kwargs)
+            self.calls = []
+            self.return_json = return_json
+
+        def __call__(self, prompt=None, messages=None, **kwargs):
+            self.calls.append({"prompt": prompt, "messages": messages, "kwargs": kwargs})
+            if self.return_json:
+                return ["{'score':'0.5', 'count':'2'}"]
+            return ["[[ ## score ## ]]\n0.5\n[[ ## count ## ]]\n2"]
+
+    class ConstrainedSignature(dspy.Signature):
+        """Test signature with constrained fields."""
+
+        # Input with length and value constraints
+        text: str = dspy.InputField(min_length=5, max_length=100, desc="Input text")
+        number: int = dspy.InputField(gt=0, lt=10, desc="A number between 0 and 10")
+
+        # Output with multiple constraints
+        score: float = dspy.OutputField(ge=0.0, le=1.0, desc="Score between 0 and 1")
+        count: int = dspy.OutputField(multiple_of=2, desc="Even number count")
+
+    program = Predict(ConstrainedSignature)
+    lm = SpyLM("dummy_model")
+    if adapter_type == "chat":
+        lm = SpyLM("dummy_model")
+        dspy.settings.configure(adapter=dspy.ChatAdapter(), lm=lm)
+    else:
+        lm = SpyLM("dummy_model", return_json=True)
+        dspy.settings.configure(adapter=dspy.JSONAdapter(), lm=lm)
+
+    # Call the predictor to trigger instruction generation
+    program(text="hello world", number=5)
+
+    # Get the system message containing the instructions
+    system_message = lm.calls[0]["messages"][0]["content"]
+
+    # Verify constraints are included in the field descriptions
+    assert "minimum length: 5" in system_message
+    assert "maximum length: 100" in system_message
+    assert "greater than: 0" in system_message
+    assert "less than: 10" in system_message
+    assert "greater than or equal to: 0.0" in system_message
+    assert "less than or equal to: 1.0" in system_message
+    assert "a multiple of the given number: 2" in system_message
diff --git a/tests/signatures/test_signature.py b/tests/signatures/test_signature.py
index 136fac21..7805e6db 100644
--- a/tests/signatures/test_signature.py
+++ b/tests/signatures/test_signature.py
@@ -391,3 +391,21 @@ def test_make_signature_from_string():
     assert sig.output_fields["output1"].annotation == List[str]
     assert "output2" in sig.output_fields
     assert sig.output_fields["output2"].annotation == Union[int, str]
+
+
+def test_signature_field_with_constraints():
+    class MySignature(Signature):
+        inputs: str = InputField()
+        outputs1: str = OutputField(min_length=5, max_length=10)
+        outputs2: int = OutputField(ge=5, le=10)
+
+    assert "outputs1" in MySignature.output_fields
+    output1_constraints = MySignature.output_fields["outputs1"].json_schema_extra["constraints"]
+
+    assert "minimum length: 5" in output1_constraints
+    assert "maximum length: 10" in output1_constraints
+
+    assert "outputs2" in MySignature.output_fields
+    output2_constraints = MySignature.output_fields["outputs2"].json_schema_extra["constraints"]
+    assert "greater than or equal to: 5" in output2_constraints
+    assert "less than or equal to: 10" in output2_constraints