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.
- package/LICENSE +176 -0
- package/NOTICE +19 -0
- package/README.md +108 -0
- package/bin/setup.mjs +297 -0
- package/client.js +436 -0
- package/cordis.patch.yml +9 -0
- package/index.js +870 -0
- package/install.mjs +38 -0
- package/package.json +49 -0
- package/service/finetuned_judge.py +324 -0
- package/service/intent_parser.py +1012 -0
- package/service/laya/__init__.py +51 -0
- package/service/laya/agent.py +447 -0
- package/service/laya/common.py +280 -0
- package/service/laya/email.py +90 -0
- package/service/laya/lang.py +324 -0
- package/service/laya/presets.py +187 -0
- package/service/laya/pyproject.toml +38 -0
- package/service/laya/router.py +447 -0
- package/service/laya_router.py +320 -0
- package/service/requirements.lock.txt +27 -0
- package/service/start_router.ps1 +102 -0
- package/service/start_router.sh +144 -0
- package/weights/fetch.mjs +219 -0
- package/weights/manifest.json +36 -0
|
@@ -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
|
+
}
|