Skip to content

Commit 2b3a6b2

Browse files
committed
dynamically generated prompt works in frontend
1 parent 8a7b045 commit 2b3a6b2

9 files changed

Lines changed: 158 additions & 53 deletions

File tree

stringsight/_public/async_api.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ async def extract_properties_only_async(
2020
method: str = "single_model",
2121
system_prompt: str | None = None,
2222
task_description: str | None = None,
23+
fail_on_empty_properties: bool = True,
2324
prompt_builder: Callable[[pd.Series, str], str] | None = None,
2425
model_name: str = "gpt-4.1",
2526
temperature: float = 0.7,
@@ -52,8 +53,6 @@ async def extract_properties_only_async(
5253
See extract_properties_only() for full documentation.
5354
"""
5455
# Just call the sync version's implementation but await the pipeline
55-
from ..prompts import get_system_prompt
56-
from ..pipeline import Pipeline
5756
from ..extractors import get_extractor
5857
from ..postprocess import LLMJsonParser, PropertyValidator
5958
from ..core.preprocessing import validate_and_prepare_dataframe
@@ -113,7 +112,7 @@ async def extract_properties_only_async(
113112

114113
extractor = get_extractor(**extractor_kwargs) # type: ignore[arg-type]
115114
parser = LLMJsonParser(fail_fast=False, **common_cfg) # type: ignore[arg-type]
116-
validator = PropertyValidator(**common_cfg) # type: ignore[arg-type]
115+
validator = PropertyValidator(fail_on_empty=fail_on_empty_properties, **common_cfg) # type: ignore[arg-type]
117116

118117
if output_dir:
119118
extractor.output_dir = output_dir # type: ignore[attr-defined]

stringsight/_public/sync_api.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ def extract_properties_only(
2222
method: str = "single_model",
2323
system_prompt: str | None = None,
2424
task_description: str | None = None,
25+
fail_on_empty_properties: bool = True,
2526
# Data preparation
2627
score_columns: List[str] | None = None,
2728
sample_size: int | None = None,
@@ -59,6 +60,8 @@ def extract_properties_only(
5960
method: "single_model" | "side_by_side"
6061
system_prompt: Explicit system prompt text or a short prompt name from stringsight.prompts
6162
task_description: Optional task-aware description (used only if the chosen prompt has {task_description})
63+
fail_on_empty_properties: If True, raise a RuntimeError when 0 valid properties remain after validation.
64+
If False, return an empty PropertyDataset.properties list.
6265
score_columns: Optional list of column names containing score metrics to convert to dict format
6366
sample_size: Optional number of rows to sample from the dataset before processing
6467
model_a: For side_by_side method with tidy data, specifies first model to select
@@ -150,7 +153,7 @@ def extract_properties_only(
150153
extractor = get_extractor(**extractor_kwargs) # type: ignore[arg-type]
151154
# Do not fail the whole run on parsing errors – collect failures and drop those rows
152155
parser = LLMJsonParser(fail_fast=False, output_dir=output_dir, **common_cfg) # type: ignore[arg-type]
153-
validator = PropertyValidator(output_dir=output_dir, **common_cfg) # type: ignore[arg-type]
156+
validator = PropertyValidator(output_dir=output_dir, fail_on_empty=fail_on_empty_properties, **common_cfg) # type: ignore[arg-type]
154157

155158
pipeline = PipelineBuilder(name=f"StringSight-extract-{method}") \
156159
.extract_properties(extractor) \

stringsight/postprocess/validator.py

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,6 @@
44
This stage validates and cleans extracted properties.
55
"""
66

7-
from pathlib import Path
8-
import json
9-
import pandas as pd
107
from typing import Optional, List, Any
118
from ..core.stage import PipelineStage
129
from ..core.data_objects import PropertyDataset, Property
@@ -26,12 +23,22 @@ def __init__(
2623
self,
2724
output_dir: Optional[str] = None,
2825
storage: Optional[StorageAdapter] = None,
26+
fail_on_empty: bool = True,
2927
**kwargs
3028
):
31-
"""Initialize the property validator."""
29+
"""Initialize the property validator.
30+
31+
Args:
32+
output_dir: Optional directory to auto-save stage artefacts.
33+
storage: Optional StorageAdapter for writing artefacts.
34+
fail_on_empty: If True, raise a RuntimeError when 0 valid properties remain after validation.
35+
If False, keep an empty `properties` list and allow the pipeline to continue/return.
36+
**kwargs: Forwarded to PipelineStage / LoggingMixin configuration.
37+
"""
3238
super().__init__(**kwargs)
3339
self.output_dir = output_dir
3440
self.storage = storage or get_storage_adapter()
41+
self.fail_on_empty = fail_on_empty
3542

3643
def run(self, data: PropertyDataset, progress_callback: Any = None, **kwargs: Any) -> PropertyDataset:
3744
"""
@@ -67,13 +74,19 @@ def run(self, data: PropertyDataset, progress_callback: Any = None, **kwargs: An
6774

6875

6976
# Check for 0 valid properties and provide helpful error message
70-
if len(valid_properties) == 0:
77+
if len(valid_properties) == 0 and self.fail_on_empty:
7178
raise RuntimeError(
7279
"ERROR: 0 valid properties after validation. "
7380
"This typically means: (1) LLM returned empty/invalid responses, "
7481
"(2) JSON parsing failures, or (3) All properties filtered during validation. "
7582
"Check logs above for details."
7683
)
84+
if len(valid_properties) == 0 and not self.fail_on_empty:
85+
self.log(
86+
"WARNING: 0 valid properties after validation. Returning an empty properties list. "
87+
"This typically means: (1) LLM returned empty/invalid responses, (2) JSON parsing failures, "
88+
"or (3) All properties filtered during validation."
89+
)
7790

7891
# Auto-save validation results if output_dir is provided
7992
if self.output_dir:

stringsight/prompt_generation.py

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,14 @@ def generate_prompts(
5757
# Can generate prompts with or without task_description (will infer from conversations if not provided)
5858
logger.info(f"Prompt generation config: use_dynamic_prompts={use_dynamic_prompts}, system_prompt_override={system_prompt_override is not None}")
5959

60-
if use_dynamic_prompts and not system_prompt_override:
61-
logger.info("Generating dynamic prompts...")
60+
# Check if system_prompt_override is a known template alias (not a custom literal prompt)
61+
KNOWN_PROMPT_ALIASES = {"default", "agent", "universal", "agent_universal"}
62+
is_custom_literal_prompt = system_prompt_override is not None and system_prompt_override not in KNOWN_PROMPT_ALIASES
63+
64+
# Only skip dynamic generation if there's a custom literal prompt
65+
# Template aliases like "default" should still allow dynamic generation
66+
if use_dynamic_prompts and not is_custom_literal_prompt:
67+
logger.info(f"Generating dynamic prompts (system_prompt_override={system_prompt_override})...")
6268

6369
# Use a default task description if none provided
6470
task_desc_for_generation = task_description_clean or "Analyze the behavioral patterns and characteristics in these AI model conversations."
@@ -128,6 +134,7 @@ def generate_prompts(
128134
clustering_prompts=custom_clustering_prompts,
129135
expanded_task_description=result.expanded_task_description
130136
)
137+
_save_metadata_to_file(output_dir=output_dir, metadata=metadata)
131138
except Exception as e:
132139
logger.error(f"Dynamic prompt generation failed: {e}. Using static prompts.")
133140
discovery_prompt = get_system_prompt(method, system_prompt_override, task_description_clean)
@@ -159,10 +166,38 @@ def generate_prompts(
159166
clustering_prompts=None,
160167
expanded_task_description=task_description_clean
161168
)
169+
_save_metadata_to_file(output_dir=output_dir, metadata=metadata)
162170

163171
return discovery_prompt, custom_clustering_prompts, metadata
164172

165173

174+
def _save_metadata_to_file(
175+
output_dir: str,
176+
metadata: PromptsMetadata
177+
) -> None:
178+
"""Save prompts metadata to JSON file in output directory.
179+
180+
Args:
181+
output_dir: Directory to save metadata to (relative paths resolved relative to results dir).
182+
metadata: PromptsMetadata object to save.
183+
"""
184+
import json
185+
from stringsight.utils.paths import _get_results_dir
186+
187+
# Resolve output_dir relative to results directory if it's not absolute
188+
output_path = Path(output_dir)
189+
if not output_path.is_absolute():
190+
results_base = _get_results_dir()
191+
output_path = results_base / output_dir
192+
193+
output_path.mkdir(parents=True, exist_ok=True)
194+
195+
metadata_file = output_path / "prompts_metadata.json"
196+
with open(metadata_file, "w") as f:
197+
json.dump(metadata.dict(), f, indent=2)
198+
logger.info(f"Saved prompts metadata to {metadata_file}")
199+
200+
166201
def _save_prompts_to_file(
167202
output_dir: str,
168203
discovery_prompt: str,
@@ -172,12 +207,19 @@ def _save_prompts_to_file(
172207
"""Save generated prompts to text files in output directory.
173208
174209
Args:
175-
output_dir: Directory to save prompts to.
210+
output_dir: Directory to save prompts to (relative paths resolved relative to results dir).
176211
discovery_prompt: The discovery/extraction prompt.
177212
clustering_prompts: Optional dict of clustering prompts.
178213
expanded_task_description: Optional expanded task description.
179214
"""
215+
from stringsight.utils.paths import _get_results_dir
216+
217+
# Resolve output_dir relative to results directory if it's not absolute
180218
output_path = Path(output_dir)
219+
if not output_path.is_absolute():
220+
results_base = _get_results_dir()
221+
output_path = results_base / output_dir
222+
181223
output_path.mkdir(parents=True, exist_ok=True)
182224

183225
# Save discovery prompt

stringsight/prompts/__init__.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -219,9 +219,12 @@ def get_system_prompt(method: str, system_prompt: str | None = None, task_descri
219219
desc = task_description if task_description is not None else cast(str, default_desc)
220220
return _format_task_aware(template, desc)
221221
if task_description is not None:
222-
raise ValueError(
223-
"A task_description was provided, but the given system_prompt string does not "
224-
"contain {task_description}. Please include the placeholder or use an alias ('default'|'agent')."
222+
# Match the behavior of prompt templates loaded from this module:
223+
# if the prompt doesn't support {task_description}, ignore it rather than erroring.
224+
import warnings
225+
warnings.warn(
226+
"task_description was provided but the given system_prompt string does not contain "
227+
"{task_description}. The task_description will be ignored."
225228
)
226229
return template
227230
except Exception as e:

stringsight/routers/extraction.py

Lines changed: 34 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,7 @@ async def extract_single(req: ExtractSingleRequest) -> Dict[str, Any]:
381381
method=method,
382382
system_prompt=discovery_prompt if discovery_prompt else req.system_prompt,
383383
task_description=None, # task_description already incorporated into discovery_prompt
384+
fail_on_empty_properties=False,
384385
model_name=req.model_name or "gpt-4.1",
385386
temperature=req.temperature or 0.7,
386387
top_p=req.top_p or 0.95,
@@ -444,30 +445,29 @@ async def extract_batch(req: ExtractBatchRequest) -> Dict[str, Any]:
444445
"available": list(df.columns),
445446
})
446447

447-
# Generate prompts and capture metadata
448-
prompts_metadata = None
449-
if req.task_description and req.use_dynamic_prompts:
450-
from stringsight.core.data_objects import PropertyDataset
451-
from stringsight.prompt_generation import generate_prompts
448+
# Generate prompts and capture metadata (always generate to get metadata)
449+
from stringsight.core.data_objects import PropertyDataset
450+
from stringsight.prompt_generation import generate_prompts
452451

453-
temp_dataset = PropertyDataset.from_dataframe(df, method=method)
454-
discovery_prompt, custom_clustering_prompts, prompts_metadata = generate_prompts(
455-
task_description=req.task_description,
456-
dataset=temp_dataset,
457-
method=method,
458-
use_dynamic_prompts=req.use_dynamic_prompts,
459-
dynamic_prompt_samples=req.dynamic_prompt_samples or 5,
460-
model=req.model_name or "gpt-4.1",
461-
system_prompt_override=req.system_prompt,
462-
output_dir=req.output_dir
463-
)
452+
temp_dataset = PropertyDataset.from_dataframe(df, method=method)
453+
discovery_prompt, custom_clustering_prompts, prompts_metadata = generate_prompts(
454+
task_description=req.task_description,
455+
dataset=temp_dataset,
456+
method=method,
457+
use_dynamic_prompts=req.use_dynamic_prompts if req.use_dynamic_prompts is not None else True,
458+
dynamic_prompt_samples=req.dynamic_prompt_samples or 5,
459+
model=req.model_name or "gpt-4.1",
460+
system_prompt_override=req.system_prompt,
461+
output_dir=req.output_dir
462+
)
464463

465464
try:
466465
result = await public_api.extract_properties_only_async(
467466
df,
468467
method=method,
469-
system_prompt=req.system_prompt,
470-
task_description=req.task_description,
468+
system_prompt=discovery_prompt if discovery_prompt else req.system_prompt,
469+
task_description=None, # task_description already incorporated into discovery_prompt
470+
fail_on_empty_properties=False,
471471
model_name=req.model_name or "gpt-4.1",
472472
temperature=req.temperature or 0.7,
473473
top_p=req.top_p or 0.95,
@@ -578,24 +578,23 @@ def update_progress(completed: int, total: int):
578578
# Create dataset once and reuse
579579
dataset = PropertyDataset.from_dataframe(df, method=method)
580580

581-
# Generate prompts and capture metadata
582-
prompts_metadata = None
583-
if req.task_description and req.use_dynamic_prompts:
584-
discovery_prompt, custom_clustering_prompts, prompts_metadata = generate_prompts(
585-
task_description=req.task_description,
586-
dataset=dataset,
587-
method=method,
588-
use_dynamic_prompts=req.use_dynamic_prompts,
589-
dynamic_prompt_samples=req.dynamic_prompt_samples or 5,
590-
model=req.model_name or "gpt-4.1",
591-
system_prompt_override=req.system_prompt,
592-
output_dir=req.output_dir
593-
)
594-
# Store prompts metadata in job
595-
with _JOBS_LOCK:
596-
job.prompts_metadata = prompts_metadata.dict() if prompts_metadata else None
581+
# Generate prompts and capture metadata (always generate to get metadata)
582+
discovery_prompt, custom_clustering_prompts, prompts_metadata = generate_prompts(
583+
task_description=req.task_description,
584+
dataset=dataset,
585+
method=method,
586+
use_dynamic_prompts=req.use_dynamic_prompts if req.use_dynamic_prompts is not None else True,
587+
dynamic_prompt_samples=req.dynamic_prompt_samples or 5,
588+
model=req.model_name or "gpt-4.1",
589+
system_prompt_override=req.system_prompt,
590+
output_dir=req.output_dir
591+
)
592+
# Store prompts metadata in job
593+
with _JOBS_LOCK:
594+
job.prompts_metadata = prompts_metadata.dict() if prompts_metadata else None
597595

598-
system_prompt = get_system_prompt(method, req.system_prompt, req.task_description)
596+
# Use the generated discovery_prompt if available, otherwise fall back to get_system_prompt
597+
system_prompt = discovery_prompt if discovery_prompt else get_system_prompt(method, req.system_prompt, req.task_description)
599598

600599
extractor = get_extractor(
601600
model_name=req.model_name or "gpt-4.1",

stringsight/routers/jobs.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,10 +184,31 @@ def get_job_results(
184184
# Read JSONL file using storage adapter
185185
properties = storage.read_jsonl(result_file_path)
186186

187-
return {
187+
response = {
188188
"properties": properties,
189189
"result_path": job.result_path,
190190
"count": len(properties)
191191
}
192+
193+
# Try to load prompts metadata from saved files
194+
prompts_metadata_file = str(full_result_path / "prompts_metadata.json")
195+
import logging
196+
logger = logging.getLogger(__name__)
197+
logger.info(f"Looking for prompts metadata at: {prompts_metadata_file}")
198+
199+
if storage.exists(prompts_metadata_file):
200+
try:
201+
prompts_metadata_content = storage.read_text(prompts_metadata_file)
202+
import json
203+
prompts_metadata = json.loads(prompts_metadata_content)
204+
response["prompts"] = prompts_metadata
205+
logger.info(f"Successfully loaded prompts metadata")
206+
except Exception as e:
207+
# Log but don't fail the request if prompts metadata can't be loaded
208+
logger.warning(f"Failed to load prompts metadata: {e}")
209+
else:
210+
logger.warning(f"Prompts metadata file not found at: {prompts_metadata_file}")
211+
212+
return response
192213
except Exception as e:
193214
raise HTTPException(status_code=500, detail=f"Failed to read results: {str(e)}")

stringsight/schemas.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ class ExtractBatchRequest(BaseModel):
3131
return_debug: Optional[bool] = False
3232
sample_size: Optional[int] = None
3333
use_dynamic_prompts: Optional[bool] = True
34-
dynamic_prompt_samples: Optional[int] = 5
34+
dynamic_prompt_samples: Optional[int] = 10
3535

3636
class ExtractJobStartRequest(ExtractBatchRequest):
3737
pass

stringsight/workers/tasks.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,9 +93,25 @@ def update_progress(completed: int, total_count: int):
9393
except Exception as e:
9494
logger.error(f"Failed to update progress: {e}")
9595

96-
system_prompt = get_system_prompt(method, req.system_prompt, req.task_description)
96+
# Generate prompts and capture metadata (always generate to get metadata)
97+
from stringsight.prompt_generation import generate_prompts
98+
9799
dataset = PropertyDataset.from_dataframe(df, method=method)
98100

101+
discovery_prompt, custom_clustering_prompts, prompts_metadata = generate_prompts(
102+
task_description=req.task_description,
103+
dataset=dataset,
104+
method=method,
105+
use_dynamic_prompts=req.use_dynamic_prompts if req.use_dynamic_prompts is not None else True,
106+
dynamic_prompt_samples=req.dynamic_prompt_samples or 5,
107+
model=req.model_name or "gpt-4.1",
108+
system_prompt_override=req.system_prompt,
109+
output_dir=None # We'll save to output_dir later after determining it
110+
)
111+
112+
# Use the generated discovery_prompt if available, otherwise fall back to get_system_prompt
113+
system_prompt = discovery_prompt if discovery_prompt else get_system_prompt(method, req.system_prompt, req.task_description)
114+
99115
extractor = get_extractor(
100116
model_name=req.model_name or "gpt-4.1",
101117
system_prompt=system_prompt,
@@ -125,6 +141,15 @@ def update_progress(completed: int, total_count: int):
125141
storage.ensure_directory(output_dir)
126142
logger.info(f"Results will be saved to: {output_dir}")
127143

144+
# Save prompts metadata to output directory
145+
if prompts_metadata:
146+
import json
147+
from pathlib import Path
148+
metadata_file = Path(output_dir) / "prompts_metadata.json"
149+
with open(metadata_file, "w") as f:
150+
json.dump(prompts_metadata.dict(), f, indent=2)
151+
logger.info(f"Saved prompts metadata to {metadata_file}")
152+
128153
# Run parsing and validation
129154
parser = LLMJsonParser(fail_fast=False, verbose=False, use_wandb=False, output_dir=output_dir)
130155
parsed_dataset = parser.run(extracted_dataset)

0 commit comments

Comments
 (0)