@tasksai/install 0.1.20 → 0.1.21
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/package.json +2 -1
- package/runtime/server.py +178 -75
- package/runtime/skill_matcher.py +492 -0
- package/src/index.js +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tasksai/install",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.21",
|
|
4
4
|
"description": "Shared TasksAI MCP installer CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
"src/",
|
|
11
11
|
"runtime/server.py",
|
|
12
|
+
"runtime/skill_matcher.py",
|
|
12
13
|
"runtime/requirements.txt",
|
|
13
14
|
"README.md"
|
|
14
15
|
],
|
package/runtime/server.py
CHANGED
|
@@ -29,6 +29,15 @@ import uuid
|
|
|
29
29
|
from datetime import datetime
|
|
30
30
|
from pathlib import Path
|
|
31
31
|
|
|
32
|
+
# The runtime is installed as a pair of adjacent Python files and is also
|
|
33
|
+
# loaded directly by path in focused tests, so make the sibling matcher
|
|
34
|
+
# importable without requiring a package install.
|
|
35
|
+
_RUNTIME_DIR = Path(__file__).resolve().parent
|
|
36
|
+
if str(_RUNTIME_DIR) not in sys.path:
|
|
37
|
+
sys.path.insert(0, str(_RUNTIME_DIR))
|
|
38
|
+
|
|
39
|
+
from skill_matcher import POLICY_VERSION, match_skills
|
|
40
|
+
|
|
32
41
|
# Force UTF-8 stdout/stderr on Windows (default is CP1252 which breaks emoji)
|
|
33
42
|
if sys.stdout and hasattr(sys.stdout, 'reconfigure'):
|
|
34
43
|
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
|
|
@@ -484,6 +493,9 @@ _skills_cache_err_until = 0.0
|
|
|
484
493
|
_triggers_cache = None
|
|
485
494
|
_triggers_cache_ts = 0.0
|
|
486
495
|
_triggers_cache_err_until = 0.0
|
|
496
|
+
_matcher_config_cache = None
|
|
497
|
+
_matcher_config_cache_ts = 0.0
|
|
498
|
+
_matcher_config_err_until = 0.0
|
|
487
499
|
|
|
488
500
|
|
|
489
501
|
async def api_get(path):
|
|
@@ -541,6 +553,25 @@ async def report_activation_event(event_name, *, skill_id=None, file_format=None
|
|
|
541
553
|
pass
|
|
542
554
|
|
|
543
555
|
|
|
556
|
+
def fallback_vertical(product_id):
|
|
557
|
+
"""Build local vertical metadata without an API request."""
|
|
558
|
+
product_id = str(product_id or "").strip().lower() or "tasksai"
|
|
559
|
+
known = _VERTICAL_FALLBACKS.get(product_id)
|
|
560
|
+
if known:
|
|
561
|
+
return dict(known)
|
|
562
|
+
safe_id = re.sub(r"[^a-z0-9]+", "", product_id) or "tasksai"
|
|
563
|
+
display_name = f"{safe_id.title()}TasksAI"
|
|
564
|
+
return {
|
|
565
|
+
"product_id": product_id,
|
|
566
|
+
"product_name": display_name,
|
|
567
|
+
"display_name": display_name,
|
|
568
|
+
"tool_prefix": f"{safe_id}tasksai",
|
|
569
|
+
"occupation": "professional",
|
|
570
|
+
"support_email": "hello@tasksai.com",
|
|
571
|
+
"domain": "tasksai.com",
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
|
|
544
575
|
async def load_vertical():
|
|
545
576
|
"""Fetch vertical metadata from /v1/me without crossing product boundaries."""
|
|
546
577
|
global _vertical
|
|
@@ -548,22 +579,7 @@ async def load_vertical():
|
|
|
548
579
|
path = f"/v1/me?product_id={PRODUCT_ID}" if PRODUCT_ID else "/v1/me"
|
|
549
580
|
_vertical = await api_get(path)
|
|
550
581
|
except Exception:
|
|
551
|
-
|
|
552
|
-
known = _VERTICAL_FALLBACKS.get(product_id)
|
|
553
|
-
if known:
|
|
554
|
-
_vertical = dict(known)
|
|
555
|
-
else:
|
|
556
|
-
safe_id = re.sub(r"[^a-z0-9]+", "", product_id) or "tasksai"
|
|
557
|
-
display_name = f"{safe_id.title()}TasksAI"
|
|
558
|
-
_vertical = {
|
|
559
|
-
"product_id": product_id,
|
|
560
|
-
"product_name": display_name,
|
|
561
|
-
"display_name": display_name,
|
|
562
|
-
"tool_prefix": f"{safe_id}tasksai",
|
|
563
|
-
"occupation": "professional",
|
|
564
|
-
"support_email": "hello@tasksai.com",
|
|
565
|
-
"domain": "tasksai.com",
|
|
566
|
-
}
|
|
582
|
+
_vertical = fallback_vertical(PRODUCT_ID)
|
|
567
583
|
return _vertical
|
|
568
584
|
|
|
569
585
|
|
|
@@ -603,9 +619,21 @@ async def get_skills():
|
|
|
603
619
|
return _skills_cache
|
|
604
620
|
|
|
605
621
|
|
|
622
|
+
def product_ids_match(left, right):
|
|
623
|
+
"""Compare canonical and legacy TasksAI product IDs."""
|
|
624
|
+
aliases = {
|
|
625
|
+
"farmertasksai": "farmer",
|
|
626
|
+
"lawtasksai": "law",
|
|
627
|
+
"realtortasksai": "realtor",
|
|
628
|
+
}
|
|
629
|
+
left_id = aliases.get(str(left or "").strip().lower(), str(left or "").strip().lower())
|
|
630
|
+
right_id = aliases.get(str(right or "").strip().lower(), str(right or "").strip().lower())
|
|
631
|
+
return left_id == right_id
|
|
632
|
+
|
|
633
|
+
|
|
606
634
|
async def get_triggers():
|
|
607
635
|
"""Return trigger phrases {skill_id: [phrase, ...]}. Fails silently."""
|
|
608
|
-
global _triggers_cache, _triggers_cache_ts, _triggers_cache_err_until
|
|
636
|
+
global _triggers_cache, _triggers_cache_ts, _triggers_cache_err_until, _matcher_config_cache
|
|
609
637
|
now = time.monotonic()
|
|
610
638
|
if _triggers_cache is not None and (now - _triggers_cache_ts) < CACHE_TTL:
|
|
611
639
|
return _triggers_cache
|
|
@@ -613,10 +641,37 @@ async def get_triggers():
|
|
|
613
641
|
return _triggers_cache if _triggers_cache is not None else {}
|
|
614
642
|
try:
|
|
615
643
|
raw = await api_get("/v1/skills/triggers")
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
644
|
+
if isinstance(raw, dict) and raw.get("policy_version"):
|
|
645
|
+
_matcher_config_cache = {
|
|
646
|
+
key: raw[key]
|
|
647
|
+
for key in (
|
|
648
|
+
"policy_version", "product_id", "default_result_count",
|
|
649
|
+
"maximum_result_count", "thresholds", "stop_words",
|
|
650
|
+
"context_words", "abbreviations",
|
|
651
|
+
)
|
|
652
|
+
if key in raw
|
|
653
|
+
}
|
|
654
|
+
if isinstance(raw, dict) and isinstance(raw.get("skills"), dict):
|
|
655
|
+
trigger_records = raw["skills"]
|
|
656
|
+
elif (
|
|
657
|
+
isinstance(raw, dict)
|
|
658
|
+
and raw.get("policy_version")
|
|
659
|
+
and isinstance(raw.get("triggers"), dict)
|
|
660
|
+
):
|
|
661
|
+
trigger_records = raw["triggers"]
|
|
662
|
+
else:
|
|
663
|
+
trigger_records = raw if isinstance(raw, dict) else {}
|
|
664
|
+
active_product = (_vertical or {}).get("product_id", PRODUCT_ID)
|
|
665
|
+
_triggers_cache = {}
|
|
666
|
+
for sid, value in trigger_records.items():
|
|
667
|
+
if isinstance(value, dict):
|
|
668
|
+
if value.get("product_id") and not product_ids_match(value["product_id"], active_product):
|
|
669
|
+
continue
|
|
670
|
+
value = value.get("triggers", [])
|
|
671
|
+
if isinstance(value, str):
|
|
672
|
+
value = [value]
|
|
673
|
+
if isinstance(value, (list, tuple)):
|
|
674
|
+
_triggers_cache[str(sid)] = [str(phrase).lower() for phrase in value if str(phrase).strip()]
|
|
620
675
|
_triggers_cache_ts = now
|
|
621
676
|
_triggers_cache_err_until = 0.0
|
|
622
677
|
except Exception:
|
|
@@ -626,13 +681,54 @@ async def get_triggers():
|
|
|
626
681
|
return _triggers_cache
|
|
627
682
|
|
|
628
683
|
|
|
684
|
+
async def get_matching_config():
|
|
685
|
+
"""Return cached safe matching policy metadata for the active product."""
|
|
686
|
+
global _matcher_config_cache, _matcher_config_cache_ts, _matcher_config_err_until
|
|
687
|
+
now = time.monotonic()
|
|
688
|
+
if _matcher_config_cache is not None and (now - _matcher_config_cache_ts) < CACHE_TTL:
|
|
689
|
+
return _matcher_config_cache
|
|
690
|
+
if now < _matcher_config_err_until:
|
|
691
|
+
return _matcher_config_cache
|
|
692
|
+
try:
|
|
693
|
+
raw = await api_get("/v1/skills/matching-config")
|
|
694
|
+
active_product = (_vertical or {}).get("product_id", PRODUCT_ID)
|
|
695
|
+
if (
|
|
696
|
+
isinstance(raw, dict)
|
|
697
|
+
and raw.get("policy_version") == POLICY_VERSION
|
|
698
|
+
and product_ids_match(raw.get("product_id"), active_product)
|
|
699
|
+
):
|
|
700
|
+
_matcher_config_cache = {
|
|
701
|
+
key: raw[key]
|
|
702
|
+
for key in (
|
|
703
|
+
"policy_version", "product_id", "default_result_count",
|
|
704
|
+
"maximum_result_count", "thresholds", "stop_words",
|
|
705
|
+
"context_words", "abbreviations",
|
|
706
|
+
)
|
|
707
|
+
if key in raw
|
|
708
|
+
}
|
|
709
|
+
_matcher_config_cache_ts = now
|
|
710
|
+
_matcher_config_err_until = 0.0
|
|
711
|
+
except Exception:
|
|
712
|
+
_matcher_config_err_until = now + ERROR_COOLDOWN
|
|
713
|
+
return _matcher_config_cache
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
async def load_discovery_snapshot():
|
|
717
|
+
"""Refresh public discovery metadata before accepting user prompts."""
|
|
718
|
+
await asyncio.gather(get_skills(), get_triggers(), get_matching_config())
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def active_abbreviations(product_id):
|
|
722
|
+
"""Return the already-loaded product abbreviation map without network I/O."""
|
|
723
|
+
if _abbrevs_db is not None:
|
|
724
|
+
return _abbrevs_db
|
|
725
|
+
return _ABBREVS_FALLBACK.get(product_id, _DEFAULT_ABBREVS)
|
|
726
|
+
|
|
727
|
+
|
|
629
728
|
def expand_query(query, product_id):
|
|
630
729
|
"""Expand vertical-specific abbreviations before matching."""
|
|
631
730
|
# Prefer DB-loaded abbreviations; fall back to hardcoded map
|
|
632
|
-
|
|
633
|
-
abbrevs = _abbrevs_db
|
|
634
|
-
else:
|
|
635
|
-
abbrevs = _ABBREVS_FALLBACK.get(product_id, _DEFAULT_ABBREVS)
|
|
731
|
+
abbrevs = active_abbreviations(product_id)
|
|
636
732
|
if not abbrevs:
|
|
637
733
|
return query
|
|
638
734
|
words = query.lower().split()
|
|
@@ -640,29 +736,6 @@ def expand_query(query, product_id):
|
|
|
640
736
|
return (query + " " + " ".join(expansions)).strip() if expansions else query
|
|
641
737
|
|
|
642
738
|
|
|
643
|
-
def _word_in_text(word, text):
|
|
644
|
-
"""True if `word` appears as a whole word in `text`."""
|
|
645
|
-
return bool(re.search(r'(?<!\w)' + re.escape(word) + r'(?!\w)', text))
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
def score_skill(skill, query_lower, query_words, triggers):
|
|
649
|
-
"""Three-tier scoring: trigger match (10) > name match (3) > description match (1)."""
|
|
650
|
-
skill_id = skill.get("id", "")
|
|
651
|
-
name_text = skill.get("name", "").lower()
|
|
652
|
-
desc_text = skill.get("description", "").lower()
|
|
653
|
-
full_text = name_text + " " + desc_text
|
|
654
|
-
# Tier 1 — trigger phrase (whole-word, bidirectional)
|
|
655
|
-
for phrase in triggers.get(skill_id, []):
|
|
656
|
-
if _word_in_text(phrase, query_lower) or _word_in_text(query_lower, phrase):
|
|
657
|
-
return 10
|
|
658
|
-
# Tier 2 — keyword
|
|
659
|
-
return sum(
|
|
660
|
-
3 if _word_in_text(w, name_text) else 1
|
|
661
|
-
for w in query_words
|
|
662
|
-
if _word_in_text(w, full_text)
|
|
663
|
-
)
|
|
664
|
-
|
|
665
|
-
|
|
666
739
|
def safe_filename_component(value, fallback="tasksai-output", max_length=90):
|
|
667
740
|
"""Return a conservative filename stem with no path separators."""
|
|
668
741
|
text = str(value or "").strip()
|
|
@@ -1091,6 +1164,13 @@ def build_tools(prefix, product_name, occupation):
|
|
|
1091
1164
|
"query": {
|
|
1092
1165
|
"type": "string",
|
|
1093
1166
|
"description": f"{domain_adjective.title()} topic or task ({examples})"
|
|
1167
|
+
},
|
|
1168
|
+
"max_results": {
|
|
1169
|
+
"type": "integer",
|
|
1170
|
+
"minimum": 1,
|
|
1171
|
+
"maximum": 10,
|
|
1172
|
+
"default": 3,
|
|
1173
|
+
"description": "Maximum recommendations to show; defaults to 3. Increase to show more."
|
|
1094
1174
|
}
|
|
1095
1175
|
},
|
|
1096
1176
|
"required": ["query"]
|
|
@@ -1241,6 +1321,7 @@ async def list_tools():
|
|
|
1241
1321
|
if _vertical is None:
|
|
1242
1322
|
await load_vertical()
|
|
1243
1323
|
await load_abbreviations()
|
|
1324
|
+
await load_discovery_snapshot()
|
|
1244
1325
|
_rebuild_tools()
|
|
1245
1326
|
return _tools
|
|
1246
1327
|
|
|
@@ -1261,10 +1342,16 @@ def _rebuild_tools():
|
|
|
1261
1342
|
|
|
1262
1343
|
@server.call_tool()
|
|
1263
1344
|
async def call_tool(name, arguments):
|
|
1264
|
-
|
|
1345
|
+
global _vertical
|
|
1346
|
+
# Standard startup loads all public discovery metadata before accepting
|
|
1347
|
+
# requests. Preserve privacy if a host bypasses startup and calls search or
|
|
1348
|
+
# categories directly: use only bundled identity and empty/local caches.
|
|
1265
1349
|
if _vertical is None:
|
|
1266
|
-
|
|
1267
|
-
|
|
1350
|
+
if name.endswith("_search") or name.endswith("_categories"):
|
|
1351
|
+
_vertical = fallback_vertical(PRODUCT_ID)
|
|
1352
|
+
else:
|
|
1353
|
+
await load_vertical()
|
|
1354
|
+
await load_abbreviations()
|
|
1268
1355
|
_rebuild_tools()
|
|
1269
1356
|
|
|
1270
1357
|
v = _vertical or {}
|
|
@@ -1276,35 +1363,49 @@ async def call_tool(name, arguments):
|
|
|
1276
1363
|
try:
|
|
1277
1364
|
# ── Search ────────────────────────────────────────────────────────────
|
|
1278
1365
|
if name == f"{prefix}_search":
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
matches = [s for _, s in scored[:5]]
|
|
1366
|
+
# Discovery is snapshot-only. A user's raw prompt never causes a
|
|
1367
|
+
# metadata refresh, API request, log entry, or telemetry event.
|
|
1368
|
+
skills = _skills_cache if _skills_cache is not None else []
|
|
1369
|
+
triggers = _triggers_cache if _triggers_cache is not None else {}
|
|
1370
|
+
match = match_skills(
|
|
1371
|
+
arguments.get("query", ""),
|
|
1372
|
+
skills,
|
|
1373
|
+
triggers,
|
|
1374
|
+
product_id,
|
|
1375
|
+
abbreviations=active_abbreviations(product_id),
|
|
1376
|
+
config=_matcher_config_cache,
|
|
1377
|
+
result_count=arguments.get("max_results"),
|
|
1378
|
+
)
|
|
1293
1379
|
|
|
1294
|
-
if
|
|
1380
|
+
if match["status"] == "clarification":
|
|
1295
1381
|
return [TextContent(type="text", text=(
|
|
1296
|
-
|
|
1297
|
-
"
|
|
1298
|
-
"
|
|
1382
|
+
"**I need a little more detail to find the right workflow.**\n\n"
|
|
1383
|
+
"Add the specific task, intended outcome, or document type, then search again.\n\n"
|
|
1384
|
+
"**Options:**\n"
|
|
1385
|
+
"- Rephrase with more specific details\n"
|
|
1299
1386
|
f"- Use `{prefix}_categories` to browse all skill categories\n"
|
|
1300
|
-
"- Ask the user
|
|
1387
|
+
"- Ask the user a short clarifying question\n\n"
|
|
1301
1388
|
f"**DO NOT call `{prefix}_execute`** — no skill has been selected."
|
|
1302
1389
|
))]
|
|
1303
1390
|
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1391
|
+
matches = match["results"]
|
|
1392
|
+
top_confidence = matches[0]["confidence"]
|
|
1393
|
+
lines = [
|
|
1394
|
+
f"**{len(matches)} recommended workflow{'s' if len(matches) != 1 else ''} "
|
|
1395
|
+
f"({top_confidence} confidence):**\n"
|
|
1396
|
+
]
|
|
1397
|
+
for i, result in enumerate(matches, 1):
|
|
1398
|
+
desc = result.get("description", "")[:100]
|
|
1399
|
+
lines.append(
|
|
1400
|
+
f"{i}. **{result['name']}** (`{result['skill_id']}`)\n"
|
|
1401
|
+
f" {desc}\n"
|
|
1402
|
+
)
|
|
1403
|
+
|
|
1404
|
+
if match["has_more"]:
|
|
1405
|
+
lines.append(
|
|
1406
|
+
f"More qualifying workflows are available. Search again with `max_results` greater than "
|
|
1407
|
+
f"{len(matches)} (maximum 10) to show more.\n"
|
|
1408
|
+
)
|
|
1308
1409
|
|
|
1309
1410
|
lines.append("---")
|
|
1310
1411
|
lines.append(
|
|
@@ -1371,7 +1472,7 @@ async def call_tool(name, arguments):
|
|
|
1371
1472
|
|
|
1372
1473
|
# ── Categories ────────────────────────────────────────────────────────
|
|
1373
1474
|
elif name == f"{prefix}_categories":
|
|
1374
|
-
skills =
|
|
1475
|
+
skills = _skills_cache if _skills_cache is not None else []
|
|
1375
1476
|
cats: dict[str, int] = {}
|
|
1376
1477
|
for s in skills:
|
|
1377
1478
|
cat = s.get("category_id") or s.get("category", "General")
|
|
@@ -1419,6 +1520,7 @@ async def main():
|
|
|
1419
1520
|
# Load vertical metadata + abbreviations before accepting connections
|
|
1420
1521
|
await load_vertical()
|
|
1421
1522
|
await load_abbreviations()
|
|
1523
|
+
await load_discovery_snapshot()
|
|
1422
1524
|
_rebuild_tools()
|
|
1423
1525
|
|
|
1424
1526
|
# Ping first-connection tracker (idempotent — API only records it once)
|
|
@@ -1431,6 +1533,7 @@ async def main():
|
|
|
1431
1533
|
import sys as _sys
|
|
1432
1534
|
print(f"[OK] {v.get('product_name', 'TasksAI')} MCP Server ready (v{SERVER_VERSION})", file=_sys.stderr, flush=True)
|
|
1433
1535
|
print(f" Abbreviations: {abbrev_count} loaded from {abbrev_src}", file=_sys.stderr, flush=True)
|
|
1536
|
+
print(f" Matcher: {POLICY_VERSION} | Discovery metadata loaded before prompts", file=_sys.stderr, flush=True)
|
|
1434
1537
|
print(f" Vertical: {v.get('product_id', 'unknown')} | "
|
|
1435
1538
|
f"Tools: {v.get('tool_prefix', 'tasksai')}_search / execute / save_document / balance / categories",
|
|
1436
1539
|
file=_sys.stderr, flush=True)
|
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
"""Local, deterministic skill discovery for the TasksAI terminal runtime.
|
|
2
|
+
|
|
3
|
+
This module is deliberately pure: it performs no network, filesystem, logging,
|
|
4
|
+
or telemetry operations. Public skill metadata and product-scoped triggers are
|
|
5
|
+
provided by the caller from a snapshot loaded before a discovery request.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import unicodedata
|
|
11
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
POLICY_VERSION = "tasksai-trigger-matcher/2.0"
|
|
15
|
+
|
|
16
|
+
DEFAULT_STOP_WORDS = {
|
|
17
|
+
"a", "an", "and", "are", "as", "at", "be", "by", "can", "create",
|
|
18
|
+
"document", "for", "from", "in", "is", "it", "its", "of", "on",
|
|
19
|
+
"or", "the", "to", "was", "with",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
DEFAULT_CONTEXT_WORDS = {
|
|
23
|
+
"accounting": {"accountant", "accounting", "bookkeeping", "finance"},
|
|
24
|
+
"chiropractor": {"chiropractic", "chiropractor", "patient", "practice"},
|
|
25
|
+
"church": {"church", "congregation", "ministry", "parish"},
|
|
26
|
+
"contractor": {"build", "construction", "contractor", "project"},
|
|
27
|
+
"dentist": {"dental", "dentist", "patient", "practice"},
|
|
28
|
+
"designer": {"creative", "design", "designer", "project"},
|
|
29
|
+
"electrician": {"electrical", "electrician", "installation", "project"},
|
|
30
|
+
"eventplanner": {"event", "guest", "planning", "venue"},
|
|
31
|
+
"farmer": {"agriculture", "crop", "farm", "farmer", "farming", "livestock"},
|
|
32
|
+
"funeral": {"family", "funeral", "memorial", "service"},
|
|
33
|
+
"hr": {"employee", "employment", "human resources", "workplace"},
|
|
34
|
+
"insurance": {"claim", "coverage", "insurance", "policy"},
|
|
35
|
+
"landlord": {"landlord", "property", "rental", "tenant"},
|
|
36
|
+
"law": {"attorney", "client", "court", "law", "legal", "litigation"},
|
|
37
|
+
"marketing": {"audience", "brand", "campaign", "marketing"},
|
|
38
|
+
"militaryspouse": {"deployment", "military", "military spouse", "relocation"},
|
|
39
|
+
"mortgage": {"borrower", "loan", "mortgage", "property"},
|
|
40
|
+
"mortuary": {"funeral", "memorial", "mortuary", "service"},
|
|
41
|
+
"nutritionist": {"client", "diet", "nutrition", "nutritionist"},
|
|
42
|
+
"pastor": {"church", "congregation", "ministry", "pastor"},
|
|
43
|
+
"personaltrainer": {"client", "exercise", "fitness", "training"},
|
|
44
|
+
"plumber": {"installation", "plumber", "plumbing", "project"},
|
|
45
|
+
"principal": {"education", "principal", "school", "student"},
|
|
46
|
+
"realtor": {"buyer", "listing", "property", "real estate", "seller"},
|
|
47
|
+
"restaurant": {"food service", "guest", "restaurant", "staff"},
|
|
48
|
+
"salon": {"beauty", "client", "salon", "stylist"},
|
|
49
|
+
"teacher": {"classroom", "education", "student", "teacher"},
|
|
50
|
+
"therapist": {"client", "mental health", "patient", "therapy"},
|
|
51
|
+
"travelagent": {"client", "destination", "travel", "traveler"},
|
|
52
|
+
"vet": {"animal", "patient", "practice", "veterinary"},
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
DEFAULT_THRESHOLDS = {"high": 90, "medium": 45}
|
|
56
|
+
DEFAULT_RESULT_COUNT = 3
|
|
57
|
+
MAXIMUM_RESULT_COUNT = 10
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def normalize_text(value: object) -> str:
|
|
61
|
+
"""NFKC/casefold text and replace punctuation and separators with spaces."""
|
|
62
|
+
text = unicodedata.normalize("NFKC", str(value or "")).casefold()
|
|
63
|
+
normalized = "".join(char if char.isalnum() else " " for char in text)
|
|
64
|
+
return " ".join(normalized.split())
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _tokens(value: object) -> tuple[str, ...]:
|
|
68
|
+
normalized = normalize_text(value)
|
|
69
|
+
return tuple(normalized.split()) if normalized else ()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _canonical_product_id(value: object) -> str:
|
|
73
|
+
product_id = normalize_text(value).replace(" ", "")
|
|
74
|
+
aliases = {
|
|
75
|
+
"farmertasksai": "farmer",
|
|
76
|
+
"lawtasksai": "law",
|
|
77
|
+
"realtortasksai": "realtor",
|
|
78
|
+
}
|
|
79
|
+
return aliases.get(product_id, product_id)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _declared_product_ids(record: Mapping[str, object]) -> set[str]:
|
|
83
|
+
values: list[object] = []
|
|
84
|
+
for key in ("product_id", "vertical_id"):
|
|
85
|
+
if record.get(key):
|
|
86
|
+
values.append(record[key])
|
|
87
|
+
|
|
88
|
+
product = record.get("product")
|
|
89
|
+
if isinstance(product, Mapping):
|
|
90
|
+
for key in ("product_id", "id", "slug"):
|
|
91
|
+
if product.get(key):
|
|
92
|
+
values.append(product[key])
|
|
93
|
+
elif product:
|
|
94
|
+
values.append(product)
|
|
95
|
+
|
|
96
|
+
product_ids = record.get("product_ids")
|
|
97
|
+
if isinstance(product_ids, Sequence) and not isinstance(product_ids, (str, bytes)):
|
|
98
|
+
values.extend(product_ids)
|
|
99
|
+
|
|
100
|
+
return {_canonical_product_id(value) for value in values if _canonical_product_id(value)}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _belongs_to_product(record: Mapping[str, object], product_id: str) -> bool:
|
|
104
|
+
declared = _declared_product_ids(record)
|
|
105
|
+
return not declared or _canonical_product_id(product_id) in declared
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _expand_abbreviations(
|
|
109
|
+
tokens: tuple[str, ...], abbreviations: Mapping[str, object]
|
|
110
|
+
) -> tuple[str, ...]:
|
|
111
|
+
expansions: list[tuple[tuple[str, ...], tuple[str, ...]]] = []
|
|
112
|
+
for abbreviation, expansion in abbreviations.items():
|
|
113
|
+
abbreviation_tokens = _tokens(abbreviation)
|
|
114
|
+
expansion_tokens = _tokens(expansion)
|
|
115
|
+
if abbreviation_tokens and expansion_tokens:
|
|
116
|
+
expansions.append((abbreviation_tokens, expansion_tokens))
|
|
117
|
+
expansions.sort(key=lambda item: (-len(item[0]), item[0]))
|
|
118
|
+
|
|
119
|
+
expanded: list[str] = []
|
|
120
|
+
index = 0
|
|
121
|
+
while index < len(tokens):
|
|
122
|
+
match = next(
|
|
123
|
+
(
|
|
124
|
+
(abbreviation_tokens, expansion_tokens)
|
|
125
|
+
for abbreviation_tokens, expansion_tokens in expansions
|
|
126
|
+
if tokens[index:index + len(abbreviation_tokens)] == abbreviation_tokens
|
|
127
|
+
),
|
|
128
|
+
None,
|
|
129
|
+
)
|
|
130
|
+
if match is None:
|
|
131
|
+
expanded.append(tokens[index])
|
|
132
|
+
index += 1
|
|
133
|
+
continue
|
|
134
|
+
abbreviation_tokens, expansion_tokens = match
|
|
135
|
+
expanded.extend(expansion_tokens)
|
|
136
|
+
index += len(abbreviation_tokens)
|
|
137
|
+
return tuple(expanded)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _normalize_scoring_tokens(
|
|
141
|
+
value: object,
|
|
142
|
+
stop_words: set[str],
|
|
143
|
+
abbreviations: Mapping[str, object],
|
|
144
|
+
) -> tuple[str, ...]:
|
|
145
|
+
expanded = _expand_abbreviations(_tokens(value), abbreviations)
|
|
146
|
+
return tuple(token for token in expanded if token not in stop_words)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _contains_tokens(container: tuple[str, ...], candidate: tuple[str, ...]) -> bool:
|
|
150
|
+
if not candidate or len(candidate) > len(container):
|
|
151
|
+
return False
|
|
152
|
+
width = len(candidate)
|
|
153
|
+
return any(container[index:index + width] == candidate for index in range(len(container) - width + 1))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _category_text(skill: Mapping[str, object]) -> str:
|
|
157
|
+
category = skill.get("category")
|
|
158
|
+
if isinstance(category, Mapping):
|
|
159
|
+
category = category.get("name") or category.get("id") or ""
|
|
160
|
+
return " ".join(
|
|
161
|
+
str(value)
|
|
162
|
+
for value in (category, skill.get("category_name"), skill.get("category_id"))
|
|
163
|
+
if value
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _family_id(skill: Mapping[str, object]) -> str:
|
|
168
|
+
family = skill.get("family")
|
|
169
|
+
if isinstance(family, Mapping):
|
|
170
|
+
family = family.get("id") or family.get("name") or ""
|
|
171
|
+
value = skill.get("family_id") or skill.get("skill_family_id") or family
|
|
172
|
+
return normalize_text(value)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _trigger_values(
|
|
176
|
+
triggers: Mapping[str, object], skill_id: str, product_id: str
|
|
177
|
+
) -> tuple[str, ...]:
|
|
178
|
+
value = triggers.get(skill_id, ())
|
|
179
|
+
if isinstance(value, Mapping):
|
|
180
|
+
if not _belongs_to_product(value, product_id):
|
|
181
|
+
return ()
|
|
182
|
+
value = value.get("triggers", ())
|
|
183
|
+
if isinstance(value, str):
|
|
184
|
+
value = (value,)
|
|
185
|
+
if not isinstance(value, Iterable):
|
|
186
|
+
return ()
|
|
187
|
+
return tuple(str(trigger) for trigger in value if str(trigger).strip())
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _resolve_config(product_id: str, config: Mapping[str, object] | None) -> tuple[dict, list[str]]:
|
|
191
|
+
diagnostics: list[str] = []
|
|
192
|
+
config_product = _canonical_product_id(config.get("product_id")) if config else ""
|
|
193
|
+
valid_config = (
|
|
194
|
+
config
|
|
195
|
+
if config
|
|
196
|
+
and config.get("policy_version") == POLICY_VERSION
|
|
197
|
+
and (not config_product or config_product == _canonical_product_id(product_id))
|
|
198
|
+
else None
|
|
199
|
+
)
|
|
200
|
+
if valid_config is None:
|
|
201
|
+
if config is None:
|
|
202
|
+
diagnostics.append("bundled_policy_defaults")
|
|
203
|
+
elif config.get("policy_version") != POLICY_VERSION:
|
|
204
|
+
diagnostics.append("bundled_policy_defaults:policy_version_mismatch")
|
|
205
|
+
else:
|
|
206
|
+
diagnostics.append("bundled_policy_defaults:product_mismatch")
|
|
207
|
+
|
|
208
|
+
source = valid_config or {}
|
|
209
|
+
supplied_stop_words = source.get("stop_words", DEFAULT_STOP_WORDS)
|
|
210
|
+
if not isinstance(supplied_stop_words, Sequence) or isinstance(supplied_stop_words, (str, bytes)):
|
|
211
|
+
supplied_stop_words = DEFAULT_STOP_WORDS
|
|
212
|
+
stop_words = {
|
|
213
|
+
token
|
|
214
|
+
for value in supplied_stop_words
|
|
215
|
+
for token in _tokens(value)
|
|
216
|
+
}
|
|
217
|
+
context_default = DEFAULT_CONTEXT_WORDS.get(_canonical_product_id(product_id), set())
|
|
218
|
+
supplied_context_words = source.get("context_words", context_default)
|
|
219
|
+
if not isinstance(supplied_context_words, Sequence) or isinstance(supplied_context_words, (str, bytes)):
|
|
220
|
+
supplied_context_words = context_default
|
|
221
|
+
context_words = {
|
|
222
|
+
token
|
|
223
|
+
for value in supplied_context_words
|
|
224
|
+
for token in _tokens(value)
|
|
225
|
+
}
|
|
226
|
+
thresholds = dict(DEFAULT_THRESHOLDS)
|
|
227
|
+
supplied_thresholds = source.get("thresholds")
|
|
228
|
+
if isinstance(supplied_thresholds, Mapping):
|
|
229
|
+
for key in ("high", "medium"):
|
|
230
|
+
value = supplied_thresholds.get(key)
|
|
231
|
+
if isinstance(value, int) and not isinstance(value, bool) and value >= 0:
|
|
232
|
+
thresholds[key] = value
|
|
233
|
+
if thresholds["high"] < 1 or thresholds["medium"] > thresholds["high"]:
|
|
234
|
+
diagnostics.append("bundled_thresholds:invalid_order")
|
|
235
|
+
thresholds = dict(DEFAULT_THRESHOLDS)
|
|
236
|
+
|
|
237
|
+
default_count = source.get("default_result_count", DEFAULT_RESULT_COUNT)
|
|
238
|
+
maximum_count = source.get("maximum_result_count", MAXIMUM_RESULT_COUNT)
|
|
239
|
+
if not isinstance(default_count, int) or isinstance(default_count, bool) or default_count < 1:
|
|
240
|
+
default_count = DEFAULT_RESULT_COUNT
|
|
241
|
+
if not isinstance(maximum_count, int) or isinstance(maximum_count, bool) or maximum_count < 1:
|
|
242
|
+
maximum_count = MAXIMUM_RESULT_COUNT
|
|
243
|
+
maximum_count = min(maximum_count, MAXIMUM_RESULT_COUNT)
|
|
244
|
+
default_count = min(default_count, maximum_count)
|
|
245
|
+
|
|
246
|
+
configured_abbreviations = source.get("abbreviations", {})
|
|
247
|
+
if not isinstance(configured_abbreviations, Mapping):
|
|
248
|
+
configured_abbreviations = {}
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
"stop_words": stop_words,
|
|
252
|
+
"context_words": context_words,
|
|
253
|
+
"thresholds": thresholds,
|
|
254
|
+
"default_result_count": default_count,
|
|
255
|
+
"maximum_result_count": maximum_count,
|
|
256
|
+
"abbreviations": configured_abbreviations,
|
|
257
|
+
}, diagnostics
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _score_trigger(
|
|
261
|
+
query_tokens: tuple[str, ...],
|
|
262
|
+
trigger: str,
|
|
263
|
+
stop_words: set[str],
|
|
264
|
+
context_words: set[str],
|
|
265
|
+
abbreviations: Mapping[str, object],
|
|
266
|
+
) -> tuple[int, int, set[str], str]:
|
|
267
|
+
trigger_tokens = _normalize_scoring_tokens(trigger, stop_words, abbreviations)
|
|
268
|
+
if not trigger_tokens:
|
|
269
|
+
return 0, 0, set(), ""
|
|
270
|
+
|
|
271
|
+
query_set = set(query_tokens)
|
|
272
|
+
trigger_set = set(trigger_tokens)
|
|
273
|
+
matched = query_set & trigger_set
|
|
274
|
+
specific_tokens = trigger_set - context_words
|
|
275
|
+
specificity = len(specific_tokens)
|
|
276
|
+
|
|
277
|
+
if query_tokens == trigger_tokens:
|
|
278
|
+
return 120, specificity, matched, "trigger_exact"
|
|
279
|
+
if _contains_tokens(query_tokens, trigger_tokens):
|
|
280
|
+
bonus = min(15, max(0, specificity - 1) * 3)
|
|
281
|
+
return 90 + bonus, specificity, matched, "trigger_complete"
|
|
282
|
+
query_specific = query_set - context_words
|
|
283
|
+
if len(query_specific) >= 2 and _contains_tokens(trigger_tokens, query_tokens):
|
|
284
|
+
return 70, specificity, matched, "trigger_query_complete"
|
|
285
|
+
|
|
286
|
+
coverage_tokens = specific_tokens or trigger_set
|
|
287
|
+
covered = query_set & coverage_tokens
|
|
288
|
+
coverage = len(covered) / len(coverage_tokens)
|
|
289
|
+
if len(covered) >= 2 and coverage >= 0.67:
|
|
290
|
+
return round(40 * coverage), specificity, matched, "trigger_partial"
|
|
291
|
+
return 0, specificity, set(), ""
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _score_skill(
|
|
295
|
+
skill: Mapping[str, object],
|
|
296
|
+
query_tokens: tuple[str, ...],
|
|
297
|
+
triggers: Mapping[str, object],
|
|
298
|
+
product_id: str,
|
|
299
|
+
policy: Mapping[str, object],
|
|
300
|
+
) -> dict:
|
|
301
|
+
stop_words = policy["stop_words"]
|
|
302
|
+
context_words = policy["context_words"]
|
|
303
|
+
abbreviations = policy["abbreviations"]
|
|
304
|
+
query_set = set(query_tokens)
|
|
305
|
+
specific_query = query_set - context_words
|
|
306
|
+
context_query = query_set & context_words
|
|
307
|
+
skill_id = str(skill.get("id", ""))
|
|
308
|
+
|
|
309
|
+
strongest_trigger = (0, 0, set(), "", "")
|
|
310
|
+
for trigger in _trigger_values(triggers, skill_id, product_id):
|
|
311
|
+
score, specificity, matched, evidence = _score_trigger(
|
|
312
|
+
query_tokens, trigger, stop_words, context_words, abbreviations
|
|
313
|
+
)
|
|
314
|
+
candidate = (score, specificity, matched, evidence, normalize_text(trigger))
|
|
315
|
+
candidate_key = (score, specificity, len(matched), normalize_text(trigger))
|
|
316
|
+
strongest_key = (
|
|
317
|
+
strongest_trigger[0],
|
|
318
|
+
strongest_trigger[1],
|
|
319
|
+
len(strongest_trigger[2]),
|
|
320
|
+
strongest_trigger[4],
|
|
321
|
+
)
|
|
322
|
+
if candidate_key > strongest_key:
|
|
323
|
+
strongest_trigger = candidate
|
|
324
|
+
|
|
325
|
+
trigger_score, trigger_specificity, trigger_matched, trigger_evidence, _ = strongest_trigger
|
|
326
|
+
evidence = [trigger_evidence] if trigger_evidence else []
|
|
327
|
+
matched_tokens = set(trigger_matched)
|
|
328
|
+
|
|
329
|
+
name_tokens = _normalize_scoring_tokens(
|
|
330
|
+
skill.get("name", ""), stop_words, abbreviations
|
|
331
|
+
)
|
|
332
|
+
name_set = set(name_tokens)
|
|
333
|
+
identity_score = 0
|
|
334
|
+
if name_tokens and _contains_tokens(query_tokens, name_tokens):
|
|
335
|
+
identity_score += 35
|
|
336
|
+
evidence.append("name_complete")
|
|
337
|
+
for token in sorted(specific_query & name_set):
|
|
338
|
+
identity_score += 8
|
|
339
|
+
matched_tokens.add(token)
|
|
340
|
+
evidence.append(f"name:{token}")
|
|
341
|
+
for token in sorted(context_query & name_set):
|
|
342
|
+
identity_score += 3
|
|
343
|
+
matched_tokens.add(token)
|
|
344
|
+
evidence.append(f"name_context:{token}")
|
|
345
|
+
|
|
346
|
+
category_set = set(
|
|
347
|
+
_normalize_scoring_tokens(_category_text(skill), stop_words, abbreviations)
|
|
348
|
+
)
|
|
349
|
+
category_score = 0
|
|
350
|
+
for token in sorted(specific_query & category_set):
|
|
351
|
+
category_score += 3
|
|
352
|
+
matched_tokens.add(token)
|
|
353
|
+
evidence.append(f"category:{token}")
|
|
354
|
+
for token in sorted(context_query & category_set):
|
|
355
|
+
category_score += 1
|
|
356
|
+
matched_tokens.add(token)
|
|
357
|
+
evidence.append(f"category_context:{token}")
|
|
358
|
+
|
|
359
|
+
description_set = set(
|
|
360
|
+
_normalize_scoring_tokens(skill.get("description", ""), stop_words, abbreviations)
|
|
361
|
+
)
|
|
362
|
+
description_matches = sorted(specific_query & description_set)
|
|
363
|
+
description_score = len(description_matches)
|
|
364
|
+
for token in description_matches:
|
|
365
|
+
matched_tokens.add(token)
|
|
366
|
+
evidence.append(f"description:{token}")
|
|
367
|
+
|
|
368
|
+
score = trigger_score + identity_score + category_score + description_score
|
|
369
|
+
if trigger_score == identity_score == category_score == 0:
|
|
370
|
+
score = min(score, policy["thresholds"]["high"] - 1)
|
|
371
|
+
|
|
372
|
+
if score >= policy["thresholds"]["high"]:
|
|
373
|
+
confidence = "high"
|
|
374
|
+
elif score >= policy["thresholds"]["medium"]:
|
|
375
|
+
confidence = "medium"
|
|
376
|
+
else:
|
|
377
|
+
confidence = "low"
|
|
378
|
+
|
|
379
|
+
return {
|
|
380
|
+
"skill_id": skill_id,
|
|
381
|
+
"name": str(skill.get("name", "")),
|
|
382
|
+
"description": str(skill.get("description", "")),
|
|
383
|
+
"category": _category_text(skill),
|
|
384
|
+
"score": score,
|
|
385
|
+
"confidence": confidence,
|
|
386
|
+
"evidence": evidence,
|
|
387
|
+
"_trigger_specificity": trigger_specificity,
|
|
388
|
+
"_matched_tokens": matched_tokens,
|
|
389
|
+
"_description_normalized": normalize_text(skill.get("description", "")),
|
|
390
|
+
"_family_id": _family_id(skill),
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _sort_key(result: Mapping[str, object]) -> tuple:
|
|
395
|
+
return (
|
|
396
|
+
-result["score"],
|
|
397
|
+
-result["_trigger_specificity"],
|
|
398
|
+
-len(result["_matched_tokens"]),
|
|
399
|
+
normalize_text(result["name"]),
|
|
400
|
+
str(result["skill_id"]),
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _diversify(results: list[dict]) -> list[dict]:
|
|
405
|
+
diversified: list[dict] = []
|
|
406
|
+
seen_ids: set[str] = set()
|
|
407
|
+
seen_descriptions: set[str] = set()
|
|
408
|
+
family_counts: dict[str, int] = {}
|
|
409
|
+
for result in results:
|
|
410
|
+
skill_id = result["skill_id"]
|
|
411
|
+
description = result["_description_normalized"]
|
|
412
|
+
family_id = result["_family_id"]
|
|
413
|
+
if skill_id in seen_ids:
|
|
414
|
+
continue
|
|
415
|
+
if description and description in seen_descriptions:
|
|
416
|
+
continue
|
|
417
|
+
if family_id and family_counts.get(family_id, 0) >= 2:
|
|
418
|
+
continue
|
|
419
|
+
diversified.append(result)
|
|
420
|
+
seen_ids.add(skill_id)
|
|
421
|
+
if description:
|
|
422
|
+
seen_descriptions.add(description)
|
|
423
|
+
if family_id:
|
|
424
|
+
family_counts[family_id] = family_counts.get(family_id, 0) + 1
|
|
425
|
+
return diversified
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def match_skills(
|
|
429
|
+
query: str,
|
|
430
|
+
skills: Sequence[Mapping[str, object]],
|
|
431
|
+
triggers: Mapping[str, object],
|
|
432
|
+
product_id: str,
|
|
433
|
+
*,
|
|
434
|
+
abbreviations: Mapping[str, object] | None = None,
|
|
435
|
+
config: Mapping[str, object] | None = None,
|
|
436
|
+
result_count: int | None = None,
|
|
437
|
+
) -> dict:
|
|
438
|
+
"""Rank product-scoped public skill metadata under matcher policy 2.0."""
|
|
439
|
+
if not product_id or not _canonical_product_id(product_id):
|
|
440
|
+
raise ValueError("product_id is required for product-isolated matching")
|
|
441
|
+
|
|
442
|
+
policy, diagnostics = _resolve_config(product_id, config)
|
|
443
|
+
configured_abbreviations = dict(policy["abbreviations"])
|
|
444
|
+
if abbreviations:
|
|
445
|
+
configured_abbreviations.update(abbreviations)
|
|
446
|
+
policy["abbreviations"] = configured_abbreviations
|
|
447
|
+
|
|
448
|
+
raw_tokens = _tokens(query)
|
|
449
|
+
expanded_tokens = _expand_abbreviations(raw_tokens, configured_abbreviations)
|
|
450
|
+
query_tokens = tuple(token for token in expanded_tokens if token not in policy["stop_words"])
|
|
451
|
+
query_normalized = " ".join(query_tokens)
|
|
452
|
+
|
|
453
|
+
requested_count = policy["default_result_count"] if result_count is None else result_count
|
|
454
|
+
if not isinstance(requested_count, int) or isinstance(requested_count, bool):
|
|
455
|
+
requested_count = policy["default_result_count"]
|
|
456
|
+
requested_count = max(1, min(requested_count, policy["maximum_result_count"]))
|
|
457
|
+
|
|
458
|
+
scoped_skills = [skill for skill in skills if _belongs_to_product(skill, product_id)]
|
|
459
|
+
if len(scoped_skills) != len(skills):
|
|
460
|
+
diagnostics.append("product_mismatches_filtered")
|
|
461
|
+
|
|
462
|
+
ranked = [
|
|
463
|
+
_score_skill(skill, query_tokens, triggers, product_id, policy)
|
|
464
|
+
for skill in scoped_skills
|
|
465
|
+
if skill.get("id")
|
|
466
|
+
]
|
|
467
|
+
ranked.sort(key=_sort_key)
|
|
468
|
+
qualified = [
|
|
469
|
+
result
|
|
470
|
+
for result in _diversify(ranked)
|
|
471
|
+
if result["confidence"] in {"high", "medium"}
|
|
472
|
+
]
|
|
473
|
+
selected = qualified[:requested_count]
|
|
474
|
+
|
|
475
|
+
public_results = []
|
|
476
|
+
for result in selected:
|
|
477
|
+
public_results.append({
|
|
478
|
+
key: value
|
|
479
|
+
for key, value in result.items()
|
|
480
|
+
if not key.startswith("_")
|
|
481
|
+
})
|
|
482
|
+
|
|
483
|
+
return {
|
|
484
|
+
"policy_version": POLICY_VERSION,
|
|
485
|
+
"status": "matched" if public_results else "clarification",
|
|
486
|
+
"query_normalized": query_normalized,
|
|
487
|
+
"results": public_results,
|
|
488
|
+
"total_qualified": len(qualified),
|
|
489
|
+
"has_more": len(qualified) > len(public_results),
|
|
490
|
+
"result_count": requested_count,
|
|
491
|
+
"diagnostics": diagnostics,
|
|
492
|
+
}
|
package/src/index.js
CHANGED
|
@@ -732,8 +732,10 @@ async function checkWritable({ label, targetPath, kind }) {
|
|
|
732
732
|
async function downloadRuntime(source, runtimeDir, manifest = {}) {
|
|
733
733
|
const useBundledRuntime = manifest.runtime?.source === "shared_installer";
|
|
734
734
|
const serverText = await loadRuntimeText(source, "server.py", { useBundledRuntime });
|
|
735
|
+
const matcherText = await loadRuntimeText(source, "skill_matcher.py", { useBundledRuntime });
|
|
735
736
|
const requirementsText = await loadRuntimeText(source, "requirements.txt", { useBundledRuntime });
|
|
736
737
|
await fsp.writeFile(path.join(runtimeDir, "server.py"), serverText, "utf8");
|
|
738
|
+
await fsp.writeFile(path.join(runtimeDir, "skill_matcher.py"), matcherText, "utf8");
|
|
737
739
|
await fsp.writeFile(path.join(runtimeDir, "requirements.txt"), requirementsText, "utf8");
|
|
738
740
|
}
|
|
739
741
|
|