mike boone commited on
Commit
667cd22
·
1 Parent(s): 24febbb

fix: improve dataset routing and cpg finance

Browse files
demoprep_app/dataset/generators/template_generator.py CHANGED
@@ -371,6 +371,40 @@ class TemplateDatasetGenerator(ScenarioDatasetGenerator):
371
  rows.update({"INVESTED_CAPITAL_USD": invested, "CURRENT_VALUE_USD": current, "GAIN_LOSS_USD": round(current - invested, 2), "RETURN_PCT": self._pct(current - invested, invested)})
372
  return rows
373
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
374
  if profile == "professional_services":
375
  budget_hours = self._whole(rng, 120, 12_000, mult)
376
  billable_hours = self._bounded_child(rng, budget_hours, 0.72, 1.12)
 
371
  rows.update({"INVESTED_CAPITAL_USD": invested, "CURRENT_VALUE_USD": current, "GAIN_LOSS_USD": round(current - invested, 2), "RETURN_PCT": self._pct(current - invested, invested)})
372
  return rows
373
 
374
+ if profile == "cpg_finance":
375
+ cases = self._whole(rng, 1_500, 180_000, mult)
376
+ gross_price_per_case = round(rng.uniform(18, 92), 2)
377
+ gross_sales = round(cases * gross_price_per_case, 2)
378
+ trade_spend = round(gross_sales * rng.uniform(0.06, 0.24), 2)
379
+ deductions = round(gross_sales * rng.uniform(0.01, 0.06), 2)
380
+ net_sales = round(gross_sales - trade_spend - deductions, 2)
381
+ cogs = round(net_sales * rng.uniform(0.52, 0.70), 2)
382
+ freight = round(cases * rng.uniform(0.42, 2.85), 2)
383
+ spoilage = round(net_sales * rng.uniform(0.001, 0.018), 2)
384
+ margin = round(net_sales - cogs - freight - spoilage, 2)
385
+ planned_net_sales = round(net_sales * rng.uniform(0.88, 1.14), 2)
386
+ forecast_net_sales = round(net_sales * rng.uniform(0.91, 1.10), 2)
387
+ promo_lift = round(rng.uniform(0, 32), 2)
388
+ return {
389
+ "CASES_SHIPPED": cases,
390
+ "GROSS_SALES_USD": gross_sales,
391
+ "TRADE_SPEND_USD": trade_spend,
392
+ "TRADE_SPEND_RATE_PCT": self._pct(trade_spend, gross_sales),
393
+ "DEDUCTIONS_USD": deductions,
394
+ "NET_SALES_USD": net_sales,
395
+ "COGS_USD": cogs,
396
+ "FREIGHT_COST_USD": freight,
397
+ "SPOILAGE_WRITE_OFF_USD": spoilage,
398
+ "GROSS_MARGIN_USD": margin,
399
+ "GROSS_MARGIN_PCT": self._pct(margin, net_sales),
400
+ "AVG_NET_PRICE_PER_CASE_USD": self._div(net_sales, cases),
401
+ "PROMOTION_LIFT_PCT": promo_lift,
402
+ "PLANNED_NET_SALES_USD": planned_net_sales,
403
+ "FORECAST_NET_SALES_USD": forecast_net_sales,
404
+ "PLAN_VARIANCE_USD": round(net_sales - planned_net_sales, 2),
405
+ "FORECAST_VARIANCE_PCT": self._pct(net_sales - forecast_net_sales, forecast_net_sales),
406
+ }
407
+
408
  if profile == "professional_services":
409
  budget_hours = self._whole(rng, 120, 12_000, mult)
410
  billable_hours = self._bounded_child(rng, budget_hours, 0.72, 1.12)
demoprep_app/pipeline/dataset_first.py CHANGED
@@ -23,6 +23,33 @@ class DatasetFirstBuild:
23
 
24
  def infer_scenario_type(use_case: str, vertical: str | None = None, function: str | None = None) -> str | None:
25
  text = f"{vertical or ''} {function or ''} {use_case or ''}".lower()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  if any(tok in text for tok in ("air transport", "airline", "airport", "aircraft", "flight", "passenger", "baggage")) and "finance" in text:
27
  return "airline_finance"
28
  if any(tok in text for tok in ("air transport", "airline", "airport", "aircraft", "flight", "passenger", "baggage")):
@@ -31,19 +58,27 @@ def infer_scenario_type(use_case: str, vertical: str | None = None, function: st
31
  return "sports_venue_fan_engagement"
32
  if any(tok in text for tok in ("restaurant", "coffee", "cafe", "store operations", "daypart", "drive-thru", "mobile order")):
33
  return "restaurant_store_operations"
34
- if (function or "").strip().lower() == "it" and any(tok in text for tok in ("technology", "software", "saas", "service", "incident", "uptime")):
35
- return "it_operations"
 
 
36
  if any(tok in text for tok in ("software", "saas", "technology")) and "sales" in text:
37
  return "saas_sales"
38
  if any(tok in text for tok in ("shipping", "parcel", "shipment", "freight")) and "sales" in text:
39
  return "shipping_sales"
40
  if any(tok in text for tok in ("trucking", "truckload", "carrier", "driver", "fleet")) and "finance" in text:
41
  return "trucking_finance"
 
 
 
 
42
  if any(tok in text for tok in ("automotive", "vehicle", "dealer", "dealership")) and "sales" in text:
43
  return "automotive_sales"
 
 
44
  if any(tok in text for tok in ("life sciences", "pharma", "pharmaceutical", "medical device", "therapy", "biotech")) and "sales" in text:
45
  return "life_sciences_sales"
46
- if any(tok in text for tok in ("hotel", "hotels", "hospitality", "occupancy", "revpar", "adr")) and "finance" in text:
47
  return "hotel_finance"
48
  if any(tok in text for tok in ("fashion", "apparel", "footwear")) and "marketing" in text:
49
  return "apparel_marketing"
@@ -84,10 +119,19 @@ def build_dataset_first_demo(
84
  "automotive_sales",
85
  "hotel_finance",
86
  "apparel_marketing",
 
87
  "trucking_finance",
88
  "airline_finance",
89
  "life_sciences_sales",
90
  "it_operations",
 
 
 
 
 
 
 
 
91
  }
92
  if preliminary_type in deterministic_types:
93
  scenario_type = preliminary_type
 
23
 
24
  def infer_scenario_type(use_case: str, vertical: str | None = None, function: str | None = None) -> str | None:
25
  text = f"{vertical or ''} {function or ''} {use_case or ''}".lower()
26
+ fn = (function or "").strip().lower()
27
+
28
+ if fn == "hr":
29
+ return "workforce_hr"
30
+ if fn == "legal":
31
+ return "legal_matter_management"
32
+ if fn == "marketing":
33
+ if any(tok in text for tok in ("apparel", "fashion", "garment", "footwear", "seasonal drop", "markdown")):
34
+ return "apparel_marketing"
35
+ if any(tok in text for tok in ("ecommerce", "e-commerce", "commerce", "online store")):
36
+ return "ecommerce_marketing"
37
+ return "marketing_funnel"
38
+ if fn == "finance":
39
+ if any(tok in text for tok in ("air transport", "airline", "airport", "aircraft", "flight", "passenger", "baggage")):
40
+ return "airline_finance"
41
+ if any(tok in text for tok in ("trucking", "truckload", "carrier", "driver", "fleet")):
42
+ return "trucking_finance"
43
+ if any(tok in text for tok in ("hotel", "hotels", "occupancy", "revpar", "adr")):
44
+ return "hotel_finance"
45
+ if any(tok in text for tok in ("grocery", "food", "beverage", "consumer goods", "cpg", "packaged goods", "retail")):
46
+ return "cpg_finance"
47
+ if any(tok in text for tok in ("asset", "wealth", "portfolio", "investment", "investments")):
48
+ return "portfolio_financials"
49
+ return "finance_unit_economics"
50
+ if fn == "it":
51
+ return "it_operations"
52
+
53
  if any(tok in text for tok in ("air transport", "airline", "airport", "aircraft", "flight", "passenger", "baggage")) and "finance" in text:
54
  return "airline_finance"
55
  if any(tok in text for tok in ("air transport", "airline", "airport", "aircraft", "flight", "passenger", "baggage")):
 
58
  return "sports_venue_fan_engagement"
59
  if any(tok in text for tok in ("restaurant", "coffee", "cafe", "store operations", "daypart", "drive-thru", "mobile order")):
60
  return "restaurant_store_operations"
61
+ if "electronics manufacturing" in text and "sales" in text:
62
+ return "sales_pipeline"
63
+ if any(tok in text for tok in ("hardware", "device", "devices", "consumer electronics", "electronics")) and "sales" in text:
64
+ return "retail_sales"
65
  if any(tok in text for tok in ("software", "saas", "technology")) and "sales" in text:
66
  return "saas_sales"
67
  if any(tok in text for tok in ("shipping", "parcel", "shipment", "freight")) and "sales" in text:
68
  return "shipping_sales"
69
  if any(tok in text for tok in ("trucking", "truckload", "carrier", "driver", "fleet")) and "finance" in text:
70
  return "trucking_finance"
71
+ if any(tok in text for tok in ("trucking", "truckload", "carrier", "driver", "fleet")) and "sales" in text:
72
+ return "sales_pipeline"
73
+ if any(tok in text for tok in ("warehousing", "warehouse", "supply chain", "fulfillment", "distribution center")):
74
+ return "inventory_supply_chain"
75
  if any(tok in text for tok in ("automotive", "vehicle", "dealer", "dealership")) and "sales" in text:
76
  return "automotive_sales"
77
+ if any(tok in text for tok in ("healthcare provider", "healthcare providers", "healthcare payer", "healthcare payers", "hospital", "clinic")) and "sales" in text:
78
+ return "sales_pipeline"
79
  if any(tok in text for tok in ("life sciences", "pharma", "pharmaceutical", "medical device", "therapy", "biotech")) and "sales" in text:
80
  return "life_sciences_sales"
81
+ if any(tok in text for tok in ("hotel", "hotels", "occupancy", "revpar", "adr")) and "finance" in text:
82
  return "hotel_finance"
83
  if any(tok in text for tok in ("fashion", "apparel", "footwear")) and "marketing" in text:
84
  return "apparel_marketing"
 
119
  "automotive_sales",
120
  "hotel_finance",
121
  "apparel_marketing",
122
+ "sales_pipeline",
123
  "trucking_finance",
124
  "airline_finance",
125
  "life_sciences_sales",
126
  "it_operations",
127
+ "workforce_hr",
128
+ "legal_matter_management",
129
+ "marketing_funnel",
130
+ "ecommerce_marketing",
131
+ "finance_unit_economics",
132
+ "cpg_finance",
133
+ "portfolio_financials",
134
+ "inventory_supply_chain",
135
  }
136
  if preliminary_type in deterministic_types:
137
  scenario_type = preliminary_type
demoprep_app/scenario/families.py CHANGED
@@ -130,6 +130,20 @@ SCENARIO_FAMILIES: dict[str, ScenarioFamilyTemplate] = {
130
  "finance",
131
  ("How are margins trending?", "Where is CAC highest?", "Which segments are most efficient?"),
132
  ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
  "hotel_finance": ScenarioFamilyTemplate(
134
  "hotel_finance",
135
  "HOTEL_FINANCIALS",
 
130
  "finance",
131
  ("How are margins trending?", "Where is CAC highest?", "Which segments are most efficient?"),
132
  ),
133
+ "cpg_finance": ScenarioFamilyTemplate(
134
+ "cpg_finance",
135
+ "CPG_FINANCIALS",
136
+ "product-customer-channel-month",
137
+ "NET_SALES_USD",
138
+ ("PRODUCTS", "CUSTOMERS", "CHANNELS", "REGIONS", "SCENARIOS"),
139
+ ("Actual", "Budget", "Forecast"),
140
+ "cpg_finance",
141
+ (
142
+ "Which products and customers drive net sales and gross margin?",
143
+ "Where is trade spend eroding margin?",
144
+ "How do actuals compare with budget and forecast by channel?",
145
+ ),
146
+ ),
147
  "hotel_finance": ScenarioFamilyTemplate(
148
  "hotel_finance",
149
  "HOTEL_FINANCIALS",
liveboard_creator.py CHANGED
@@ -3941,23 +3941,33 @@ def enhance_mcp_liveboard(
3941
  liveboard_tml['liveboard']['groups'] = groups
3942
  enhancements_applied.append(f"Added {len(groups)} group with KPIs")
3943
 
3944
- # Step 3.5: Convert LINE charts to KPIs (MCP creates LINE instead of KPI)
3945
- # Only convert single-metric time series (no "by X" dimension) — breakdowns are not KPIs.
3946
- if fix_kpis and len(kpi_vizs) == 0 and len(trend_vizs) >= 1:
3947
- print(f" Converting LINE charts to KPIs (MCP creates LINE instead of KPI)...", flush=True)
 
 
3948
  converted_count = 0
3949
  kpi_candidates = []
3950
 
3951
- for viz_id in trend_vizs:
3952
- for viz in visualizations:
3953
- if viz.get('id') != viz_id:
3954
- continue
3955
- answer = viz.get('answer', {})
3956
- viz_name = answer.get('name', '').lower()
3957
- # Skip dimensional breakdowns — they are NOT KPIs
3958
- if ' by ' in viz_name:
3959
- print(f" Skipping '{answer.get('name', viz_id)}' (dimensional breakdown — not a KPI)", flush=True)
3960
- continue
 
 
 
 
 
 
 
 
3961
  kpi_candidates.append(viz_id)
3962
 
3963
  # Convert up to 2 single-metric trend charts to KPIs
@@ -3979,18 +3989,22 @@ def enhance_mcp_liveboard(
3979
  if converted_count > 0:
3980
  enhancements_applied.append(f"Converted {converted_count} LINE charts to KPIs")
3981
  print(f" [OK] Converted {converted_count} LINE→KPI", flush=True)
3982
-
3983
- # Update the groups if we just created KPIs
3984
- if add_groups and 'groups' in liveboard_tml.get('liveboard', {}):
3985
- groups = liveboard_tml['liveboard']['groups']
3986
- if not groups: # No groups yet
3987
- groups.append({
3988
- 'id': 'Group_1',
3989
- 'name': 'Key Metrics',
3990
- 'visualizations': list(kpi_vizs)
3991
- })
3992
- liveboard_tml['liveboard']['groups'] = groups
3993
- print(f" [OK] Added Key Metrics group with {len(kpi_vizs)} KPIs", flush=True)
 
 
 
 
3994
 
3995
  # Step 4: Fix KPI visualizations for sparklines
3996
  if fix_kpis and kpi_vizs:
 
3941
  liveboard_tml['liveboard']['groups'] = groups
3942
  enhancements_applied.append(f"Added {len(groups)} group with KPIs")
3943
 
3944
+ # Step 3.5: Convert KPI-style time-grain charts to KPIs.
3945
+ # MCP/Spotter often returns LINE charts for questions like "revenue weekly",
3946
+ # and sometimes returns COLUMN/BAR. Single-metric time-grain charts are KPI
3947
+ # cards; dimensional breakdowns ("by X") stay as analysis charts.
3948
+ if fix_kpis and len(kpi_vizs) == 0:
3949
+ print(f" Converting KPI-style time-grain charts to KPIs...", flush=True)
3950
  converted_count = 0
3951
  kpi_candidates = []
3952
 
3953
+ kpi_chart_types = {'LINE', 'AREA', 'COLUMN', 'BAR'}
3954
+ time_suffixes = ('weekly', 'monthly', 'daily', 'quarterly', 'yearly')
3955
+ for viz in visualizations:
3956
+ viz_id = viz.get('id')
3957
+ if not viz_id or 'note_tile' in viz:
3958
+ continue
3959
+ answer = viz.get('answer', {})
3960
+ chart = answer.get('chart', {})
3961
+ chart_type = chart.get('type', '').upper()
3962
+ viz_name = answer.get('name', '').lower().strip()
3963
+
3964
+ if chart_type not in kpi_chart_types:
3965
+ continue
3966
+ # Skip dimensional breakdowns — they are NOT KPIs.
3967
+ if ' by ' in viz_name:
3968
+ print(f" Skipping '{answer.get('name', viz_id)}' (dimensional breakdown — not a KPI)", flush=True)
3969
+ continue
3970
+ if viz_id in trend_vizs or viz_name.endswith(time_suffixes):
3971
  kpi_candidates.append(viz_id)
3972
 
3973
  # Convert up to 2 single-metric trend charts to KPIs
 
3989
  if converted_count > 0:
3990
  enhancements_applied.append(f"Converted {converted_count} LINE charts to KPIs")
3991
  print(f" [OK] Converted {converted_count} LINE→KPI", flush=True)
3992
+
3993
+ # If KPI conversion happened after the first grouping pass, ensure the
3994
+ # Key Metrics group exists before layout is built.
3995
+ if add_groups and kpi_vizs:
3996
+ groups = liveboard_tml.get('liveboard', {}).setdefault('groups', [])
3997
+ key_metrics_group = next((g for g in groups if g.get('name') == 'Key Metrics'), None)
3998
+ if key_metrics_group:
3999
+ key_metrics_group['visualizations'] = list(kpi_vizs)
4000
+ else:
4001
+ groups.insert(0, {
4002
+ 'id': 'Group_1',
4003
+ 'name': 'Key Metrics',
4004
+ 'visualizations': list(kpi_vizs)
4005
+ })
4006
+ enhancements_applied.append("Added Key Metrics group with KPIs")
4007
+ print(f" [OK] Added Key Metrics group with {len(kpi_vizs)} KPIs", flush=True)
4008
 
4009
  # Step 4: Fix KPI visualizations for sparklines
4010
  if fix_kpis and kpi_vizs:
tests/test_dataset_first_builders.py CHANGED
@@ -5,7 +5,7 @@ dataset-first builders produce coherent table bundles and relationship-bearing
5
  DDL before the slower browser quality suite runs against the test Space.
6
  """
7
 
8
- from demoprep_app.pipeline.dataset_first import build_dataset_first_demo
9
  from demoprep_app.scenario.families import SCENARIO_FAMILIES
10
 
11
 
@@ -187,6 +187,104 @@ def test_quality_pool_domains_do_not_fall_back_to_generic_templates():
187
  assert expected_columns <= column_names
188
 
189
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
190
  def test_it_operations_has_two_year_hourly_calendar():
191
  build = build_dataset_first_demo(
192
  company_name="Microsoft",
 
5
  DDL before the slower browser quality suite runs against the test Space.
6
  """
7
 
8
+ from demoprep_app.pipeline.dataset_first import build_dataset_first_demo, infer_scenario_type
9
  from demoprep_app.scenario.families import SCENARIO_FAMILIES
10
 
11
 
 
187
  assert expected_columns <= column_names
188
 
189
 
190
+ def test_transportation_function_routing_keeps_sales_and_finance_distinct():
191
+ cases = [
192
+ ("Shipping", "Sales", "shipping_sales"),
193
+ ("Shipping", "Marketing", "marketing_funnel"),
194
+ ("Shipping", "HR", "workforce_hr"),
195
+ ("Shipping", "IT", "it_operations"),
196
+ ("Shipping", "Legal", "legal_matter_management"),
197
+ ("Trucking", "Sales", "sales_pipeline"),
198
+ ("Trucking", "Marketing", "marketing_funnel"),
199
+ ("Trucking", "Finance", "trucking_finance"),
200
+ ("Trucking", "HR", "workforce_hr"),
201
+ ("Trucking", "IT", "it_operations"),
202
+ ("Trucking", "Legal", "legal_matter_management"),
203
+ ("Air Transport", "Sales", "airline_route_operations"),
204
+ ("Air Transport", "Finance", "airline_finance"),
205
+ ("Air Transport", "HR", "workforce_hr"),
206
+ ("Air Transport", "Legal", "legal_matter_management"),
207
+ ]
208
+
209
+ for line, function, expected in cases:
210
+ assert infer_scenario_type(
211
+ use_case=f"{line} {function}",
212
+ vertical="Transportation & Logistics",
213
+ function=function,
214
+ ) == expected
215
+
216
+
217
+ def test_trucking_sales_does_not_route_to_trucking_finance():
218
+ build = build_dataset_first_demo(
219
+ company_name="Linxup",
220
+ company_url="https://www.linxup.com/",
221
+ use_case="Trucking Sales",
222
+ vertical="Transportation & Logistics",
223
+ function="Sales",
224
+ row_count_guidance=100,
225
+ )
226
+
227
+ assert build is not None
228
+ assert build.scenario.scenario_type == "sales_pipeline"
229
+ assert "SALES_PIPELINE" in build.dataset.table_map()
230
+ assert "TRUCKING_FINANCIALS" not in build.dataset.table_map()
231
+
232
+
233
+ def test_grocery_finance_routes_to_cpg_financials_not_sales_fact():
234
+ build = build_dataset_first_demo(
235
+ company_name="General Mills",
236
+ company_url="https://generalmills.com",
237
+ use_case="Grocery Finance",
238
+ vertical="Retail & Consumer Goods",
239
+ function="Finance",
240
+ row_count_guidance=100,
241
+ )
242
+
243
+ assert build is not None
244
+ assert build.scenario.scenario_type == "cpg_finance"
245
+ table_map = build.dataset.table_map()
246
+ assert "CPG_FINANCIALS" in table_map
247
+ assert "SALES_FACT" not in table_map
248
+ fact_columns = {column.name for column in table_map["CPG_FINANCIALS"].columns}
249
+ assert {
250
+ "CASES_SHIPPED",
251
+ "TRADE_SPEND_USD",
252
+ "NET_SALES_USD",
253
+ "COGS_USD",
254
+ "FREIGHT_COST_USD",
255
+ "GROSS_MARGIN_USD",
256
+ "FORECAST_VARIANCE_PCT",
257
+ } <= fact_columns
258
+ assert "CAC_USD" not in fact_columns
259
+ assert "LTV_USD" not in fact_columns
260
+
261
+
262
+ def test_function_specific_routing_overrides_industry_terms():
263
+ cases = [
264
+ ("Financial Services", "Banking", "HR", "workforce_hr"),
265
+ ("Financial Services", "Banking", "Legal", "legal_matter_management"),
266
+ ("Travel & Hospitality", "Restaurants/Catering", "Marketing", "marketing_funnel"),
267
+ ("Technology", "Hardware", "Sales", "retail_sales"),
268
+ ("Healthcare & Life Sciences", "Healthcare Providers", "Sales", "sales_pipeline"),
269
+ ("Retail & Consumer Goods", "Grocery", "Finance", "cpg_finance"),
270
+ ("Retail & Consumer Goods", "Consumer Electronics", "IT", "it_operations"),
271
+ ("Transportation & Logistics", "Warehousing", "Sales", "inventory_supply_chain"),
272
+ ("Transportation & Logistics", "Supply Chain", "Sales", "inventory_supply_chain"),
273
+ ("Manufacturing", "Automotive", "Finance", "finance_unit_economics"),
274
+ ("Manufacturing", "Electronics Manufacturing", "IT", "it_operations"),
275
+ ("Manufacturing", "Electronics Manufacturing", "Sales", "sales_pipeline"),
276
+ ("Travel & Hospitality", "Restaurants/Catering", "Finance", "finance_unit_economics"),
277
+ ("Travel & Hospitality", "Travel/Tourism", "Finance", "finance_unit_economics"),
278
+ ]
279
+
280
+ for vertical, line, function, expected in cases:
281
+ assert infer_scenario_type(
282
+ use_case=f"{line} {function}",
283
+ vertical=vertical,
284
+ function=function,
285
+ ) == expected
286
+
287
+
288
  def test_it_operations_has_two_year_hourly_calendar():
289
  build = build_dataset_first_demo(
290
  company_name="Microsoft",