mike boone commited on
Commit
8db53a2
·
1 Parent(s): 889a00d

fix: require researched retail data contracts

Browse files
demoprep_app/dataset/company_contract.py ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Validate researched company-specific dimensions before dataset generation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass, field
7
+
8
+ from demoprep_app.scenario.extractor import ExtractedScenario
9
+
10
+
11
+ @dataclass(slots=True)
12
+ class CompanyDataContract:
13
+ dimensions: dict[str, list[str]] = field(default_factory=dict)
14
+ warnings: list[str] = field(default_factory=list)
15
+ blockers: list[str] = field(default_factory=list)
16
+ sources: dict[str, str] = field(default_factory=dict)
17
+
18
+ @property
19
+ def ok(self) -> bool:
20
+ return not self.blockers
21
+
22
+
23
+ VISIBLE_DIMENSIONS_BY_SCENARIO = {
24
+ "retail_sales": {"PRODUCTS", "CHANNELS", "STORES"},
25
+ }
26
+
27
+ GENERIC_PRODUCT_VALUES = {
28
+ "accessories",
29
+ "products",
30
+ "product",
31
+ "streaming",
32
+ "mobile app",
33
+ "app",
34
+ "online",
35
+ "retail",
36
+ "stores",
37
+ "store",
38
+ "service",
39
+ "services",
40
+ }
41
+
42
+ GENERIC_PRODUCT_PHRASE_TERMS = {
43
+ "analytics",
44
+ "demo",
45
+ "retail sales",
46
+ "sales retail",
47
+ "sales demo",
48
+ "use case",
49
+ }
50
+
51
+ APPAREL_DOMAIN_TERMS = {
52
+ "apparel",
53
+ "fashion",
54
+ "footwear",
55
+ "shoe",
56
+ "shoes",
57
+ "sneaker",
58
+ "sneakers",
59
+ "clothing",
60
+ "garment",
61
+ "sportswear",
62
+ "athletic",
63
+ "running",
64
+ "basketball",
65
+ "training",
66
+ }
67
+
68
+ APPAREL_INCOMPATIBLE_PRODUCT_TERMS = {
69
+ "soundbar",
70
+ "speaker",
71
+ "streaming",
72
+ "smart tv",
73
+ "television",
74
+ "remote",
75
+ }
76
+
77
+
78
+ def build_company_data_contract(
79
+ *,
80
+ company_name: str,
81
+ scenario_type: str,
82
+ extraction: ExtractedScenario | None,
83
+ use_case: str,
84
+ research_context: str | None = None,
85
+ ) -> CompanyDataContract:
86
+ """Return validated visible dimensions sourced from research/extraction.
87
+
88
+ The contract gate deliberately avoids company-specific production lists.
89
+ Its job is to decide whether extraction produced enough credible company
90
+ terms for generation to proceed.
91
+ """
92
+ dimensions = _clean_dimensions(extraction.dimensions if extraction else {})
93
+ contract = CompanyDataContract(dimensions=dimensions)
94
+ for name in VISIBLE_DIMENSIONS_BY_SCENARIO.get(scenario_type, set()):
95
+ if name in dimensions:
96
+ contract.sources[name] = extraction.source if extraction else "extraction"
97
+
98
+ if scenario_type == "retail_sales":
99
+ _validate_retail_sales_contract(
100
+ contract,
101
+ company_name=company_name,
102
+ use_case=use_case,
103
+ research_context=research_context,
104
+ extraction=extraction,
105
+ )
106
+ return contract
107
+
108
+
109
+ def _validate_retail_sales_contract(
110
+ contract: CompanyDataContract,
111
+ *,
112
+ company_name: str,
113
+ use_case: str,
114
+ research_context: str | None,
115
+ extraction: ExtractedScenario | None,
116
+ ) -> None:
117
+ text = f"{company_name} {use_case} {research_context or ''} {extraction.business_domain if extraction else ''}".lower()
118
+ products = _specific_product_values(contract.dimensions.get("PRODUCTS", []))
119
+
120
+ if _is_apparel_domain(text):
121
+ incompatible = _values_with_terms(products, APPAREL_INCOMPATIBLE_PRODUCT_TERMS)
122
+ if incompatible:
123
+ contract.blockers.append(
124
+ "Retail/apparel product contract contains incompatible product terms from extraction: "
125
+ + ", ".join(incompatible[:8])
126
+ )
127
+
128
+ if len(products) < 4:
129
+ source = extraction.source if extraction else "none"
130
+ contract.blockers.append(
131
+ "Retail sales contract lacks enough researched company-specific products "
132
+ f"({len(products)} found from {source})."
133
+ )
134
+ return
135
+
136
+ if products != contract.dimensions.get("PRODUCTS", []):
137
+ contract.warnings.append("Removed generic product labels from researched product contract.")
138
+ contract.dimensions["PRODUCTS"] = products
139
+
140
+
141
+ def _clean_dimensions(dimensions: dict[str, list[str]]) -> dict[str, list[str]]:
142
+ cleaned: dict[str, list[str]] = {}
143
+ for name, values in (dimensions or {}).items():
144
+ key = str(name).upper()
145
+ cleaned_values = []
146
+ seen = set()
147
+ for value in values or []:
148
+ text = str(value).strip()
149
+ if not text:
150
+ continue
151
+ text = re.sub(r"\s+", " ", text)
152
+ low = text.lower()
153
+ if low in seen:
154
+ continue
155
+ cleaned_values.append(text)
156
+ seen.add(low)
157
+ if cleaned_values:
158
+ cleaned[key] = cleaned_values[:36]
159
+ return cleaned
160
+
161
+
162
+ def _specific_product_values(values: list[str]) -> list[str]:
163
+ specific = []
164
+ for value in values:
165
+ low = value.lower().strip()
166
+ if low in GENERIC_PRODUCT_VALUES:
167
+ continue
168
+ if any(term in low for term in GENERIC_PRODUCT_PHRASE_TERMS):
169
+ continue
170
+ if "sales" in low and not any(term in low for term in ("shoe", "apparel", "hoodie", "short", "jersey", "sneaker")):
171
+ continue
172
+ if re.search(r"\bvariant\s+\d{1,3}\b", low):
173
+ continue
174
+ if re.search(r"\b(?:product|item|sku)\s+\d{1,3}\b", low):
175
+ continue
176
+ specific.append(value)
177
+ return specific
178
+
179
+
180
+ def _values_with_terms(values: list[str], terms: set[str]) -> list[str]:
181
+ return [
182
+ value
183
+ for value in values
184
+ if any(re.search(rf"(?<![a-z0-9]){re.escape(term)}(?![a-z0-9])", value.lower()) for term in terms)
185
+ ]
186
+
187
+
188
+ def _is_apparel_domain(text: str) -> bool:
189
+ return any(re.search(rf"(?<![a-z0-9]){re.escape(term)}(?![a-z0-9])", text) for term in APPAREL_DOMAIN_TERMS)
demoprep_app/dataset/generators/retail_sales.py CHANGED
@@ -361,9 +361,53 @@ def _product_tuples_from_values(values: list[str]) -> list[tuple[str, str, float
361
  if not base:
362
  return []
363
  expanded = list(base)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
364
  while len(expanded) < 36:
365
  seed = base[len(expanded) % len(base)]
366
- expanded.append(f"{seed} Variant {len(expanded) + 1:02d}")
 
 
 
 
 
367
  return [(_product_name(value, idx), _product_category(value), _product_price(value, idx)) for idx, value in enumerate(expanded[:36])]
368
 
369
 
 
361
  if not base:
362
  return []
363
  expanded = list(base)
364
+ descriptors = [
365
+ "Core",
366
+ "Pro",
367
+ "Max",
368
+ "Mini",
369
+ "Plus",
370
+ "Ultra",
371
+ "Studio",
372
+ "Essential",
373
+ "Premium",
374
+ "Signature",
375
+ "Performance",
376
+ "Lifestyle",
377
+ "Home",
378
+ "Portable",
379
+ "Wireless",
380
+ "Connected",
381
+ "Outdoor",
382
+ "Theater",
383
+ "Compact",
384
+ "Flagship",
385
+ "Select",
386
+ "Elite",
387
+ "Limited",
388
+ "Smart",
389
+ "Voice Enabled",
390
+ "Surround",
391
+ "Travel",
392
+ "Everyday",
393
+ "Active",
394
+ "Classic",
395
+ "Modern",
396
+ "Heritage",
397
+ "Seasonal",
398
+ "Retail Exclusive",
399
+ "Direct",
400
+ "Partner",
401
+ ]
402
+ descriptor_idx = 0
403
  while len(expanded) < 36:
404
  seed = base[len(expanded) % len(base)]
405
+ descriptor = descriptors[descriptor_idx % len(descriptors)]
406
+ descriptor_idx += 1
407
+ candidate = f"{descriptor} {seed}"
408
+ if candidate.lower() in {value.lower() for value in expanded}:
409
+ candidate = f"{seed} {descriptor}"
410
+ expanded.append(candidate)
411
  return [(_product_name(value, idx), _product_category(value), _product_price(value, idx)) for idx, value in enumerate(expanded[:36])]
412
 
413
 
demoprep_app/pipeline/dataset_first.py CHANGED
@@ -5,6 +5,7 @@ from __future__ import annotations
5
  import hashlib
6
  from dataclasses import dataclass
7
 
 
8
  from demoprep_app.dataset.contracts import DatasetBundle
9
  from demoprep_app.dataset.generators.router import DatasetGeneratorRouter
10
  from demoprep_app.ddl import DatasetDdlCompiler
@@ -189,7 +190,16 @@ def build_dataset_first_demo(
189
  metadata={"seed": seed},
190
  )
191
  elif scenario_type == "retail_sales":
192
- extracted_dimensions = extraction.dimensions if extraction else {}
 
 
 
 
 
 
 
 
 
193
  dimensions = [
194
  DimensionSpec(name=name, semantic_role=name.lower(), values=values)
195
  for name, values in extracted_dimensions.items()
@@ -225,6 +235,8 @@ def build_dataset_first_demo(
225
  "business_domain": extraction.business_domain if extraction else "",
226
  "extracted_dimensions": extracted_dimensions,
227
  "extracted_measures": extraction.measures if extraction else [],
 
 
228
  },
229
  )
230
  else:
 
5
  import hashlib
6
  from dataclasses import dataclass
7
 
8
+ from demoprep_app.dataset.company_contract import build_company_data_contract
9
  from demoprep_app.dataset.contracts import DatasetBundle
10
  from demoprep_app.dataset.generators.router import DatasetGeneratorRouter
11
  from demoprep_app.ddl import DatasetDdlCompiler
 
190
  metadata={"seed": seed},
191
  )
192
  elif scenario_type == "retail_sales":
193
+ company_contract = build_company_data_contract(
194
+ company_name=company_name,
195
+ scenario_type=scenario_type,
196
+ extraction=extraction,
197
+ use_case=use_case,
198
+ research_context=research_context,
199
+ )
200
+ if not company_contract.ok:
201
+ raise ValueError("Company data contract failed before dataset generation:\n" + "\n".join(company_contract.blockers))
202
+ extracted_dimensions = company_contract.dimensions
203
  dimensions = [
204
  DimensionSpec(name=name, semantic_role=name.lower(), values=values)
205
  for name, values in extracted_dimensions.items()
 
235
  "business_domain": extraction.business_domain if extraction else "",
236
  "extracted_dimensions": extracted_dimensions,
237
  "extracted_measures": extraction.measures if extraction else [],
238
+ "company_contract_sources": company_contract.sources,
239
+ "company_contract_warnings": company_contract.warnings,
240
  },
241
  )
242
  else:
demoprep_app/scenario/extractor.py CHANGED
@@ -212,6 +212,20 @@ def _infer_from_text(text: str) -> str | None:
212
  def _context_dimension_values(name: str, text: str) -> list[str]:
213
  phrases = _extract_named_phrases(text)
214
  product_terms = (
 
 
 
 
 
 
 
 
 
 
 
 
 
 
215
  "smart tv",
216
  "smart tvs",
217
  "tv",
@@ -220,6 +234,10 @@ def _context_dimension_values(name: str, text: str) -> list[str]:
220
  "televisions",
221
  "soundbar",
222
  "soundbars",
 
 
 
 
223
  "home theater",
224
  "watchfree",
225
  "sports zone",
 
212
  def _context_dimension_values(name: str, text: str) -> list[str]:
213
  phrases = _extract_named_phrases(text)
214
  product_terms = (
215
+ "running shoes",
216
+ "basketball shoes",
217
+ "training shoes",
218
+ "soccer boots",
219
+ "sneakers",
220
+ "footwear",
221
+ "apparel",
222
+ "performance apparel",
223
+ "dri-fit",
224
+ "hoodies",
225
+ "shorts",
226
+ "jerseys",
227
+ "sportswear",
228
+ "equipment",
229
  "smart tv",
230
  "smart tvs",
231
  "tv",
 
234
  "televisions",
235
  "soundbar",
236
  "soundbars",
237
+ "speaker",
238
+ "speakers",
239
+ "portable audio",
240
+ "home audio",
241
  "home theater",
242
  "watchfree",
243
  "sports zone",
tests/test_dataset_first_builders.py CHANGED
@@ -5,8 +5,10 @@ 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, infer_scenario_type
9
  from demoprep_app.dataset.quality import validate_dataset_quality
 
10
  from demoprep_app.scenario.families import SCENARIO_FAMILIES
11
 
12
 
@@ -49,6 +51,13 @@ def test_saas_sales_specialized_builder_has_expected_shape():
49
  def test_all_scenario_families_generate_relationship_ddl():
50
  failures = []
51
  for scenario_type in SCENARIO_FAMILIES:
 
 
 
 
 
 
 
52
  build = build_dataset_first_demo(
53
  company_name="Acme",
54
  company_url="https://example.com",
@@ -56,6 +65,7 @@ def test_all_scenario_families_generate_relationship_ddl():
56
  vertical="",
57
  function="",
58
  row_count_guidance=25,
 
59
  )
60
  if build is None:
61
  failures.append(f"{scenario_type}: no build")
@@ -222,6 +232,10 @@ def test_retail_sales_store_names_are_demo_safe_and_not_numbered_placeholders():
222
  vertical="Retail & Consumer Goods",
223
  function="Sales",
224
  row_count_guidance=100,
 
 
 
 
225
  )
226
 
227
  assert build is not None
@@ -235,6 +249,93 @@ def test_retail_sales_store_names_are_demo_safe_and_not_numbered_placeholders():
235
  assert validate_dataset_quality(build.dataset).ok
236
 
237
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
238
  def test_transportation_function_routing_keeps_sales_and_finance_distinct():
239
  cases = [
240
  ("Shipping", "Sales", "shipping_sales"),
 
5
  DDL before the slower browser quality suite runs against the test Space.
6
  """
7
 
8
+ from demoprep_app.dataset.generators.retail_sales import RetailSalesDatasetGenerator
9
  from demoprep_app.pipeline.dataset_first import build_dataset_first_demo, infer_scenario_type
10
  from demoprep_app.dataset.quality import validate_dataset_quality
11
+ from demoprep_app.scenario.contract import DimensionSpec, ScenarioContract
12
  from demoprep_app.scenario.families import SCENARIO_FAMILIES
13
 
14
 
 
51
  def test_all_scenario_families_generate_relationship_ddl():
52
  failures = []
53
  for scenario_type in SCENARIO_FAMILIES:
54
+ research_context = ""
55
+ if scenario_type == "retail_sales":
56
+ research_context = (
57
+ "Retail sales products include running shoes, basketball shoes, training apparel, "
58
+ "hoodies, shorts, jerseys, sneakers, backpacks, and performance socks. "
59
+ "Channels include retail stores, online, mobile app, outlet, and retail partners."
60
+ )
61
  build = build_dataset_first_demo(
62
  company_name="Acme",
63
  company_url="https://example.com",
 
65
  vertical="",
66
  function="",
67
  row_count_guidance=25,
68
+ research_context=research_context,
69
  )
70
  if build is None:
71
  failures.append(f"{scenario_type}: no build")
 
232
  vertical="Retail & Consumer Goods",
233
  function="Sales",
234
  row_count_guidance=100,
235
+ research_context=(
236
+ "Nike retail sales products include running shoes, basketball shoes, training shoes, "
237
+ "Dri-FIT performance apparel, hoodies, shorts, jerseys, sneakers, backpacks, and performance socks."
238
+ ),
239
  )
240
 
241
  assert build is not None
 
249
  assert validate_dataset_quality(build.dataset).ok
250
 
251
 
252
+ def test_retail_product_expansion_uses_named_variants_not_numbered_placeholders():
253
+ scenario = ScenarioContract(
254
+ company_name="Sonos",
255
+ company_url="https://sonos.com",
256
+ use_case="Consumer Electronics Sales",
257
+ scenario_type="retail_sales",
258
+ fact_grain="store-product-channel-day",
259
+ dimensions=[
260
+ DimensionSpec(name="PRODUCTS", semantic_role="product", values=["Soundbars", "Speakers", "Accessories", "Streaming"]),
261
+ ],
262
+ metadata={"seed": 123},
263
+ )
264
+
265
+ dataset = RetailSalesDatasetGenerator().generate(scenario, row_count=100)
266
+ values = [row["PRODUCT_NAME"] for row in dataset.table_map()["PRODUCTS"].rows]
267
+
268
+ assert len(values) == 36
269
+ assert not any("Variant " in value for value in values)
270
+ assert not any(value.endswith(tuple(str(i).zfill(2) for i in range(1, 40))) for value in values)
271
+ assert validate_dataset_quality(dataset).ok
272
+
273
+
274
+ def test_retail_contract_uses_researched_products_before_generation():
275
+ build = build_dataset_first_demo(
276
+ company_name="Nike",
277
+ company_url="https://nike.com",
278
+ use_case="Retail Sales",
279
+ vertical="Retail & Consumer Goods",
280
+ function="Sales",
281
+ row_count_guidance=100,
282
+ research_context=(
283
+ "Nike retail sales demo products include running shoes, basketball shoes, "
284
+ "training shoes, soccer boots, Dri-FIT performance apparel, hoodies, shorts, and jerseys. "
285
+ "Channels include Nike stores, Nike.com, mobile app, outlet, and retail partners."
286
+ ),
287
+ )
288
+
289
+ assert build is not None
290
+ products = [row["PRODUCT_NAME"] for row in build.dataset.table_map()["PRODUCTS"].rows]
291
+
292
+ assert "Running Shoes" in products
293
+ assert "Basketball Shoes" in products
294
+ assert "Dri-FIT" in products
295
+ assert build.scenario.metadata["company_contract_sources"]["PRODUCTS"] == "fallback"
296
+ assert validate_dataset_quality(build.dataset).ok
297
+
298
+
299
+ def test_retail_contract_blocks_incompatible_research_terms_before_generation():
300
+ try:
301
+ build_dataset_first_demo(
302
+ company_name="Nike",
303
+ company_url="https://nike.com",
304
+ use_case="Fashion/Apparel Sales",
305
+ vertical="Retail & Consumer Goods",
306
+ function="Sales",
307
+ row_count_guidance=100,
308
+ research_context="Bad extracted terms should not drive products: soundbars, streaming, smart tv.",
309
+ )
310
+ except ValueError as exc:
311
+ message = str(exc)
312
+ else:
313
+ raise AssertionError("Expected company contract failure")
314
+
315
+ assert "Company data contract failed" in message
316
+ assert "incompatible product terms" in message or "lacks enough researched" in message
317
+
318
+
319
+ def test_sonos_retail_contract_allows_consumer_electronics_products():
320
+ build = build_dataset_first_demo(
321
+ company_name="Sonos",
322
+ company_url="https://sonos.com",
323
+ use_case="Consumer Electronics Sales",
324
+ vertical="Retail & Consumer Goods",
325
+ function="Sales",
326
+ row_count_guidance=100,
327
+ research_context="Sonos sells speakers, soundbars, portable audio, home theater, and accessories.",
328
+ )
329
+
330
+ assert build is not None
331
+ products = [row["PRODUCT_NAME"] for row in build.dataset.table_map()["PRODUCTS"].rows]
332
+
333
+ assert "Soundbars" in products
334
+ assert "Speakers" in products
335
+ assert build.scenario.metadata["company_contract_sources"]["PRODUCTS"] == "fallback"
336
+ assert validate_dataset_quality(build.dataset).ok
337
+
338
+
339
  def test_transportation_function_routing_keeps_sales_and_finance_distinct():
340
  cases = [
341
  ("Shipping", "Sales", "shipping_sales"),