dsh-router-laya 2.1.0

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.
@@ -0,0 +1,280 @@
1
+ """Core model architecture, token sequence construction, and confidence estimation for laya."""
2
+ import json
3
+ import math
4
+ import os
5
+ from typing import Dict, List, Optional, Union
6
+
7
+ import numpy as np
8
+ import torch
9
+ import torch.nn as nn
10
+
11
+ QTYPES = {"choice": 0, "score": 1, "noul": 2}
12
+ QTYPE_NAMES = {v: k for k, v in QTYPES.items()}
13
+
14
+
15
+ def serialize_state(state: Union[str, dict, list]) -> str:
16
+ if isinstance(state, str):
17
+ return state
18
+ return json.dumps(state, ensure_ascii=False)
19
+
20
+
21
+ def render_criterion(value) -> str:
22
+ """Render one criterion value as text.
23
+
24
+ Strings pass through; anything structured (dict, list, number) becomes compact JSON, so a
25
+ rubric reads as JSON rather than a Python repr. Without this a dict-valued criterion
26
+ crashed `noul` outright and leaked `{'desc': ...}` into `choice` and `score` prompts.
27
+ """
28
+ if isinstance(value, str):
29
+ return value
30
+ return json.dumps(value, ensure_ascii=False, separators=(", ", ": "), default=str)
31
+
32
+
33
+ def render_options(q: Dict) -> List[str]:
34
+ """Render option texts in label-index order. Noul is always [false, true]."""
35
+ t, crit = q["t"], q.get("crit")
36
+ if t == "choice":
37
+ # only None/"" mean "no description"; 0 and False are legitimate criterion values
38
+ return [k if v is None or v == "" else "%s: %s" % (k, render_criterion(v)) for k, v in crit.items()]
39
+ if t == "score":
40
+ return ["level %d: %s" % (i, render_criterion(c)) for i, c in enumerate(crit)]
41
+ crit = crit or {}
42
+ false_crit, true_crit = crit.get("false"), crit.get("true")
43
+ return [
44
+ "false: " + (render_criterion(false_crit) if false_crit not in (None, "") else "no, the statement does not hold"),
45
+ "true: " + (render_criterion(true_crit) if true_crit not in (None, "") else "yes, the statement holds"),
46
+ ]
47
+
48
+
49
+ def build_sequence(
50
+ tok,
51
+ state: Union[str, dict, list],
52
+ q: Dict,
53
+ max_len: int = 512,
54
+ head_max_len: int = 192,
55
+ option_order: Optional[List[int]] = None,
56
+ truncate_left: bool = False,
57
+ ):
58
+ """Format: [CLS] <type> instructions [SEP] [MASK] opt0 [MASK] opt1 ... [SEP] state [SEP]."""
59
+ mask_tok = tok.mask_token
60
+ opts = render_options(q)
61
+ order = option_order if option_order is not None else list(range(len(opts)))
62
+ ins = str(q["ins"]).replace(mask_tok, " ")
63
+ head_ids = tok("%s question: %s" % (q["t"], ins), add_special_tokens=False)["input_ids"]
64
+ opt_ids = []
65
+ for i in order:
66
+ opt_ids.append(
67
+ [tok.mask_token_id]
68
+ + tok(" " + opts[i].replace(mask_tok, " "), add_special_tokens=False)["input_ids"][:48]
69
+ )
70
+ opt_budget = head_max_len - sum(len(o) for o in opt_ids)
71
+ if opt_budget < 16:
72
+ per = max(4, (head_max_len - 16) // max(1, len(opt_ids)))
73
+ opt_ids = [o[:per] for o in opt_ids]
74
+ opt_budget = head_max_len - sum(len(o) for o in opt_ids)
75
+ head_ids = head_ids[: max(8, opt_budget)]
76
+ ids = [tok.cls_token_id] + head_ids + [tok.sep_token_id]
77
+ markers = []
78
+ for o in opt_ids:
79
+ markers.append(len(ids))
80
+ ids.extend(o)
81
+ ids.append(tok.sep_token_id)
82
+ room = max(0, max_len - len(ids) - 1)
83
+ st = tok(serialize_state(state).replace(mask_tok, " "), add_special_tokens=False)["input_ids"]
84
+ st = st[-room:] if truncate_left else st[:room]
85
+ ids = ids + st + [tok.sep_token_id]
86
+ return ids[:max_len], [m for m in markers if m < max_len]
87
+
88
+
89
+ class DecisionModel(nn.Module):
90
+ """Bidirectional transformer encoder backbone + typed decision head."""
91
+
92
+ def __init__(self, encoder: nn.Module, head_layers: int = 2, n_act: int = 2, dropout: float = 0.1):
93
+ super().__init__()
94
+ self.encoder = encoder
95
+ d = encoder.config.hidden_size
96
+ nhead = max(1, d // 64)
97
+ layer = nn.TransformerEncoderLayer(d, nhead, 4 * d, dropout, batch_first=True, norm_first=True)
98
+ self.head = nn.TransformerEncoder(layer, head_layers, enable_nested_tensor=False) if head_layers > 0 else None
99
+ self.type_emb = nn.Embedding(3, d)
100
+ self.scorer = nn.Sequential(nn.LayerNorm(d), nn.Linear(d, d), nn.GELU(), nn.Linear(d, 1))
101
+ self.act_head = nn.Sequential(nn.Linear(d + 4, 256), nn.GELU(), nn.Linear(256, n_act))
102
+ self.register_buffer("temperature", torch.ones(3))
103
+ self.head_checkpointing = False
104
+
105
+ def forward(self, input_ids, attention_mask, marker_pos, marker_mask, qtype, detach_encoder: bool = False):
106
+ h = self.encoder(input_ids=input_ids, attention_mask=attention_mask).last_hidden_state
107
+ if detach_encoder:
108
+ h = h.detach()
109
+ h = h + self.type_emb(qtype)[:, None, :]
110
+ if self.head is not None:
111
+ pad = ~attention_mask.bool()
112
+ for layer in self.head.layers:
113
+ h = layer(h, src_key_padding_mask=pad)
114
+ idx = marker_pos.clamp(min=0)[:, :, None].expand(-1, -1, h.size(-1))
115
+ m = torch.gather(h, 1, idx)
116
+ logits = self.scorer(m).squeeze(-1).float()
117
+ logits = logits.masked_fill(~marker_mask, -1e4)
118
+
119
+ p = torch.softmax(logits.detach(), -1)
120
+ k = marker_mask.sum(-1).clamp(min=2).float()
121
+ ent = -(p * torch.log(p.clamp_min(1e-9))).sum(-1) / torch.log(k)
122
+ if p.size(-1) >= 2:
123
+ top2 = p.topk(2, -1).values
124
+ else:
125
+ # A single-option question has exactly one marker, so p.topk(2, ...)
126
+ # has nothing to select for the second slot and raises. The answer
127
+ # is still well-defined: softmax over one logit is 1.0 regardless of
128
+ # its value, so pad the missing second entry with 0.0 - that gives
129
+ # the act head top1 - top2 == 1.0, the same "fully decided" signal
130
+ # it would see for any other unambiguous top-1-vs-rest gap.
131
+ top1 = p.topk(1, -1).values
132
+ top2 = torch.cat([top1, torch.zeros_like(top1)], dim=-1)
133
+ feats = torch.stack([top2[:, 0], top2[:, 0] - top2[:, 1], ent, k / 255.0], -1)
134
+ pooled = h[:, 0].float()
135
+ act_logits = self.act_head(torch.cat([pooled, feats], -1))
136
+ return logits, act_logits
137
+
138
+
139
+ def build_model(cfg: Dict, encoder_dir: Optional[str] = None, pretrained: bool = True) -> DecisionModel:
140
+ from transformers import AutoConfig, AutoModel
141
+
142
+ if not pretrained or (encoder_dir and os.path.exists(encoder_dir)):
143
+ ecfg = AutoConfig.from_pretrained(encoder_dir or cfg["encoder"])
144
+ enc = AutoModel.from_config(ecfg, attn_implementation="sdpa")
145
+ else:
146
+ enc = AutoModel.from_pretrained(cfg["encoder"], attn_implementation="sdpa")
147
+ return DecisionModel(enc, cfg.get("head_layers", 2), len(cfg.get("act_costs", {})) + 1)
148
+
149
+
150
+ def proper_reward(
151
+ q: torch.Tensor,
152
+ target: torch.Tensor,
153
+ qtype: torch.Tensor,
154
+ mask: torch.Tensor,
155
+ w_sph: float = 0.5,
156
+ w_rps: float = 1.0,
157
+ log_floor: float = -9.21,
158
+ ) -> torch.Tensor:
159
+ """Strictly proper scoring rule reward: log score + spherical score + ranked probability score.
160
+
161
+ q: [..., N, K] reported distributions
162
+ target: [N, K] (one-hot or soft target distributions)
163
+ """
164
+ q = q * mask
165
+ logq = torch.log(q.clamp_min(1e-12)).clamp_min(log_floor)
166
+ log_score = (target * logq).sum(-1)
167
+ sph = (target * q).sum(-1) / q.norm(dim=-1).clamp_min(1e-9)
168
+ r = log_score + w_sph * sph
169
+ is_score = (qtype == QTYPES["score"]).float()
170
+ if is_score.any():
171
+ k = mask.sum(-1).clamp(min=2).float()
172
+ cdf_q = torch.cumsum(q, -1)
173
+ cdf_t = torch.cumsum(target, -1)
174
+ rps = (((cdf_q - cdf_t) ** 2) * mask).sum(-1) / (k - 1)
175
+ r = r - w_rps * rps * is_score
176
+ return r
177
+
178
+
179
+ def td_lambda_targets(p_true: torch.Tensor, batch: Dict, lam: float = 1.0) -> torch.Tensor:
180
+ """TD(lambda) targets for multi-turn conversation trajectories."""
181
+ target = batch["target"].clone()
182
+ groups = batch.get("ep_group")
183
+ if groups is None:
184
+ return target
185
+ for g in torch.unique(groups[groups >= 0]).tolist():
186
+ idx = (groups == g).nonzero(as_tuple=True)[0]
187
+ idx = idx[torch.argsort(batch["ep_step"][idx])]
188
+ y = batch["target"][idx[-1], 1]
189
+ G = y
190
+ for j in range(len(idx) - 1, -1, -1):
191
+ if j < len(idx) - 1:
192
+ G = (1 - lam) * p_true[idx[j + 1]] + lam * G
193
+ target[idx[j], 0], target[idx[j], 1] = 1 - G, G
194
+ return target
195
+
196
+
197
+ def ece_score(conf: np.ndarray, correct: np.ndarray, bins: int = 15) -> float:
198
+ """Expected Calibration Error across confidence bins."""
199
+ if len(conf) == 0:
200
+ return float("nan")
201
+ edges = np.linspace(0, 1, bins + 1)
202
+ e = 0.0
203
+ for lo, hi in zip(edges[:-1], edges[1:]):
204
+ sel = (conf > lo) & (conf <= hi)
205
+ if sel.any():
206
+ e += sel.mean() * abs(conf[sel].mean() - correct[sel].mean())
207
+ return float(e)
208
+
209
+
210
+ def confidence_from_probs(p: np.ndarray, k: int) -> float:
211
+ """Normalized Shannon entropy confidence: 1 - H(p) / log(k)."""
212
+ if k < 2:
213
+ return 1.0
214
+ p = p[:k]
215
+ ent = -(p * np.log(np.clip(p, 1e-12, 1.0))).sum()
216
+ return float(np.clip(1.0 - ent / math.log(k), 0.0, 1.0))
217
+
218
+
219
+ def temp_bucket(qtype: int, k: int) -> str:
220
+ size = "2" if k <= 2 else "3-5" if k <= 5 else "6-10" if k <= 10 else "11+"
221
+ return "%s:%s" % (QTYPE_NAMES[int(qtype)], size)
222
+
223
+
224
+ # A fitted temperature below 1 sharpens the logits instead of softening them. The shipped
225
+ # `choice:11+` bucket is 0.1006, which multiplies them ~10x: a 0.24 top probability is published as
226
+ # 0.99, so a caller gating on confidence is told a coin flip is a certainty. No honest calibration
227
+ # needs to sharpen this hard, so refuse to apply one that does.
228
+ TEMP_MIN = 0.5
229
+ TEMP_MAX = 5.0
230
+
231
+
232
+ def clamp_temperature(t, lo: float = TEMP_MIN, hi: float = TEMP_MAX) -> float:
233
+ """A usable temperature: `t` confined to [lo, hi], falling back to 1.0 if it is not a number."""
234
+ try:
235
+ t = float(t)
236
+ except (TypeError, ValueError):
237
+ return 1.0
238
+ if t != t or t in (float("inf"), float("-inf")): # NaN / inf
239
+ return 1.0
240
+ return min(hi, max(lo, t))
241
+
242
+
243
+ def amp_dtype(name: Optional[str]) -> torch.dtype:
244
+ return torch.bfloat16 if name == "bf16" else torch.float16
245
+
246
+
247
+ def collate_items(batch, pad_id: int):
248
+ items = [it for group in batch for it in group]
249
+ if not items:
250
+ return None
251
+ n, L = len(items), max(len(it["ids"]) for it in items)
252
+ kmax = max(len(it["markers"]) for it in items)
253
+ ids = torch.full((n, L), pad_id, dtype=torch.long)
254
+ att = torch.zeros((n, L), dtype=torch.long)
255
+ mpos = torch.zeros((n, kmax), dtype=torch.long)
256
+ mmask = torch.zeros((n, kmax), dtype=torch.bool)
257
+ has_target = any("target" in it for it in items)
258
+ target = torch.zeros((n, kmax), dtype=torch.float32) if has_target else None
259
+
260
+ for i, it in enumerate(items):
261
+ ids[i, : len(it["ids"])] = torch.tensor(it["ids"])
262
+ att[i, : len(it["ids"])] = 1
263
+ k = len(it["markers"])
264
+ mpos[i, :k] = torch.tensor(it["markers"])
265
+ mmask[i, :k] = True
266
+ if has_target and "target" in it:
267
+ target[i, : len(it["target"])] = torch.tensor(it["target"], dtype=torch.float32)
268
+
269
+ res = {
270
+ "input_ids": ids,
271
+ "attention_mask": att,
272
+ "marker_pos": mpos,
273
+ "marker_mask": mmask,
274
+ "qtype": torch.tensor([it["qtype"] for it in items]),
275
+ "label": torch.tensor([it.get("label", -1) for it in items]),
276
+ "meta": [{k: it[k] for k in it if k not in ("ids", "markers", "target")} for it in items],
277
+ }
278
+ if target is not None:
279
+ res["target"] = target
280
+ return res
@@ -0,0 +1,90 @@
1
+ """Email utilities for cleaning and structuring email inputs in laya."""
2
+ import re
3
+ from typing import Dict, Optional
4
+
5
+ _QUOTE_HEADERS = [
6
+ re.compile(r"^\s*On .{0,300}wrote:\s*$", re.I),
7
+ re.compile(r"^\s*-{2,}\s*(Original|Forwarded) Message\s*-{2,}", re.I),
8
+ re.compile(r"^\s*_{8,}\s*$"),
9
+ re.compile(r"^\s*From:\s.+$", re.I),
10
+ ]
11
+ _SIGNATURE_MARKERS = [
12
+ re.compile(r"^\s*--\s*$"),
13
+ re.compile(r"^\s*(best|kind|warm|many thanks|thanks|thank you|regards|cheers|sincerely)[\w ,!.]*$", re.I),
14
+ re.compile(r"^\s*sent from my (iphone|android|mobile|ipad)", re.I),
15
+ ]
16
+ _DISCLAIMER = re.compile(
17
+ r"(confidential|intended (solely )?for the (use of the )?(named )?(addressee|recipient)|"
18
+ r"if you (have )?received this (e-?mail|message) in error)",
19
+ re.I,
20
+ )
21
+
22
+
23
+ def clean_email_body(body: str, max_chars: int = 3000) -> str:
24
+ """Remove quoted email history, signatures and disclaimers to keep input focused."""
25
+ text = (body or "").replace("\r\n", "\n").replace("\r", "\n").replace("\\n", "\n")
26
+ lines = []
27
+ for line in text.split("\n"):
28
+ if any(p.match(line) for p in _QUOTE_HEADERS) and lines:
29
+ break
30
+ if line.lstrip().startswith(">"):
31
+ continue
32
+ lines.append(line.rstrip())
33
+ cut = len(lines)
34
+ for i in range(max(1, min(int(len(lines) * 0.6), len(lines) - 8)), len(lines)):
35
+ if len(lines[i].strip()) <= 40 and any(p.match(lines[i]) for p in _SIGNATURE_MARKERS):
36
+ cut = i
37
+ break
38
+ lines = lines[:cut]
39
+ paragraphs = [p for p in re.split(r"\n\s*\n", "\n".join(lines)) if not _DISCLAIMER.search(p)]
40
+ text = re.sub(r"[ \t]+", " ", "\n\n".join(p.strip() for p in paragraphs if p.strip()))
41
+ return text[:max_chars]
42
+
43
+
44
+ def email_state(subject: str, body: str, sender: Optional[str] = None, clean: bool = True, **extra) -> Dict:
45
+ """Construct a clean state dictionary for email classification."""
46
+ state = {
47
+ "subject": (subject or "").strip(),
48
+ "body": clean_email_body(body) if clean else (body or ""),
49
+ }
50
+ if sender:
51
+ state["from"] = sender
52
+ state.update({k: v for k, v in extra.items() if v is not None})
53
+ return state
54
+
55
+
56
+ def email_questions(categories: Optional[Dict[str, str]] = None) -> Dict:
57
+ """Standard pre-built questions for email triage."""
58
+ categories = categories or {
59
+ "billing": "invoices, payments, refunds",
60
+ "technical": "bugs, outages, integrations",
61
+ "sales": "pricing, demos, new purchases",
62
+ "security": "phishing, scams, account compromise",
63
+ "hr": "hiring, leave, payroll",
64
+ "other": "none of the above",
65
+ }
66
+ return {
67
+ "category": {
68
+ "type": "choice",
69
+ "instructions": "Which team should handle the email in `body`?",
70
+ "criteria": categories,
71
+ },
72
+ "is_spam": {
73
+ "type": "noul",
74
+ "instructions": "Is this email unsolicited spam or bulk marketing?",
75
+ },
76
+ "is_phishing": {
77
+ "type": "noul",
78
+ "instructions": "Is this email a phishing or scam attempt to steal money, credentials, or personal data?",
79
+ "criteria": {"true": "phishing, scam, or fraud", "false": "a legitimate email"},
80
+ },
81
+ "urgency": {
82
+ "type": "score",
83
+ "instructions": "How urgent is the request in `body`?",
84
+ "criteria": ["no time pressure", "needs attention soon", "blocking issue or hard deadline"],
85
+ },
86
+ "needs_reply": {
87
+ "type": "noul",
88
+ "instructions": "Does the sender expect a reply?",
89
+ },
90
+ }