This is page 13 of 17. Use http://codebase.md/stanfordnlp/dspy?lines=true&page={x} to view the full context. # Directory Structure ``` ├── .github │ ├── .internal_dspyai │ │ ├── internals │ │ │ ├── build-and-release.md │ │ │ └── release-checklist.md │ │ └── pyproject.toml │ ├── .tmp │ │ └── .generated-actions │ │ └── run-pypi-publish-in-docker-container │ │ └── action.yml │ ├── ISSUE_TEMPLATE │ │ ├── bug_report.yml │ │ └── feature_request.yml │ ├── PULL_REQUEST_TEMPLATE │ │ └── pull_request_template.md │ ├── workflow_scripts │ │ └── install_testpypi_pkg.sh │ └── workflows │ ├── build_and_release.yml │ ├── build_utils │ │ └── test_version.py │ ├── docs-push.yml │ ├── precommits_check.yml │ └── run_tests.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CONTRIBUTING.md ├── docs │ ├── .gitignore │ ├── docs │ │ ├── api │ │ │ ├── adapters │ │ │ │ ├── Adapter.md │ │ │ │ ├── ChatAdapter.md │ │ │ │ ├── JSONAdapter.md │ │ │ │ └── TwoStepAdapter.md │ │ │ ├── evaluation │ │ │ │ ├── answer_exact_match.md │ │ │ │ ├── answer_passage_match.md │ │ │ │ ├── CompleteAndGrounded.md │ │ │ │ ├── Evaluate.md │ │ │ │ ├── EvaluationResult.md │ │ │ │ └── SemanticF1.md │ │ │ ├── experimental │ │ │ │ ├── Citations.md │ │ │ │ └── Document.md │ │ │ ├── index.md │ │ │ ├── models │ │ │ │ ├── Embedder.md │ │ │ │ └── LM.md │ │ │ ├── modules │ │ │ │ ├── BestOfN.md │ │ │ │ ├── ChainOfThought.md │ │ │ │ ├── CodeAct.md │ │ │ │ ├── Module.md │ │ │ │ ├── MultiChainComparison.md │ │ │ │ ├── Parallel.md │ │ │ │ ├── Predict.md │ │ │ │ ├── ProgramOfThought.md │ │ │ │ ├── ReAct.md │ │ │ │ └── Refine.md │ │ │ ├── optimizers │ │ │ │ ├── BetterTogether.md │ │ │ │ ├── BootstrapFewShot.md │ │ │ │ ├── BootstrapFewShotWithRandomSearch.md │ │ │ │ ├── BootstrapFinetune.md │ │ │ │ ├── BootstrapRS.md │ │ │ │ ├── COPRO.md │ │ │ │ ├── Ensemble.md │ │ │ │ ├── GEPA │ │ │ │ │ ├── GEPA_Advanced.md │ │ │ │ │ └── overview.md │ │ │ │ ├── InferRules.md │ │ │ │ ├── KNN.md │ │ │ │ ├── KNNFewShot.md │ │ │ │ ├── LabeledFewShot.md │ │ │ │ ├── MIPROv2.md │ │ │ │ └── SIMBA.md │ │ │ ├── primitives │ │ │ │ ├── Audio.md │ │ │ │ ├── Code.md │ │ │ │ ├── Example.md │ │ │ │ ├── History.md │ │ │ │ ├── Image.md │ │ │ │ ├── Prediction.md │ │ │ │ ├── Tool.md │ │ │ │ └── ToolCalls.md │ │ │ ├── signatures │ │ │ │ ├── InputField.md │ │ │ │ ├── OutputField.md │ │ │ │ └── Signature.md │ │ │ ├── tools │ │ │ │ ├── ColBERTv2.md │ │ │ │ ├── Embeddings.md │ │ │ │ └── PythonInterpreter.md │ │ │ └── utils │ │ │ ├── asyncify.md │ │ │ ├── configure_cache.md │ │ │ ├── disable_litellm_logging.md │ │ │ ├── disable_logging.md │ │ │ ├── enable_litellm_logging.md │ │ │ ├── enable_logging.md │ │ │ ├── inspect_history.md │ │ │ ├── load.md │ │ │ ├── StatusMessage.md │ │ │ ├── StatusMessageProvider.md │ │ │ ├── streamify.md │ │ │ └── StreamListener.md │ │ ├── cheatsheet.md │ │ ├── community │ │ │ ├── community-resources.md │ │ │ ├── how-to-contribute.md │ │ │ └── use-cases.md │ │ ├── deep-dive │ │ │ └── data-handling │ │ │ ├── built-in-datasets.md │ │ │ ├── examples.md │ │ │ ├── img │ │ │ │ └── data-loading.png │ │ │ └── loading-custom-data.md │ │ ├── faqs.md │ │ ├── index.md │ │ ├── js │ │ │ └── runllm-widget.js │ │ ├── learn │ │ │ ├── evaluation │ │ │ │ ├── data.md │ │ │ │ ├── metrics.md │ │ │ │ └── overview.md │ │ │ ├── figures │ │ │ │ ├── native_tool_call.png │ │ │ │ └── teleprompter-classes.png │ │ │ ├── index.md │ │ │ ├── optimization │ │ │ │ ├── optimizers.md │ │ │ │ └── overview.md │ │ │ └── programming │ │ │ ├── 7-assertions.md │ │ │ ├── adapters.md │ │ │ ├── language_models.md │ │ │ ├── mcp.md │ │ │ ├── modules.md │ │ │ ├── overview.md │ │ │ ├── signatures.md │ │ │ └── tools.md │ │ ├── production │ │ │ └── index.md │ │ ├── roadmap.md │ │ ├── static │ │ │ ├── .nojekyll │ │ │ └── img │ │ │ ├── dspy_logo.png │ │ │ ├── logo.png │ │ │ ├── mlflow-tracing-rag.png │ │ │ ├── modular.png │ │ │ ├── optimize.png │ │ │ ├── undraw_docusaurus_mountain.svg │ │ │ ├── undraw_docusaurus_react.svg │ │ │ ├── undraw_docusaurus_tree.svg │ │ │ └── universal_compatibility.png │ │ ├── stylesheets │ │ │ └── extra.css │ │ └── tutorials │ │ ├── agents │ │ │ ├── index.ipynb │ │ │ └── mlflow-tracing-agent.png │ │ ├── ai_text_game │ │ │ └── index.md │ │ ├── async │ │ │ └── index.md │ │ ├── audio │ │ │ └── index.ipynb │ │ ├── build_ai_program │ │ │ └── index.md │ │ ├── cache │ │ │ └── index.md │ │ ├── classification │ │ │ └── index.md │ │ ├── classification_finetuning │ │ │ ├── index.ipynb │ │ │ └── mlflow-tracing-classification.png │ │ ├── conversation_history │ │ │ └── index.md │ │ ├── core_development │ │ │ └── index.md │ │ ├── custom_module │ │ │ ├── index.ipynb │ │ │ └── mlflow-tracing-custom-module.png │ │ ├── customer_service_agent │ │ │ ├── index.ipynb │ │ │ └── mlflow-tracing-customer-service-agent.png │ │ ├── deployment │ │ │ ├── dspy_mlflow_ui.png │ │ │ └── index.md │ │ ├── email_extraction │ │ │ ├── index.md │ │ │ └── mlflow-tracing-email-extraction.png │ │ ├── entity_extraction │ │ │ ├── index.ipynb │ │ │ └── mlflow-tracing-entity-extraction.png │ │ ├── games │ │ │ ├── index.ipynb │ │ │ └── mlflow-tracing-agent.png │ │ ├── gepa_ai_program │ │ │ └── index.md │ │ ├── gepa_aime │ │ │ ├── index.ipynb │ │ │ ├── mlflow-tracing-gepa-aime.png │ │ │ └── mlflow-tracking-gepa-aime-optimization.png │ │ ├── gepa_facilitysupportanalyzer │ │ │ ├── index.ipynb │ │ │ ├── mlflow-tracing-gepa-support.png │ │ │ └── mlflow-tracking-gepa-support-optimization.png │ │ ├── gepa_papillon │ │ │ ├── index.ipynb │ │ │ ├── mlflow-tracing-gepa-papilon.png │ │ │ └── mlflow-tracking-gepa-papilon-optimization.png │ │ ├── image_generation_prompting │ │ │ └── index.ipynb │ │ ├── index.md │ │ ├── llms_txt_generation │ │ │ └── index.md │ │ ├── math │ │ │ ├── index.ipynb │ │ │ └── mlflow-tracing-math.png │ │ ├── mcp │ │ │ └── index.md │ │ ├── mem0_react_agent │ │ │ └── index.md │ │ ├── multihop_search │ │ │ ├── index.ipynb │ │ │ └── mlflow-tracing-multi-hop.png │ │ ├── observability │ │ │ ├── index.md │ │ │ ├── mlflow_trace_ui_navigation.gif │ │ │ ├── mlflow_trace_ui.png │ │ │ └── mlflow_trace_view.png │ │ ├── optimize_ai_program │ │ │ └── index.md │ │ ├── optimizer_tracking │ │ │ ├── child_run.png │ │ │ ├── experiment.png │ │ │ ├── index.md │ │ │ └── parent_run.png │ │ ├── output_refinement │ │ │ └── best-of-n-and-refine.md │ │ ├── papillon │ │ │ └── index.md │ │ ├── program_of_thought │ │ │ └── index.ipynb │ │ ├── rag │ │ │ ├── index.ipynb │ │ │ └── mlflow-tracing-rag.png │ │ ├── real_world_examples │ │ │ └── index.md │ │ ├── rl_ai_program │ │ │ └── index.md │ │ ├── rl_multihop │ │ │ └── index.ipynb │ │ ├── rl_papillon │ │ │ └── index.ipynb │ │ ├── sample_code_generation │ │ │ └── index.md │ │ ├── saving │ │ │ └── index.md │ │ ├── streaming │ │ │ └── index.md │ │ ├── tool_use │ │ │ └── index.ipynb │ │ └── yahoo_finance_react │ │ └── index.md │ ├── mkdocs.yml │ ├── overrides │ │ ├── home.html │ │ ├── main.html │ │ └── partials │ │ └── tabs.html │ ├── Pipfile │ ├── Pipfile.lock │ ├── README.md │ ├── requirements.txt │ ├── scripts │ │ ├── generate_api_docs.py │ │ └── generate_api_summary.py │ └── vercel.json ├── dspy │ ├── __init__.py │ ├── __metadata__.py │ ├── adapters │ │ ├── __init__.py │ │ ├── baml_adapter.py │ │ ├── base.py │ │ ├── chat_adapter.py │ │ ├── json_adapter.py │ │ ├── two_step_adapter.py │ │ ├── types │ │ │ ├── __init__.py │ │ │ ├── audio.py │ │ │ ├── base_type.py │ │ │ ├── citation.py │ │ │ ├── code.py │ │ │ ├── document.py │ │ │ ├── history.py │ │ │ ├── image.py │ │ │ └── tool.py │ │ ├── utils.py │ │ └── xml_adapter.py │ ├── clients │ │ ├── __init__.py │ │ ├── base_lm.py │ │ ├── cache.py │ │ ├── databricks.py │ │ ├── embedding.py │ │ ├── lm_local_arbor.py │ │ ├── lm_local.py │ │ ├── lm.py │ │ ├── openai.py │ │ ├── provider.py │ │ └── utils_finetune.py │ ├── datasets │ │ ├── __init__.py │ │ ├── alfworld │ │ │ ├── __init__.py │ │ │ ├── alfworld.py │ │ │ └── base_config.yml │ │ ├── colors.py │ │ ├── dataloader.py │ │ ├── dataset.py │ │ ├── gsm8k.py │ │ ├── hotpotqa.py │ │ └── math.py │ ├── dsp │ │ ├── __init__.py │ │ ├── colbertv2.py │ │ └── utils │ │ ├── __init__.py │ │ ├── dpr.py │ │ ├── settings.py │ │ └── utils.py │ ├── evaluate │ │ ├── __init__.py │ │ ├── auto_evaluation.py │ │ ├── evaluate.py │ │ └── metrics.py │ ├── experimental │ │ └── __init__.py │ ├── predict │ │ ├── __init__.py │ │ ├── aggregation.py │ │ ├── avatar │ │ │ ├── __init__.py │ │ │ ├── avatar.py │ │ │ ├── models.py │ │ │ └── signatures.py │ │ ├── best_of_n.py │ │ ├── chain_of_thought.py │ │ ├── code_act.py │ │ ├── knn.py │ │ ├── multi_chain_comparison.py │ │ ├── parallel.py │ │ ├── parameter.py │ │ ├── predict.py │ │ ├── program_of_thought.py │ │ ├── react.py │ │ ├── refine.py │ │ └── retry.py │ ├── primitives │ │ ├── __init__.py │ │ ├── base_module.py │ │ ├── example.py │ │ ├── module.py │ │ ├── prediction.py │ │ ├── python_interpreter.py │ │ └── runner.js │ ├── propose │ │ ├── __init__.py │ │ ├── dataset_summary_generator.py │ │ ├── grounded_proposer.py │ │ ├── propose_base.py │ │ └── utils.py │ ├── retrievers │ │ ├── __init__.py │ │ ├── databricks_rm.py │ │ ├── embeddings.py │ │ ├── retrieve.py │ │ └── weaviate_rm.py │ ├── signatures │ │ ├── __init__.py │ │ ├── field.py │ │ ├── signature.py │ │ └── utils.py │ ├── streaming │ │ ├── __init__.py │ │ ├── messages.py │ │ ├── streamify.py │ │ └── streaming_listener.py │ ├── teleprompt │ │ ├── __init__.py │ │ ├── avatar_optimizer.py │ │ ├── bettertogether.py │ │ ├── bootstrap_finetune.py │ │ ├── bootstrap_trace.py │ │ ├── bootstrap.py │ │ ├── copro_optimizer.py │ │ ├── ensemble.py │ │ ├── gepa │ │ │ ├── __init__.py │ │ │ ├── gepa_utils.py │ │ │ ├── gepa.py │ │ │ └── instruction_proposal.py │ │ ├── grpo.py │ │ ├── infer_rules.py │ │ ├── knn_fewshot.py │ │ ├── mipro_optimizer_v2.py │ │ ├── random_search.py │ │ ├── signature_opt.py │ │ ├── simba_utils.py │ │ ├── simba.py │ │ ├── teleprompt_optuna.py │ │ ├── teleprompt.py │ │ ├── utils.py │ │ └── vanilla.py │ └── utils │ ├── __init__.py │ ├── annotation.py │ ├── asyncify.py │ ├── caching.py │ ├── callback.py │ ├── dummies.py │ ├── exceptions.py │ ├── hasher.py │ ├── inspect_history.py │ ├── langchain_tool.py │ ├── logging_utils.py │ ├── mcp.py │ ├── parallelizer.py │ ├── saving.py │ ├── syncify.py │ ├── unbatchify.py │ └── usage_tracker.py ├── LICENSE ├── pyproject.toml ├── README.md ├── tests │ ├── __init__.py │ ├── adapters │ │ ├── test_adapter_utils.py │ │ ├── test_baml_adapter.py │ │ ├── test_base_type.py │ │ ├── test_chat_adapter.py │ │ ├── test_citation.py │ │ ├── test_code.py │ │ ├── test_document.py │ │ ├── test_json_adapter.py │ │ ├── test_tool.py │ │ ├── test_two_step_adapter.py │ │ └── test_xml_adapter.py │ ├── callback │ │ └── test_callback.py │ ├── clients │ │ ├── test_cache.py │ │ ├── test_databricks.py │ │ ├── test_embedding.py │ │ ├── test_inspect_global_history.py │ │ └── test_lm.py │ ├── conftest.py │ ├── datasets │ │ └── test_dataset.py │ ├── docs │ │ └── test_mkdocs_links.py │ ├── evaluate │ │ ├── test_evaluate.py │ │ └── test_metrics.py │ ├── examples │ │ └── test_baleen.py │ ├── metadata │ │ └── test_metadata.py │ ├── predict │ │ ├── test_aggregation.py │ │ ├── test_best_of_n.py │ │ ├── test_chain_of_thought.py │ │ ├── test_code_act.py │ │ ├── test_knn.py │ │ ├── test_multi_chain_comparison.py │ │ ├── test_parallel.py │ │ ├── test_predict.py │ │ ├── test_program_of_thought.py │ │ ├── test_react.py │ │ ├── test_refine.py │ │ └── test_retry.py │ ├── primitives │ │ ├── resources │ │ │ └── saved_program.json │ │ ├── test_base_module.py │ │ ├── test_example.py │ │ ├── test_module.py │ │ └── test_python_interpreter.py │ ├── propose │ │ └── test_grounded_proposer.py │ ├── README.md │ ├── reliability │ │ ├── __init__.py │ │ ├── complex_types │ │ │ └── generated │ │ │ ├── test_many_types_1 │ │ │ │ ├── inputs │ │ │ │ │ ├── input1.json │ │ │ │ │ └── input2.json │ │ │ │ ├── program.py │ │ │ │ └── schema.json │ │ │ ├── test_nesting_1 │ │ │ │ ├── inputs │ │ │ │ │ ├── input1.json │ │ │ │ │ └── input2.json │ │ │ │ ├── program.py │ │ │ │ └── schema.json │ │ │ └── test_nesting_2 │ │ │ ├── inputs │ │ │ │ └── input1.json │ │ │ ├── program.py │ │ │ └── schema.json │ │ ├── conftest.py │ │ ├── generate │ │ │ ├── __init__.py │ │ │ ├── __main__.py │ │ │ └── utils.py │ │ ├── input_formats │ │ │ └── generated │ │ │ └── test_markdown_1 │ │ │ ├── inputs │ │ │ │ ├── input1.json │ │ │ │ └── input2.json │ │ │ ├── program.py │ │ │ └── schema.json │ │ ├── README.md │ │ ├── reliability_conf.yaml │ │ ├── test_generated.py │ │ ├── test_pydantic_models.py │ │ └── utils.py │ ├── retrievers │ │ └── test_embeddings.py │ ├── signatures │ │ ├── test_adapter_image.py │ │ ├── test_custom_types.py │ │ └── test_signature.py │ ├── streaming │ │ └── test_streaming.py │ ├── teleprompt │ │ ├── gepa_dummy_lm_custom_component_selector_custom_instruction_proposer.json │ │ ├── gepa_dummy_lm.json │ │ ├── test_bootstrap_finetune.py │ │ ├── test_bootstrap_trace.py │ │ ├── test_bootstrap.py │ │ ├── test_copro_optimizer.py │ │ ├── test_ensemble.py │ │ ├── test_finetune.py │ │ ├── test_gepa_instruction_proposer.py │ │ ├── test_gepa.py │ │ ├── test_grpo.py │ │ ├── test_knn_fewshot.py │ │ ├── test_random_search.py │ │ ├── test_teleprompt.py │ │ └── test_utils.py │ ├── test_utils │ │ ├── __init__.py │ │ └── server │ │ ├── __init__.py │ │ ├── litellm_server_config.yaml │ │ └── litellm_server.py │ └── utils │ ├── __init__.py │ ├── resources │ │ └── mcp_server.py │ ├── test_annotation.py │ ├── test_asyncify.py │ ├── test_exceptions.py │ ├── test_langchain_tool.py │ ├── test_mcp.py │ ├── test_parallelizer.py │ ├── test_saving.py │ ├── test_settings.py │ ├── test_syncify.py │ ├── test_unbatchify.py │ └── test_usage_tracker.py └── uv.lock ``` # Files -------------------------------------------------------------------------------- /tests/signatures/test_signature.py: -------------------------------------------------------------------------------- ```python 1 | from types import UnionType 2 | from typing import Any, Optional, Union 3 | 4 | import pydantic 5 | import pytest 6 | 7 | import dspy 8 | from dspy import InputField, OutputField, Signature, infer_prefix 9 | from dspy.utils.dummies import DummyLM 10 | 11 | 12 | def test_field_types_and_custom_attributes(): 13 | class TestSignature(Signature): 14 | """Instructions""" 15 | 16 | input1: str = InputField() 17 | input2: int = InputField() 18 | output1: list[str] = OutputField() 19 | output2 = OutputField() 20 | 21 | assert TestSignature.instructions == "Instructions" 22 | assert TestSignature.input_fields["input1"].annotation == str 23 | assert TestSignature.input_fields["input2"].annotation == int 24 | assert TestSignature.output_fields["output1"].annotation == list[str] 25 | assert TestSignature.output_fields["output2"].annotation == str 26 | 27 | 28 | def test_no_input_output(): 29 | with pytest.raises(TypeError): 30 | 31 | class TestSignature(Signature): 32 | input1: str 33 | 34 | 35 | def test_no_input_output2(): 36 | with pytest.raises(TypeError): 37 | 38 | class TestSignature(Signature): 39 | input1: str = pydantic.Field() 40 | 41 | 42 | def test_all_fields_have_prefix(): 43 | class TestSignature(Signature): 44 | input = InputField(prefix="Modified:") 45 | output = OutputField() 46 | 47 | assert TestSignature.input_fields["input"].json_schema_extra["prefix"] == "Modified:" 48 | assert TestSignature.output_fields["output"].json_schema_extra["prefix"] == "Output:" 49 | 50 | 51 | def test_signature_parsing(): 52 | signature = Signature("input1, input2 -> output") 53 | assert "input1" in signature.input_fields 54 | assert "input2" in signature.input_fields 55 | assert "output" in signature.output_fields 56 | 57 | 58 | def test_with_signature(): 59 | signature1 = Signature("input1, input2 -> output") 60 | signature2 = signature1.with_instructions("This is a test") 61 | assert signature2.instructions == "This is a test" 62 | assert signature1 is not signature2, "The type should be immutable" 63 | 64 | 65 | def test_with_updated_field(): 66 | signature1 = Signature("input1, input2 -> output") 67 | signature2 = signature1.with_updated_fields("input1", prefix="Modified:") 68 | assert signature2.input_fields["input1"].json_schema_extra["prefix"] == "Modified:" 69 | assert signature1.input_fields["input1"].json_schema_extra["prefix"] == "Input 1:" 70 | assert signature1 is not signature2, "The type should be immutable" 71 | for key in signature1.fields.keys(): 72 | if key != "input1": 73 | assert signature1.fields[key].json_schema_extra == signature2.fields[key].json_schema_extra 74 | assert signature1.instructions == signature2.instructions 75 | 76 | 77 | def test_empty_signature(): 78 | with pytest.raises(ValueError): 79 | Signature("") 80 | 81 | 82 | def test_instructions_signature(): 83 | with pytest.raises(ValueError): 84 | Signature("") 85 | 86 | 87 | def test_signature_instructions(): 88 | sig1 = Signature("input1 -> output1", instructions="This is a test") 89 | assert sig1.instructions == "This is a test" 90 | sig2 = Signature("input1 -> output1", "This is a test") 91 | assert sig2.instructions == "This is a test" 92 | 93 | 94 | def test_signature_instructions_none(): 95 | sig1 = Signature("a, b -> c") 96 | assert sig1.instructions == "Given the fields `a`, `b`, produce the fields `c`." 97 | 98 | 99 | def test_signature_from_dict(): 100 | signature = Signature( 101 | {"input1": InputField(), "input2": InputField(), "output": OutputField()}) 102 | for k in ["input1", "input2", "output"]: 103 | assert k in signature.fields 104 | assert signature.fields[k].annotation == str 105 | 106 | 107 | def test_signature_equality(): 108 | sig1 = Signature("input1 -> output1") 109 | sig2 = Signature("input1 -> output1") 110 | assert sig1.equals(sig2) 111 | 112 | 113 | def test_signature_inequality(): 114 | sig1 = Signature("input1 -> output1") 115 | sig2 = Signature("input2 -> output2") 116 | assert not sig1.equals(sig2) 117 | 118 | 119 | def test_equality_format(): 120 | class TestSignature(Signature): 121 | input = InputField(format=lambda x: x) 122 | output = OutputField() 123 | 124 | assert TestSignature.equals(TestSignature) 125 | 126 | 127 | def test_signature_reverse(): 128 | sig = Signature("input1 -> output1") 129 | assert sig.signature == "input1 -> output1" 130 | 131 | 132 | def test_insert_field_at_various_positions(): 133 | class InitialSignature(Signature): 134 | input1: str = InputField() 135 | output1: int = OutputField() 136 | 137 | s1 = InitialSignature.prepend("new_input_start", InputField(), str) 138 | s2 = InitialSignature.append("new_input_end", InputField(), str) 139 | assert "new_input_start" == list(s1.input_fields.keys())[0] # noqa: RUF015 140 | assert "new_input_end" == list(s2.input_fields.keys())[-1] 141 | 142 | s3 = InitialSignature.prepend("new_output_start", OutputField(), str) 143 | s4 = InitialSignature.append("new_output_end", OutputField(), str) 144 | assert "new_output_start" == list(s3.output_fields.keys())[0] # noqa: RUF015 145 | assert "new_output_end" == list(s4.output_fields.keys())[-1] 146 | 147 | 148 | def test_order_preserved_with_mixed_annotations(): 149 | class ExampleSignature(dspy.Signature): 150 | text: str = dspy.InputField() 151 | output = dspy.OutputField() 152 | pass_evaluation: bool = dspy.OutputField() 153 | 154 | expected_order = ["text", "output", "pass_evaluation"] 155 | actual_order = list(ExampleSignature.fields.keys()) 156 | assert actual_order == expected_order 157 | 158 | 159 | def test_infer_prefix(): 160 | assert infer_prefix( 161 | "someAttributeName42IsCool") == "Some Attribute Name 42 Is Cool" 162 | assert infer_prefix("version2Update") == "Version 2 Update" 163 | assert infer_prefix("modelT45Enhanced") == "Model T 45 Enhanced" 164 | assert infer_prefix("someAttributeName") == "Some Attribute Name" 165 | assert infer_prefix("some_attribute_name") == "Some Attribute Name" 166 | assert infer_prefix("URLAddress") == "URL Address" 167 | assert infer_prefix("isHTTPSecure") == "Is HTTP Secure" 168 | assert infer_prefix("isHTTPSSecure123") == "Is HTTPS Secure 123" 169 | 170 | 171 | def test_insantiating(): 172 | sig = Signature("input -> output") 173 | assert issubclass(sig, Signature) 174 | assert sig.__name__ == "StringSignature" 175 | value = sig(input="test", output="test") 176 | assert isinstance(value, sig) 177 | 178 | 179 | def test_insantiating2(): 180 | class SubSignature(Signature): 181 | input = InputField() 182 | output = OutputField() 183 | 184 | assert issubclass(SubSignature, Signature) 185 | assert SubSignature.__name__ == "SubSignature" 186 | value = SubSignature(input="test", output="test") 187 | assert isinstance(value, SubSignature) 188 | 189 | 190 | def test_multiline_instructions(): 191 | lm = DummyLM([{"output": "short answer"}]) 192 | dspy.settings.configure(lm=lm) 193 | 194 | class MySignature(Signature): 195 | """First line 196 | Second line 197 | Third line""" 198 | 199 | output = OutputField() 200 | 201 | predictor = dspy.Predict(MySignature) 202 | assert predictor().output == "short answer" 203 | 204 | 205 | def test_dump_and_load_state(): 206 | class CustomSignature(dspy.Signature): 207 | """I am just an instruction.""" 208 | 209 | sentence = dspy.InputField(desc="I am an innocent input!") 210 | sentiment = dspy.OutputField() 211 | 212 | state = CustomSignature.dump_state() 213 | expected = { 214 | "instructions": "I am just an instruction.", 215 | "fields": [ 216 | { 217 | "prefix": "Sentence:", 218 | "description": "I am an innocent input!", 219 | }, 220 | { 221 | "prefix": "Sentiment:", 222 | "description": "${sentiment}", 223 | }, 224 | ], 225 | } 226 | assert state == expected 227 | 228 | class CustomSignature2(dspy.Signature): 229 | """I am a malicious instruction.""" 230 | 231 | sentence = dspy.InputField(desc="I am an malicious input!") 232 | sentiment = dspy.OutputField() 233 | 234 | assert CustomSignature2.dump_state() != expected 235 | # Overwrite the state with the state of CustomSignature. 236 | loaded_signature = CustomSignature2.load_state(state) 237 | assert loaded_signature.instructions == "I am just an instruction." 238 | # After `load_state`, the state should be the same as CustomSignature. 239 | assert loaded_signature.dump_state() == expected 240 | # CustomSignature2 should not have been modified. 241 | assert CustomSignature2.instructions == "I am a malicious instruction." 242 | assert CustomSignature2.fields["sentence"].json_schema_extra["desc"] == "I am an malicious input!" 243 | assert CustomSignature2.fields["sentiment"].json_schema_extra["prefix"] == "Sentiment:" 244 | 245 | 246 | def test_typed_signatures_basic_types(): 247 | sig = Signature("input1: int, input2: str -> output: float") 248 | assert "input1" in sig.input_fields 249 | assert sig.input_fields["input1"].annotation == int 250 | assert "input2" in sig.input_fields 251 | assert sig.input_fields["input2"].annotation == str 252 | assert "output" in sig.output_fields 253 | assert sig.output_fields["output"].annotation == float 254 | 255 | 256 | def test_typed_signatures_generics(): 257 | sig = Signature( 258 | "input_list: list[int], input_dict: dict[str, float] -> output_tuple: tuple[str, int]") 259 | assert "input_list" in sig.input_fields 260 | assert sig.input_fields["input_list"].annotation == list[int] 261 | assert "input_dict" in sig.input_fields 262 | assert sig.input_fields["input_dict"].annotation == dict[str, float] 263 | assert "output_tuple" in sig.output_fields 264 | assert sig.output_fields["output_tuple"].annotation == tuple[str, int] 265 | 266 | 267 | def test_typed_signatures_unions_and_optionals(): 268 | sig = Signature( 269 | "input_opt: Optional[str], input_union: Union[int, None] -> output_union: Union[int, str]") 270 | assert "input_opt" in sig.input_fields 271 | # Optional[str] is actually Union[str, None] 272 | # Depending on the environment, it might resolve to Union[str, None] or Optional[str], either is correct. 273 | # We'll just check for a Union containing str and NoneType: 274 | input_opt_annotation = sig.input_fields["input_opt"].annotation 275 | assert input_opt_annotation == Optional[str] or ( 276 | getattr(input_opt_annotation, "__origin__", None) is Union 277 | and str in input_opt_annotation.__args__ 278 | and type(None) in input_opt_annotation.__args__ 279 | ) 280 | 281 | assert "input_union" in sig.input_fields 282 | input_union_annotation = sig.input_fields["input_union"].annotation 283 | assert ( 284 | getattr(input_union_annotation, "__origin__", None) is Union 285 | and int in input_union_annotation.__args__ 286 | and type(None) in input_union_annotation.__args__ 287 | ) 288 | 289 | assert "output_union" in sig.output_fields 290 | output_union_annotation = sig.output_fields["output_union"].annotation 291 | assert ( 292 | getattr(output_union_annotation, "__origin__", None) is Union 293 | and int in output_union_annotation.__args__ 294 | and str in output_union_annotation.__args__ 295 | ) 296 | 297 | 298 | def test_typed_signatures_any(): 299 | sig = Signature("input_any: Any -> output_any: Any") 300 | assert "input_any" in sig.input_fields 301 | assert sig.input_fields["input_any"].annotation == Any 302 | assert "output_any" in sig.output_fields 303 | assert sig.output_fields["output_any"].annotation == Any 304 | 305 | 306 | def test_typed_signatures_nested(): 307 | sig = Signature( 308 | "input_nested: list[Union[str, int]] -> output_nested: Tuple[int, Optional[float], list[str]]") 309 | input_nested_ann = sig.input_fields["input_nested"].annotation 310 | assert getattr(input_nested_ann, "__origin__", None) is list 311 | assert len(input_nested_ann.__args__) == 1 312 | union_arg = input_nested_ann.__args__[0] 313 | assert getattr(union_arg, "__origin__", None) is Union 314 | assert str in union_arg.__args__ and int in union_arg.__args__ 315 | 316 | output_nested_ann = sig.output_fields["output_nested"].annotation 317 | assert getattr(output_nested_ann, "__origin__", None) is tuple 318 | assert output_nested_ann.__args__[0] == int 319 | # The second arg is Optional[float], which is Union[float, None] 320 | second_arg = output_nested_ann.__args__[1] 321 | assert getattr(second_arg, "__origin__", None) is Union 322 | assert float in second_arg.__args__ and type(None) in second_arg.__args__ 323 | # The third arg is list[str] 324 | third_arg = output_nested_ann.__args__[2] 325 | assert getattr(third_arg, "__origin__", None) is list 326 | assert third_arg.__args__[0] == str 327 | 328 | 329 | def test_typed_signatures_from_dict(): 330 | fields = { 331 | "input_str_list": (list[str], InputField()), 332 | "input_dict_int": (dict[str, int], InputField()), 333 | "output_tup": (tuple[int, float], OutputField()), 334 | } 335 | sig = Signature(fields) 336 | assert "input_str_list" in sig.input_fields 337 | assert sig.input_fields["input_str_list"].annotation == list[str] 338 | assert "input_dict_int" in sig.input_fields 339 | assert sig.input_fields["input_dict_int"].annotation == dict[str, int] 340 | assert "output_tup" in sig.output_fields 341 | assert sig.output_fields["output_tup"].annotation == tuple[int, float] 342 | 343 | 344 | def test_typed_signatures_complex_combinations(): 345 | sig = Signature( 346 | "input_complex: dict[str, list[Optional[Tuple[int, str]]]] -> output_complex: Union[list[str], dict[str, Any]]" 347 | ) 348 | input_complex_ann = sig.input_fields["input_complex"].annotation 349 | assert getattr(input_complex_ann, "__origin__", None) is dict 350 | key_arg, value_arg = input_complex_ann.__args__ 351 | assert key_arg == str 352 | # value_arg: list[Optional[Tuple[int, str]]] 353 | assert getattr(value_arg, "__origin__", None) is list 354 | inner_union = value_arg.__args__[0] 355 | # inner_union should be Optional[Tuple[int, str]] 356 | # which is Union[Tuple[int, str], None] 357 | assert getattr(inner_union, "__origin__", None) is Union 358 | tuple_type = [t for t in inner_union.__args__ if t != type(None)][0] # noqa: RUF015 359 | assert getattr(tuple_type, "__origin__", None) is tuple 360 | assert tuple_type.__args__ == (int, str) 361 | 362 | output_complex_ann = sig.output_fields["output_complex"].annotation 363 | assert getattr(output_complex_ann, "__origin__", None) is Union 364 | assert len(output_complex_ann.__args__) == 2 365 | possible_args = set(output_complex_ann.__args__) 366 | # Expecting list[str] and dict[str, Any] 367 | # Because sets don't preserve order, just check membership. 368 | # Find the list[str] arg 369 | list_arg = next(a for a in possible_args if getattr( 370 | a, "__origin__", None) is list) 371 | dict_arg = next(a for a in possible_args if getattr( 372 | a, "__origin__", None) is dict) 373 | assert list_arg.__args__ == (str,) 374 | k, v = dict_arg.__args__ 375 | assert k == str and v == Any 376 | 377 | 378 | def test_make_signature_from_string(): 379 | sig = Signature( 380 | "input1: int, input2: dict[str, int] -> output1: list[str], output2: Union[int, str]") 381 | assert "input1" in sig.input_fields 382 | assert sig.input_fields["input1"].annotation == int 383 | assert "input2" in sig.input_fields 384 | assert sig.input_fields["input2"].annotation == dict[str, int] 385 | assert "output1" in sig.output_fields 386 | assert sig.output_fields["output1"].annotation == list[str] 387 | assert "output2" in sig.output_fields 388 | assert sig.output_fields["output2"].annotation == Union[int, str] 389 | 390 | 391 | def test_signature_field_with_constraints(): 392 | class MySignature(Signature): 393 | inputs: str = InputField() 394 | outputs1: str = OutputField(min_length=5, max_length=10) 395 | outputs2: int = OutputField(ge=5, le=10) 396 | 397 | assert "outputs1" in MySignature.output_fields 398 | output1_constraints = MySignature.output_fields["outputs1"].json_schema_extra["constraints"] 399 | 400 | assert "minimum length: 5" in output1_constraints 401 | assert "maximum length: 10" in output1_constraints 402 | 403 | assert "outputs2" in MySignature.output_fields 404 | output2_constraints = MySignature.output_fields["outputs2"].json_schema_extra["constraints"] 405 | assert "greater than or equal to: 5" in output2_constraints 406 | assert "less than or equal to: 10" in output2_constraints 407 | 408 | 409 | def test_basic_custom_type(): 410 | class CustomType(pydantic.BaseModel): 411 | value: str 412 | 413 | test_signature = dspy.Signature( 414 | "input: CustomType -> output: str", 415 | custom_types={"CustomType": CustomType} 416 | ) 417 | 418 | assert test_signature.input_fields["input"].annotation == CustomType 419 | 420 | lm = DummyLM([{"output": "processed"}]) 421 | dspy.settings.configure(lm=lm) 422 | 423 | custom_obj = CustomType(value="test") 424 | pred = dspy.Predict(test_signature)(input=custom_obj) 425 | assert pred.output == "processed" 426 | 427 | 428 | def test_custom_type_from_different_module(): 429 | from pathlib import Path 430 | 431 | test_signature = dspy.Signature("input: Path -> output: str") 432 | assert test_signature.input_fields["input"].annotation == Path 433 | 434 | lm = DummyLM([{"output": "/test/path"}]) 435 | dspy.settings.configure(lm=lm) 436 | 437 | path_obj = Path("/test/path") 438 | pred = dspy.Predict(test_signature)(input=path_obj) 439 | assert pred.output == "/test/path" 440 | 441 | def test_pep604_union_type_inline(): 442 | sig = Signature( 443 | "input1: str | None, input2: None | int -> output_union: int | str" 444 | ) 445 | 446 | # input1 and input2 test that both 'T | None' and 'None | T' are interpreted as Optional types, 447 | # regardless of the order of None in the union expression. 448 | 449 | assert "input1" in sig.input_fields 450 | input1_annotation = sig.input_fields["input1"].annotation 451 | assert input1_annotation == Optional[str] or ( 452 | getattr(input1_annotation, "__origin__", None) is Union 453 | and str in input1_annotation.__args__ 454 | and type(None) in input1_annotation.__args__ 455 | ) 456 | 457 | assert "input2" in sig.input_fields 458 | input2_annotation = sig.input_fields["input2"].annotation 459 | assert input2_annotation == Optional[int] or ( 460 | getattr(input2_annotation, "__origin__", None) is Union 461 | and int in input2_annotation.__args__ 462 | and type(None) in input2_annotation.__args__ 463 | ) 464 | 465 | assert "output_union" in sig.output_fields 466 | output_union_annotation = sig.output_fields["output_union"].annotation 467 | assert ( 468 | getattr(output_union_annotation, "__origin__", None) is Union 469 | and int in output_union_annotation.__args__ 470 | and str in output_union_annotation.__args__ 471 | ) 472 | 473 | 474 | def test_pep604_union_type_inline_equivalence(): 475 | sig1 = Signature("input: str | None -> output: int | str") 476 | sig2 = Signature("input: Optional[str] -> output: Union[int, str]") 477 | 478 | # PEP 604 union types in inline signatures should be equivalent to Optional and Union types 479 | assert sig1.equals(sig2) 480 | 481 | # Check that the annotations are equivalent 482 | assert sig1.input_fields["input"].annotation == sig2.input_fields["input"].annotation 483 | assert sig1.output_fields["output"].annotation == sig2.output_fields["output"].annotation 484 | 485 | 486 | def test_pep604_union_type_inline_nested(): 487 | sig = Signature( 488 | "input: str | (int | float) | None -> output: str" 489 | ) 490 | assert "input" in sig.input_fields 491 | input_annotation = sig.input_fields["input"].annotation 492 | 493 | # Check for the correct union: Union[str, int, float, NoneType] 494 | assert getattr(input_annotation, "__origin__", None) is Union 495 | assert set(input_annotation.__args__) == {str, int, float, type(None)} 496 | 497 | 498 | def test_pep604_union_type_class_nested(): 499 | class Sig1(Signature): 500 | input: str | (int | float) | None = InputField() 501 | output: str = OutputField() 502 | 503 | assert "input" in Sig1.input_fields 504 | input_annotation = Sig1.input_fields["input"].annotation 505 | 506 | # Check for the correct union: UnionType[str, int, float, NoneType] 507 | assert isinstance(input_annotation, UnionType) 508 | assert set(input_annotation.__args__) == {str, int, float, type(None)} 509 | 510 | 511 | def test_pep604_union_type_class_equivalence(): 512 | class Sig1(Signature): 513 | input: str | None = InputField() 514 | output: int | str = OutputField() 515 | 516 | class Sig2(Signature): 517 | input: str | None = InputField() 518 | output: Union[int, str] = OutputField() # noqa: UP007 519 | 520 | # PEP 604 union types in class signatures should be equivalent to Optional and Union types 521 | assert Sig1.equals(Sig2) 522 | 523 | # Check that the annotations are equivalent 524 | assert Sig1.input_fields["input"].annotation == Sig2.input_fields["input"].annotation 525 | assert Sig1.output_fields["output"].annotation == Sig2.output_fields["output"].annotation 526 | 527 | # Check that the pep604 annotations are of type UnionType 528 | assert isinstance(Sig1.input_fields["input"].annotation, UnionType) 529 | assert isinstance(Sig1.output_fields["output"].annotation, UnionType) 530 | 531 | 532 | def test_pep604_union_type_insert(): 533 | class PEP604Signature(Signature): 534 | input: str | None = InputField() 535 | output: int | str = OutputField() 536 | 537 | # This test ensures that inserting a field into a signature with a PEP 604 UnionType works 538 | 539 | # Insert a new input field at the start 540 | NewSig = PEP604Signature.prepend("new_input", InputField(), float | int) 541 | assert "new_input" in NewSig.input_fields 542 | 543 | new_input_annotation = NewSig.input_fields["new_input"].annotation 544 | assert isinstance(new_input_annotation, UnionType) 545 | assert set(new_input_annotation.__args__) == {float, int} 546 | 547 | # The original union type field should still be present and correct 548 | input_annotation = NewSig.input_fields["input"].annotation 549 | output_annotation = NewSig.output_fields["output"].annotation 550 | 551 | assert isinstance(input_annotation, UnionType) 552 | assert str in input_annotation.__args__ and type(None) in input_annotation.__args__ 553 | 554 | assert isinstance(output_annotation, UnionType) 555 | assert set(output_annotation.__args__) == {int, str} 556 | 557 | 558 | def test_pep604_union_type_with_custom_types(): 559 | class CustomType(pydantic.BaseModel): 560 | value: str 561 | 562 | sig = Signature( 563 | "input: CustomType | None -> output: int | str", 564 | custom_types={"CustomType": CustomType} 565 | ) 566 | 567 | assert sig.input_fields["input"].annotation == Union[CustomType, None] 568 | assert sig.output_fields["output"].annotation == Union[int, str] 569 | 570 | lm = DummyLM([{"output": "processed"}]) 571 | dspy.settings.configure(lm=lm) 572 | 573 | custom_obj = CustomType(value="test") 574 | pred = dspy.Predict(sig)(input=custom_obj) 575 | assert pred.output == "processed" 576 | ``` -------------------------------------------------------------------------------- /tests/adapters/test_chat_adapter.py: -------------------------------------------------------------------------------- ```python 1 | from typing import Literal 2 | from unittest import mock 3 | 4 | import pydantic 5 | import pytest 6 | from litellm.utils import ChatCompletionMessageToolCall, Choices, Function, Message, ModelResponse 7 | 8 | import dspy 9 | 10 | 11 | @pytest.mark.parametrize( 12 | "input_literal, output_literal, input_value, expected_input_str, expected_output_str", 13 | [ 14 | # Scenario 1: double quotes escaped within strings 15 | ( 16 | Literal["one", "two", 'three"'], 17 | Literal["four", "five", 'six"'], 18 | "two", 19 | "Literal['one', 'two', 'three\"']", 20 | "Literal['four', 'five', 'six\"']", 21 | ), 22 | # Scenario 2: Single quotes inside strings 23 | ( 24 | Literal["she's here", "okay", "test"], 25 | Literal["done", "maybe'soon", "later"], 26 | "she's here", 27 | "Literal[\"she's here\", 'okay', 'test']", 28 | "Literal['done', \"maybe'soon\", 'later']", 29 | ), 30 | # Scenario 3: Strings containing both single and double quotes 31 | ( 32 | Literal["both\"and'", "another"], 33 | Literal["yet\"another'", "plain"], 34 | "another", 35 | "Literal['both\"and\\'', 'another']", 36 | "Literal['yet\"another\\'', 'plain']", 37 | ), 38 | # Scenario 4: No quotes at all (check the default) 39 | ( 40 | Literal["foo", "bar"], 41 | Literal["baz", "qux"], 42 | "foo", 43 | "Literal['foo', 'bar']", 44 | "Literal['baz', 'qux']", 45 | ), 46 | # Scenario 5: Mixed types 47 | ( 48 | Literal[1, "bar"], 49 | Literal[True, 3, "foo"], 50 | "bar", 51 | "Literal[1, 'bar']", 52 | "Literal[True, 3, 'foo']", 53 | ), 54 | ], 55 | ) 56 | def test_chat_adapter_quotes_literals_as_expected( 57 | input_literal, output_literal, input_value, expected_input_str, expected_output_str 58 | ): 59 | """ 60 | This test verifies that when we declare Literal fields with various mixes 61 | of single/double quotes, the generated content string includes those 62 | Literals exactly as we want them to appear (like IPython does). 63 | """ 64 | 65 | class TestSignature(dspy.Signature): 66 | input_text: input_literal = dspy.InputField() 67 | output_text: output_literal = dspy.OutputField() 68 | 69 | program = dspy.Predict(TestSignature) 70 | 71 | dspy.configure(lm=dspy.LM(model="openai/gpt-4o"), adapter=dspy.ChatAdapter()) 72 | 73 | with mock.patch("litellm.completion") as mock_completion: 74 | program(input_text=input_value) 75 | 76 | mock_completion.assert_called_once() 77 | _, call_kwargs = mock_completion.call_args 78 | content = call_kwargs["messages"][0]["content"] 79 | 80 | assert expected_input_str in content 81 | assert expected_output_str in content 82 | 83 | 84 | def test_chat_adapter_sync_call(): 85 | signature = dspy.make_signature("question->answer") 86 | adapter = dspy.ChatAdapter() 87 | lm = dspy.utils.DummyLM([{"answer": "Paris"}]) 88 | result = adapter(lm, {}, signature, [], {"question": "What is the capital of France?"}) 89 | assert result == [{"answer": "Paris"}] 90 | 91 | 92 | @pytest.mark.asyncio 93 | async def test_chat_adapter_async_call(): 94 | signature = dspy.make_signature("question->answer") 95 | adapter = dspy.ChatAdapter() 96 | lm = dspy.utils.DummyLM([{"answer": "Paris"}]) 97 | result = await adapter.acall(lm, {}, signature, [], {"question": "What is the capital of France?"}) 98 | assert result == [{"answer": "Paris"}] 99 | 100 | 101 | def test_chat_adapter_with_pydantic_models(): 102 | """ 103 | This test verifies that ChatAdapter can handle different input and output field types, both basic and nested. 104 | """ 105 | 106 | class DogClass(pydantic.BaseModel): 107 | dog_breeds: list[str] = pydantic.Field(description="List of the breeds of dogs") 108 | num_dogs: int = pydantic.Field(description="Number of dogs the owner has", ge=0, le=10) 109 | 110 | class PetOwner(pydantic.BaseModel): 111 | name: str = pydantic.Field(description="Name of the owner") 112 | num_pets: int = pydantic.Field(description="Amount of pets the owner has", ge=0, le=100) 113 | dogs: DogClass = pydantic.Field(description="Nested Pydantic class with dog specific information ") 114 | 115 | class Answer(pydantic.BaseModel): 116 | result: str 117 | analysis: str 118 | 119 | class TestSignature(dspy.Signature): 120 | owner: PetOwner = dspy.InputField() 121 | question: str = dspy.InputField() 122 | output: Answer = dspy.OutputField() 123 | 124 | dspy.configure(lm=dspy.LM(model="openai/gpt-4o"), adapter=dspy.ChatAdapter()) 125 | program = dspy.Predict(TestSignature) 126 | 127 | with mock.patch("litellm.completion") as mock_completion: 128 | program( 129 | owner=PetOwner(name="John", num_pets=5, dogs=DogClass(dog_breeds=["labrador", "chihuahua"], num_dogs=2)), 130 | question="How many non-dog pets does John have?", 131 | ) 132 | 133 | mock_completion.assert_called_once() 134 | _, call_kwargs = mock_completion.call_args 135 | 136 | system_content = call_kwargs["messages"][0]["content"] 137 | user_content = call_kwargs["messages"][1]["content"] 138 | assert "1. `owner` (PetOwner)" in system_content 139 | assert "2. `question` (str)" in system_content 140 | assert "1. `output` (Answer)" in system_content 141 | 142 | assert "name" in user_content 143 | assert "num_pets" in user_content 144 | assert "dogs" in user_content 145 | assert "dog_breeds" in user_content 146 | assert "num_dogs" in user_content 147 | assert "How many non-dog pets does John have?" in user_content 148 | 149 | 150 | def test_chat_adapter_signature_information(): 151 | """ 152 | This test ensures that the signature information sent to the LM follows an expected format. 153 | """ 154 | 155 | class TestSignature(dspy.Signature): 156 | input1: str = dspy.InputField(desc="String Input") 157 | input2: int = dspy.InputField(desc="Integer Input") 158 | output: str = dspy.OutputField(desc="String Output") 159 | 160 | dspy.configure(lm=dspy.LM(model="openai/gpt-4o"), adapter=dspy.ChatAdapter()) 161 | program = dspy.Predict(TestSignature) 162 | 163 | with mock.patch("litellm.completion") as mock_completion: 164 | program(input1="Test", input2=11) 165 | 166 | mock_completion.assert_called_once() 167 | _, call_kwargs = mock_completion.call_args 168 | 169 | assert len(call_kwargs["messages"]) == 2 170 | assert call_kwargs["messages"][0]["role"] == "system" 171 | assert call_kwargs["messages"][1]["role"] == "user" 172 | 173 | system_content = call_kwargs["messages"][0]["content"] 174 | user_content = call_kwargs["messages"][1]["content"] 175 | 176 | assert "1. `input1` (str)" in system_content 177 | assert "2. `input2` (int)" in system_content 178 | assert "1. `output` (str)" in system_content 179 | assert "[[ ## input1 ## ]]\n{input1}" in system_content 180 | assert "[[ ## input2 ## ]]\n{input2}" in system_content 181 | assert "[[ ## output ## ]]\n{output}" in system_content 182 | assert "[[ ## completed ## ]]" in system_content 183 | 184 | assert "[[ ## input1 ## ]]" in user_content 185 | assert "[[ ## input2 ## ]]" in user_content 186 | assert "[[ ## output ## ]]" in user_content 187 | assert "[[ ## completed ## ]]" in user_content 188 | 189 | 190 | def test_chat_adapter_exception_raised_on_failure(): 191 | """ 192 | This test ensures that on an error, ChatAdapter raises an explicit exception. 193 | """ 194 | signature = dspy.make_signature("question->answer") 195 | adapter = dspy.ChatAdapter() 196 | invalid_completion = "{'output':'mismatched value'}" 197 | with pytest.raises(dspy.utils.exceptions.AdapterParseError, match="Adapter ChatAdapter failed to parse*"): 198 | adapter.parse(signature, invalid_completion) 199 | 200 | 201 | def test_chat_adapter_formats_image(): 202 | # Test basic image formatting 203 | image = dspy.Image(url="https://example.com/image.jpg") 204 | 205 | class MySignature(dspy.Signature): 206 | image: dspy.Image = dspy.InputField() 207 | text: str = dspy.OutputField() 208 | 209 | adapter = dspy.ChatAdapter() 210 | messages = adapter.format(MySignature, [], {"image": image}) 211 | 212 | assert len(messages) == 2 213 | user_message_content = messages[1]["content"] 214 | assert user_message_content is not None 215 | 216 | # The message should have 3 chunks of types: text, image_url, text 217 | assert len(user_message_content) == 3 218 | assert user_message_content[0]["type"] == "text" 219 | assert user_message_content[2]["type"] == "text" 220 | 221 | # Assert that the image is formatted correctly 222 | expected_image_content = {"type": "image_url", "image_url": {"url": "https://example.com/image.jpg"}} 223 | assert expected_image_content in user_message_content 224 | 225 | 226 | def test_chat_adapter_formats_image_with_few_shot_examples(): 227 | class MySignature(dspy.Signature): 228 | image: dspy.Image = dspy.InputField() 229 | text: str = dspy.OutputField() 230 | 231 | adapter = dspy.ChatAdapter() 232 | 233 | demos = [ 234 | dspy.Example( 235 | image=dspy.Image(url="https://example.com/image1.jpg"), 236 | text="This is a test image", 237 | ), 238 | dspy.Example( 239 | image=dspy.Image(url="https://example.com/image2.jpg"), 240 | text="This is another test image", 241 | ), 242 | ] 243 | messages = adapter.format(MySignature, demos, {"image": dspy.Image(url="https://example.com/image3.jpg")}) 244 | 245 | # 1 system message, 2 few shot examples (1 user and assistant message for each example), 1 user message 246 | assert len(messages) == 6 247 | 248 | assert "[[ ## completed ## ]]\n" in messages[2]["content"] 249 | assert "[[ ## completed ## ]]\n" in messages[4]["content"] 250 | 251 | assert {"type": "image_url", "image_url": {"url": "https://example.com/image1.jpg"}} in messages[1]["content"] 252 | assert {"type": "image_url", "image_url": {"url": "https://example.com/image2.jpg"}} in messages[3]["content"] 253 | assert {"type": "image_url", "image_url": {"url": "https://example.com/image3.jpg"}} in messages[5]["content"] 254 | 255 | 256 | def test_chat_adapter_formats_image_with_nested_images(): 257 | class ImageWrapper(pydantic.BaseModel): 258 | images: list[dspy.Image] 259 | tag: list[str] 260 | 261 | class MySignature(dspy.Signature): 262 | image: ImageWrapper = dspy.InputField() 263 | text: str = dspy.OutputField() 264 | 265 | image1 = dspy.Image(url="https://example.com/image1.jpg") 266 | image2 = dspy.Image(url="https://example.com/image2.jpg") 267 | image3 = dspy.Image(url="https://example.com/image3.jpg") 268 | 269 | image_wrapper = ImageWrapper(images=[image1, image2, image3], tag=["test", "example"]) 270 | 271 | adapter = dspy.ChatAdapter() 272 | messages = adapter.format(MySignature, [], {"image": image_wrapper}) 273 | 274 | expected_image1_content = {"type": "image_url", "image_url": {"url": "https://example.com/image1.jpg"}} 275 | expected_image2_content = {"type": "image_url", "image_url": {"url": "https://example.com/image2.jpg"}} 276 | expected_image3_content = {"type": "image_url", "image_url": {"url": "https://example.com/image3.jpg"}} 277 | 278 | assert expected_image1_content in messages[1]["content"] 279 | assert expected_image2_content in messages[1]["content"] 280 | assert expected_image3_content in messages[1]["content"] 281 | 282 | 283 | def test_chat_adapter_formats_image_with_few_shot_examples_with_nested_images(): 284 | class ImageWrapper(pydantic.BaseModel): 285 | images: list[dspy.Image] 286 | tag: list[str] 287 | 288 | class MySignature(dspy.Signature): 289 | image: ImageWrapper = dspy.InputField() 290 | text: str = dspy.OutputField() 291 | 292 | image1 = dspy.Image(url="https://example.com/image1.jpg") 293 | image2 = dspy.Image(url="https://example.com/image2.jpg") 294 | image3 = dspy.Image(url="https://example.com/image3.jpg") 295 | 296 | image_wrapper = ImageWrapper(images=[image1, image2, image3], tag=["test", "example"]) 297 | demos = [ 298 | dspy.Example( 299 | image=image_wrapper, 300 | text="This is a test image", 301 | ), 302 | ] 303 | 304 | image_wrapper_2 = ImageWrapper(images=[dspy.Image(url="https://example.com/image4.jpg")], tag=["test", "example"]) 305 | adapter = dspy.ChatAdapter() 306 | messages = adapter.format(MySignature, demos, {"image": image_wrapper_2}) 307 | 308 | assert len(messages) == 4 309 | 310 | # Image information in the few-shot example's user message 311 | expected_image1_content = {"type": "image_url", "image_url": {"url": "https://example.com/image1.jpg"}} 312 | expected_image2_content = {"type": "image_url", "image_url": {"url": "https://example.com/image2.jpg"}} 313 | expected_image3_content = {"type": "image_url", "image_url": {"url": "https://example.com/image3.jpg"}} 314 | assert expected_image1_content in messages[1]["content"] 315 | assert expected_image2_content in messages[1]["content"] 316 | assert expected_image3_content in messages[1]["content"] 317 | 318 | # The query image is formatted in the last user message 319 | assert {"type": "image_url", "image_url": {"url": "https://example.com/image4.jpg"}} in messages[-1]["content"] 320 | 321 | 322 | def test_chat_adapter_with_tool(): 323 | class MySignature(dspy.Signature): 324 | """Answer question with the help of the tools""" 325 | 326 | question: str = dspy.InputField() 327 | tools: list[dspy.Tool] = dspy.InputField() 328 | answer: str = dspy.OutputField() 329 | tool_calls: dspy.ToolCalls = dspy.OutputField() 330 | 331 | def get_weather(city: str) -> str: 332 | """Get the weather for a city""" 333 | return f"The weather in {city} is sunny" 334 | 335 | def get_population(country: str, year: int) -> str: 336 | """Get the population for a country""" 337 | return f"The population of {country} in {year} is 1000000" 338 | 339 | tools = [dspy.Tool(get_weather), dspy.Tool(get_population)] 340 | 341 | adapter = dspy.ChatAdapter() 342 | messages = adapter.format(MySignature, [], {"question": "What is the weather in Tokyo?", "tools": tools}) 343 | 344 | assert len(messages) == 2 345 | 346 | # The output field type description should be included in the system message even if the output field is nested 347 | assert dspy.ToolCalls.description() in messages[0]["content"] 348 | 349 | # The user message should include the question and the tools 350 | assert "What is the weather in Tokyo?" in messages[1]["content"] 351 | assert "get_weather" in messages[1]["content"] 352 | assert "get_population" in messages[1]["content"] 353 | 354 | # Tool arguments format should be included in the user message 355 | assert "{'city': {'type': 'string'}}" in messages[1]["content"] 356 | assert "{'country': {'type': 'string'}, 'year': {'type': 'integer'}}" in messages[1]["content"] 357 | 358 | 359 | def test_chat_adapter_with_code(): 360 | # Test with code as input field 361 | class CodeAnalysis(dspy.Signature): 362 | """Analyze the time complexity of the code""" 363 | 364 | code: dspy.Code = dspy.InputField() 365 | result: str = dspy.OutputField() 366 | 367 | adapter = dspy.ChatAdapter() 368 | messages = adapter.format(CodeAnalysis, [], {"code": "print('Hello, world!')"}) 369 | 370 | assert len(messages) == 2 371 | 372 | # The output field type description should be included in the system message even if the output field is nested 373 | assert dspy.Code.description() in messages[0]["content"] 374 | 375 | # The user message should include the question and the tools 376 | assert "print('Hello, world!')" in messages[1]["content"] 377 | 378 | # Test with code as output field 379 | class CodeGeneration(dspy.Signature): 380 | """Generate code to answer the question""" 381 | 382 | question: str = dspy.InputField() 383 | code: dspy.Code = dspy.OutputField() 384 | 385 | adapter = dspy.ChatAdapter() 386 | with mock.patch("litellm.completion") as mock_completion: 387 | mock_completion.return_value = ModelResponse( 388 | choices=[Choices(message=Message(content='[[ ## code ## ]]\nprint("Hello, world!")'))], 389 | model="openai/gpt-4o-mini", 390 | ) 391 | result = adapter( 392 | dspy.LM(model="openai/gpt-4o-mini", cache=False), 393 | {}, 394 | CodeGeneration, 395 | [], 396 | {"question": "Write a python program to print 'Hello, world!'"}, 397 | ) 398 | assert result[0]["code"].code == 'print("Hello, world!")' 399 | 400 | 401 | def test_chat_adapter_formats_conversation_history(): 402 | class MySignature(dspy.Signature): 403 | question: str = dspy.InputField() 404 | history: dspy.History = dspy.InputField() 405 | answer: str = dspy.OutputField() 406 | 407 | history = dspy.History( 408 | messages=[ 409 | {"question": "What is the capital of France?", "answer": "Paris"}, 410 | {"question": "What is the capital of Germany?", "answer": "Berlin"}, 411 | ] 412 | ) 413 | 414 | adapter = dspy.ChatAdapter() 415 | messages = adapter.format(MySignature, [], {"question": "What is the capital of France?", "history": history}) 416 | 417 | assert len(messages) == 6 418 | assert messages[1]["content"] == "[[ ## question ## ]]\nWhat is the capital of France?" 419 | assert messages[2]["content"] == "[[ ## answer ## ]]\nParis\n\n[[ ## completed ## ]]\n" 420 | assert messages[3]["content"] == "[[ ## question ## ]]\nWhat is the capital of Germany?" 421 | assert messages[4]["content"] == "[[ ## answer ## ]]\nBerlin\n\n[[ ## completed ## ]]\n" 422 | 423 | 424 | def test_chat_adapter_fallback_to_json_adapter_on_exception(): 425 | signature = dspy.make_signature("question->answer") 426 | adapter = dspy.ChatAdapter() 427 | 428 | with mock.patch("litellm.completion") as mock_completion: 429 | # Mock returning a response compatible with JSONAdapter but not ChatAdapter 430 | mock_completion.return_value = ModelResponse( 431 | choices=[Choices(message=Message(content="{'answer': 'Paris'}"))], 432 | model="openai/gpt-4o-mini", 433 | ) 434 | 435 | lm = dspy.LM("openai/gpt-4o-mini", cache=False) 436 | 437 | with mock.patch("dspy.adapters.json_adapter.JSONAdapter.__call__") as mock_json_adapter_call: 438 | adapter(lm, {}, signature, [], {"question": "What is the capital of France?"}) 439 | mock_json_adapter_call.assert_called_once() 440 | 441 | # The parse should succeed 442 | result = adapter(lm, {}, signature, [], {"question": "What is the capital of France?"}) 443 | assert result == [{"answer": "Paris"}] 444 | 445 | 446 | @pytest.mark.asyncio 447 | async def test_chat_adapter_fallback_to_json_adapter_on_exception_async(): 448 | signature = dspy.make_signature("question->answer") 449 | adapter = dspy.ChatAdapter() 450 | 451 | with mock.patch("litellm.acompletion") as mock_completion: 452 | # Mock returning a response compatible with JSONAdapter but not ChatAdapter 453 | mock_completion.return_value = ModelResponse( 454 | choices=[Choices(message=Message(content="{'answer': 'Paris'}"))], 455 | model="openai/gpt-4o-mini", 456 | ) 457 | 458 | lm = dspy.LM("openai/gpt-4o-mini", cache=False) 459 | 460 | with mock.patch("dspy.adapters.json_adapter.JSONAdapter.acall") as mock_json_adapter_acall: 461 | await adapter.acall(lm, {}, signature, [], {"question": "What is the capital of France?"}) 462 | mock_json_adapter_acall.assert_called_once() 463 | 464 | # The parse should succeed 465 | result = await adapter.acall(lm, {}, signature, [], {"question": "What is the capital of France?"}) 466 | assert result == [{"answer": "Paris"}] 467 | 468 | 469 | def test_chat_adapter_toolcalls_native_function_calling(): 470 | class MySignature(dspy.Signature): 471 | question: str = dspy.InputField() 472 | tools: list[dspy.Tool] = dspy.InputField() 473 | answer: str = dspy.OutputField() 474 | tool_calls: dspy.ToolCalls = dspy.OutputField() 475 | 476 | def get_weather(city: str) -> str: 477 | return f"The weather in {city} is sunny" 478 | 479 | tools = [dspy.Tool(get_weather)] 480 | 481 | adapter = dspy.JSONAdapter(use_native_function_calling=True) 482 | 483 | # Case 1: Tool calls are present in the response, while content is None. 484 | with mock.patch("litellm.completion") as mock_completion: 485 | mock_completion.return_value = ModelResponse( 486 | choices=[ 487 | Choices( 488 | finish_reason="tool_calls", 489 | index=0, 490 | message=Message( 491 | content=None, 492 | role="assistant", 493 | tool_calls=[ 494 | ChatCompletionMessageToolCall( 495 | function=Function(arguments='{"city":"Paris"}', name="get_weather"), 496 | id="call_pQm8ajtSMxgA0nrzK2ivFmxG", 497 | type="function", 498 | ) 499 | ], 500 | ), 501 | ), 502 | ], 503 | model="openai/gpt-4o-mini", 504 | ) 505 | result = adapter( 506 | dspy.LM(model="openai/gpt-4o-mini", cache=False), 507 | {}, 508 | MySignature, 509 | [], 510 | {"question": "What is the weather in Paris?", "tools": tools}, 511 | ) 512 | 513 | assert result[0]["tool_calls"] == dspy.ToolCalls( 514 | tool_calls=[dspy.ToolCalls.ToolCall(name="get_weather", args={"city": "Paris"})] 515 | ) 516 | # `answer` is not present, so we set it to None 517 | assert result[0]["answer"] is None 518 | 519 | # Case 2: Tool calls are not present in the response, while content is present. 520 | with mock.patch("litellm.completion") as mock_completion: 521 | mock_completion.return_value = ModelResponse( 522 | choices=[Choices(message=Message(content="{'answer': 'Paris'}"))], 523 | model="openai/gpt-4o-mini", 524 | ) 525 | result = adapter( 526 | dspy.LM(model="openai/gpt-4o-mini", cache=False), 527 | {}, 528 | MySignature, 529 | [], 530 | {"question": "What is the weather in Paris?", "tools": tools}, 531 | ) 532 | assert result[0]["answer"] == "Paris" 533 | assert result[0]["tool_calls"] is None 534 | 535 | 536 | def test_chat_adapter_toolcalls_vague_match(): 537 | class MySignature(dspy.Signature): 538 | question: str = dspy.InputField() 539 | tools: list[dspy.Tool] = dspy.InputField() 540 | tool_calls: dspy.ToolCalls = dspy.OutputField() 541 | 542 | def get_weather(city: str) -> str: 543 | return f"The weather in {city} is sunny" 544 | 545 | tools = [dspy.Tool(get_weather)] 546 | 547 | adapter = dspy.ChatAdapter() 548 | 549 | with mock.patch("litellm.completion") as mock_completion: 550 | # Case 1: tool_calls field is a list of dicts 551 | mock_completion.return_value = ModelResponse( 552 | choices=[ 553 | Choices( 554 | message=Message( 555 | content="[[ ## tool_calls ## ]]\n[{'name': 'get_weather', 'args': {'city': 'Paris'}]" 556 | ) 557 | ) 558 | ], 559 | model="openai/gpt-4o-mini", 560 | ) 561 | result = adapter( 562 | dspy.LM(model="openai/gpt-4o-mini", cache=False), 563 | {}, 564 | MySignature, 565 | [], 566 | {"question": "What is the weather in Paris?", "tools": tools}, 567 | ) 568 | assert result[0]["tool_calls"] == dspy.ToolCalls( 569 | tool_calls=[dspy.ToolCalls.ToolCall(name="get_weather", args={"city": "Paris"})] 570 | ) 571 | 572 | with mock.patch("litellm.completion") as mock_completion: 573 | # Case 2: tool_calls field is a single dict with "name" and "args" keys 574 | mock_completion.return_value = ModelResponse( 575 | choices=[ 576 | Choices( 577 | message=Message( 578 | content="[[ ## tool_calls ## ]]\n{'name': 'get_weather', 'args': {'city': 'Paris'}}" 579 | ) 580 | ) 581 | ], 582 | model="openai/gpt-4o-mini", 583 | ) 584 | result = adapter( 585 | dspy.LM(model="openai/gpt-4o-mini", cache=False), 586 | {}, 587 | MySignature, 588 | [], 589 | {"question": "What is the weather in Paris?", "tools": tools}, 590 | ) 591 | assert result[0]["tool_calls"] == dspy.ToolCalls( 592 | tool_calls=[dspy.ToolCalls.ToolCall(name="get_weather", args={"city": "Paris"})] 593 | ) 594 | ``` -------------------------------------------------------------------------------- /docs/docs/tutorials/ai_text_game/index.md: -------------------------------------------------------------------------------- ```markdown 1 | # Building a Creative Text-Based AI Game with DSPy 2 | 3 | This tutorial demonstrates how to create an interactive text-based adventure game using DSPy's modular programming approach. You'll build a dynamic game where AI handles narrative generation, character interactions, and adaptive gameplay. 4 | 5 | ## What You'll Build 6 | 7 | An intelligent text-based adventure game featuring: 8 | 9 | - Dynamic story generation and branching narratives 10 | - AI-powered character interactions and dialogue 11 | - Adaptive gameplay that responds to player choices 12 | - Inventory and character progression systems 13 | - Save/load game state functionality 14 | 15 | ## Setup 16 | 17 | ```bash 18 | pip install dspy rich typer 19 | ``` 20 | 21 | ## Step 1: Core Game Framework 22 | 23 | ```python 24 | import dspy 25 | import json 26 | from typing import Dict, List, Optional, Any 27 | from dataclasses import dataclass, field 28 | from enum import Enum 29 | import random 30 | from rich.console import Console 31 | from rich.panel import Panel 32 | from rich.text import Text 33 | import typer 34 | 35 | # Configure DSPy 36 | lm = dspy.LM(model='openai/gpt-4o-mini') 37 | dspy.configure(lm=lm) 38 | 39 | console = Console() 40 | 41 | class GameState(Enum): 42 | MENU = "menu" 43 | PLAYING = "playing" 44 | INVENTORY = "inventory" 45 | CHARACTER = "character" 46 | GAME_OVER = "game_over" 47 | 48 | @dataclass 49 | class Player: 50 | name: str 51 | health: int = 100 52 | level: int = 1 53 | experience: int = 0 54 | inventory: list[str] = field(default_factory=list) 55 | skills: dict[str, int] = field(default_factory=lambda: { 56 | "strength": 10, 57 | "intelligence": 10, 58 | "charisma": 10, 59 | "stealth": 10 60 | }) 61 | 62 | def add_item(self, item: str): 63 | self.inventory.append(item) 64 | console.print(f"[green]Added {item} to inventory![/green]") 65 | 66 | def remove_item(self, item: str) -> bool: 67 | if item in self.inventory: 68 | self.inventory.remove(item) 69 | return True 70 | return False 71 | 72 | def gain_experience(self, amount: int): 73 | self.experience += amount 74 | old_level = self.level 75 | self.level = 1 + (self.experience // 100) 76 | if self.level > old_level: 77 | console.print(f"[bold yellow]Level up! You are now level {self.level}![/bold yellow]") 78 | 79 | @dataclass 80 | class GameContext: 81 | current_location: str = "Village Square" 82 | story_progress: int = 0 83 | visited_locations: list[str] = field(default_factory=list) 84 | npcs_met: list[str] = field(default_factory=list) 85 | completed_quests: list[str] = field(default_factory=list) 86 | game_flags: dict[str, bool] = field(default_factory=dict) 87 | 88 | def add_flag(self, flag: str, value: bool = True): 89 | self.game_flags[flag] = value 90 | 91 | def has_flag(self, flag: str) -> bool: 92 | return self.game_flags.get(flag, False) 93 | 94 | class GameEngine: 95 | def __init__(self): 96 | self.player = None 97 | self.context = GameContext() 98 | self.state = GameState.MENU 99 | self.running = True 100 | 101 | def save_game(self, filename: str = "savegame.json"): 102 | """Save current game state.""" 103 | save_data = { 104 | "player": { 105 | "name": self.player.name, 106 | "health": self.player.health, 107 | "level": self.player.level, 108 | "experience": self.player.experience, 109 | "inventory": self.player.inventory, 110 | "skills": self.player.skills 111 | }, 112 | "context": { 113 | "current_location": self.context.current_location, 114 | "story_progress": self.context.story_progress, 115 | "visited_locations": self.context.visited_locations, 116 | "npcs_met": self.context.npcs_met, 117 | "completed_quests": self.context.completed_quests, 118 | "game_flags": self.context.game_flags 119 | } 120 | } 121 | 122 | with open(filename, 'w') as f: 123 | json.dump(save_data, f, indent=2) 124 | console.print(f"[green]Game saved to {filename}![/green]") 125 | 126 | def load_game(self, filename: str = "savegame.json") -> bool: 127 | """Load game state from file.""" 128 | try: 129 | with open(filename, 'r') as f: 130 | save_data = json.load(f) 131 | 132 | # Reconstruct player 133 | player_data = save_data["player"] 134 | self.player = Player( 135 | name=player_data["name"], 136 | health=player_data["health"], 137 | level=player_data["level"], 138 | experience=player_data["experience"], 139 | inventory=player_data["inventory"], 140 | skills=player_data["skills"] 141 | ) 142 | 143 | # Reconstruct context 144 | context_data = save_data["context"] 145 | self.context = GameContext( 146 | current_location=context_data["current_location"], 147 | story_progress=context_data["story_progress"], 148 | visited_locations=context_data["visited_locations"], 149 | npcs_met=context_data["npcs_met"], 150 | completed_quests=context_data["completed_quests"], 151 | game_flags=context_data["game_flags"] 152 | ) 153 | 154 | console.print(f"[green]Game loaded from {filename}![/green]") 155 | return True 156 | 157 | except FileNotFoundError: 158 | console.print(f"[red]Save file {filename} not found![/red]") 159 | return False 160 | except Exception as e: 161 | console.print(f"[red]Error loading game: {e}![/red]") 162 | return False 163 | 164 | # Initialize game engine 165 | game = GameEngine() 166 | ``` 167 | 168 | ## Step 2: AI-Powered Story Generation 169 | 170 | ```python 171 | class StoryGenerator(dspy.Signature): 172 | """Generate dynamic story content based on current game state.""" 173 | location: str = dspy.InputField(desc="Current location") 174 | player_info: str = dspy.InputField(desc="Player information and stats") 175 | story_progress: int = dspy.InputField(desc="Current story progress level") 176 | recent_actions: str = dspy.InputField(desc="Player's recent actions") 177 | 178 | scene_description: str = dspy.OutputField(desc="Vivid description of current scene") 179 | available_actions: list[str] = dspy.OutputField(desc="List of possible player actions") 180 | npcs_present: list[str] = dspy.OutputField(desc="NPCs present in this location") 181 | items_available: list[str] = dspy.OutputField(desc="Items that can be found or interacted with") 182 | 183 | class DialogueGenerator(dspy.Signature): 184 | """Generate NPC dialogue and responses.""" 185 | npc_name: str = dspy.InputField(desc="Name and type of NPC") 186 | npc_personality: str = dspy.InputField(desc="NPC personality and background") 187 | player_input: str = dspy.InputField(desc="What the player said or did") 188 | context: str = dspy.InputField(desc="Current game context and history") 189 | 190 | npc_response: str = dspy.OutputField(desc="NPC's dialogue response") 191 | mood_change: str = dspy.OutputField(desc="How NPC's mood changed (positive/negative/neutral)") 192 | quest_offered: bool = dspy.OutputField(desc="Whether NPC offers a quest") 193 | information_revealed: str = dspy.OutputField(desc="Any important information shared") 194 | 195 | class ActionResolver(dspy.Signature): 196 | """Resolve player actions and determine outcomes.""" 197 | action: str = dspy.InputField(desc="Player's chosen action") 198 | player_stats: str = dspy.InputField(desc="Player's current stats and skills") 199 | context: str = dspy.InputField(desc="Current game context") 200 | difficulty: str = dspy.InputField(desc="Difficulty level of the action") 201 | 202 | success: bool = dspy.OutputField(desc="Whether the action succeeded") 203 | outcome_description: str = dspy.OutputField(desc="Description of what happened") 204 | stat_changes: dict[str, int] = dspy.OutputField(desc="Changes to player stats") 205 | items_gained: list[str] = dspy.OutputField(desc="Items gained from this action") 206 | experience_gained: int = dspy.OutputField(desc="Experience points gained") 207 | 208 | class GameAI(dspy.Module): 209 | """Main AI module for game logic and narrative.""" 210 | 211 | def __init__(self): 212 | super().__init__() 213 | self.story_gen = dspy.ChainOfThought(StoryGenerator) 214 | self.dialogue_gen = dspy.ChainOfThought(DialogueGenerator) 215 | self.action_resolver = dspy.ChainOfThought(ActionResolver) 216 | 217 | def generate_scene(self, player: Player, context: GameContext, recent_actions: str = "") -> Dict: 218 | """Generate current scene description and options.""" 219 | 220 | player_info = f"Level {player.level} {player.name}, Health: {player.health}, Skills: {player.skills}" 221 | 222 | scene = self.story_gen( 223 | location=context.current_location, 224 | player_info=player_info, 225 | story_progress=context.story_progress, 226 | recent_actions=recent_actions 227 | ) 228 | 229 | return { 230 | "description": scene.scene_description, 231 | "actions": scene.available_actions, 232 | "npcs": scene.npcs_present, 233 | "items": scene.items_available 234 | } 235 | 236 | def handle_dialogue(self, npc_name: str, player_input: str, context: GameContext) -> Dict: 237 | """Handle conversation with NPCs.""" 238 | 239 | # Create NPC personality based on name and context 240 | personality_map = { 241 | "Village Elder": "Wise, knowledgeable, speaks in riddles, has ancient knowledge", 242 | "Merchant": "Greedy but fair, loves to bargain, knows about valuable items", 243 | "Guard": "Dutiful, suspicious of strangers, follows rules strictly", 244 | "Thief": "Sneaky, untrustworthy, has information about hidden things", 245 | "Wizard": "Mysterious, powerful, speaks about magic and ancient forces" 246 | } 247 | 248 | personality = personality_map.get(npc_name, "Friendly villager with local knowledge") 249 | game_context = f"Location: {context.current_location}, Story progress: {context.story_progress}" 250 | 251 | response = self.dialogue_gen( 252 | npc_name=npc_name, 253 | npc_personality=personality, 254 | player_input=player_input, 255 | context=game_context 256 | ) 257 | 258 | return { 259 | "response": response.npc_response, 260 | "mood": response.mood_change, 261 | "quest": response.quest_offered, 262 | "info": response.information_revealed 263 | } 264 | 265 | def resolve_action(self, action: str, player: Player, context: GameContext) -> Dict: 266 | """Resolve player actions and determine outcomes.""" 267 | 268 | player_stats = f"Level {player.level}, Health {player.health}, Skills: {player.skills}" 269 | game_context = f"Location: {context.current_location}, Progress: {context.story_progress}" 270 | 271 | # Determine difficulty based on action type 272 | difficulty = "medium" 273 | if any(word in action.lower() for word in ["fight", "battle", "attack"]): 274 | difficulty = "hard" 275 | elif any(word in action.lower() for word in ["look", "examine", "talk"]): 276 | difficulty = "easy" 277 | 278 | result = self.action_resolver( 279 | action=action, 280 | player_stats=player_stats, 281 | context=game_context, 282 | difficulty=difficulty 283 | ) 284 | 285 | return { 286 | "success": result.success, 287 | "description": result.outcome_description, 288 | "stat_changes": result.stat_changes, 289 | "items": result.items_gained, 290 | "experience": result.experience_gained 291 | } 292 | 293 | # Initialize AI 294 | ai = GameAI() 295 | ``` 296 | 297 | ## Step 3: Game Interface and Interaction 298 | 299 | ```python 300 | def display_game_header(): 301 | """Display the game header.""" 302 | header = Text("🏰 MYSTIC REALM ADVENTURE 🏰", style="bold magenta") 303 | console.print(Panel(header, style="bright_blue")) 304 | 305 | def display_player_status(player: Player): 306 | """Display player status panel.""" 307 | status = f""" 308 | [bold]Name:[/bold] {player.name} 309 | [bold]Level:[/bold] {player.level} (XP: {player.experience}) 310 | [bold]Health:[/bold] {player.health}/100 311 | [bold]Skills:[/bold] 312 | • Strength: {player.skills['strength']} 313 | • Intelligence: {player.skills['intelligence']} 314 | • Charisma: {player.skills['charisma']} 315 | • Stealth: {player.skills['stealth']} 316 | [bold]Inventory:[/bold] {len(player.inventory)} items 317 | """ 318 | console.print(Panel(status.strip(), title="Player Status", style="green")) 319 | 320 | def display_location(context: GameContext, scene: Dict): 321 | """Display current location and scene.""" 322 | location_panel = f""" 323 | [bold yellow]{context.current_location}[/bold yellow] 324 | 325 | {scene['description']} 326 | """ 327 | 328 | if scene['npcs']: 329 | location_panel += f"\n\n[bold]NPCs present:[/bold] {', '.join(scene['npcs'])}" 330 | 331 | if scene['items']: 332 | location_panel += f"\n[bold]Items visible:[/bold] {', '.join(scene['items'])}" 333 | 334 | console.print(Panel(location_panel.strip(), title="Current Location", style="cyan")) 335 | 336 | def display_actions(actions: list[str]): 337 | """Display available actions.""" 338 | action_text = "\n".join([f"{i+1}. {action}" for i, action in enumerate(actions)]) 339 | console.print(Panel(action_text, title="Available Actions", style="yellow")) 340 | 341 | def get_player_choice(max_choices: int) -> int: 342 | """Get player's choice with input validation.""" 343 | while True: 344 | try: 345 | choice = typer.prompt("Choose an action (number)") 346 | choice_num = int(choice) 347 | if 1 <= choice_num <= max_choices: 348 | return choice_num - 1 349 | else: 350 | console.print(f"[red]Please enter a number between 1 and {max_choices}[/red]") 351 | except ValueError: 352 | console.print("[red]Please enter a valid number[/red]") 353 | 354 | def show_inventory(player: Player): 355 | """Display player inventory.""" 356 | if not player.inventory: 357 | console.print(Panel("Your inventory is empty.", title="Inventory", style="red")) 358 | else: 359 | items = "\n".join([f"• {item}" for item in player.inventory]) 360 | console.print(Panel(items, title="Inventory", style="green")) 361 | 362 | def main_menu(): 363 | """Display main menu and handle selection.""" 364 | console.clear() 365 | display_game_header() 366 | 367 | menu_options = [ 368 | "1. New Game", 369 | "2. Load Game", 370 | "3. How to Play", 371 | "4. Exit" 372 | ] 373 | 374 | menu_text = "\n".join(menu_options) 375 | console.print(Panel(menu_text, title="Main Menu", style="bright_blue")) 376 | 377 | choice = typer.prompt("Select an option") 378 | return choice 379 | 380 | def show_help(): 381 | """Display help information.""" 382 | help_text = """ 383 | [bold]How to Play:[/bold] 384 | 385 | • This is a text-based adventure game powered by AI 386 | • Make choices by selecting numbered options 387 | • Talk to NPCs to learn about the world and get quests 388 | • Explore different locations to find items and adventures 389 | • Your choices affect the story and character development 390 | • Use 'inventory' to check your items 391 | • Use 'status' to see your character info 392 | • Type 'save' to save your progress 393 | • Type 'quit' to return to main menu 394 | 395 | [bold]Tips:[/bold] 396 | • Different skills affect your success in various actions 397 | • NPCs remember your previous interactions 398 | • Explore thoroughly - there are hidden secrets! 399 | • Your reputation affects how NPCs treat you 400 | """ 401 | console.print(Panel(help_text.strip(), title="Game Help", style="blue")) 402 | typer.prompt("Press Enter to continue") 403 | ``` 404 | 405 | ## Step 4: Main Game Loop 406 | 407 | ```python 408 | def create_new_character(): 409 | """Create a new player character.""" 410 | console.clear() 411 | display_game_header() 412 | 413 | name = typer.prompt("Enter your character's name") 414 | 415 | # Character creation with skill point allocation 416 | console.print("\n[bold]Character Creation[/bold]") 417 | console.print("You have 10 extra skill points to distribute among your skills.") 418 | console.print("Base skills start at 10 each.\n") 419 | 420 | skills = {"strength": 10, "intelligence": 10, "charisma": 10, "stealth": 10} 421 | points_remaining = 10 422 | 423 | for skill in skills.keys(): 424 | if points_remaining > 0: 425 | console.print(f"Points remaining: {points_remaining}") 426 | while True: 427 | try: 428 | points = int(typer.prompt(f"Points to add to {skill} (0-{points_remaining})")) 429 | if 0 <= points <= points_remaining: 430 | skills[skill] += points 431 | points_remaining -= points 432 | break 433 | else: 434 | console.print(f"[red]Enter a number between 0 and {points_remaining}[/red]") 435 | except ValueError: 436 | console.print("[red]Please enter a valid number[/red]") 437 | 438 | player = Player(name=name, skills=skills) 439 | console.print(f"\n[green]Welcome to Mystic Realm, {name}![/green]") 440 | return player 441 | 442 | def game_loop(): 443 | """Main game loop.""" 444 | recent_actions = "" 445 | 446 | while game.running and game.state == GameState.PLAYING: 447 | console.clear() 448 | display_game_header() 449 | 450 | # Generate current scene 451 | scene = ai.generate_scene(game.player, game.context, recent_actions) 452 | 453 | # Display game state 454 | display_player_status(game.player) 455 | display_location(game.context, scene) 456 | 457 | # Add standard actions 458 | all_actions = scene['actions'] + ["Check inventory", "Character status", "Save game", "Quit to menu"] 459 | display_actions(all_actions) 460 | 461 | # Get player choice 462 | choice_idx = get_player_choice(len(all_actions)) 463 | chosen_action = all_actions[choice_idx] 464 | 465 | # Handle special commands 466 | if chosen_action == "Check inventory": 467 | show_inventory(game.player) 468 | typer.prompt("Press Enter to continue") 469 | continue 470 | elif chosen_action == "Character status": 471 | display_player_status(game.player) 472 | typer.prompt("Press Enter to continue") 473 | continue 474 | elif chosen_action == "Save game": 475 | game.save_game() 476 | typer.prompt("Press Enter to continue") 477 | continue 478 | elif chosen_action == "Quit to menu": 479 | game.state = GameState.MENU 480 | break 481 | 482 | # Handle game actions 483 | if chosen_action in scene['actions']: 484 | # Check if it's dialogue with an NPC 485 | npc_target = None 486 | for npc in scene['npcs']: 487 | if npc.lower() in chosen_action.lower(): 488 | npc_target = npc 489 | break 490 | 491 | if npc_target: 492 | # Handle NPC interaction 493 | console.print(f"\n[bold]Talking to {npc_target}...[/bold]") 494 | dialogue = ai.handle_dialogue(npc_target, chosen_action, game.context) 495 | 496 | console.print(f"\n[italic]{npc_target}:[/italic] \"{dialogue['response']}\"") 497 | 498 | if dialogue['quest']: 499 | console.print(f"[yellow]💼 Quest opportunity detected![/yellow]") 500 | 501 | if dialogue['info']: 502 | console.print(f"[blue]ℹ️ {dialogue['info']}[/blue]") 503 | 504 | # Add NPC to met list 505 | if npc_target not in game.context.npcs_met: 506 | game.context.npcs_met.append(npc_target) 507 | 508 | recent_actions = f"Talked to {npc_target}: {chosen_action}" 509 | else: 510 | # Handle general action 511 | result = ai.resolve_action(chosen_action, game.player, game.context) 512 | 513 | console.print(f"\n{result['description']}") 514 | 515 | # Apply results 516 | if result['success']: 517 | console.print("[green]✅ Success![/green]") 518 | 519 | # Apply stat changes 520 | for stat, change in result['stat_changes'].items(): 521 | if stat in game.player.skills: 522 | game.player.skills[stat] += change 523 | if change > 0: 524 | console.print(f"[green]{stat.title()} increased by {change}![/green]") 525 | elif stat == "health": 526 | game.player.health = max(0, min(100, game.player.health + change)) 527 | if change > 0: 528 | console.print(f"[green]Health restored by {change}![/green]") 529 | elif change < 0: 530 | console.print(f"[red]Health decreased by {abs(change)}![/red]") 531 | 532 | # Add items 533 | for item in result['items']: 534 | game.player.add_item(item) 535 | 536 | # Give experience 537 | if result['experience'] > 0: 538 | game.player.gain_experience(result['experience']) 539 | 540 | # Update story progress 541 | game.context.story_progress += 1 542 | else: 543 | console.print("[red]❌ The action didn't go as planned...[/red]") 544 | 545 | recent_actions = f"Attempted: {chosen_action}" 546 | 547 | # Check for game over conditions 548 | if game.player.health <= 0: 549 | console.print("\n[bold red]💀 You have died! Game Over![/bold red]") 550 | game.state = GameState.GAME_OVER 551 | break 552 | 553 | typer.prompt("\nPress Enter to continue") 554 | 555 | def main(): 556 | """Main game function.""" 557 | while game.running: 558 | if game.state == GameState.MENU: 559 | choice = main_menu() 560 | 561 | if choice == "1": 562 | game.player = create_new_character() 563 | game.context = GameContext() 564 | game.state = GameState.PLAYING 565 | console.print("\n[italic]Your adventure begins...[/italic]") 566 | typer.prompt("Press Enter to start") 567 | 568 | elif choice == "2": 569 | if game.load_game(): 570 | game.state = GameState.PLAYING 571 | typer.prompt("Press Enter to continue") 572 | 573 | elif choice == "3": 574 | show_help() 575 | 576 | elif choice == "4": 577 | game.running = False 578 | console.print("[bold]Thanks for playing! Goodbye![/bold]") 579 | 580 | elif game.state == GameState.PLAYING: 581 | game_loop() 582 | 583 | elif game.state == GameState.GAME_OVER: 584 | console.print("\n[bold]Game Over[/bold]") 585 | restart = typer.confirm("Would you like to return to the main menu?") 586 | if restart: 587 | game.state = GameState.MENU 588 | else: 589 | game.running = False 590 | 591 | if __name__ == "__main__": 592 | main() 593 | ``` 594 | 595 | ## Example Gameplay 596 | 597 | When you run the game, you'll experience: 598 | 599 | **Character Creation:** 600 | ``` 601 | 🏰 MYSTIC REALM ADVENTURE 🏰 602 | 603 | Enter your character's name: Aria 604 | 605 | Character Creation 606 | You have 10 extra skill points to distribute among your skills. 607 | Base skills start at 10 each. 608 | 609 | Points remaining: 10 610 | Points to add to strength (0-10): 2 611 | Points to add to intelligence (0-8): 4 612 | Points to add to charisma (0-4): 3 613 | Points to add to stealth (0-1): 1 614 | 615 | Welcome to Mystic Realm, Aria! 616 | ``` 617 | 618 | **Dynamic Scene Generation:** 619 | ``` 620 | ┌──────────── Current Location ────────────┐ 621 | │ Village Square │ 622 | │ │ 623 | │ You stand in the bustling heart of │ 624 | │ Willowbrook Village. The ancient stone │ 625 | │ fountain bubbles cheerfully as merchants │ 626 | │ hawk their wares and children play. A │ 627 | │ mysterious hooded figure lurks near the │ 628 | │ shadows of the old oak tree. │ 629 | │ │ 630 | │ NPCs present: Village Elder, Merchant │ 631 | │ Items visible: Strange Medallion, Herbs │ 632 | └──────────────────────────────────────────┘ 633 | 634 | ┌────────── Available Actions ─────────────┐ 635 | │ 1. Approach the hooded figure │ 636 | │ 2. Talk to the Village Elder │ 637 | │ 3. Browse the merchant's wares │ 638 | │ 4. Examine the strange medallion │ 639 | │ 5. Gather herbs near the fountain │ 640 | │ 6. Head to the forest path │ 641 | └───────────────────────────────────────────┘ 642 | ``` 643 | 644 | **AI-Generated Dialogue:** 645 | ``` 646 | Talking to Village Elder... 647 | 648 | Village Elder: "Ah, young traveler, I sense a great destiny 649 | surrounds you like morning mist. The ancient prophecy speaks 650 | of one who would come bearing the mark of courage. Tell me, 651 | have you noticed anything... unusual in your travels?" 652 | 653 | 💼 Quest opportunity detected! 654 | ℹ️ The Village Elder knows about an ancient prophecy that might involve you 655 | ``` 656 | 657 | ## Next Steps 658 | 659 | - **Combat System**: Add turn-based battles with strategy 660 | - **Magic System**: Spellcasting with resource management 661 | - **Multiplayer**: Network support for cooperative adventures 662 | - **Quest System**: Complex multi-step missions with branching outcomes 663 | - **World Building**: Procedurally generated locations and characters 664 | - **Audio**: Add sound effects and background music 665 | 666 | This tutorial demonstrates how DSPy's modular approach enables complex, interactive systems where AI handles creative content generation while maintaining consistent game logic and player agency. 667 | ``` -------------------------------------------------------------------------------- /docs/docs/index.md: -------------------------------------------------------------------------------- ```markdown 1 | --- 2 | sidebar_position: 1 3 | hide: 4 | - navigation 5 | - toc 6 | 7 | --- 8 | 9 | { width="200", align=left } 10 | 11 | # _Programming_—not prompting—_LMs_ 12 | 13 | [](https://pepy.tech/projects/dspy) 14 | 15 | DSPy is a declarative framework for building modular AI software. It allows you to **iterate fast on structured code**, rather than brittle strings, and offers algorithms that **compile AI programs into effective prompts and weights** for your language models, whether you're building simple classifiers, sophisticated RAG pipelines, or Agent loops. 16 | 17 | Instead of wrangling prompts or training jobs, DSPy (Declarative Self-improving Python) enables you to **build AI software from natural-language modules** and to _generically compose them_ with different models, inference strategies, or learning algorithms. This makes AI software **more reliable, maintainable, and portable** across models and strategies. 18 | 19 | *tl;dr* Think of DSPy as a higher-level language for AI programming ([lecture](https://www.youtube.com/watch?v=JEMYuzrKLUw)), like the shift from assembly to C or pointer arithmetic to SQL. Meet the community, seek help, or start contributing via [GitHub](https://github.com/stanfordnlp/dspy) and [Discord](https://discord.gg/XCGy2WDCQB). 20 | 21 | <!-- Its abstractions make your AI software more reliable and maintainable, and allow it to become more portable as new models and learning techniques emerge. It's also just rather elegant! --> 22 | 23 | !!! info "Getting Started I: Install DSPy and set up your LM" 24 | 25 | ```bash 26 | > pip install -U dspy 27 | ``` 28 | 29 | === "OpenAI" 30 | You can authenticate by setting the `OPENAI_API_KEY` env variable or passing `api_key` below. 31 | 32 | ```python linenums="1" 33 | import dspy 34 | lm = dspy.LM("openai/gpt-4o-mini", api_key="YOUR_OPENAI_API_KEY") 35 | dspy.configure(lm=lm) 36 | ``` 37 | 38 | === "Anthropic" 39 | You can authenticate by setting the `ANTHROPIC_API_KEY` env variable or passing `api_key` below. 40 | 41 | ```python linenums="1" 42 | import dspy 43 | lm = dspy.LM("anthropic/claude-3-opus-20240229", api_key="YOUR_ANTHROPIC_API_KEY") 44 | dspy.configure(lm=lm) 45 | ``` 46 | 47 | === "Databricks" 48 | If you're on the Databricks platform, authentication is automatic via their SDK. If not, you can set the env variables `DATABRICKS_API_KEY` and `DATABRICKS_API_BASE`, or pass `api_key` and `api_base` below. 49 | 50 | ```python linenums="1" 51 | import dspy 52 | lm = dspy.LM( 53 | "databricks/databricks-llama-4-maverick", 54 | api_key="YOUR_DATABRICKS_ACCESS_TOKEN", 55 | api_base="YOUR_DATABRICKS_WORKSPACE_URL", # e.g.: https://dbc-64bf4923-e39e.cloud.databricks.com/serving-endpoints 56 | ) 57 | dspy.configure(lm=lm) 58 | ``` 59 | 60 | === "Gemini" 61 | You can authenticate by setting the `GEMINI_API_KEY` env variable or passing `api_key` below. 62 | 63 | ```python linenums="1" 64 | import dspy 65 | lm = dspy.LM("gemini/gemini-2.5-flash", api_key="YOUR_GEMINI_API_KEY") 66 | dspy.configure(lm=lm) 67 | ``` 68 | 69 | === "Local LMs on your laptop" 70 | First, install [Ollama](https://github.com/ollama/ollama) and launch its server with your LM. 71 | 72 | ```bash 73 | > curl -fsSL https://ollama.ai/install.sh | sh 74 | > ollama run llama3.2:1b 75 | ``` 76 | 77 | Then, connect to it from your DSPy code. 78 | 79 | ```python linenums="1" 80 | import dspy 81 | lm = dspy.LM("ollama_chat/llama3.2:1b", api_base="http://localhost:11434", api_key="") 82 | dspy.configure(lm=lm) 83 | ``` 84 | 85 | === "Local LMs on a GPU server" 86 | First, install [SGLang](https://docs.sglang.ai/get_started/install.html) and launch its server with your LM. 87 | 88 | ```bash 89 | > pip install "sglang[all]" 90 | > pip install flashinfer -i https://flashinfer.ai/whl/cu121/torch2.4/ 91 | 92 | > CUDA_VISIBLE_DEVICES=0 python -m sglang.launch_server --port 7501 --model-path meta-llama/Llama-3.1-8B-Instruct 93 | ``` 94 | 95 | If you don't have access from Meta to download `meta-llama/Llama-3.1-8B-Instruct`, use `Qwen/Qwen2.5-7B-Instruct` for example. 96 | 97 | Next, connect to your local LM from your DSPy code as an `OpenAI`-compatible endpoint. 98 | 99 | ```python linenums="1" 100 | lm = dspy.LM("openai/meta-llama/Llama-3.1-8B-Instruct", 101 | api_base="http://localhost:7501/v1", # ensure this points to your port 102 | api_key="local", model_type="chat") 103 | dspy.configure(lm=lm) 104 | ``` 105 | 106 | === "Other providers" 107 | In DSPy, you can use any of the dozens of [LLM providers supported by LiteLLM](https://docs.litellm.ai/docs/providers). Simply follow their instructions for which `{PROVIDER}_API_KEY` to set and how to write pass the `{provider_name}/{model_name}` to the constructor. 108 | 109 | Some examples: 110 | 111 | - `anyscale/mistralai/Mistral-7B-Instruct-v0.1`, with `ANYSCALE_API_KEY` 112 | - `together_ai/togethercomputer/llama-2-70b-chat`, with `TOGETHERAI_API_KEY` 113 | - `sagemaker/<your-endpoint-name>`, with `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and `AWS_REGION_NAME` 114 | - `azure/<your_deployment_name>`, with `AZURE_API_KEY`, `AZURE_API_BASE`, `AZURE_API_VERSION`, and the optional `AZURE_AD_TOKEN` and `AZURE_API_TYPE` 115 | 116 | 117 | If your provider offers an OpenAI-compatible endpoint, just add an `openai/` prefix to your full model name. 118 | 119 | ```python linenums="1" 120 | import dspy 121 | lm = dspy.LM("openai/your-model-name", api_key="PROVIDER_API_KEY", api_base="YOUR_PROVIDER_URL") 122 | dspy.configure(lm=lm) 123 | ``` 124 | 125 | ??? "Calling the LM directly." 126 | 127 | Idiomatic DSPy involves using _modules_, which we define in the rest of this page. However, it's still easy to call the `lm` you configured above directly. This gives you a unified API and lets you benefit from utilities like automatic caching. 128 | 129 | ```python linenums="1" 130 | lm("Say this is a test!", temperature=0.7) # => ['This is a test!'] 131 | lm(messages=[{"role": "user", "content": "Say this is a test!"}]) # => ['This is a test!'] 132 | ``` 133 | 134 | 135 | ## 1) **Modules** help you describe AI behavior as _code_, not strings. 136 | 137 | To build reliable AI systems, you must iterate fast. But maintaining prompts makes that hard: it forces you to tinker with strings or data _every time you change your LM, metrics, or pipeline_. Having built over a dozen best-in-class compound LM systems since 2020, we learned this the hard way—and so built DSPy to decouple AI system design from messy incidental choices about specific LMs or prompting strategies. 138 | 139 | DSPy shifts your focus from tinkering with prompt strings to **programming with structured and declarative natural-language modules**. For every AI component in your system, you specify input/output behavior as a _signature_ and select a _module_ to assign a strategy for invoking your LM. DSPy expands your signatures into prompts and parses your typed outputs, so you can compose different modules together into ergonomic, portable, and optimizable AI systems. 140 | 141 | 142 | !!! info "Getting Started II: Build DSPy modules for various tasks" 143 | Try the examples below after configuring your `lm` above. Adjust the fields to explore what tasks your LM can do well out of the box. Each tab below sets up a DSPy module, like `dspy.Predict`, `dspy.ChainOfThought`, or `dspy.ReAct`, with a task-specific _signature_. For example, `question -> answer: float` tells the module to take a question and to produce a `float` answer. 144 | 145 | === "Math" 146 | 147 | ```python linenums="1" 148 | math = dspy.ChainOfThought("question -> answer: float") 149 | math(question="Two dice are tossed. What is the probability that the sum equals two?") 150 | ``` 151 | 152 | **Possible Output:** 153 | ```text 154 | Prediction( 155 | reasoning='When two dice are tossed, each die has 6 faces, resulting in a total of 6 x 6 = 36 possible outcomes. The sum of the numbers on the two dice equals two only when both dice show a 1. This is just one specific outcome: (1, 1). Therefore, there is only 1 favorable outcome. The probability of the sum being two is the number of favorable outcomes divided by the total number of possible outcomes, which is 1/36.', 156 | answer=0.0277776 157 | ) 158 | ``` 159 | 160 | === "RAG" 161 | 162 | ```python linenums="1" 163 | def search_wikipedia(query: str) -> list[str]: 164 | results = dspy.ColBERTv2(url="http://20.102.90.50:2017/wiki17_abstracts")(query, k=3) 165 | return [x["text"] for x in results] 166 | 167 | rag = dspy.ChainOfThought("context, question -> response") 168 | 169 | question = "What's the name of the castle that David Gregory inherited?" 170 | rag(context=search_wikipedia(question), question=question) 171 | ``` 172 | 173 | **Possible Output:** 174 | ```text 175 | Prediction( 176 | reasoning='The context provides information about David Gregory, a Scottish physician and inventor. It specifically mentions that he inherited Kinnairdy Castle in 1664. This detail directly answers the question about the name of the castle that David Gregory inherited.', 177 | response='Kinnairdy Castle' 178 | ) 179 | ``` 180 | 181 | === "Classification" 182 | 183 | ```python linenums="1" 184 | from typing import Literal 185 | 186 | class Classify(dspy.Signature): 187 | """Classify sentiment of a given sentence.""" 188 | 189 | sentence: str = dspy.InputField() 190 | sentiment: Literal["positive", "negative", "neutral"] = dspy.OutputField() 191 | confidence: float = dspy.OutputField() 192 | 193 | classify = dspy.Predict(Classify) 194 | classify(sentence="This book was super fun to read, though not the last chapter.") 195 | ``` 196 | 197 | **Possible Output:** 198 | 199 | ```text 200 | Prediction( 201 | sentiment='positive', 202 | confidence=0.75 203 | ) 204 | ``` 205 | 206 | === "Information Extraction" 207 | 208 | ```python linenums="1" 209 | class ExtractInfo(dspy.Signature): 210 | """Extract structured information from text.""" 211 | 212 | text: str = dspy.InputField() 213 | title: str = dspy.OutputField() 214 | headings: list[str] = dspy.OutputField() 215 | entities: list[dict[str, str]] = dspy.OutputField(desc="a list of entities and their metadata") 216 | 217 | module = dspy.Predict(ExtractInfo) 218 | 219 | text = "Apple Inc. announced its latest iPhone 14 today." \ 220 | "The CEO, Tim Cook, highlighted its new features in a press release." 221 | response = module(text=text) 222 | 223 | print(response.title) 224 | print(response.headings) 225 | print(response.entities) 226 | ``` 227 | 228 | **Possible Output:** 229 | ```text 230 | Apple Inc. Announces iPhone 14 231 | ['Introduction', "CEO's Statement", 'New Features'] 232 | [{'name': 'Apple Inc.', 'type': 'Organization'}, {'name': 'iPhone 14', 'type': 'Product'}, {'name': 'Tim Cook', 'type': 'Person'}] 233 | ``` 234 | 235 | === "Agents" 236 | 237 | ```python linenums="1" 238 | def evaluate_math(expression: str): 239 | return dspy.PythonInterpreter({}).execute(expression) 240 | 241 | def search_wikipedia(query: str): 242 | results = dspy.ColBERTv2(url="http://20.102.90.50:2017/wiki17_abstracts")(query, k=3) 243 | return [x["text"] for x in results] 244 | 245 | react = dspy.ReAct("question -> answer: float", tools=[evaluate_math, search_wikipedia]) 246 | 247 | pred = react(question="What is 9362158 divided by the year of birth of David Gregory of Kinnairdy castle?") 248 | print(pred.answer) 249 | ``` 250 | 251 | **Possible Output:** 252 | 253 | ```text 254 | 5761.328 255 | ``` 256 | 257 | === "Multi-Stage Pipelines" 258 | 259 | ```python linenums="1" 260 | class Outline(dspy.Signature): 261 | """Outline a thorough overview of a topic.""" 262 | 263 | topic: str = dspy.InputField() 264 | title: str = dspy.OutputField() 265 | sections: list[str] = dspy.OutputField() 266 | section_subheadings: dict[str, list[str]] = dspy.OutputField(desc="mapping from section headings to subheadings") 267 | 268 | class DraftSection(dspy.Signature): 269 | """Draft a top-level section of an article.""" 270 | 271 | topic: str = dspy.InputField() 272 | section_heading: str = dspy.InputField() 273 | section_subheadings: list[str] = dspy.InputField() 274 | content: str = dspy.OutputField(desc="markdown-formatted section") 275 | 276 | class DraftArticle(dspy.Module): 277 | def __init__(self): 278 | self.build_outline = dspy.ChainOfThought(Outline) 279 | self.draft_section = dspy.ChainOfThought(DraftSection) 280 | 281 | def forward(self, topic): 282 | outline = self.build_outline(topic=topic) 283 | sections = [] 284 | for heading, subheadings in outline.section_subheadings.items(): 285 | section, subheadings = f"## {heading}", [f"### {subheading}" for subheading in subheadings] 286 | section = self.draft_section(topic=outline.title, section_heading=section, section_subheadings=subheadings) 287 | sections.append(section.content) 288 | return dspy.Prediction(title=outline.title, sections=sections) 289 | 290 | draft_article = DraftArticle() 291 | article = draft_article(topic="World Cup 2002") 292 | ``` 293 | 294 | **Possible Output:** 295 | 296 | A 1500-word article on the topic, e.g. 297 | 298 | ```text 299 | ## Qualification Process 300 | 301 | The qualification process for the 2002 FIFA World Cup involved a series of..... [shortened here for presentation]. 302 | 303 | ### UEFA Qualifiers 304 | 305 | The UEFA qualifiers involved 50 teams competing for 13..... [shortened here for presentation]. 306 | 307 | .... [rest of the article] 308 | ``` 309 | 310 | Note that DSPy makes it straightforward to optimize multi-stage modules like this. As long as you can evaluate the _final_ output of the system, every DSPy optimizer can tune all of the intermediate modules. 311 | 312 | ??? "Using DSPy in practice: from quick scripting to building sophisticated systems." 313 | 314 | Standard prompts conflate interface ("what should the LM do?") with implementation ("how do we tell it to do that?"). DSPy isolates the former as _signatures_ so we can infer the latter or learn it from data — in the context of a bigger program. 315 | 316 | Even before you start using optimizers, DSPy's modules allow you to script effective LM systems as ergonomic, portable _code_. Across many tasks and LMs, we maintain _signature test suites_ that assess the reliability of the built-in DSPy adapters. Adapters are the components that map signatures to prompts prior to optimization. If you find a task where a simple prompt consistently outperforms idiomatic DSPy for your LM, consider that a bug and [file an issue](https://github.com/stanfordnlp/dspy/issues). We'll use this to improve the built-in adapters. 317 | 318 | 319 | ## 2) **Optimizers** tune the prompts and weights of your AI modules. 320 | 321 | DSPy provides you with the tools to compile high-level code with natural language annotations into the low-level computations, prompts, or weight updates that align your LM with your program's structure and metrics. If you change your code or your metrics, you can simply re-compile accordingly. 322 | 323 | Given a few tens or hundreds of representative _inputs_ of your task and a _metric_ that can measure the quality of your system's outputs, you can use a DSPy optimizer. Different optimizers in DSPy work by **synthesizing good few-shot examples** for every module, like `dspy.BootstrapRS`,<sup>[1](https://arxiv.org/abs/2310.03714)</sup> **proposing and intelligently exploring better natural-language instructions** for every prompt, like [`dspy.GEPA`](https://dspy.ai/tutorials/gepa_ai_program/)<sup>[2](https://arxiv.org/abs/2507.19457)</sup>, `dspy.MIPROv2`,<sup>[3](https://arxiv.org/abs/2406.11695)</sup> and **building datasets for your modules and using them to finetune the LM weights** in your system, like `dspy.BootstrapFinetune`.<sup>[4](https://arxiv.org/abs/2407.10930)</sup> For detailed tutorials on running `dspy.GEPA`, please take a look at [dspy.GEPA tutorials](https://dspy.ai/tutorials/gepa_ai_program/). 324 | 325 | 326 | !!! info "Getting Started III: Optimizing the LM prompts or weights in DSPy programs" 327 | A typical simple optimization run costs on the order of $2 USD and takes around 20 minutes, but be careful when running optimizers with very large LMs or very large datasets. 328 | Optimization can cost as little as a few cents or up to tens of dollars, depending on your LM, dataset, and configuration. 329 | 330 | Examples below rely on HuggingFace/datasets, you can install it by the command below. 331 | 332 | ```bash 333 | > pip install -U datasets 334 | ``` 335 | 336 | === "Optimizing prompts for a ReAct agent" 337 | This is a minimal but fully runnable example of setting up a `dspy.ReAct` agent that answers questions via 338 | search from Wikipedia and then optimizing it using `dspy.MIPROv2` in the cheap `light` mode on 500 339 | question-answer pairs sampled from the `HotPotQA` dataset. 340 | 341 | ```python linenums="1" 342 | import dspy 343 | from dspy.datasets import HotPotQA 344 | 345 | dspy.configure(lm=dspy.LM("openai/gpt-4o-mini")) 346 | 347 | def search_wikipedia(query: str) -> list[str]: 348 | results = dspy.ColBERTv2(url="http://20.102.90.50:2017/wiki17_abstracts")(query, k=3) 349 | return [x["text"] for x in results] 350 | 351 | trainset = [x.with_inputs('question') for x in HotPotQA(train_seed=2024, train_size=500).train] 352 | react = dspy.ReAct("question -> answer", tools=[search_wikipedia]) 353 | 354 | tp = dspy.MIPROv2(metric=dspy.evaluate.answer_exact_match, auto="light", num_threads=24) 355 | optimized_react = tp.compile(react, trainset=trainset) 356 | ``` 357 | 358 | An informal run like this raises ReAct's score from 24% to 51%, by teaching `gpt-4o-mini` more about the specifics of the task. 359 | 360 | === "Optimizing prompts for RAG" 361 | Given a retrieval index to `search`, your favorite `dspy.LM`, and a small `trainset` of questions and ground-truth responses, the following code snippet can optimize your RAG system with long outputs against the built-in `SemanticF1` metric, which is implemented as a DSPy module. 362 | 363 | ```python linenums="1" 364 | class RAG(dspy.Module): 365 | def __init__(self, num_docs=5): 366 | self.num_docs = num_docs 367 | self.respond = dspy.ChainOfThought("context, question -> response") 368 | 369 | def forward(self, question): 370 | context = search(question, k=self.num_docs) # defined in tutorial linked below 371 | return self.respond(context=context, question=question) 372 | 373 | tp = dspy.MIPROv2(metric=dspy.evaluate.SemanticF1(decompositional=True), auto="medium", num_threads=24) 374 | optimized_rag = tp.compile(RAG(), trainset=trainset, max_bootstrapped_demos=2, max_labeled_demos=2) 375 | ``` 376 | 377 | For a complete RAG example that you can run, start this [tutorial](tutorials/rag/index.ipynb). It improves the quality of a RAG system over a subset of StackExchange communities by 10% relative gain. 378 | 379 | === "Optimizing weights for Classification" 380 | <details><summary>Click to show dataset setup code.</summary> 381 | 382 | ```python linenums="1" 383 | import random 384 | from typing import Literal 385 | 386 | from datasets import load_dataset 387 | 388 | import dspy 389 | from dspy.datasets import DataLoader 390 | 391 | # Load the Banking77 dataset. 392 | CLASSES = load_dataset("PolyAI/banking77", split="train", trust_remote_code=True).features["label"].names 393 | kwargs = {"fields": ("text", "label"), "input_keys": ("text",), "split": "train", "trust_remote_code": True} 394 | 395 | # Load the first 2000 examples from the dataset, and assign a hint to each *training* example. 396 | trainset = [ 397 | dspy.Example(x, hint=CLASSES[x.label], label=CLASSES[x.label]).with_inputs("text", "hint") 398 | for x in DataLoader().from_huggingface(dataset_name="PolyAI/banking77", **kwargs)[:2000] 399 | ] 400 | random.Random(0).shuffle(trainset) 401 | ``` 402 | </details> 403 | 404 | ```python linenums="1" 405 | import dspy 406 | lm=dspy.LM('openai/gpt-4o-mini-2024-07-18') 407 | 408 | # Define the DSPy module for classification. It will use the hint at training time, if available. 409 | signature = dspy.Signature("text, hint -> label").with_updated_fields("label", type_=Literal[tuple(CLASSES)]) 410 | classify = dspy.ChainOfThought(signature) 411 | classify.set_lm(lm) 412 | 413 | # Optimize via BootstrapFinetune. 414 | optimizer = dspy.BootstrapFinetune(metric=(lambda x, y, trace=None: x.label == y.label), num_threads=24) 415 | optimized = optimizer.compile(classify, trainset=trainset) 416 | 417 | optimized(text="What does a pending cash withdrawal mean?") 418 | 419 | # For a complete fine-tuning tutorial, see: https://dspy.ai/tutorials/classification_finetuning/ 420 | ``` 421 | 422 | **Possible Output (from the last line):** 423 | ```text 424 | Prediction( 425 | reasoning='A pending cash withdrawal indicates that a request to withdraw cash has been initiated but has not yet been completed or processed. This status means that the transaction is still in progress and the funds have not yet been deducted from the account or made available to the user.', 426 | label='pending_cash_withdrawal' 427 | ) 428 | ``` 429 | 430 | An informal run similar to this on DSPy 2.5.29 raises GPT-4o-mini's score 66% to 87%. 431 | 432 | 433 | ??? "What's an example of a DSPy optimizer? How do different optimizers work?" 434 | 435 | Take the `dspy.MIPROv2` optimizer as an example. First, MIPRO starts with the **bootstrapping stage**. It takes your program, which may be unoptimized at this point, and runs it many times across different inputs to collect traces of input/output behavior for each one of your modules. It filters these traces to keep only those that appear in trajectories scored highly by your metric. Second, MIPRO enters its **grounded proposal stage**. It previews your DSPy program's code, your data, and traces from running your program, and uses them to draft many potential instructions for every prompt in your program. Third, MIPRO launches the **discrete search stage**. It samples mini-batches from your training set, proposes a combination of instructions and traces to use for constructing every prompt in the pipeline, and evaluates the candidate program on the mini-batch. Using the resulting score, MIPRO updates a surrogate model that helps the proposals get better over time. 436 | 437 | One thing that makes DSPy optimizers so powerful is that they can be composed. You can run `dspy.MIPROv2` and use the produced program as an input to `dspy.MIPROv2` again or, say, to `dspy.BootstrapFinetune` to get better results. This is partly the essence of `dspy.BetterTogether`. Alternatively, you can run the optimizer and then extract the top-5 candidate programs and build a `dspy.Ensemble` of them. This allows you to scale _inference-time compute_ (e.g., ensembles) as well as DSPy's unique _pre-inference time compute_ (i.e., optimization budget) in highly systematic ways. 438 | 439 | 440 | 441 | <!-- Future: 442 | BootstrapRS or MIPRO on ??? with a local SGLang LM 443 | BootstrapFS on MATH with a tiny LM like Llama-3.2 with Ollama (maybe with a big teacher) --> 444 | 445 | 446 | 447 | ## 3) **DSPy's Ecosystem** advances open-source AI research. 448 | 449 | Compared to monolithic LMs, DSPy's modular paradigm enables a large community to improve the compositional architectures, inference-time strategies, and optimizers for LM programs in an open, distributed way. This gives DSPy users more control, helps them iterate much faster, and allows their programs to get better over time by applying the latest optimizers or modules. 450 | 451 | The DSPy research effort started at Stanford NLP in Feb 2022, building on what we had learned from developing early [compound LM systems](https://bair.berkeley.edu/blog/2024/02/18/compound-ai-systems/) like [ColBERT-QA](https://arxiv.org/abs/2007.00814), [Baleen](https://arxiv.org/abs/2101.00436), and [Hindsight](https://arxiv.org/abs/2110.07752). The first version was released as [DSP](https://arxiv.org/abs/2212.14024) in Dec 2022 and evolved by Oct 2023 into [DSPy](https://arxiv.org/abs/2310.03714). Thanks to [250 contributors](https://github.com/stanfordnlp/dspy/graphs/contributors), DSPy has introduced tens of thousands of people to building and optimizing modular LM programs. 452 | 453 | Since then, DSPy's community has produced a large body of work on optimizers, like [MIPROv2](https://arxiv.org/abs/2406.11695), [BetterTogether](https://arxiv.org/abs/2407.10930), and [LeReT](https://arxiv.org/abs/2410.23214), on program architectures, like [STORM](https://arxiv.org/abs/2402.14207), [IReRa](https://arxiv.org/abs/2401.12178), and [DSPy Assertions](https://arxiv.org/abs/2312.13382), and on successful applications to new problems, like [PAPILLON](https://arxiv.org/abs/2410.17127), [PATH](https://arxiv.org/abs/2406.11706), [WangLab@MEDIQA](https://arxiv.org/abs/2404.14544), [UMD's Prompting Case Study](https://arxiv.org/abs/2406.06608), and [Haize's Red-Teaming Program](https://blog.haizelabs.com/posts/dspy/), in addition to many open-source projects, production applications, and other [use cases](community/use-cases.md). 454 | ``` -------------------------------------------------------------------------------- /docs/docs/tutorials/sample_code_generation/index.md: -------------------------------------------------------------------------------- ```markdown 1 | # Automated Code Generation from Documentation with DSPy 2 | 3 | This tutorial demonstrates how to use DSPy to automatically fetch documentation from URLs and generate working code examples for any library. The system can analyze documentation websites, extract key concepts, and produce tailored code examples. 4 | 5 | ## What You'll Build 6 | 7 | A documentation-powered code generation system that: 8 | 9 | - Fetches and parses documentation from multiple URLs 10 | - Extracts API patterns, methods, and usage examples 11 | - Generates working code for specific use cases 12 | - Provides explanations and best practices 13 | - Works with any library's documentation 14 | 15 | ## Setup 16 | 17 | ```bash 18 | pip install dspy requests beautifulsoup4 html2text 19 | ``` 20 | 21 | ## Step 1: Documentation Fetching and Processing 22 | 23 | ```python 24 | import dspy 25 | import requests 26 | from bs4 import BeautifulSoup 27 | import html2text 28 | from typing import List, Dict, Any 29 | import json 30 | from urllib.parse import urljoin, urlparse 31 | import time 32 | 33 | # Configure DSPy 34 | lm = dspy.LM(model='openai/gpt-4o-mini') 35 | dspy.configure(lm=lm) 36 | 37 | class DocumentationFetcher: 38 | """Fetches and processes documentation from URLs.""" 39 | 40 | def __init__(self, max_retries=3, delay=1): 41 | self.session = requests.Session() 42 | self.session.headers.update({ 43 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' 44 | }) 45 | self.max_retries = max_retries 46 | self.delay = delay 47 | self.html_converter = html2text.HTML2Text() 48 | self.html_converter.ignore_links = False 49 | self.html_converter.ignore_images = True 50 | 51 | def fetch_url(self, url: str) -> dict[str, str]: 52 | """Fetch content from a single URL.""" 53 | for attempt in range(self.max_retries): 54 | try: 55 | print(f"📡 Fetching: {url} (attempt {attempt + 1})") 56 | response = self.session.get(url, timeout=10) 57 | response.raise_for_status() 58 | 59 | soup = BeautifulSoup(response.content, 'html.parser') 60 | 61 | # Remove script and style elements 62 | for script in soup(["script", "style", "nav", "footer", "header"]): 63 | script.decompose() 64 | 65 | # Convert to markdown for better LLM processing 66 | markdown_content = self.html_converter.handle(str(soup)) 67 | 68 | return { 69 | "url": url, 70 | "title": soup.title.string if soup.title else "No title", 71 | "content": markdown_content, 72 | "success": True 73 | } 74 | 75 | except Exception as e: 76 | print(f"❌ Error fetching {url}: {e}") 77 | if attempt < self.max_retries - 1: 78 | time.sleep(self.delay) 79 | else: 80 | return { 81 | "url": url, 82 | "title": "Failed to fetch", 83 | "content": f"Error: {str(e)}", 84 | "success": False 85 | } 86 | 87 | return {"url": url, "title": "Failed", "content": "", "success": False} 88 | 89 | def fetch_documentation(self, urls: list[str]) -> list[dict[str, str]]: 90 | """Fetch documentation from multiple URLs.""" 91 | results = [] 92 | 93 | for url in urls: 94 | result = self.fetch_url(url) 95 | results.append(result) 96 | time.sleep(self.delay) # Be respectful to servers 97 | 98 | return results 99 | 100 | class LibraryAnalyzer(dspy.Signature): 101 | """Analyze library documentation to understand core concepts and patterns.""" 102 | library_name: str = dspy.InputField(desc="Name of the library to analyze") 103 | documentation_content: str = dspy.InputField(desc="Combined documentation content") 104 | 105 | core_concepts: list[str] = dspy.OutputField(desc="Main concepts and components") 106 | common_patterns: list[str] = dspy.OutputField(desc="Common usage patterns") 107 | key_methods: list[str] = dspy.OutputField(desc="Important methods and functions") 108 | installation_info: str = dspy.OutputField(desc="Installation and setup information") 109 | code_examples: list[str] = dspy.OutputField(desc="Example code snippets found") 110 | 111 | class CodeGenerator(dspy.Signature): 112 | """Generate code examples for specific use cases using the target library.""" 113 | library_info: str = dspy.InputField(desc="Library concepts and patterns") 114 | use_case: str = dspy.InputField(desc="Specific use case to implement") 115 | requirements: str = dspy.InputField(desc="Additional requirements or constraints") 116 | 117 | code_example: str = dspy.OutputField(desc="Complete, working code example") 118 | explanation: str = dspy.OutputField(desc="Step-by-step explanation of the code") 119 | best_practices: list[str] = dspy.OutputField(desc="Best practices and tips") 120 | imports_needed: list[str] = dspy.OutputField(desc="Required imports and dependencies") 121 | 122 | class DocumentationLearningAgent(dspy.Module): 123 | """Agent that learns from documentation URLs and generates code examples.""" 124 | 125 | def __init__(self): 126 | super().__init__() 127 | self.fetcher = DocumentationFetcher() 128 | self.analyze_docs = dspy.ChainOfThought(LibraryAnalyzer) 129 | self.generate_code = dspy.ChainOfThought(CodeGenerator) 130 | self.refine_code = dspy.ChainOfThought( 131 | "code, feedback -> improved_code: str, changes_made: list[str]" 132 | ) 133 | 134 | def learn_from_urls(self, library_name: str, doc_urls: list[str]) -> Dict: 135 | """Learn about a library from its documentation URLs.""" 136 | 137 | print(f"📚 Learning about {library_name} from {len(doc_urls)} URLs...") 138 | 139 | # Fetch all documentation 140 | docs = self.fetcher.fetch_documentation(doc_urls) 141 | 142 | # Combine successful fetches 143 | combined_content = "\n\n---\n\n".join([ 144 | f"URL: {doc['url']}\nTitle: {doc['title']}\n\n{doc['content']}" 145 | for doc in docs if doc['success'] 146 | ]) 147 | 148 | if not combined_content: 149 | raise ValueError("No documentation could be fetched successfully") 150 | 151 | # Analyze combined documentation 152 | analysis = self.analyze_docs( 153 | library_name=library_name, 154 | documentation_content=combined_content 155 | ) 156 | 157 | return { 158 | "library": library_name, 159 | "source_urls": [doc['url'] for doc in docs if doc['success']], 160 | "core_concepts": analysis.core_concepts, 161 | "patterns": analysis.common_patterns, 162 | "methods": analysis.key_methods, 163 | "installation": analysis.installation_info, 164 | "examples": analysis.code_examples, 165 | "fetched_docs": docs 166 | } 167 | 168 | def generate_example(self, library_info: Dict, use_case: str, requirements: str = "") -> Dict: 169 | """Generate a code example for a specific use case.""" 170 | 171 | # Format library information for the generator 172 | info_text = f""" 173 | Library: {library_info['library']} 174 | Core Concepts: {', '.join(library_info['core_concepts'])} 175 | Common Patterns: {', '.join(library_info['patterns'])} 176 | Key Methods: {', '.join(library_info['methods'])} 177 | Installation: {library_info['installation']} 178 | Example Code Snippets: {'; '.join(library_info['examples'][:3])} # First 3 examples 179 | """ 180 | 181 | code_result = self.generate_code( 182 | library_info=info_text, 183 | use_case=use_case, 184 | requirements=requirements 185 | ) 186 | 187 | return { 188 | "code": code_result.code_example, 189 | "explanation": code_result.explanation, 190 | "best_practices": code_result.best_practices, 191 | "imports": code_result.imports_needed 192 | } 193 | 194 | # Initialize the learning agent 195 | agent = DocumentationLearningAgent() 196 | ``` 197 | 198 | ## Step 2: Learning from Documentation URLs 199 | 200 | ```python 201 | def learn_library_from_urls(library_name: str, documentation_urls: list[str]) -> Dict: 202 | """Learn about any library from its documentation URLs.""" 203 | 204 | try: 205 | library_info = agent.learn_from_urls(library_name, documentation_urls) 206 | 207 | print(f"\n🔍 Library Analysis Results for {library_name}:") 208 | print(f"Sources: {len(library_info['source_urls'])} successful fetches") 209 | print(f"Core Concepts: {library_info['core_concepts']}") 210 | print(f"Common Patterns: {library_info['patterns']}") 211 | print(f"Key Methods: {library_info['methods']}") 212 | print(f"Installation: {library_info['installation']}") 213 | print(f"Found {len(library_info['examples'])} code examples") 214 | 215 | return library_info 216 | 217 | except Exception as e: 218 | print(f"❌ Error learning library: {e}") 219 | raise 220 | 221 | # Example 1: Learn FastAPI from official documentation 222 | fastapi_urls = [ 223 | "https://fastapi.tiangolo.com/", 224 | "https://fastapi.tiangolo.com/tutorial/first-steps/", 225 | "https://fastapi.tiangolo.com/tutorial/path-params/", 226 | "https://fastapi.tiangolo.com/tutorial/query-params/" 227 | ] 228 | 229 | print("🚀 Learning FastAPI from official documentation...") 230 | fastapi_info = learn_library_from_urls("FastAPI", fastapi_urls) 231 | 232 | # Example 2: Learn a different library (you can replace with any library) 233 | streamlit_urls = [ 234 | "https://docs.streamlit.io/", 235 | "https://docs.streamlit.io/get-started", 236 | "https://docs.streamlit.io/develop/api-reference" 237 | ] 238 | 239 | print("\n\n📊 Learning Streamlit from official documentation...") 240 | streamlit_info = learn_library_from_urls("Streamlit", streamlit_urls) 241 | ``` 242 | 243 | ## Step 3: Generating Code Examples 244 | 245 | ```python 246 | def generate_examples_for_library(library_info: Dict, library_name: str): 247 | """Generate code examples for any library based on its documentation.""" 248 | 249 | # Define generic use cases that can apply to most libraries 250 | use_cases = [ 251 | { 252 | "name": "Basic Setup and Hello World", 253 | "description": f"Create a minimal working example with {library_name}", 254 | "requirements": "Include installation, imports, and basic usage" 255 | }, 256 | { 257 | "name": "Common Operations", 258 | "description": f"Demonstrate the most common {library_name} operations", 259 | "requirements": "Show typical workflow and best practices" 260 | }, 261 | { 262 | "name": "Advanced Usage", 263 | "description": f"Create a more complex example showcasing {library_name} capabilities", 264 | "requirements": "Include error handling and optimization" 265 | } 266 | ] 267 | 268 | generated_examples = [] 269 | 270 | print(f"\n🔧 Generating examples for {library_name}...") 271 | 272 | for use_case in use_cases: 273 | print(f"\n📝 {use_case['name']}") 274 | print(f"Description: {use_case['description']}") 275 | 276 | example = agent.generate_example( 277 | library_info=library_info, 278 | use_case=use_case['description'], 279 | requirements=use_case['requirements'] 280 | ) 281 | 282 | print("\n💻 Generated Code:") 283 | print("```python") 284 | print(example['code']) 285 | print("```") 286 | 287 | print("\n📦 Required Imports:") 288 | for imp in example['imports']: 289 | print(f" • {imp}") 290 | 291 | print("\n📝 Explanation:") 292 | print(example['explanation']) 293 | 294 | print("\n✅ Best Practices:") 295 | for practice in example['best_practices']: 296 | print(f" • {practice}") 297 | 298 | generated_examples.append({ 299 | "use_case": use_case['name'], 300 | "code": example['code'], 301 | "imports": example['imports'], 302 | "explanation": example['explanation'], 303 | "best_practices": example['best_practices'] 304 | }) 305 | 306 | print("-" * 80) 307 | 308 | return generated_examples 309 | 310 | # Generate examples for both libraries 311 | print("🎯 Generating FastAPI Examples:") 312 | fastapi_examples = generate_examples_for_library(fastapi_info, "FastAPI") 313 | 314 | print("\n\n🎯 Generating Streamlit Examples:") 315 | streamlit_examples = generate_examples_for_library(streamlit_info, "Streamlit") 316 | ``` 317 | 318 | ## Step 4: Interactive Library Learning Function 319 | 320 | ```python 321 | def learn_any_library(library_name: str, documentation_urls: list[str], use_cases: list[str] = None): 322 | """Learn any library from its documentation and generate examples.""" 323 | 324 | if use_cases is None: 325 | use_cases = [ 326 | "Basic setup and hello world example", 327 | "Common operations and workflows", 328 | "Advanced usage with best practices" 329 | ] 330 | 331 | print(f"🚀 Starting automated learning for {library_name}...") 332 | print(f"Documentation sources: {len(documentation_urls)} URLs") 333 | 334 | try: 335 | # Step 1: Learn from documentation 336 | library_info = agent.learn_from_urls(library_name, documentation_urls) 337 | 338 | # Step 2: Generate examples for each use case 339 | all_examples = [] 340 | 341 | for i, use_case in enumerate(use_cases, 1): 342 | print(f"\n📝 Generating example {i}/{len(use_cases)}: {use_case}") 343 | 344 | example = agent.generate_example( 345 | library_info=library_info, 346 | use_case=use_case, 347 | requirements="Include error handling, comments, and follow best practices" 348 | ) 349 | 350 | all_examples.append({ 351 | "use_case": use_case, 352 | "code": example['code'], 353 | "imports": example['imports'], 354 | "explanation": example['explanation'], 355 | "best_practices": example['best_practices'] 356 | }) 357 | 358 | return { 359 | "library_info": library_info, 360 | "examples": all_examples 361 | } 362 | 363 | except Exception as e: 364 | print(f"❌ Error learning {library_name}: {e}") 365 | return None 366 | 367 | def interactive_learning_session(): 368 | """Interactive session for learning libraries with user input.""" 369 | 370 | print("🎯 Welcome to the Interactive Library Learning System!") 371 | print("This system will help you learn any Python library from its documentation.\n") 372 | 373 | learned_libraries = {} 374 | 375 | while True: 376 | print("\n" + "="*60) 377 | print("🚀 LIBRARY LEARNING SESSION") 378 | print("="*60) 379 | 380 | # Get library name from user 381 | library_name = input("\n📚 Enter the library name you want to learn (or 'quit' to exit): ").strip() 382 | 383 | if library_name.lower() in ['quit', 'exit', 'q']: 384 | print("\n👋 Thanks for using the Interactive Library Learning System!") 385 | break 386 | 387 | if not library_name: 388 | print("❌ Please enter a valid library name.") 389 | continue 390 | 391 | # Get documentation URLs 392 | print(f"\n🔗 Enter documentation URLs for {library_name} (one per line, empty line to finish):") 393 | urls = [] 394 | while True: 395 | url = input(" URL: ").strip() 396 | if not url: 397 | break 398 | if not url.startswith(('http://', 'https://')): 399 | print(" ⚠️ Please enter a valid URL starting with http:// or https://") 400 | continue 401 | urls.append(url) 402 | 403 | if not urls: 404 | print("❌ No valid URLs provided. Skipping this library.") 405 | continue 406 | 407 | # Get custom use cases from user 408 | print(f"\n🎯 Define use cases for {library_name} (optional, press Enter for defaults):") 409 | print(" Default use cases will be: Basic setup, Common operations, Advanced usage") 410 | 411 | user_wants_custom = input(" Do you want to define custom use cases? (y/n): ").strip().lower() 412 | 413 | use_cases = None 414 | if user_wants_custom in ['y', 'yes']: 415 | print(" Enter your use cases (one per line, empty line to finish):") 416 | use_cases = [] 417 | while True: 418 | use_case = input(" Use case: ").strip() 419 | if not use_case: 420 | break 421 | use_cases.append(use_case) 422 | 423 | if not use_cases: 424 | print(" No custom use cases provided, using defaults.") 425 | use_cases = None 426 | 427 | # Learn the library 428 | print(f"\n🚀 Starting learning process for {library_name}...") 429 | result = learn_any_library(library_name, urls, use_cases) 430 | 431 | if result: 432 | learned_libraries[library_name] = result 433 | print(f"\n✅ Successfully learned {library_name}!") 434 | 435 | # Show summary 436 | print(f"\n📊 Learning Summary for {library_name}:") 437 | print(f" • Core concepts: {len(result['library_info']['core_concepts'])} identified") 438 | print(f" • Common patterns: {len(result['library_info']['patterns'])} found") 439 | print(f" • Examples generated: {len(result['examples'])}") 440 | 441 | # Ask if user wants to see examples 442 | show_examples = input(f"\n👀 Do you want to see the generated examples for {library_name}? (y/n): ").strip().lower() 443 | 444 | if show_examples in ['y', 'yes']: 445 | for i, example in enumerate(result['examples'], 1): 446 | print(f"\n{'─'*50}") 447 | print(f"📝 Example {i}: {example['use_case']}") 448 | print(f"{'─'*50}") 449 | 450 | print("\n💻 Generated Code:") 451 | print("```python") 452 | print(example['code']) 453 | print("```") 454 | 455 | print(f"\n📦 Required Imports:") 456 | for imp in example['imports']: 457 | print(f" • {imp}") 458 | 459 | print(f"\n📝 Explanation:") 460 | print(example['explanation']) 461 | 462 | print(f"\n✅ Best Practices:") 463 | for practice in example['best_practices']: 464 | print(f" • {practice}") 465 | 466 | # Ask if user wants to see the next example 467 | if i < len(result['examples']): 468 | continue_viewing = input(f"\nContinue to next example? (y/n): ").strip().lower() 469 | if continue_viewing not in ['y', 'yes']: 470 | break 471 | 472 | # Offer to save results 473 | save_results = input(f"\n💾 Save learning results for {library_name} to file? (y/n): ").strip().lower() 474 | 475 | if save_results in ['y', 'yes']: 476 | filename = input(f" Enter filename (default: {library_name.lower()}_learning.json): ").strip() 477 | if not filename: 478 | filename = f"{library_name.lower()}_learning.json" 479 | 480 | try: 481 | import json 482 | with open(filename, 'w') as f: 483 | json.dump(result, f, indent=2, default=str) 484 | print(f" ✅ Results saved to {filename}") 485 | except Exception as e: 486 | print(f" ❌ Error saving file: {e}") 487 | 488 | else: 489 | print(f"❌ Failed to learn {library_name}") 490 | 491 | # Ask if user wants to learn another library 492 | print(f"\n📚 Libraries learned so far: {list(learned_libraries.keys())}") 493 | continue_learning = input("\n🔄 Do you want to learn another library? (y/n): ").strip().lower() 494 | 495 | if continue_learning not in ['y', 'yes']: 496 | break 497 | 498 | # Final summary 499 | if learned_libraries: 500 | print(f"\n🎉 Session Summary:") 501 | print(f"Successfully learned {len(learned_libraries)} libraries:") 502 | for lib_name, info in learned_libraries.items(): 503 | print(f" • {lib_name}: {len(info['examples'])} examples generated") 504 | 505 | return learned_libraries 506 | 507 | # Example: Run interactive learning session 508 | if __name__ == "__main__": 509 | # Run interactive session 510 | learned_libraries = interactive_learning_session() 511 | ``` 512 | 513 | ## Example Output 514 | 515 | When you run the interactive learning system, you'll see: 516 | 517 | **Interactive Session Start:** 518 | ``` 519 | 🎯 Welcome to the Interactive Library Learning System! 520 | This system will help you learn any Python library from its documentation. 521 | 522 | ============================================================ 523 | 🚀 LIBRARY LEARNING SESSION 524 | ============================================================ 525 | 526 | 📚 Enter the library name you want to learn (or 'quit' to exit): FastAPI 527 | 528 | 🔗 Enter documentation URLs for FastAPI (one per line, empty line to finish): 529 | URL: https://fastapi.tiangolo.com/ 530 | URL: https://fastapi.tiangolo.com/tutorial/first-steps/ 531 | URL: https://fastapi.tiangolo.com/tutorial/path-params/ 532 | URL: 533 | 534 | 🎯 Define use cases for FastAPI (optional, press Enter for defaults): 535 | Default use cases will be: Basic setup, Common operations, Advanced usage 536 | Do you want to define custom use cases? (y/n): y 537 | Enter your use cases (one per line, empty line to finish): 538 | Use case: Create a REST API with authentication 539 | Use case: Build a file upload endpoint 540 | Use case: Add database integration with SQLAlchemy 541 | Use case: 542 | ``` 543 | 544 | **Documentation Processing:** 545 | ``` 546 | 🚀 Starting learning process for FastAPI... 547 | 🚀 Starting automated learning for FastAPI... 548 | Documentation sources: 3 URLs 549 | 📡 Fetching: https://fastapi.tiangolo.com/ (attempt 1) 550 | 📡 Fetching: https://fastapi.tiangolo.com/tutorial/first-steps/ (attempt 1) 551 | 📡 Fetching: https://fastapi.tiangolo.com/tutorial/path-params/ (attempt 1) 552 | 📚 Learning about FastAPI from 3 URLs... 553 | 554 | 🔍 Library Analysis Results for FastAPI: 555 | Sources: 3 successful fetches 556 | Core Concepts: ['FastAPI app', 'path operations', 'dependencies', 'request/response models'] 557 | Common Patterns: ['app = FastAPI()', 'decorator-based routing', 'Pydantic models'] 558 | Key Methods: ['FastAPI()', '@app.get()', '@app.post()', 'uvicorn.run()'] 559 | Installation: pip install fastapi uvicorn 560 | ``` 561 | 562 | **Code Generation:** 563 | ``` 564 | 📝 Generating example 1/3: Create a REST API with authentication 565 | 566 | ✅ Successfully learned FastAPI! 567 | 568 | 📊 Learning Summary for FastAPI: 569 | • Core concepts: 4 identified 570 | • Common patterns: 3 found 571 | • Examples generated: 3 572 | 573 | 👀 Do you want to see the generated examples for FastAPI? (y/n): y 574 | 575 | ────────────────────────────────────────────────── 576 | 📝 Example 1: Create a REST API with authentication 577 | ────────────────────────────────────────────────── 578 | 579 | 💻 Generated Code: 580 | from fastapi import FastAPI, Depends, HTTPException, status 581 | from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials 582 | import uvicorn 583 | from typing import Dict 584 | import jwt 585 | from datetime import datetime, timedelta 586 | 587 | app = FastAPI(title="Authenticated API", version="1.0.0") 588 | security = HTTPBearer() 589 | 590 | # Secret key for JWT (use environment variable in production) 591 | SECRET_KEY = "your-secret-key-here" 592 | ALGORITHM = "HS256" 593 | 594 | def verify_token(credentials: HTTPAuthorizationCredentials = Depends(security)): 595 | try: 596 | payload = jwt.decode(credentials.credentials, SECRET_KEY, algorithms=[ALGORITHM]) 597 | username: str = payload.get("sub") 598 | if username is None: 599 | raise HTTPException(status_code=401, detail="Invalid token") 600 | return username 601 | except jwt.PyJWTError: 602 | raise HTTPException(status_code=401, detail="Invalid token") 603 | 604 | @app.post("/login") 605 | async def login(username: str, password: str) -> dict[str, str]: 606 | # In production, verify against database 607 | if username == "admin" and password == "secret": 608 | token_data = {"sub": username, "exp": datetime.utcnow() + timedelta(hours=24)} 609 | token = jwt.encode(token_data, SECRET_KEY, algorithm=ALGORITHM) 610 | return {"access_token": token, "token_type": "bearer"} 611 | raise HTTPException(status_code=401, detail="Invalid credentials") 612 | 613 | @app.get("/protected") 614 | async def protected_route(current_user: str = Depends(verify_token)) -> dict[str, str]: 615 | return {"message": f"Hello {current_user}! This is a protected route."} 616 | 617 | if __name__ == "__main__": 618 | uvicorn.run(app, host="0.0.0.0", port=8000) 619 | 620 | 📦 Required Imports: 621 | • pip install fastapi uvicorn python-jose[cryptography] 622 | • from fastapi import FastAPI, Depends, HTTPException, status 623 | • from fastapi.security import HTTPBearer 624 | • import jwt 625 | 626 | 📝 Explanation: 627 | This example creates a FastAPI application with JWT-based authentication. It includes a login endpoint that returns a JWT token and a protected route that requires authentication... 628 | 629 | ✅ Best Practices: 630 | • Use environment variables for secret keys 631 | • Implement proper password hashing in production 632 | • Add token expiration and refresh logic 633 | • Include proper error handling 634 | 635 | Continue to next example? (y/n): n 636 | 637 | 💾 Save learning results for FastAPI to file? (y/n): y 638 | Enter filename (default: fastapi_learning.json): 639 | ✅ Results saved to fastapi_learning.json 640 | 641 | 📚 Libraries learned so far: ['FastAPI'] 642 | 643 | 🔄 Do you want to learn another library? (y/n): n 644 | 645 | 🎉 Session Summary: 646 | Successfully learned 1 libraries: 647 | • FastAPI: 3 examples generated 648 | ``` 649 | 650 | 651 | ## Next Steps 652 | 653 | - **GitHub Integration**: Learn from README files and example repositories 654 | - **Video Tutorial Processing**: Extract information from video documentation 655 | - **Community Examples**: Aggregate examples from Stack Overflow and forums 656 | - **Version Comparison**: Track API changes across library versions 657 | - **Testing Generation**: Automatically create unit tests for generated code 658 | - **Page Crawling**: Automatically crawl documentation pages to actively understand the usage 659 | 660 | This tutorial demonstrates how DSPy can automate the entire process of learning unfamiliar libraries from their documentation, making it valuable for rapid technology adoption and exploration. 661 | ``` -------------------------------------------------------------------------------- /dspy/signatures/signature.py: -------------------------------------------------------------------------------- ```python 1 | """Signature class for DSPy. 2 | 3 | You typically subclass the Signature class, like this: 4 | class MySignature(dspy.Signature): 5 | input: str = InputField(desc="...") 6 | output: int = OutputField(desc="...") 7 | 8 | You can call Signature("input1, input2 -> output1, output2") to create a new signature type. 9 | You can also include instructions, Signature("input -> output", "This is a test"). 10 | But it's generally better to use the make_signature function. 11 | 12 | If you are not sure if your input is a string representation, (like "input1, input2 -> output1, output2"), 13 | or a signature, you can use the ensure_signature function. 14 | 15 | For compatibility with the legacy dsp format, you can use the signature_to_template function. 16 | """ 17 | 18 | import ast 19 | import importlib 20 | import inspect 21 | import re 22 | import sys 23 | import types 24 | import typing 25 | from copy import deepcopy 26 | from typing import Any 27 | 28 | from pydantic import BaseModel, Field, create_model 29 | from pydantic.fields import FieldInfo 30 | 31 | from dspy.signatures.field import InputField, OutputField 32 | 33 | 34 | def _default_instructions(cls) -> str: 35 | inputs_ = ", ".join([f"`{field}`" for field in cls.input_fields]) 36 | outputs_ = ", ".join([f"`{field}`" for field in cls.output_fields]) 37 | return f"Given the fields {inputs_}, produce the fields {outputs_}." 38 | 39 | 40 | class SignatureMeta(type(BaseModel)): 41 | def __call__(cls, *args, **kwargs): 42 | if cls is Signature: 43 | # We don't create an actual Signature instance, instead, we create a new Signature class. 44 | custom_types = kwargs.pop("custom_types", None) 45 | 46 | if custom_types is None and args and isinstance(args[0], str): 47 | custom_types = cls._detect_custom_types_from_caller(args[0]) 48 | 49 | return make_signature(*args, custom_types=custom_types, **kwargs) 50 | return super().__call__(*args, **kwargs) 51 | 52 | @staticmethod 53 | def _detect_custom_types_from_caller(signature_str): 54 | """Detect custom types from the caller's frame based on the signature string. 55 | 56 | Note: This method relies on Python's frame introspection which has some limitations: 57 | 1. May not work in all Python implementations (e.g., compiled with optimizations) 58 | 2. Looks up a limited number of frames in the call stack 59 | 3. Cannot find types that are imported but not in the caller's namespace 60 | 61 | For more reliable custom type resolution, explicitly provide types using the 62 | `custom_types` parameter when creating a Signature. 63 | """ 64 | 65 | # Extract potential type names from the signature string, including dotted names 66 | # Match both simple types like 'MyType' and dotted names like 'Module.Type' 67 | type_pattern = r":\s*([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*)" 68 | type_names = re.findall(type_pattern, signature_str) 69 | if not type_names: 70 | return None 71 | 72 | # Get type references from caller frames by walking the stack 73 | found_types = {} 74 | 75 | needed_types = set() 76 | dotted_types = {} 77 | 78 | for type_name in type_names: 79 | parts = type_name.split(".") 80 | base_name = parts[0] 81 | 82 | if base_name not in typing.__dict__ and base_name not in __builtins__: 83 | if len(parts) > 1: 84 | dotted_types[type_name] = base_name 85 | needed_types.add(base_name) 86 | else: 87 | needed_types.add(type_name) 88 | 89 | if not needed_types: 90 | return None 91 | 92 | frame = None 93 | try: 94 | frame = sys._getframe(1) # Start one level up (skip this function) 95 | 96 | max_frames = 100 97 | frame_count = 0 98 | 99 | while frame and needed_types and frame_count < max_frames: 100 | frame_count += 1 101 | 102 | for type_name in list(needed_types): 103 | if type_name in frame.f_locals: 104 | found_types[type_name] = frame.f_locals[type_name] 105 | needed_types.remove(type_name) 106 | elif frame.f_globals and type_name in frame.f_globals: 107 | found_types[type_name] = frame.f_globals[type_name] 108 | needed_types.remove(type_name) 109 | 110 | # If we found all needed types, stop looking 111 | if not needed_types: 112 | break 113 | 114 | frame = frame.f_back 115 | 116 | if needed_types and frame_count >= max_frames: 117 | import logging 118 | 119 | logging.getLogger("dspy").warning( 120 | f"Reached maximum frame search depth ({max_frames}) while looking for types: {needed_types}. " 121 | "Consider providing custom_types explicitly to Signature." 122 | ) 123 | except (AttributeError, ValueError): 124 | # Handle environments where frame introspection is not available 125 | import logging 126 | 127 | logging.getLogger("dspy").debug( 128 | "Frame introspection failed while trying to resolve custom types. " 129 | "Consider providing custom_types explicitly to Signature." 130 | ) 131 | finally: 132 | if frame: 133 | del frame 134 | 135 | return found_types or None 136 | 137 | def __new__(mcs, signature_name, bases, namespace, **kwargs): 138 | # At this point, the orders have been swapped already. 139 | field_order = [name for name, value in namespace.items() if isinstance(value, FieldInfo)] 140 | # Set `str` as the default type for all fields 141 | raw_annotations = namespace.get("__annotations__", {}) 142 | for name, field in namespace.items(): 143 | if not isinstance(field, FieldInfo): 144 | continue # Don't add types to non-field attributes 145 | if not name.startswith("__") and name not in raw_annotations: 146 | raw_annotations[name] = str 147 | # Create ordered annotations dictionary that preserves field order 148 | ordered_annotations = {name: raw_annotations[name] for name in field_order if name in raw_annotations} 149 | # Add any remaining annotations that weren't in field_order 150 | ordered_annotations.update({k: v for k, v in raw_annotations.items() if k not in ordered_annotations}) 151 | namespace["__annotations__"] = ordered_annotations 152 | 153 | # Let Pydantic do its thing 154 | cls = super().__new__(mcs, signature_name, bases, namespace, **kwargs) 155 | 156 | # If we don't have instructions, it might be because we are a derived generic type. 157 | # In that case, we should inherit the instructions from the base class. 158 | if cls.__doc__ is None: 159 | for base in bases: 160 | if isinstance(base, SignatureMeta): 161 | doc = getattr(base, "__doc__", "") 162 | if doc != "": 163 | cls.__doc__ = doc 164 | 165 | # The more likely case is that the user has just not given us a type. 166 | # In that case, we should default to the input/output format. 167 | if cls.__doc__ is None: 168 | cls.__doc__ = _default_instructions(cls) 169 | 170 | # Ensure all fields are declared with InputField or OutputField 171 | cls._validate_fields() 172 | 173 | # Ensure all fields have a prefix 174 | for name, field in cls.model_fields.items(): 175 | if "prefix" not in field.json_schema_extra: 176 | field.json_schema_extra["prefix"] = infer_prefix(name) + ":" 177 | if "desc" not in field.json_schema_extra: 178 | field.json_schema_extra["desc"] = f"${{{name}}}" 179 | 180 | return cls 181 | 182 | def _validate_fields(cls): 183 | for name, field in cls.model_fields.items(): 184 | extra = field.json_schema_extra or {} 185 | field_type = extra.get("__dspy_field_type") 186 | if field_type not in ["input", "output"]: 187 | raise TypeError( 188 | f"Field `{name}` in `{cls.__name__}` must be declared with InputField or OutputField, but " 189 | f"field `{name}` has `field.json_schema_extra={field.json_schema_extra}`", 190 | ) 191 | 192 | @property 193 | def instructions(cls) -> str: 194 | return inspect.cleandoc(getattr(cls, "__doc__", "")) 195 | 196 | @instructions.setter 197 | def instructions(cls, instructions: str) -> None: 198 | cls.__doc__ = instructions 199 | 200 | @property 201 | def input_fields(cls) -> dict[str, FieldInfo]: 202 | return cls._get_fields_with_type("input") 203 | 204 | @property 205 | def output_fields(cls) -> dict[str, FieldInfo]: 206 | return cls._get_fields_with_type("output") 207 | 208 | @property 209 | def fields(cls) -> dict[str, FieldInfo]: 210 | # Make sure to give input fields before output fields 211 | return {**cls.input_fields, **cls.output_fields} 212 | 213 | @property 214 | def signature(cls) -> str: 215 | """The string representation of the signature.""" 216 | input_fields = ", ".join(cls.input_fields.keys()) 217 | output_fields = ", ".join(cls.output_fields.keys()) 218 | return f"{input_fields} -> {output_fields}" 219 | 220 | def _get_fields_with_type(cls, field_type) -> dict[str, FieldInfo]: 221 | return {k: v for k, v in cls.model_fields.items() if v.json_schema_extra["__dspy_field_type"] == field_type} 222 | 223 | def __repr__(cls): 224 | """Output a representation of the signature. 225 | 226 | Uses the form: 227 | Signature(question, context -> answer 228 | question: str = InputField(desc="..."), 229 | context: list[str] = InputField(desc="..."), 230 | answer: int = OutputField(desc="..."), 231 | ). 232 | """ 233 | field_reprs = [] 234 | for name, field in cls.fields.items(): 235 | field_reprs.append(f"{name} = Field({field})") 236 | field_repr = "\n ".join(field_reprs) 237 | return f"{cls.__name__}({cls.signature}\n instructions={cls.instructions!r}\n {field_repr}\n)" 238 | 239 | 240 | class Signature(BaseModel, metaclass=SignatureMeta): 241 | "" 242 | 243 | # Note: Don't put a docstring here, as it will become the default instructions 244 | # for any signature that doesn't define it's own instructions. 245 | 246 | @classmethod 247 | def with_instructions(cls, instructions: str) -> type["Signature"]: 248 | return Signature(cls.fields, instructions) 249 | 250 | @classmethod 251 | def with_updated_fields(cls, name: str, type_: type | None = None, **kwargs: dict[str, Any]) -> type["Signature"]: 252 | """Create a new Signature class with the updated field information. 253 | 254 | Returns a new Signature class with the field, name, updated 255 | with fields[name].json_schema_extra[key] = value. 256 | 257 | Args: 258 | name: The name of the field to update. 259 | type_: The new type of the field. 260 | kwargs: The new values for the field. 261 | 262 | Returns: 263 | A new Signature class (not an instance) with the updated field information. 264 | """ 265 | fields_copy = deepcopy(cls.fields) 266 | # Update `fields_copy[name].json_schema_extra` with the new kwargs, on conflicts 267 | # we use the new value in kwargs. 268 | fields_copy[name].json_schema_extra = { 269 | **fields_copy[name].json_schema_extra, 270 | **kwargs, 271 | } 272 | if type_ is not None: 273 | fields_copy[name].annotation = type_ 274 | return Signature(fields_copy, cls.instructions) 275 | 276 | @classmethod 277 | def prepend(cls, name, field, type_=None) -> type["Signature"]: 278 | return cls.insert(0, name, field, type_) 279 | 280 | @classmethod 281 | def append(cls, name, field, type_=None) -> type["Signature"]: 282 | return cls.insert(-1, name, field, type_) 283 | 284 | @classmethod 285 | def delete(cls, name) -> type["Signature"]: 286 | fields = dict(cls.fields) 287 | 288 | fields.pop(name, None) 289 | 290 | return Signature(fields, cls.instructions) 291 | 292 | @classmethod 293 | def insert(cls, index: int, name: str, field, type_: type | None = None) -> type["Signature"]: 294 | # It's possible to set the type as annotation=type in pydantic.Field(...) 295 | # But this may be annoying for users, so we allow them to pass the type 296 | if type_ is None: 297 | type_ = field.annotation 298 | if type_ is None: 299 | type_ = str 300 | 301 | input_fields = list(cls.input_fields.items()) 302 | output_fields = list(cls.output_fields.items()) 303 | 304 | # Choose the list to insert into based on the field type 305 | lst = input_fields if field.json_schema_extra["__dspy_field_type"] == "input" else output_fields 306 | # We support negative insert indices 307 | if index < 0: 308 | index += len(lst) + 1 309 | if index < 0 or index > len(lst): 310 | raise ValueError( 311 | f"Invalid index to insert: {index}, index must be in the range of [{len(lst) - 1}, {len(lst)}] for " 312 | f"{field.json_schema_extra['__dspy_field_type']} fields, but received: {index}.", 313 | ) 314 | lst.insert(index, (name, (type_, field))) 315 | 316 | new_fields = dict(input_fields + output_fields) 317 | return Signature(new_fields, cls.instructions) 318 | 319 | @classmethod 320 | def equals(cls, other) -> bool: 321 | """Compare the JSON schema of two Signature classes.""" 322 | if not isinstance(other, type) or not issubclass(other, BaseModel): 323 | return False 324 | if cls.instructions != other.instructions: 325 | return False 326 | for name in cls.fields.keys() | other.fields.keys(): 327 | if name not in other.fields or name not in cls.fields: 328 | return False 329 | if cls.fields[name].json_schema_extra != other.fields[name].json_schema_extra: 330 | return False 331 | return True 332 | 333 | @classmethod 334 | def dump_state(cls): 335 | state = {"instructions": cls.instructions, "fields": []} 336 | for field in cls.fields: 337 | state["fields"].append( 338 | { 339 | "prefix": cls.fields[field].json_schema_extra["prefix"], 340 | "description": cls.fields[field].json_schema_extra["desc"], 341 | } 342 | ) 343 | 344 | return state 345 | 346 | @classmethod 347 | def load_state(cls, state): 348 | signature_copy = Signature(deepcopy(cls.fields), cls.instructions) 349 | 350 | signature_copy.instructions = state["instructions"] 351 | for field, saved_field in zip(signature_copy.fields.values(), state["fields"], strict=False): 352 | field.json_schema_extra["prefix"] = saved_field["prefix"] 353 | field.json_schema_extra["desc"] = saved_field["description"] 354 | 355 | return signature_copy 356 | 357 | 358 | def ensure_signature(signature: str | type[Signature], instructions=None) -> type[Signature]: 359 | if signature is None: 360 | return None 361 | if isinstance(signature, str): 362 | return Signature(signature, instructions) 363 | if instructions is not None: 364 | raise ValueError("Don't specify instructions when initializing with a Signature") 365 | return signature 366 | 367 | 368 | def make_signature( 369 | signature: str | dict[str, tuple[type, FieldInfo]], 370 | instructions: str | None = None, 371 | signature_name: str = "StringSignature", 372 | custom_types: dict[str, type] | None = None, 373 | ) -> type[Signature]: 374 | """Create a new Signature subclass with the specified fields and instructions. 375 | 376 | Args: 377 | signature: Either a string in the format "input1, input2 -> output1, output2" 378 | or a dictionary mapping field names to tuples of (type, FieldInfo). 379 | instructions: Optional string containing instructions/prompt for the signature. 380 | If not provided, defaults to a basic description of inputs and outputs. 381 | signature_name: Optional string to name the generated Signature subclass. 382 | Defaults to "StringSignature". 383 | custom_types: Optional dictionary mapping type names to their actual type objects. 384 | Useful for resolving custom types that aren't built-ins or in the typing module. 385 | 386 | Returns: 387 | A new signature class with the specified fields and instructions. 388 | 389 | Examples: 390 | 391 | ``` 392 | # Using string format 393 | sig1 = make_signature("question, context -> answer") 394 | 395 | # Using dictionary format 396 | sig2 = make_signature({ 397 | "question": (str, InputField()), 398 | "answer": (str, OutputField()) 399 | }) 400 | 401 | # Using custom types 402 | class MyType: 403 | pass 404 | 405 | sig3 = make_signature("input: MyType -> output", custom_types={"MyType": MyType}) 406 | ``` 407 | """ 408 | # Prepare the names dictionary for type resolution 409 | names = None 410 | if custom_types: 411 | names = dict(typing.__dict__) 412 | names.update(custom_types) 413 | 414 | fields = _parse_signature(signature, names) if isinstance(signature, str) else signature 415 | 416 | # Validate the fields, this is important because we sometimes forget the 417 | # slightly unintuitive syntax with tuples of (type, Field) 418 | fixed_fields = {} 419 | for name, type_field in fields.items(): 420 | if not isinstance(name, str): 421 | raise ValueError(f"Field names must be strings, but received: {name}.") 422 | if isinstance(type_field, FieldInfo): 423 | type_ = type_field.annotation 424 | field = type_field 425 | else: 426 | if not isinstance(type_field, tuple): 427 | raise ValueError(f"Field values must be tuples, but received: {type_field}.") 428 | type_, field = type_field 429 | # It might be better to be explicit about the type, but it currently would break 430 | # program of thought and teleprompters, so we just silently default to string. 431 | if type_ is None: 432 | type_ = str 433 | if not isinstance(type_, (type, typing._GenericAlias, types.GenericAlias, typing._SpecialForm, types.UnionType)): 434 | raise ValueError(f"Field types must be types, but received: {type_} of type {type(type_)}.") 435 | if not isinstance(field, FieldInfo): 436 | raise ValueError(f"Field values must be Field instances, but received: {field}.") 437 | fixed_fields[name] = (type_, field) 438 | 439 | # Default prompt when no instructions are provided 440 | if instructions is None: 441 | sig = Signature(signature, "") # Simple way to parse input/output fields 442 | instructions = _default_instructions(sig) 443 | 444 | return create_model( 445 | signature_name, 446 | __base__=Signature, 447 | __doc__=instructions, 448 | **fixed_fields, 449 | ) 450 | 451 | 452 | def _parse_signature(signature: str, names=None) -> dict[str, tuple[type, Field]]: 453 | if signature.count("->") != 1: 454 | raise ValueError(f"Invalid signature format: '{signature}', must contain exactly one '->'.") 455 | 456 | inputs_str, outputs_str = signature.split("->") 457 | 458 | fields = {} 459 | for field_name, field_type in _parse_field_string(inputs_str, names): 460 | fields[field_name] = (field_type, InputField()) 461 | for field_name, field_type in _parse_field_string(outputs_str, names): 462 | fields[field_name] = (field_type, OutputField()) 463 | 464 | return fields 465 | 466 | 467 | def _parse_field_string(field_string: str, names=None) -> dict[str, str]: 468 | """Extract the field name and type from field string in the string-based Signature. 469 | 470 | It takes a string like "x: int, y: str" and returns a dictionary mapping field names to their types. 471 | For example, "x: int, y: str" -> [("x", int), ("y", str)]. This function utitlizes the Python AST to parse the 472 | fields and types. 473 | """ 474 | 475 | args = ast.parse(f"def f({field_string}): pass").body[0].args.args 476 | field_names = [arg.arg for arg in args] 477 | types = [str if arg.annotation is None else _parse_type_node(arg.annotation, names) for arg in args] 478 | return zip(field_names, types, strict=False) 479 | 480 | 481 | def _parse_type_node(node, names=None) -> Any: 482 | """Recursively parse an AST node representing a type annotation. 483 | 484 | This function converts Python's Abstract Syntax Tree (AST) nodes into actual Python types. 485 | It's used to parse type annotations in signature strings like "x: list[int] -> y: str". 486 | 487 | Examples: 488 | - For "x: int", the AST node represents 'int' and returns the int type 489 | - For "x: list[str]", it processes a subscript node to return typing.list[str] 490 | - For "x: Optional[int]", it handles the Union type to return Optional[int] 491 | - For "x: MyModule.CustomType", it processes attribute access to return the actual type 492 | 493 | Args: 494 | node: An AST node from Python's ast module, representing a type annotation. 495 | Common node types include: 496 | - ast.Name: Simple types like 'int', 'str' 497 | - ast.Attribute: Nested types like 'typing.List' 498 | - ast.Subscript: Generic types like 'list[int]' 499 | names: Optional dictionary mapping type names to their actual type objects. 500 | Defaults to Python's typing module contents plus NoneType. 501 | 502 | Returns: 503 | The actual Python type represented by the AST node. 504 | 505 | Raises: 506 | ValueError: If the AST node represents an unknown or invalid type annotation. 507 | """ 508 | 509 | if names is None: 510 | names = dict(typing.__dict__) 511 | names["NoneType"] = type(None) 512 | 513 | def resolve_name(type_name: str): 514 | # Check if it's a built-in known type or in the provided names 515 | if type_name in names: 516 | return names[type_name] 517 | # Common built-in types 518 | builtin_types = [int, str, float, bool, list, tuple, dict, set, frozenset, complex, bytes, bytearray] 519 | 520 | # Check if it matches any known built-in type by name 521 | for t in builtin_types: 522 | if t.__name__ == type_name: 523 | return t 524 | 525 | # Attempt to import a module with this name dynamically 526 | # This allows handling of module-based annotations like `dspy.Image`. 527 | try: 528 | mod = importlib.import_module(type_name) 529 | names[type_name] = mod 530 | return mod 531 | except ImportError: 532 | pass 533 | 534 | # If we don't know the type or module, raise an error 535 | raise ValueError(f"Unknown name: {type_name}") 536 | 537 | if isinstance(node, ast.Module): 538 | if len(node.body) != 1: 539 | raise ValueError(f"Code is not syntactically valid: {ast.dump(node)}") 540 | return _parse_type_node(node.body[0], names) 541 | 542 | if isinstance(node, ast.Expr): 543 | return _parse_type_node(node.value, names) 544 | 545 | if isinstance(node, ast.Name): 546 | return resolve_name(node.id) 547 | 548 | if isinstance(node, ast.Attribute): 549 | base = _parse_type_node(node.value, names) 550 | attr_name = node.attr 551 | 552 | if hasattr(base, attr_name): 553 | return getattr(base, attr_name) 554 | 555 | if isinstance(node.value, ast.Name): 556 | full_name = f"{node.value.id}.{attr_name}" 557 | if full_name in names: 558 | return names[full_name] 559 | 560 | raise ValueError(f"Unknown attribute: {attr_name} on {base}") 561 | 562 | if isinstance(node, ast.Subscript): 563 | base_type = _parse_type_node(node.value, names) 564 | slice_node = node.slice 565 | if isinstance(slice_node, ast.Index): # For older Python versions 566 | slice_node = slice_node.value 567 | 568 | if isinstance(slice_node, ast.Tuple): 569 | arg_types = tuple(_parse_type_node(elt, names) for elt in slice_node.elts) 570 | else: 571 | arg_types = (_parse_type_node(slice_node, names),) 572 | 573 | # Special handling for Union, Optional 574 | if base_type is typing.Union: 575 | return typing.Union[arg_types] 576 | if base_type is typing.Optional: 577 | if len(arg_types) != 1: 578 | raise ValueError("Optional must have exactly one type argument") 579 | return typing.Optional[arg_types[0]] 580 | 581 | return base_type[arg_types] 582 | 583 | if isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr): 584 | # Handle PEP 604: int | None, str | float, etc. 585 | left = _parse_type_node(node.left, names) 586 | right = _parse_type_node(node.right, names) 587 | 588 | # Optional[X] is Union[X, NoneType] 589 | if right is type(None): 590 | return typing.Optional[left] 591 | if left is type(None): 592 | return typing.Optional[right] 593 | return typing.Union[left, right] 594 | 595 | if isinstance(node, ast.Tuple): 596 | return tuple(_parse_type_node(elt, names) for elt in node.elts) 597 | 598 | if isinstance(node, ast.Constant): 599 | return node.value 600 | 601 | if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == "Field": 602 | keys = [kw.arg for kw in node.keywords] 603 | values = [] 604 | for kw in node.keywords: 605 | if isinstance(kw.value, ast.Constant): 606 | values.append(kw.value.value) 607 | else: 608 | values.append(_parse_type_node(kw.value, names)) 609 | return Field(**dict(zip(keys, values, strict=False))) 610 | 611 | raise ValueError( 612 | f"Failed to parse string-base Signature due to unhandled AST node type in annotation: {ast.dump(node)}. " 613 | "Please consider using class-based DSPy Signatures instead." 614 | ) 615 | 616 | 617 | def infer_prefix(attribute_name: str) -> str: 618 | """Infer a prefix from an attribute name by converting it to a human-readable format. 619 | 620 | Examples: 621 | "camelCaseText" -> "Camel Case Text" 622 | "snake_case_text" -> "Snake Case Text" 623 | "text2number" -> "Text 2 Number" 624 | "HTMLParser" -> "HTML Parser" 625 | """ 626 | # Step 1: Convert camelCase to snake_case 627 | # Example: "camelCase" -> "camel_Case" 628 | s1 = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", attribute_name) 629 | 630 | # Handle consecutive capitals 631 | # Example: "camel_Case" -> "camel_case" 632 | intermediate_name = re.sub("([a-z0-9])([A-Z])", r"\1_\2", s1) 633 | 634 | # Step 2: Handle numbers by adding underscores around them 635 | # Example: "text2number" -> "text_2_number" 636 | with_underscores_around_numbers = re.sub( 637 | r"([a-zA-Z])(\d)", # Match letter followed by number 638 | r"\1_\2", # Add underscore between them 639 | intermediate_name, 640 | ) 641 | # Example: "2text" -> "2_text" 642 | with_underscores_around_numbers = re.sub( 643 | r"(\d)([a-zA-Z])", # Match number followed by letter 644 | r"\1_\2", # Add underscore between them 645 | with_underscores_around_numbers, 646 | ) 647 | 648 | # Step 3: Convert to Title Case while preserving acronyms 649 | words = with_underscores_around_numbers.split("_") 650 | title_cased_words = [] 651 | for word in words: 652 | if word.isupper(): 653 | # Preserve acronyms like 'HTML', 'API' as-is 654 | title_cased_words.append(word) 655 | else: 656 | # Capitalize first letter: 'text' -> 'Text' 657 | title_cased_words.append(word.capitalize()) 658 | 659 | # Join words with spaces 660 | # Example: ["Text", "2", "Number"] -> "Text 2 Number" 661 | return " ".join(title_cased_words) 662 | ```