@tasksai/install 0.1.29 → 0.1.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -75,3 +75,15 @@ folder. The save tool supports `.docx` and Markdown output, sanitizes filenames,
75
75
  and avoids overwriting existing files unless explicitly told to overwrite.
76
76
  Generated document content is handled by the customer's AI framework and local
77
77
  MCP runtime; it is not sent to TasksAI websites or APIs.
78
+
79
+ ## Realtor jurisdiction sources
80
+
81
+ For jurisdiction-sensitive RealtorTasksAI workflows, the local runtime sends
82
+ only the selected skill ID and general state, county, city, public-authority,
83
+ and MLS labels to the TasksAI API. It never sends the user's prompt, property
84
+ address, client facts, uploaded records, or generated document.
85
+
86
+ The API returns a reviewed source pack when one exists. If it does not, the
87
+ licensed runtime can request a controlled official-web lookup. Live results are
88
+ cached, source-linked, and visibly labeled `provisional`; they never substitute
89
+ for private MLS rules, brokerage policy, approved forms, or professional review.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tasksai/install",
3
- "version": "0.1.29",
3
+ "version": "0.1.31",
4
4
  "description": "Shared TasksAI MCP installer CLI",
5
5
  "type": "module",
6
6
  "bin": {
package/runtime/server.py CHANGED
@@ -125,7 +125,7 @@ if not LICENSE_KEY:
125
125
  print("Find your key in your purchase confirmation email.", file=sys.stderr, flush=True)
126
126
  sys.exit(1)
127
127
 
128
- SERVER_VERSION = "2.4.5"
128
+ SERVER_VERSION = "2.5.0"
129
129
 
130
130
  AUTH_HEADERS = {
131
131
  "Authorization": f"Bearer {LICENSE_KEY}",
@@ -509,8 +509,8 @@ async def api_get(path):
509
509
  return resp.json()
510
510
 
511
511
 
512
- async def api_post(path, payload):
513
- async with httpx.AsyncClient(timeout=10.0) as client:
512
+ async def api_post(path, payload, *, timeout=10.0):
513
+ async with httpx.AsyncClient(timeout=timeout) as client:
514
514
  resp = await client.post(
515
515
  f"{API_BASE}{path}",
516
516
  json=payload,
@@ -523,20 +523,58 @@ async def api_post(path, payload):
523
523
  def jurisdiction_sources_path(arguments):
524
524
  """Build a metadata-only source lookup path without customer or property data."""
525
525
  skill_id = (arguments.get("skill_id") or "").strip()
526
- state = (arguments.get("state") or "").strip()
526
+ state = general_authority_label(arguments.get("state"), "state")
527
527
  if not skill_id:
528
528
  raise ValueError("skill_id is required.")
529
529
 
530
530
  params = {"skill_id": skill_id}
531
531
  if state:
532
532
  params["state"] = state
533
- for field in ("county", "locality", "district"):
534
- value = (arguments.get(field) or "").strip()
533
+ for field in ("county", "locality", "district", "mls"):
534
+ value = general_authority_label(arguments.get(field), field)
535
535
  if value:
536
536
  params[field] = value
537
537
  return f"/v1/skills/jurisdiction-sources?{urlencode(params)}"
538
538
 
539
539
 
540
+ _GENERAL_AUTHORITY_LABEL = re.compile(r"^[A-Za-z0-9 .,'&()/-]{1,240}$")
541
+ _LIKELY_STREET_ADDRESS = re.compile(
542
+ r"^\d+\s+.+\b(?:street|st|avenue|ave|road|rd|drive|dr|lane|ln|boulevard|blvd|court|ct|way)\b",
543
+ re.IGNORECASE,
544
+ )
545
+
546
+
547
+ def general_authority_label(value, field):
548
+ """Normalize a public authority label and reject likely customer data."""
549
+ normalized = " ".join(str(value or "").strip().split())
550
+ if not normalized:
551
+ return None
552
+ if (
553
+ not _GENERAL_AUTHORITY_LABEL.fullmatch(normalized)
554
+ or _LIKELY_STREET_ADDRESS.search(normalized)
555
+ ):
556
+ raise ValueError(
557
+ f"{field} must be a general authority label, not an address or customer record."
558
+ )
559
+ return normalized
560
+
561
+
562
+ def jurisdiction_research_payload(arguments):
563
+ """Build the privacy-safe research request sent only after reviewed lookup misses."""
564
+ payload = {}
565
+ for field in ("skill_id", "state", "county", "locality", "district", "mls"):
566
+ value = (
567
+ (arguments.get(field) or "").strip()
568
+ if field == "skill_id"
569
+ else general_authority_label(arguments.get(field), field)
570
+ )
571
+ if value:
572
+ payload[field] = value
573
+ if not payload.get("skill_id") or not payload.get("state"):
574
+ raise ValueError("skill_id and state are required for live jurisdiction research.")
575
+ return payload
576
+
577
+
540
578
  def format_jurisdiction_source_pack(pack):
541
579
  """Render the public source pack as compact instructions for the local AI."""
542
580
  status = pack.get("status", "unsupported")
@@ -548,6 +586,7 @@ def format_jurisdiction_source_pack(pack):
548
586
  f"- Skill: `{pack.get('skill_id', '')}`",
549
587
  f"- Registry reviewed: {pack.get('registry_reviewed_on', 'not supplied')}",
550
588
  f"- Registry refresh due: {pack.get('registry_refresh_due_on', 'not supplied')}",
589
+ f"- Resolution: **{pack.get('resolution_source', 'packaged')}**",
551
590
  ]
552
591
  if pack.get("state_name"):
553
592
  lines.append(f"- State: {pack['state_name']} ({pack.get('state_code', '')})")
@@ -566,6 +605,8 @@ def format_jurisdiction_source_pack(pack):
566
605
  lines.extend(["", "## Sources"])
567
606
  for source in sources:
568
607
  effective = source.get("effective_date") or "not specified"
608
+ verification = source.get("verification_status", "reviewed")
609
+ date_label = "Machine researched" if verification == "machine_researched" else "Reviewed"
569
610
  lines.extend(
570
611
  [
571
612
  "",
@@ -573,7 +614,8 @@ def format_jurisdiction_source_pack(pack):
573
614
  f"- Authority: {source.get('authority', '')}",
574
615
  f"- Jurisdiction: {source.get('jurisdiction', '')} ({source.get('scope_type', 'scope not supplied')})",
575
616
  f"- Effective date: {effective}",
576
- f"- Reviewed: {source.get('reviewed_on', '')}",
617
+ f"- Verification: {verification}",
618
+ f"- {date_label}: {source.get('reviewed_on', '')}",
577
619
  f"- Refresh due: {source.get('refresh_due_on', '')}",
578
620
  f"- URL: {source.get('url', '')}",
579
621
  f"- Use: {source.get('purpose', '')}",
@@ -1672,8 +1714,9 @@ def build_tools(prefix, product_name, occupation):
1672
1714
  Tool(
1673
1715
  name=f"{prefix}_jurisdiction_sources",
1674
1716
  description=(
1675
- f"Retrieve the curated current jurisdiction-and-authority source pack for a jurisdiction-sensitive {product_name} workflow. "
1717
+ f"Retrieve the current jurisdiction-and-authority sources for a jurisdiction-sensitive {product_name} workflow. "
1676
1718
  "Use after the applicable state is known and before applying state, local, district, regulator, form, or policy requirements. "
1719
+ "For RealtorTasksAI, a missing reviewed pack can trigger a licensed, privacy-safe official-web lookup whose results are explicitly provisional. "
1677
1720
  "Send only the skill ID and general authority labels; never send an address, person name, student or client facts, documents, or generated content."
1678
1721
  ),
1679
1722
  inputSchema={
@@ -1699,6 +1742,10 @@ def build_tools(prefix, product_name, occupation):
1699
1742
  "type": "string",
1700
1743
  "description": "Optional public authority or school-district name only; do not include a person, student, or record identifier.",
1701
1744
  },
1745
+ "mls": {
1746
+ "type": "string",
1747
+ "description": "Optional MLS or Realtor-association name only; never send credentials, listing data, or member-only content.",
1748
+ },
1702
1749
  },
1703
1750
  "required": ["skill_id"],
1704
1751
  },
@@ -1905,6 +1952,24 @@ async def call_tool(name, arguments):
1905
1952
  }:
1906
1953
  path = jurisdiction_sources_path(arguments or {})
1907
1954
  result = await api_get(path)
1955
+ if (
1956
+ prefix == "realtortasksai"
1957
+ and (arguments or {}).get("state")
1958
+ and result.get("status") not in {"ready", "provisional"}
1959
+ ):
1960
+ try:
1961
+ result = await api_post(
1962
+ "/v1/skills/jurisdiction-research",
1963
+ jurisdiction_research_payload(arguments or {}),
1964
+ timeout=90.0,
1965
+ )
1966
+ except Exception:
1967
+ result = dict(result)
1968
+ instructions = list(result.get("instructions") or [])
1969
+ instructions.append(
1970
+ "Controlled live research was unavailable. Continue only with intake or professional-supplied current official sources; do not substitute another jurisdiction."
1971
+ )
1972
+ result["instructions"] = instructions
1908
1973
  return [TextContent(type="text", text=format_jurisdiction_source_pack(result))]
1909
1974
 
1910
1975
  # ── Save Document ───────────────────────────────────────────────────
@@ -11,7 +11,7 @@ import unicodedata
11
11
  from collections.abc import Iterable, Mapping, Sequence
12
12
 
13
13
 
14
- POLICY_VERSION = "tasksai-trigger-matcher/2.0"
14
+ POLICY_VERSION = "tasksai-trigger-matcher/2.1"
15
15
 
16
16
  DEFAULT_STOP_WORDS = {
17
17
  "a", "an", "and", "are", "as", "at", "be", "by", "can", "create",
@@ -57,11 +57,44 @@ DEFAULT_CONTEXT_WORDS = {
57
57
  DEFAULT_THRESHOLDS = {"high": 90, "medium": 45}
58
58
  DEFAULT_RESULT_COUNT = 3
59
59
  MAXIMUM_RESULT_COUNT = 10
60
+ DEFAULT_RESULT_COUNTS = {"realtor": 5}
61
+ REALTOR_ACTION_WORDS = {
62
+ "assemble", "build", "create", "develop", "document", "draft",
63
+ "generate", "make", "prepare", "produce", "write", "writing",
64
+ }
65
+ DEFAULT_TOKEN_ALIASES = {
66
+ "realtor": {
67
+ "assemble": "create",
68
+ "build": "create",
69
+ "develop": "create",
70
+ "draft": "create",
71
+ "dwelling": "property",
72
+ "generate": "create",
73
+ "home": "property",
74
+ "house": "property",
75
+ "listed": "listing",
76
+ "listings": "listing",
77
+ "list": "listing",
78
+ "make": "create",
79
+ "prepare": "create",
80
+ "prepared": "create",
81
+ "preparing": "create",
82
+ "preparation": "create",
83
+ "produce": "create",
84
+ "residence": "property",
85
+ "residential": "property",
86
+ "write": "create",
87
+ "writing": "create",
88
+ },
89
+ }
60
90
  TRIGGER_EVIDENCE_RANK = {
61
- "trigger_exact": 4,
91
+ "trigger_exact": 5,
92
+ "trigger_equivalent": 4,
62
93
  "trigger_complete": 3,
63
94
  "trigger_query_complete": 2,
64
- "trigger_partial": 1,
95
+ "trigger_partial": 2,
96
+ "trigger_broad": 1,
97
+ "trigger_context": 1,
65
98
  }
66
99
 
67
100
 
@@ -149,9 +182,95 @@ def _normalize_scoring_tokens(
149
182
  value: object,
150
183
  stop_words: set[str],
151
184
  abbreviations: Mapping[str, object],
185
+ token_aliases: Mapping[str, str],
152
186
  ) -> tuple[str, ...]:
153
187
  expanded = _expand_abbreviations(_tokens(value), abbreviations)
154
- return tuple(token for token in expanded if token not in stop_words)
188
+ canonical = tuple(token_aliases.get(token, token) for token in expanded)
189
+ return tuple(token for token in canonical if token not in stop_words)
190
+
191
+
192
+ def _same_token_bag(left: tuple[str, ...], right: tuple[str, ...]) -> bool:
193
+ return bool(left) and len(left) == len(right) and sorted(left) == sorted(right)
194
+
195
+
196
+ def _within_one_edit(left: str, right: str) -> bool:
197
+ """Return True for one insertion, deletion, substitution, or transposition."""
198
+ if left == right or abs(len(left) - len(right)) > 1:
199
+ return False
200
+ if len(left) == len(right):
201
+ differences = [index for index, pair in enumerate(zip(left, right)) if pair[0] != pair[1]]
202
+ if len(differences) == 1:
203
+ return True
204
+ return (
205
+ len(differences) == 2
206
+ and differences[1] == differences[0] + 1
207
+ and left[differences[0]] == right[differences[1]]
208
+ and left[differences[1]] == right[differences[0]]
209
+ )
210
+ shorter, longer = (left, right) if len(left) < len(right) else (right, left)
211
+ short_index = long_index = differences = 0
212
+ while short_index < len(shorter) and long_index < len(longer):
213
+ if shorter[short_index] == longer[long_index]:
214
+ short_index += 1
215
+ long_index += 1
216
+ continue
217
+ differences += 1
218
+ long_index += 1
219
+ if differences > 1:
220
+ return False
221
+ return True
222
+
223
+
224
+ def _build_token_index(
225
+ skills: Sequence[Mapping[str, object]],
226
+ triggers: Mapping[str, object],
227
+ product_id: str,
228
+ policy: Mapping[str, object],
229
+ ) -> tuple[dict[tuple[int, str], set[str]], set[str]]:
230
+ """Index public discovery tokens by length and first character for bounded typo repair."""
231
+ stop_words = policy["stop_words"]
232
+ abbreviations = policy["abbreviations"]
233
+ token_aliases = policy["token_aliases"]
234
+ vocabulary = set(token_aliases) | set(token_aliases.values())
235
+ for skill in skills:
236
+ for value in (skill.get("name", ""), _category_text(skill)):
237
+ vocabulary.update(
238
+ _normalize_scoring_tokens(value, stop_words, abbreviations, token_aliases)
239
+ )
240
+ skill_id = str(skill.get("id", ""))
241
+ for trigger in _trigger_values(triggers, skill_id, product_id):
242
+ vocabulary.update(
243
+ _normalize_scoring_tokens(trigger, stop_words, abbreviations, token_aliases)
244
+ )
245
+ index: dict[tuple[int, str], set[str]] = {}
246
+ for token in vocabulary:
247
+ if len(token) < 5:
248
+ continue
249
+ index.setdefault((len(token), token[0]), set()).add(token)
250
+ return index, vocabulary
251
+
252
+
253
+ def _repair_query_tokens(
254
+ tokens: tuple[str, ...],
255
+ token_index: Mapping[tuple[int, str], set[str]],
256
+ vocabulary: set[str],
257
+ ) -> tuple[tuple[str, ...], list[str]]:
258
+ repaired: list[str] = []
259
+ diagnostics: list[str] = []
260
+ for token in tokens:
261
+ if len(token) < 5 or token in vocabulary:
262
+ repaired.append(token)
263
+ continue
264
+ candidates: set[str] = set()
265
+ for length in range(len(token) - 1, len(token) + 2):
266
+ candidates.update(token_index.get((length, token[0]), set()))
267
+ matches = sorted(candidate for candidate in candidates if _within_one_edit(token, candidate))
268
+ if len(matches) == 1:
269
+ repaired.append(matches[0])
270
+ diagnostics.append(f"fuzzy_corrected:{token}->{matches[0]}")
271
+ else:
272
+ repaired.append(token)
273
+ return tuple(repaired), diagnostics
155
274
 
156
275
 
157
276
  def _contains_tokens(container: tuple[str, ...], candidate: tuple[str, ...]) -> bool:
@@ -222,6 +341,8 @@ def _resolve_config(product_id: str, config: Mapping[str, object] | None) -> tup
222
341
  for value in supplied_stop_words
223
342
  for token in _tokens(value)
224
343
  }
344
+ if _canonical_product_id(product_id) == "realtor":
345
+ stop_words.difference_update(REALTOR_ACTION_WORDS)
225
346
  context_default = DEFAULT_CONTEXT_WORDS.get(_canonical_product_id(product_id), set())
226
347
  supplied_context_words = source.get("context_words", context_default)
227
348
  if not isinstance(supplied_context_words, Sequence) or isinstance(supplied_context_words, (str, bytes)):
@@ -242,10 +363,13 @@ def _resolve_config(product_id: str, config: Mapping[str, object] | None) -> tup
242
363
  diagnostics.append("bundled_thresholds:invalid_order")
243
364
  thresholds = dict(DEFAULT_THRESHOLDS)
244
365
 
245
- default_count = source.get("default_result_count", DEFAULT_RESULT_COUNT)
366
+ product_default_count = DEFAULT_RESULT_COUNTS.get(
367
+ _canonical_product_id(product_id), DEFAULT_RESULT_COUNT
368
+ )
369
+ default_count = source.get("default_result_count", product_default_count)
246
370
  maximum_count = source.get("maximum_result_count", MAXIMUM_RESULT_COUNT)
247
371
  if not isinstance(default_count, int) or isinstance(default_count, bool) or default_count < 1:
248
- default_count = DEFAULT_RESULT_COUNT
372
+ default_count = product_default_count
249
373
  if not isinstance(maximum_count, int) or isinstance(maximum_count, bool) or maximum_count < 1:
250
374
  maximum_count = MAXIMUM_RESULT_COUNT
251
375
  maximum_count = min(maximum_count, MAXIMUM_RESULT_COUNT)
@@ -262,6 +386,8 @@ def _resolve_config(product_id: str, config: Mapping[str, object] | None) -> tup
262
386
  "default_result_count": default_count,
263
387
  "maximum_result_count": maximum_count,
264
388
  "abbreviations": configured_abbreviations,
389
+ "token_aliases": DEFAULT_TOKEN_ALIASES.get(_canonical_product_id(product_id), {}),
390
+ "broad_matching": _canonical_product_id(product_id) == "realtor",
265
391
  }, diagnostics
266
392
 
267
393
 
@@ -271,8 +397,12 @@ def _score_trigger(
271
397
  stop_words: set[str],
272
398
  context_words: set[str],
273
399
  abbreviations: Mapping[str, object],
400
+ token_aliases: Mapping[str, str],
401
+ broad_matching: bool,
274
402
  ) -> tuple[int, int, set[str], str]:
275
- trigger_tokens = _normalize_scoring_tokens(trigger, stop_words, abbreviations)
403
+ trigger_tokens = _normalize_scoring_tokens(
404
+ trigger, stop_words, abbreviations, token_aliases
405
+ )
276
406
  if not trigger_tokens:
277
407
  return 0, 0, set(), ""
278
408
 
@@ -284,10 +414,14 @@ def _score_trigger(
284
414
 
285
415
  if query_tokens == trigger_tokens:
286
416
  return 120, specificity, matched, "trigger_exact"
417
+ if broad_matching and _same_token_bag(query_tokens, trigger_tokens):
418
+ return 110, specificity, matched, "trigger_equivalent"
287
419
  if _contains_tokens(query_tokens, trigger_tokens):
288
420
  bonus = min(15, max(0, specificity - 1) * 3)
289
421
  return 90 + bonus, specificity, matched, "trigger_complete"
290
422
  query_specific = query_set - context_words
423
+ if broad_matching and not query_specific and query_set and query_set <= trigger_set:
424
+ return 50, specificity, matched, "trigger_context"
291
425
  if len(query_specific) >= 2 and _contains_tokens(trigger_tokens, query_tokens):
292
426
  return 70, specificity, matched, "trigger_query_complete"
293
427
 
@@ -296,6 +430,10 @@ def _score_trigger(
296
430
  coverage = len(covered) / len(coverage_tokens)
297
431
  if len(covered) >= 2 and coverage >= 0.67:
298
432
  return round(40 * coverage), specificity, matched, "trigger_partial"
433
+ matched_specific_query = matched & (query_set - context_words)
434
+ matched_context = matched & context_words
435
+ if broad_matching and matched_specific_query and matched_context:
436
+ return 35, specificity, matched, "trigger_broad"
299
437
  return 0, specificity, set(), ""
300
438
 
301
439
 
@@ -309,6 +447,8 @@ def _score_skill(
309
447
  stop_words = policy["stop_words"]
310
448
  context_words = policy["context_words"]
311
449
  abbreviations = policy["abbreviations"]
450
+ token_aliases = policy["token_aliases"]
451
+ broad_matching = policy["broad_matching"]
312
452
  query_set = set(query_tokens)
313
453
  specific_query = query_set - context_words
314
454
  context_query = query_set & context_words
@@ -317,7 +457,8 @@ def _score_skill(
317
457
  strongest_trigger = (0, 0, set(), "", "")
318
458
  for trigger in _trigger_values(triggers, skill_id, product_id):
319
459
  score, specificity, matched, evidence = _score_trigger(
320
- query_tokens, trigger, stop_words, context_words, abbreviations
460
+ query_tokens, trigger, stop_words, context_words, abbreviations,
461
+ token_aliases, broad_matching
321
462
  )
322
463
  candidate = (score, specificity, matched, evidence, normalize_text(trigger))
323
464
  candidate_key = (score, specificity, len(matched), normalize_text(trigger))
@@ -335,7 +476,7 @@ def _score_skill(
335
476
  matched_tokens = set(trigger_matched)
336
477
 
337
478
  name_tokens = _normalize_scoring_tokens(
338
- skill.get("name", ""), stop_words, abbreviations
479
+ skill.get("name", ""), stop_words, abbreviations, token_aliases
339
480
  )
340
481
  name_set = set(name_tokens)
341
482
  identity_score = 0
@@ -352,7 +493,9 @@ def _score_skill(
352
493
  evidence.append(f"name_context:{token}")
353
494
 
354
495
  category_set = set(
355
- _normalize_scoring_tokens(_category_text(skill), stop_words, abbreviations)
496
+ _normalize_scoring_tokens(
497
+ _category_text(skill), stop_words, abbreviations, token_aliases
498
+ )
356
499
  )
357
500
  category_score = 0
358
501
  for token in sorted(specific_query & category_set):
@@ -365,7 +508,9 @@ def _score_skill(
365
508
  evidence.append(f"category_context:{token}")
366
509
 
367
510
  description_set = set(
368
- _normalize_scoring_tokens(skill.get("description", ""), stop_words, abbreviations)
511
+ _normalize_scoring_tokens(
512
+ skill.get("description", ""), stop_words, abbreviations, token_aliases
513
+ )
369
514
  )
370
515
  description_matches = sorted(specific_query & description_set)
371
516
  description_score = len(description_matches)
@@ -457,7 +602,28 @@ def match_skills(
457
602
 
458
603
  raw_tokens = _tokens(query)
459
604
  expanded_tokens = _expand_abbreviations(raw_tokens, configured_abbreviations)
460
- query_tokens = tuple(token for token in expanded_tokens if token not in policy["stop_words"])
605
+ aliased_tokens = tuple(
606
+ policy["token_aliases"].get(token, token) for token in expanded_tokens
607
+ )
608
+ initial_query_tokens = tuple(
609
+ token for token in aliased_tokens if token not in policy["stop_words"]
610
+ )
611
+
612
+ scoped_skills = [skill for skill in skills if _belongs_to_product(skill, product_id)]
613
+ if policy["broad_matching"]:
614
+ token_index, vocabulary = _build_token_index(
615
+ scoped_skills, triggers, product_id, policy
616
+ )
617
+ repaired_tokens, fuzzy_diagnostics = _repair_query_tokens(
618
+ initial_query_tokens, token_index, vocabulary
619
+ )
620
+ else:
621
+ repaired_tokens, fuzzy_diagnostics = initial_query_tokens, []
622
+ query_tokens = tuple(
623
+ policy["token_aliases"].get(token, token) for token in repaired_tokens
624
+ if policy["token_aliases"].get(token, token) not in policy["stop_words"]
625
+ )
626
+ diagnostics.extend(fuzzy_diagnostics)
461
627
  query_normalized = " ".join(query_tokens)
462
628
 
463
629
  requested_count = policy["default_result_count"] if result_count is None else result_count
@@ -465,7 +631,6 @@ def match_skills(
465
631
  requested_count = policy["default_result_count"]
466
632
  requested_count = max(1, min(requested_count, policy["maximum_result_count"]))
467
633
 
468
- scoped_skills = [skill for skill in skills if _belongs_to_product(skill, product_id)]
469
634
  if len(scoped_skills) != len(skills):
470
635
  diagnostics.append("product_mismatches_filtered")
471
636