Eric Xu commited on
Extract demographic filters from audience context for Nemotron cohorts
Browse filesThe Nemotron path was ignoring audience context entirely (empty filters),
returning random Americans regardless of what the user specified. Now the
LLM extracts structured filters (sex, age range, city, state, education,
occupation) from the audience context and passes them to filter_personas.
"Women in Chicago aged 25-45" → {sex: Female, city: Chicago, state: IL,
age_min: 25, age_max: 45} → only matching Nemotron personas are sampled.
Extracted filters are shown in the progress log for transparency.
- web/app.py +54 -1
- web/static/index.html +11 -1
web/app.py
CHANGED
|
@@ -131,6 +131,7 @@ class EntityInput(BaseModel):
|
|
| 131 |
|
| 132 |
class CohortConfig(BaseModel):
|
| 133 |
description: str
|
|
|
|
| 134 |
segments: list[dict] # [{"label": "...", "count": N}, ...]
|
| 135 |
parallel: int = 3
|
| 136 |
|
|
@@ -318,6 +319,50 @@ Be concrete and relevant — no generic segments."""
|
|
| 318 |
raise HTTPException(500, f"Failed to suggest segments: {e}")
|
| 319 |
|
| 320 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 321 |
@app.post("/api/cohort/generate")
|
| 322 |
async def generate_cohort_endpoint(config: CohortConfig):
|
| 323 |
"""Generate a cohort — from Nemotron if available, else LLM-generated."""
|
|
@@ -330,7 +375,14 @@ async def generate_cohort_endpoint(config: CohortConfig):
|
|
| 330 |
import random
|
| 331 |
pl = _lazy_persona_loader()
|
| 332 |
ss = _lazy_stratified_sampler()
|
| 333 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 334 |
profiles = [pl.to_profile(row, i) for i, row in enumerate(filtered)]
|
| 335 |
|
| 336 |
# Use only age + education to keep strata count < total
|
|
@@ -379,6 +431,7 @@ async def generate_cohort_endpoint(config: CohortConfig):
|
|
| 379 |
return {
|
| 380 |
"session_id": sid, "cohort_size": len(all_personas),
|
| 381 |
"cohort": all_personas, "source": source,
|
|
|
|
| 382 |
}
|
| 383 |
|
| 384 |
|
|
|
|
| 131 |
|
| 132 |
class CohortConfig(BaseModel):
|
| 133 |
description: str
|
| 134 |
+
audience_context: str = ""
|
| 135 |
segments: list[dict] # [{"label": "...", "count": N}, ...]
|
| 136 |
parallel: int = 3
|
| 137 |
|
|
|
|
| 319 |
raise HTTPException(500, f"Failed to suggest segments: {e}")
|
| 320 |
|
| 321 |
|
| 322 |
+
def extract_filters(client, model, audience_context, entity_text=""):
|
| 323 |
+
"""Use LLM to extract structured Nemotron filters from audience context."""
|
| 324 |
+
if not audience_context.strip():
|
| 325 |
+
return {}
|
| 326 |
+
|
| 327 |
+
prompt = f"""Extract structured demographic filters from this audience description.
|
| 328 |
+
Only include filters that are explicitly stated or clearly implied.
|
| 329 |
+
|
| 330 |
+
Audience: {audience_context}
|
| 331 |
+
Entity context: {entity_text[:500]}
|
| 332 |
+
|
| 333 |
+
Return JSON with ONLY the fields that apply (omit fields that aren't specified):
|
| 334 |
+
{{
|
| 335 |
+
"sex": "Male" or "Female",
|
| 336 |
+
"age_min": <number>,
|
| 337 |
+
"age_max": <number>,
|
| 338 |
+
"state": "<2-letter state code, e.g. IL for Illinois>",
|
| 339 |
+
"city": "<city name substring>",
|
| 340 |
+
"education_level": ["bachelors", "graduate", ...],
|
| 341 |
+
"occupation": "<occupation substring>"
|
| 342 |
+
}}
|
| 343 |
+
|
| 344 |
+
If the audience is "women in Chicago aged 25-35", return:
|
| 345 |
+
{{"sex": "Female", "city": "Chicago", "state": "IL", "age_min": 25, "age_max": 35}}
|
| 346 |
+
|
| 347 |
+
If nothing specific is stated, return {{}}."""
|
| 348 |
+
|
| 349 |
+
try:
|
| 350 |
+
resp = client.chat.completions.create(
|
| 351 |
+
model=model,
|
| 352 |
+
messages=[{"role": "user", "content": prompt}],
|
| 353 |
+
response_format={"type": "json_object"},
|
| 354 |
+
max_tokens=256,
|
| 355 |
+
temperature=0.2,
|
| 356 |
+
)
|
| 357 |
+
content = resp.choices[0].message.content
|
| 358 |
+
content = re.sub(r'<think>[\s\S]*?</think>', '', content).strip()
|
| 359 |
+
filters = json.loads(content)
|
| 360 |
+
# Clean empty values
|
| 361 |
+
return {k: v for k, v in filters.items() if v is not None and v != "" and v != []}
|
| 362 |
+
except Exception:
|
| 363 |
+
return {}
|
| 364 |
+
|
| 365 |
+
|
| 366 |
@app.post("/api/cohort/generate")
|
| 367 |
async def generate_cohort_endpoint(config: CohortConfig):
|
| 368 |
"""Generate a cohort — from Nemotron if available, else LLM-generated."""
|
|
|
|
| 375 |
import random
|
| 376 |
pl = _lazy_persona_loader()
|
| 377 |
ss = _lazy_stratified_sampler()
|
| 378 |
+
|
| 379 |
+
# Extract structured filters from audience context
|
| 380 |
+
client = get_client()
|
| 381 |
+
model = get_model()
|
| 382 |
+
filters = extract_filters(client, model, config.audience_context, config.description)
|
| 383 |
+
print(f"Nemotron filters from audience context: {filters}")
|
| 384 |
+
|
| 385 |
+
filtered = pl.filter_personas(ds, filters, limit=max(total * 20, 2000))
|
| 386 |
profiles = [pl.to_profile(row, i) for i, row in enumerate(filtered)]
|
| 387 |
|
| 388 |
# Use only age + education to keep strata count < total
|
|
|
|
| 431 |
return {
|
| 432 |
"session_id": sid, "cohort_size": len(all_personas),
|
| 433 |
"cohort": all_personas, "source": source,
|
| 434 |
+
"filters": filters if ds is not None else None,
|
| 435 |
}
|
| 436 |
|
| 437 |
|
web/static/index.html
CHANGED
|
@@ -719,7 +719,7 @@ async function runFullPipeline() {
|
|
| 719 |
const cohortResp = await fetch('/api/cohort/generate', {
|
| 720 |
method: 'POST',
|
| 721 |
headers: {'Content-Type': 'application/json'},
|
| 722 |
-
body: JSON.stringify({description: desc, segments, parallel: 3}),
|
| 723 |
});
|
| 724 |
const cohortData = await cohortResp.json();
|
| 725 |
|
|
@@ -731,6 +731,16 @@ async function runFullPipeline() {
|
|
| 731 |
});
|
| 732 |
|
| 733 |
const src = cohortData.source === 'nemotron' ? 'census-grounded (Nemotron)' : 'LLM-generated';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 734 |
logStep(`${cohortData.cohort_size} evaluators ready — ${src}`, 'pos');
|
| 735 |
if (panelSize < 40) {
|
| 736 |
logStep(`Tip: a panel of 40-60 gives more reliable results across segments`, 'neu');
|
|
|
|
| 719 |
const cohortResp = await fetch('/api/cohort/generate', {
|
| 720 |
method: 'POST',
|
| 721 |
headers: {'Content-Type': 'application/json'},
|
| 722 |
+
body: JSON.stringify({description: desc, audience_context: audienceCtx, segments, parallel: 3}),
|
| 723 |
});
|
| 724 |
const cohortData = await cohortResp.json();
|
| 725 |
|
|
|
|
| 731 |
});
|
| 732 |
|
| 733 |
const src = cohortData.source === 'nemotron' ? 'census-grounded (Nemotron)' : 'LLM-generated';
|
| 734 |
+
if (cohortData.filters && Object.keys(cohortData.filters).length > 0) {
|
| 735 |
+
const f = cohortData.filters;
|
| 736 |
+
const parts = [];
|
| 737 |
+
if (f.sex) parts.push(f.sex);
|
| 738 |
+
if (f.age_min || f.age_max) parts.push(`age ${f.age_min||'?'}-${f.age_max||'?'}`);
|
| 739 |
+
if (f.city) parts.push(f.city);
|
| 740 |
+
if (f.state) parts.push(f.state);
|
| 741 |
+
if (f.occupation) parts.push(f.occupation);
|
| 742 |
+
logStep(`Filtered: ${parts.join(', ')}`, 'pos');
|
| 743 |
+
}
|
| 744 |
logStep(`${cohortData.cohort_size} evaluators ready — ${src}`, 'pos');
|
| 745 |
if (panelSize < 40) {
|
| 746 |
logStep(`Tip: a panel of 40-60 gives more reliable results across segments`, 'neu');
|