Skip to content

Commit 6f50802

Browse files
committed
feat: add hybrid rule-based extraction support and persistence endpoints for DSPy configs
1 parent e48c663 commit 6f50802

File tree

9 files changed

+881
-45
lines changed

9 files changed

+881
-45
lines changed

.env.example

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,12 @@ GUARDRAILS_INCLUDE_OUTPUT_IN_CORRECTION=true
6464
# Truncate invalid output in correction prompts (unset = no limit)
6565
# GUARDRAILS_MAX_CORRECTION_OUTPUT_LENGTH=1000
6666

67+
# ── Hybrid regex/LLM (langcore-hybrid) ───────────────────────────────────
68+
# Enable hybrid regex+LLM model wrapping (skips LLM for high-confidence patterns)
69+
HYBRID_ENABLED=false
70+
# Minimum regex confidence to bypass LLM (0.0-1.0)
71+
HYBRID_MIN_CONFIDENCE=0.8
72+
6773
# ── DSPy prompt optimization (langcore-dspy) ──────────────────────────────
6874
# Enable DSPy prompt optimization endpoint
6975
DSPY_ENABLED=false
@@ -78,6 +84,8 @@ DSPY_MAX_BOOTSTRAPPED_DEMOS=3
7884
DSPY_MAX_LABELED_DEMOS=4
7985
# Thread count for parallel evaluation during optimization
8086
DSPY_NUM_THREADS=4
87+
# Directory for saved optimized configs (save/load persistence)
88+
DSPY_CONFIG_DIR=.dspy_configs
8189

8290
# ── RAG query parsing (langcore-rag) ──────────────────────────────────────
8391
# Enable RAG query parsing endpoint

app/api/routes/dspy.py

Lines changed: 179 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""DSPy prompt optimization route."""
1+
"""DSPy prompt optimization, persistence, and evaluation routes."""
22

33
from __future__ import annotations
44

@@ -8,10 +8,22 @@
88

99
from app.core.config import get_settings
1010
from app.schemas.plugins import (
11+
DSPyEvaluateRequest,
12+
DSPyEvaluateResponse,
13+
DSPyListResponse,
14+
DSPyLoadResponse,
1115
DSPyOptimizationRequest,
1216
DSPyOptimizationResponse,
17+
DSPySaveRequest,
18+
DSPySaveResponse,
19+
)
20+
from app.services.dspy_optimizer import (
21+
async_load_config,
22+
async_run_evaluation,
23+
async_run_optimization,
24+
async_save_config,
25+
list_configs,
1326
)
14-
from app.services.dspy_optimizer import async_run_optimization
1527

1628
logger = logging.getLogger(__name__)
1729

@@ -40,15 +52,7 @@ async def optimize_prompt(
4052
calls internally. Expect response times of 30s-5min
4153
depending on training set size and optimizer strategy.
4254
"""
43-
settings = get_settings()
44-
45-
if not settings.DSPY_ENABLED:
46-
raise HTTPException(
47-
status_code=503,
48-
detail=(
49-
"DSPy optimization is disabled. " "Set DSPY_ENABLED=true to enable."
50-
),
51-
)
55+
_check_dspy_enabled()
5256

5357
if len(request.train_texts) != len(request.expected_results):
5458
raise HTTPException(
@@ -83,3 +87,167 @@ async def optimize_prompt(
8387
) from exc
8488

8589
return DSPyOptimizationResponse(**result)
90+
91+
92+
# -------------------------------------------------------------------
93+
# Config persistence endpoints
94+
# -------------------------------------------------------------------
95+
96+
97+
def _check_dspy_enabled() -> None:
98+
"""Raise 503 if DSPy is disabled."""
99+
settings = get_settings()
100+
if not settings.DSPY_ENABLED:
101+
raise HTTPException(
102+
status_code=503,
103+
detail=(
104+
"DSPy is disabled. Set DSPY_ENABLED=true to enable."
105+
),
106+
)
107+
108+
109+
@router.post(
110+
"/dspy/configs/save",
111+
response_model=DSPySaveResponse,
112+
summary="Save an optimized DSPy config",
113+
description=(
114+
"Persist an optimized prompt description and curated "
115+
"few-shot examples to disk under the configured "
116+
"``DSPY_CONFIG_DIR``. The saved config can later be loaded "
117+
"for extraction or evaluation without re-running "
118+
"optimization."
119+
),
120+
)
121+
async def save_config(request: DSPySaveRequest) -> DSPySaveResponse:
122+
"""Save an optimized DSPy config to disk."""
123+
_check_dspy_enabled()
124+
125+
try:
126+
result = await async_save_config(
127+
config_name=request.config_name,
128+
prompt_description=request.prompt_description,
129+
examples=request.examples,
130+
metadata=request.metadata,
131+
)
132+
except Exception as exc:
133+
logger.exception("Failed to save DSPy config '%s'", request.config_name)
134+
raise HTTPException(
135+
status_code=500,
136+
detail=f"Failed to save config: {exc}",
137+
) from exc
138+
139+
return DSPySaveResponse(**result)
140+
141+
142+
@router.get(
143+
"/dspy/configs",
144+
response_model=DSPyListResponse,
145+
summary="List saved DSPy configs",
146+
description=(
147+
"Return the names of all saved optimized configs "
148+
"available under ``DSPY_CONFIG_DIR``."
149+
),
150+
)
151+
async def list_saved_configs() -> DSPyListResponse:
152+
"""List all saved DSPy config names."""
153+
_check_dspy_enabled()
154+
return DSPyListResponse(configs=list_configs())
155+
156+
157+
@router.get(
158+
"/dspy/configs/{config_name}",
159+
response_model=DSPyLoadResponse,
160+
summary="Load a saved DSPy config",
161+
description=(
162+
"Load a previously saved optimized config by name. "
163+
"Returns the prompt description, examples, and any "
164+
"stored metadata."
165+
),
166+
)
167+
async def load_config(config_name: str) -> DSPyLoadResponse:
168+
"""Load a saved DSPy config from disk."""
169+
_check_dspy_enabled()
170+
171+
try:
172+
result = await async_load_config(config_name)
173+
except FileNotFoundError as exc:
174+
raise HTTPException(status_code=404, detail=str(exc)) from exc
175+
except Exception as exc:
176+
logger.exception("Failed to load DSPy config '%s'", config_name)
177+
raise HTTPException(
178+
status_code=500,
179+
detail=f"Failed to load config: {exc}",
180+
) from exc
181+
182+
return DSPyLoadResponse(**result)
183+
184+
185+
# -------------------------------------------------------------------
186+
# Evaluation endpoint
187+
# -------------------------------------------------------------------
188+
189+
190+
@router.post(
191+
"/dspy/evaluate",
192+
response_model=DSPyEvaluateResponse,
193+
summary="Evaluate an optimized DSPy config",
194+
description=(
195+
"Evaluate an optimized config against test documents "
196+
"with expected extractions. Returns precision, recall, "
197+
"F1 score, and per-document metrics. Supply either a "
198+
"``config_name`` (previously saved) or inline "
199+
"``prompt_description`` + ``examples``."
200+
),
201+
)
202+
async def evaluate_config(
203+
request: DSPyEvaluateRequest,
204+
) -> DSPyEvaluateResponse:
205+
"""Evaluate a DSPy config against test data."""
206+
_check_dspy_enabled()
207+
208+
if len(request.test_texts) != len(request.expected_results):
209+
raise HTTPException(
210+
status_code=400,
211+
detail=(
212+
f"test_texts ({len(request.test_texts)}) and "
213+
f"expected_results ({len(request.expected_results)}) "
214+
"must have the same length."
215+
),
216+
)
217+
218+
# Validate that exactly one source is provided
219+
has_config = request.config_name is not None
220+
has_inline = (
221+
request.prompt_description is not None
222+
and request.examples is not None
223+
)
224+
if not has_config and not has_inline:
225+
raise HTTPException(
226+
status_code=400,
227+
detail=(
228+
"Provide either config_name or both "
229+
"prompt_description and examples."
230+
),
231+
)
232+
233+
try:
234+
result = await async_run_evaluation(
235+
test_texts=request.test_texts,
236+
expected_results=request.expected_results,
237+
config_name=request.config_name,
238+
prompt_description=request.prompt_description,
239+
examples=request.examples,
240+
model_id=request.model_id,
241+
)
242+
except FileNotFoundError as exc:
243+
raise HTTPException(status_code=404, detail=str(exc)) from exc
244+
except ValueError as exc:
245+
raise HTTPException(status_code=400, detail=str(exc)) from exc
246+
except Exception as exc:
247+
logger.exception("DSPy evaluation failed")
248+
raise HTTPException(
249+
status_code=500,
250+
detail=f"Evaluation failed: {exc}",
251+
) from exc
252+
253+
return DSPyEvaluateResponse(**result)

app/core/config.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,10 @@ class Settings(BaseSettings):
111111
GUARDRAILS_MAX_CORRECTION_PROMPT_LENGTH: int | None = None
112112
GUARDRAILS_MAX_CORRECTION_OUTPUT_LENGTH: int | None = None
113113

114+
# ── Hybrid rule-based extraction (langcore-hybrid) ──────────────
115+
HYBRID_ENABLED: bool = False
116+
HYBRID_MIN_CONFIDENCE: float = 0.8
117+
114118
# ── DSPy prompt optimization ────────────────────────────────────
115119
DSPY_ENABLED: bool = False
116120
DSPY_MODEL_ID: str = "gemini/gemini-2.5-flash"
@@ -119,6 +123,7 @@ class Settings(BaseSettings):
119123
DSPY_MAX_BOOTSTRAPPED_DEMOS: int = 3
120124
DSPY_MAX_LABELED_DEMOS: int = 4
121125
DSPY_NUM_THREADS: int = 4
126+
DSPY_CONFIG_DIR: str = ".dspy_configs"
122127

123128
# ── RAG query parsing ───────────────────────────────────────────
124129
RAG_ENABLED: bool = False

app/schemas/__init__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,14 @@
1313
from app.schemas.enums import TaskState
1414
from app.schemas.health import CeleryHealthResponse, HealthResponse
1515
from app.schemas.plugins import (
16+
DSPyEvaluateRequest,
17+
DSPyEvaluateResponse,
18+
DSPyListResponse,
19+
DSPyLoadResponse,
1620
DSPyOptimizationRequest,
1721
DSPyOptimizationResponse,
22+
DSPySaveRequest,
23+
DSPySaveResponse,
1824
RAGQueryParseRequest,
1925
RAGQueryParseResponse,
2026
)
@@ -43,8 +49,14 @@
4349
"BatchExtractionRequest",
4450
"BatchTaskSubmitResponse",
4551
"CeleryHealthResponse",
52+
"DSPyEvaluateRequest",
53+
"DSPyEvaluateResponse",
54+
"DSPyListResponse",
55+
"DSPyLoadResponse",
4656
"DSPyOptimizationRequest",
4757
"DSPyOptimizationResponse",
58+
"DSPySaveRequest",
59+
"DSPySaveResponse",
4860
"ExtractedEntity",
4961
"ExtractionConfig",
5062
"ExtractionMetadata",

0 commit comments

Comments
 (0)