Rewrite all reference files, asset templates, and SKILL.md to use current API patterns (.call(), result.field, T::Enum classes, Tools::Base). Add two new reference files (toolsets, observability) covering tools DSL, event system, and Langfuse integration. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
301 lines
7.8 KiB
Ruby
301 lines
7.8 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
# =============================================================================
|
|
# DSPy.rb Module Template — v0.34.3 API
|
|
#
|
|
# Modules orchestrate predictors, tools, and business logic.
|
|
#
|
|
# Key patterns:
|
|
# - Use .call() to invoke (not .forward())
|
|
# - Access results with result.field (not result[:field])
|
|
# - Use DSPy::Tools::Base for tools (not DSPy::Tool)
|
|
# - Use lifecycle callbacks (before/around/after) for cross-cutting concerns
|
|
# - Use DSPy.with_lm for temporary model overrides
|
|
# - Use configure_predictor for fine-grained agent control
|
|
# =============================================================================
|
|
|
|
# --- Basic Module ---
|
|
|
|
class BasicClassifier < DSPy::Module
|
|
def initialize
|
|
super
|
|
@predictor = DSPy::Predict.new(ClassificationSignature)
|
|
end
|
|
|
|
def forward(text:)
|
|
@predictor.call(text: text)
|
|
end
|
|
end
|
|
|
|
# Usage:
|
|
# classifier = BasicClassifier.new
|
|
# result = classifier.call(text: "This is a test")
|
|
# result.category # => "technical"
|
|
# result.confidence # => 0.95
|
|
|
|
# --- Module with Chain of Thought ---
|
|
|
|
class ReasoningClassifier < DSPy::Module
|
|
def initialize
|
|
super
|
|
@predictor = DSPy::ChainOfThought.new(ClassificationSignature)
|
|
end
|
|
|
|
def forward(text:)
|
|
result = @predictor.call(text: text)
|
|
# ChainOfThought adds result.reasoning automatically
|
|
result
|
|
end
|
|
end
|
|
|
|
# --- Module with Lifecycle Callbacks ---
|
|
|
|
class InstrumentedModule < DSPy::Module
|
|
before :setup_metrics
|
|
around :manage_context
|
|
after :log_completion
|
|
|
|
def initialize
|
|
super
|
|
@predictor = DSPy::Predict.new(AnalysisSignature)
|
|
@start_time = nil
|
|
end
|
|
|
|
def forward(query:)
|
|
@predictor.call(query: query)
|
|
end
|
|
|
|
private
|
|
|
|
# Runs before forward
|
|
def setup_metrics
|
|
@start_time = Time.now
|
|
Rails.logger.info "Starting prediction"
|
|
end
|
|
|
|
# Wraps forward — must call yield
|
|
def manage_context
|
|
load_user_context
|
|
result = yield
|
|
save_updated_context(result)
|
|
result
|
|
end
|
|
|
|
# Runs after forward completes
|
|
def log_completion
|
|
duration = Time.now - @start_time
|
|
Rails.logger.info "Prediction completed in #{duration}s"
|
|
end
|
|
|
|
def load_user_context = nil
|
|
def save_updated_context(_result) = nil
|
|
end
|
|
|
|
# Execution order: before → around (before yield) → forward → around (after yield) → after
|
|
# Callbacks are inherited from parent classes and execute in registration order.
|
|
|
|
# --- Module with Tools ---
|
|
|
|
class SearchTool < DSPy::Tools::Base
|
|
tool_name "search"
|
|
tool_description "Search for information by query"
|
|
|
|
sig { params(query: String, max_results: Integer).returns(T::Array[T::Hash[Symbol, String]]) }
|
|
def call(query:, max_results: 5)
|
|
# Implementation here
|
|
[{ title: "Result 1", url: "https://example.com" }]
|
|
end
|
|
end
|
|
|
|
class FinishTool < DSPy::Tools::Base
|
|
tool_name "finish"
|
|
tool_description "Submit the final answer"
|
|
|
|
sig { params(answer: String).returns(String) }
|
|
def call(answer:)
|
|
answer
|
|
end
|
|
end
|
|
|
|
class ResearchAgent < DSPy::Module
|
|
def initialize
|
|
super
|
|
tools = [SearchTool.new, FinishTool.new]
|
|
@agent = DSPy::ReAct.new(
|
|
ResearchSignature,
|
|
tools: tools,
|
|
max_iterations: 5
|
|
)
|
|
end
|
|
|
|
def forward(question:)
|
|
@agent.call(question: question)
|
|
end
|
|
end
|
|
|
|
# --- Module with Per-Task Model Selection ---
|
|
|
|
class SmartRouter < DSPy::Module
|
|
def initialize
|
|
super
|
|
@classifier = DSPy::Predict.new(RouteSignature)
|
|
@analyzer = DSPy::ChainOfThought.new(AnalysisSignature)
|
|
end
|
|
|
|
def forward(text:)
|
|
# Use fast model for classification
|
|
DSPy.with_lm(fast_model) do
|
|
route = @classifier.call(text: text)
|
|
|
|
if route.requires_deep_analysis
|
|
# Switch to powerful model for analysis
|
|
DSPy.with_lm(powerful_model) do
|
|
@analyzer.call(text: text)
|
|
end
|
|
else
|
|
route
|
|
end
|
|
end
|
|
end
|
|
|
|
private
|
|
|
|
def fast_model
|
|
@fast_model ||= DSPy::LM.new(
|
|
ENV.fetch("DSPY_SELECTOR_MODEL", "ruby_llm/gemini-2.5-flash-lite"),
|
|
structured_outputs: true
|
|
)
|
|
end
|
|
|
|
def powerful_model
|
|
@powerful_model ||= DSPy::LM.new(
|
|
ENV.fetch("DSPY_SYNTHESIZER_MODEL", "ruby_llm/gemini-2.5-flash"),
|
|
structured_outputs: true
|
|
)
|
|
end
|
|
end
|
|
|
|
# --- Module with configure_predictor ---
|
|
|
|
class ConfiguredAgent < DSPy::Module
|
|
def initialize
|
|
super
|
|
tools = [SearchTool.new, FinishTool.new]
|
|
@agent = DSPy::ReAct.new(ResearchSignature, tools: tools)
|
|
|
|
# Set default model for all internal predictors
|
|
@agent.configure { |c| c.lm = DSPy::LM.new('ruby_llm/gemini-2.5-flash', structured_outputs: true) }
|
|
|
|
# Override specific predictor with a more capable model
|
|
@agent.configure_predictor('thought_generator') do |c|
|
|
c.lm = DSPy::LM.new('ruby_llm/claude-sonnet-4-20250514', structured_outputs: true)
|
|
end
|
|
end
|
|
|
|
def forward(question:)
|
|
@agent.call(question: question)
|
|
end
|
|
end
|
|
|
|
# Available internal predictors by agent type:
|
|
# DSPy::ReAct → thought_generator, observation_processor
|
|
# DSPy::CodeAct → code_generator, observation_processor
|
|
# DSPy::DeepSearch → seed_predictor, search_predictor, reader_predictor, reason_predictor
|
|
|
|
# --- Module with Event Subscriptions ---
|
|
|
|
class TokenTrackingModule < DSPy::Module
|
|
subscribe 'lm.tokens', :track_tokens, scope: :descendants
|
|
|
|
def initialize
|
|
super
|
|
@predictor = DSPy::Predict.new(AnalysisSignature)
|
|
@total_tokens = 0
|
|
end
|
|
|
|
def forward(query:)
|
|
@predictor.call(query: query)
|
|
end
|
|
|
|
def track_tokens(_event, attrs)
|
|
@total_tokens += attrs.fetch(:total_tokens, 0)
|
|
end
|
|
|
|
def token_usage
|
|
@total_tokens
|
|
end
|
|
end
|
|
|
|
# Module-scoped subscriptions automatically scope to the module instance and descendants.
|
|
# Use scope: :self_only to restrict delivery to the module itself (ignoring children).
|
|
|
|
# --- Tool That Wraps a Prediction ---
|
|
|
|
class RerankTool < DSPy::Tools::Base
|
|
tool_name "rerank"
|
|
tool_description "Score and rank search results by relevance"
|
|
|
|
MAX_ITEMS = 200
|
|
MIN_ITEMS_FOR_LLM = 5
|
|
|
|
sig { params(query: String, items: T::Array[T::Hash[Symbol, T.untyped]]).returns(T::Hash[Symbol, T.untyped]) }
|
|
def call(query:, items: [])
|
|
# Short-circuit: skip LLM for small sets
|
|
return { scored_items: items, reranked: false } if items.size < MIN_ITEMS_FOR_LLM
|
|
|
|
# Cap to prevent token overflow
|
|
capped_items = items.first(MAX_ITEMS)
|
|
|
|
predictor = DSPy::Predict.new(RerankSignature)
|
|
predictor.configure { |c| c.lm = DSPy::LM.new("ruby_llm/gemini-2.5-flash", structured_outputs: true) }
|
|
|
|
result = predictor.call(query: query, items: capped_items)
|
|
{ scored_items: result.scored_items, reranked: true }
|
|
rescue => e
|
|
Rails.logger.warn "[RerankTool] LLM rerank failed: #{e.message}"
|
|
{ error: "Rerank failed: #{e.message}", scored_items: items, reranked: false }
|
|
end
|
|
end
|
|
|
|
# Key patterns for tools wrapping predictions:
|
|
# - Short-circuit LLM calls when unnecessary (small data, trivial cases)
|
|
# - Cap input size to prevent token overflow
|
|
# - Per-tool model selection via configure
|
|
# - Graceful error handling with fallback data
|
|
|
|
# --- Multi-Step Pipeline ---
|
|
|
|
class AnalysisPipeline < DSPy::Module
|
|
def initialize
|
|
super
|
|
@classifier = DSPy::Predict.new(ClassifySignature)
|
|
@analyzer = DSPy::ChainOfThought.new(AnalyzeSignature)
|
|
@summarizer = DSPy::Predict.new(SummarizeSignature)
|
|
end
|
|
|
|
def forward(text:)
|
|
classification = @classifier.call(text: text)
|
|
analysis = @analyzer.call(text: text, category: classification.category)
|
|
@summarizer.call(analysis: analysis.reasoning, category: classification.category)
|
|
end
|
|
end
|
|
|
|
# --- Observability with Spans ---
|
|
|
|
class TracedModule < DSPy::Module
|
|
def initialize
|
|
super
|
|
@predictor = DSPy::Predict.new(AnalysisSignature)
|
|
end
|
|
|
|
def forward(query:)
|
|
DSPy::Context.with_span(
|
|
operation: "traced_module.analyze",
|
|
"dspy.module" => self.class.name,
|
|
"query.length" => query.length.to_s
|
|
) do
|
|
@predictor.call(query: query)
|
|
end
|
|
end
|
|
end
|