laya-mlx 0.1.0__py3-none-any.whl

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.
laya_mlx/__init__.py ADDED
@@ -0,0 +1,34 @@
1
+ """Laya typed decisions on Apple silicon with MLX."""
2
+
3
+ from .agent import Agent, RLAgent, load
4
+ from .email import clean_email_body, email_state
5
+ from .lang import analyse as detect_language
6
+ from .lang import detect_script, is_english
7
+ from .presets import (
8
+ email_questions,
9
+ guard_questions,
10
+ moderation_questions,
11
+ router_questions,
12
+ triage_questions,
13
+ )
14
+ from .router import DEFAULT_MODELS, RouteDecision, Router
15
+
16
+ __version__ = "0.1.0"
17
+ __all__ = [
18
+ "Agent",
19
+ "RLAgent",
20
+ "load",
21
+ "Router",
22
+ "RouteDecision",
23
+ "DEFAULT_MODELS",
24
+ "detect_language",
25
+ "detect_script",
26
+ "is_english",
27
+ "clean_email_body",
28
+ "email_state",
29
+ "email_questions",
30
+ "guard_questions",
31
+ "moderation_questions",
32
+ "router_questions",
33
+ "triage_questions",
34
+ ]
laya_mlx/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
laya_mlx/agent.py ADDED
@@ -0,0 +1,262 @@
1
+ """Public MLX inference runtime; prompt and result formats follow upstream Laya."""
2
+
3
+ import json
4
+ import math
5
+ from pathlib import Path, PurePosixPath
6
+
7
+ import mlx.core as mx
8
+ import numpy as np
9
+ from huggingface_hub import snapshot_download
10
+
11
+ from .common import QTYPES, build_sequence, confidence_from_probs, render_options, temp_bucket
12
+ from .model import DecisionModel, EncoderConfig, sanitize_weights
13
+ from .prepared import PrefixCache
14
+ from .tokenizer import Tokenizer
15
+
16
+ DTYPES = {"float32": mx.float32, "float16": mx.float16, "bfloat16": mx.bfloat16}
17
+
18
+
19
+ def resolve_model(model_id_or_path, *, token=None, subfolder=None, revision=None):
20
+ if subfolder:
21
+ part = PurePosixPath(subfolder)
22
+ if part.is_absolute() or ".." in part.parts:
23
+ raise ValueError("subfolder must be a relative path inside the model repository")
24
+ path = Path(model_id_or_path).expanduser()
25
+ if not path.exists():
26
+ value = str(model_id_or_path)
27
+ if value.startswith(("/", "./", "../", "~")) or isinstance(model_id_or_path, Path):
28
+ raise FileNotFoundError(f"Local model directory does not exist: {value}")
29
+ prefix = subfolder.rstrip("/") + "/" if subfolder else ""
30
+ patterns = [
31
+ prefix + name
32
+ for name in (
33
+ "model.safetensors",
34
+ "rl_agent_config.json",
35
+ "encoder/config.json",
36
+ "tokenizer/*",
37
+ "mlx_config.json",
38
+ )
39
+ ]
40
+ path = Path(
41
+ snapshot_download(value, token=token, revision=revision, allow_patterns=patterns)
42
+ )
43
+ if subfolder:
44
+ path /= subfolder
45
+ for name in ("model.safetensors", "rl_agent_config.json", "encoder/config.json"):
46
+ if not (path / name).is_file():
47
+ raise FileNotFoundError(f"Not a complete Laya checkpoint: {path / name} is missing")
48
+ return path
49
+
50
+
51
+ def collate_items(items, pad_id, *, pad_to_multiple=None, max_length=None):
52
+ if not items:
53
+ raise ValueError("Cannot collate an empty batch")
54
+ n, length = len(items), max(len(item["ids"]) for item in items)
55
+ if pad_to_multiple:
56
+ length = ((length + pad_to_multiple - 1) // pad_to_multiple) * pad_to_multiple
57
+ if max_length is not None:
58
+ length = min(length, max_length)
59
+ count = max(2, max(len(item["markers"]) for item in items))
60
+ batch = {
61
+ "input_ids": np.full((n, length), pad_id, dtype=np.int32),
62
+ "attention_mask": np.zeros((n, length), dtype=np.bool_),
63
+ "marker_pos": np.zeros((n, count), dtype=np.int32),
64
+ "marker_mask": np.zeros((n, count), dtype=np.bool_),
65
+ "qtype": np.array([item["qtype"] for item in items], dtype=np.int32),
66
+ }
67
+ for i, item in enumerate(items):
68
+ length, count = len(item["ids"]), len(item["markers"])
69
+ batch["input_ids"][i, :length] = item["ids"]
70
+ batch["attention_mask"][i, :length] = True
71
+ batch["marker_pos"][i, :count] = item["markers"]
72
+ batch["marker_mask"][i, :count] = True
73
+ return batch
74
+
75
+
76
+ class Agent:
77
+ def __init__(
78
+ self,
79
+ model_id_or_path="convaiinnovations/laya",
80
+ device=None,
81
+ token=None,
82
+ subfolder=None,
83
+ *,
84
+ dtype="float16",
85
+ revision=None,
86
+ batch_size=16,
87
+ compile=False,
88
+ pad_to_multiple=None,
89
+ cache_prompts=False,
90
+ ):
91
+ if dtype not in DTYPES:
92
+ raise ValueError(f"dtype must be one of {list(DTYPES)}")
93
+ if device not in (None, "gpu", "metal", "cpu"):
94
+ raise ValueError("MLX device must be 'gpu', 'metal', or 'cpu'")
95
+ if not isinstance(batch_size, int) or isinstance(batch_size, bool) or batch_size < 1:
96
+ raise ValueError("batch_size must be a positive integer")
97
+ self.device = (
98
+ mx.default_device() if device is None else (mx.cpu if device == "cpu" else mx.gpu)
99
+ )
100
+ self.dtype = DTYPES[dtype]
101
+ self.batch_size = batch_size
102
+ if pad_to_multiple is not None and (
103
+ not isinstance(pad_to_multiple, int)
104
+ or isinstance(pad_to_multiple, bool)
105
+ or pad_to_multiple < 1
106
+ ):
107
+ raise ValueError("pad_to_multiple must be a positive integer or None")
108
+ self.pad_to_multiple = pad_to_multiple
109
+ self._prefix_cache = PrefixCache() if cache_prompts else None
110
+ self.model_id = str(model_id_or_path)
111
+ self.revision = revision
112
+ self.model_dir = resolve_model(
113
+ model_id_or_path, token=token, subfolder=subfolder, revision=revision
114
+ )
115
+ self.cfg = json.loads((self.model_dir / "rl_agent_config.json").read_text())
116
+ self.encoder_cfg = json.loads((self.model_dir / "encoder/config.json").read_text())
117
+ if "encoder" not in self.cfg or "head_layers" not in self.cfg:
118
+ raise ValueError("Laya config must specify encoder and head_layers")
119
+ enc_cfg = EncoderConfig.from_dict(self.encoder_cfg)
120
+ max_len = self.cfg.get("max_len", 512)
121
+ head_max_len = self.cfg.get("head_max_len", 192)
122
+ if not 4 < head_max_len < max_len <= enc_cfg.max_position_embeddings:
123
+ raise ValueError("Expected 4 < head_max_len < max_len <= max_position_embeddings")
124
+ self.temperature = self.cfg.get("temperature", [1.0, 1.0, 1.0])
125
+ self.temperature_by_options = self.cfg.get("temperature_by_options", {})
126
+ if len(self.temperature) != 3 or any(
127
+ not math.isfinite(float(t)) or float(t) <= 0
128
+ for t in [*self.temperature, *self.temperature_by_options.values()]
129
+ ):
130
+ raise ValueError("Calibration temperatures must be finite and positive")
131
+ self.tok = Tokenizer(self.model_dir / "tokenizer")
132
+ with mx.stream(self.device):
133
+ self.model = DecisionModel(enc_cfg, self.cfg)
134
+ weights = sanitize_weights(mx.load(str(self.model_dir / "model.safetensors")))
135
+ weights = {k: v.astype(self.dtype) for k, v in weights.items()}
136
+ self.model.load_weights(list(weights.items()), strict=True)
137
+ self.model.eval()
138
+ mx.eval(self.model.parameters())
139
+ # Frozen inference instance: changing weights or module structure requires a new Agent.
140
+ self._inference = mx.compile(self.model) if compile else self.model
141
+
142
+ @staticmethod
143
+ def _to_internal(qdef):
144
+ if not isinstance(qdef, dict):
145
+ raise ValueError("Each question must be a dictionary")
146
+ kind = qdef.get("type")
147
+ if kind not in QTYPES:
148
+ raise ValueError(f"Unknown question type {kind!r}; expected choice, score, or noul")
149
+ if "instructions" not in qdef:
150
+ raise ValueError("Question is missing instructions")
151
+ criteria = qdef.get("criteria")
152
+ if kind == "choice":
153
+ if isinstance(criteria, list):
154
+ if not all(isinstance(c, str) for c in criteria):
155
+ raise ValueError("Choice labels must be strings")
156
+ if len(set(criteria)) != len(criteria):
157
+ raise ValueError("Choice labels must be unique")
158
+ criteria = dict.fromkeys(criteria)
159
+ if not isinstance(criteria, dict) or not criteria:
160
+ raise ValueError("Choice criteria must be a nonempty dictionary or list")
161
+ if not all(isinstance(k, str) for k in criteria):
162
+ raise ValueError("Choice labels must be strings")
163
+ elif kind == "score":
164
+ if not isinstance(criteria, list) or not criteria:
165
+ raise ValueError("Score criteria must be a nonempty list")
166
+ elif criteria is not None and not isinstance(criteria, dict):
167
+ raise ValueError("Noul criteria must be a dictionary with false/true descriptions")
168
+ instructions = qdef["instructions"]
169
+ if not isinstance(instructions, str):
170
+ instructions = json.dumps(instructions)
171
+ return {"t": kind, "ins": instructions, "crit": criteria}
172
+
173
+ def prepare(self, state, questions):
174
+ """Construct upstream-compatible CPU inputs, useful for parity and profiling."""
175
+ if self._prefix_cache is not None:
176
+ return self._prefix_cache.prepare(self, state, questions)
177
+ if not isinstance(questions, dict):
178
+ raise ValueError("questions must be a dictionary keyed by question id")
179
+ items, internal = [], []
180
+ for qid, definition in questions.items():
181
+ q = self._to_internal(definition)
182
+ ids, markers = build_sequence(
183
+ self.tok, state, q, self.cfg.get("max_len", 512), self.cfg.get("head_max_len", 192)
184
+ )
185
+ if len(markers) != len(render_options(q)):
186
+ raise ValueError(f"Question {qid!r} has too many options for the token budget")
187
+ items.append({"ids": ids, "markers": markers, "qtype": QTYPES[q["t"]]})
188
+ internal.append(q)
189
+ return items, internal
190
+
191
+ def forward(self, batch):
192
+ """Run one prepared batch and return evaluated MLX logits on this agent's device."""
193
+ with mx.stream(self.device):
194
+ tensors = {k: mx.array(v) for k, v in batch.items()}
195
+ result = self._inference(**tensors)
196
+ mx.eval(result)
197
+ return result
198
+
199
+ def system_one(self, state, questions):
200
+ items, internal = self.prepare(state, questions)
201
+ answers = {}
202
+ question_ids = list(questions)
203
+ for start in range(0, len(items), self.batch_size):
204
+ chunk = items[start : start + self.batch_size]
205
+ batch = collate_items(
206
+ chunk,
207
+ self.tok.pad_token_id,
208
+ pad_to_multiple=self.pad_to_multiple,
209
+ max_length=self.cfg.get("max_len", 512),
210
+ )
211
+ logits, act = self.forward(batch)
212
+ logits, act = np.asarray(logits), np.asarray(act)
213
+ if not np.isfinite(logits).all() or not np.isfinite(act).all():
214
+ raise FloatingPointError("Non-finite model outputs; retry with dtype='float32'")
215
+ act = np.exp(act - act.max(axis=-1, keepdims=True))
216
+ act /= act.sum(axis=-1, keepdims=True)
217
+ for row, item in enumerate(chunk):
218
+ qid, q = question_ids[start + row], internal[start + row]
219
+ k, qt = len(item["markers"]), item["qtype"]
220
+ scale = self.temperature_by_options.get(temp_bucket(qt, k), self.temperature[qt])
221
+ z = logits[row, :k] / max(1e-3, float(scale))
222
+ p = np.exp(z - z.max())
223
+ p /= p.sum()
224
+ answer = {
225
+ "type": q["t"],
226
+ "confidence": round(confidence_from_probs(p, k), 4),
227
+ "action": {"act_probability": round(float(act[row, 0]), 4)},
228
+ }
229
+ if q["t"] == "choice":
230
+ labels = list(q["crit"])
231
+ answer.update(
232
+ choice=labels[int(p.argmax())],
233
+ probabilities={label: round(float(v), 4) for label, v in zip(labels, p)},
234
+ )
235
+ elif q["t"] == "score":
236
+ answer.update(
237
+ score=round(float((np.arange(k) * p).sum()), 4),
238
+ legend={str(i): value for i, value in enumerate(q["crit"])},
239
+ probabilities={str(i): round(float(v), 4) for i, v in enumerate(p)},
240
+ )
241
+ else:
242
+ answer.update(
243
+ noul=round(float(p[1]), 4),
244
+ confidence=round(max(float(p[1]), 1.0 - float(p[1])), 4),
245
+ )
246
+ answers[qid] = answer
247
+ return {
248
+ "model": "laya-rl-agent",
249
+ "answers": answers,
250
+ "usage": {"input_tokens": sum(len(item["ids"]) for item in items), "output_tokens": 0},
251
+ }
252
+
253
+ predict = system_one
254
+
255
+
256
+ RLAgent = Agent
257
+
258
+
259
+ def load(
260
+ model_id_or_path="convaiinnovations/laya", device=None, token=None, subfolder=None, **kwargs
261
+ ):
262
+ return Agent(model_id_or_path, device=device, token=token, subfolder=subfolder, **kwargs)
laya_mlx/cli.py ADDED
@@ -0,0 +1,55 @@
1
+ """Command-line prediction and checkpoint conversion."""
2
+
3
+ import argparse
4
+ import json
5
+ from pathlib import Path
6
+
7
+ from . import __version__
8
+ from .agent import DTYPES, Agent
9
+
10
+
11
+ def main(argv=None):
12
+ parser = argparse.ArgumentParser(prog="laya-mlx", description=__doc__)
13
+ parser.add_argument("--version", action="version", version=__version__)
14
+ commands = parser.add_subparsers(dest="command", required=True)
15
+ for command in ("predict", "convert"):
16
+ sub = commands.add_parser(command)
17
+ sub.add_argument("--model", default="convaiinnovations/laya")
18
+ sub.add_argument("--subfolder")
19
+ sub.add_argument("--revision")
20
+ sub.add_argument("--dtype", choices=DTYPES, default="float16")
21
+ if command == "predict":
22
+ source = sub.add_mutually_exclusive_group(required=True)
23
+ source.add_argument("--state", help="Plain text input")
24
+ source.add_argument("--state-file", type=Path, help="JSON state file")
25
+ sub.add_argument(
26
+ "--questions", required=True, type=Path, help="JSON question definitions"
27
+ )
28
+ sub.add_argument("--device", choices=("gpu", "cpu"), default="gpu")
29
+ sub.add_argument("--batch-size", type=int, default=16)
30
+ else:
31
+ sub.add_argument("--output", type=Path, required=True)
32
+ args = parser.parse_args(argv)
33
+ if args.command == "convert":
34
+ from .convert import convert
35
+
36
+ result = convert(
37
+ args.model,
38
+ args.output,
39
+ dtype=args.dtype,
40
+ revision=args.revision,
41
+ subfolder=args.subfolder,
42
+ )
43
+ print(json.dumps({"output": str(result), "dtype": args.dtype}))
44
+ else:
45
+ state = args.state if args.state is not None else json.loads(args.state_file.read_text())
46
+ questions = json.loads(args.questions.read_text())
47
+ agent = Agent(
48
+ args.model,
49
+ device=args.device,
50
+ dtype=args.dtype,
51
+ revision=args.revision,
52
+ subfolder=args.subfolder,
53
+ batch_size=args.batch_size,
54
+ )
55
+ print(json.dumps(agent.predict(state, questions), ensure_ascii=False, indent=2))
laya_mlx/common.py ADDED
@@ -0,0 +1,119 @@
1
+ """Laya prompt construction and calibration, adapted from upstream (see NOTICE)."""
2
+
3
+ import json
4
+ import math
5
+ from typing import Dict, List, Optional, Union
6
+
7
+ import numpy as np
8
+
9
+ QTYPES = {"choice": 0, "score": 1, "noul": 2}
10
+ QTYPE_NAMES = {v: k for k, v in QTYPES.items()}
11
+
12
+
13
+ def serialize_state(state: Union[str, dict, list]) -> str:
14
+ if isinstance(state, str):
15
+ return state
16
+ return json.dumps(state, ensure_ascii=False)
17
+
18
+
19
+ def render_criterion(value) -> str:
20
+ """Render one criterion value as text.
21
+
22
+ Strings pass through; anything structured (dict, list, number) becomes compact JSON, so a
23
+ rubric reads as JSON rather than a Python repr. Without this a dict-valued criterion
24
+ crashed `noul` outright and leaked `{'desc': ...}` into `choice` and `score` prompts.
25
+ """
26
+ if isinstance(value, str):
27
+ return value
28
+ return json.dumps(value, ensure_ascii=False, separators=(", ", ": "), default=str)
29
+
30
+
31
+ def render_options(q: Dict) -> List[str]:
32
+ """Render option texts in label-index order. Noul is always [false, true]."""
33
+ t, crit = q["t"], q.get("crit")
34
+ if t == "choice":
35
+ # only None/"" mean "no description"; 0 and False are legitimate criterion values
36
+ return [
37
+ k if v is None or v == "" else "%s: %s" % (k, render_criterion(v))
38
+ for k, v in crit.items()
39
+ ]
40
+ if t == "score":
41
+ return ["level %d: %s" % (i, render_criterion(c)) for i, c in enumerate(crit)]
42
+ crit = crit or {}
43
+ false_crit, true_crit = crit.get("false"), crit.get("true")
44
+ return [
45
+ "false: "
46
+ + (
47
+ render_criterion(false_crit)
48
+ if false_crit not in (None, "")
49
+ else "no, the statement does not hold"
50
+ ),
51
+ "true: "
52
+ + (
53
+ render_criterion(true_crit)
54
+ if true_crit not in (None, "")
55
+ else "yes, the statement holds"
56
+ ),
57
+ ]
58
+
59
+
60
+ def build_prefix(tok, q: Dict, head_max_len: int = 192, option_order=None):
61
+ """Build the question-only prefix, before state tokens and final truncation."""
62
+ mask_tok = tok.mask_token
63
+ opts = render_options(q)
64
+ order = option_order if option_order is not None else list(range(len(opts)))
65
+ ins = str(q["ins"]).replace(mask_tok, " ")
66
+ head_ids = tok("%s question: %s" % (q["t"], ins), add_special_tokens=False)["input_ids"]
67
+ opt_ids = []
68
+ for i in order:
69
+ opt_ids.append(
70
+ [tok.mask_token_id]
71
+ + tok(" " + opts[i].replace(mask_tok, " "), add_special_tokens=False)["input_ids"][:48]
72
+ )
73
+ opt_budget = head_max_len - sum(len(o) for o in opt_ids)
74
+ if opt_budget < 16:
75
+ per = max(4, (head_max_len - 16) // max(1, len(opt_ids)))
76
+ opt_ids = [o[:per] for o in opt_ids]
77
+ opt_budget = head_max_len - sum(len(o) for o in opt_ids)
78
+ head_ids = head_ids[: max(8, opt_budget)]
79
+ ids = [tok.cls_token_id] + head_ids + [tok.sep_token_id]
80
+ markers = []
81
+ for o in opt_ids:
82
+ markers.append(len(ids))
83
+ ids.extend(o)
84
+ ids.append(tok.sep_token_id)
85
+ return ids, markers
86
+
87
+
88
+ def build_sequence(
89
+ tok,
90
+ state: Union[str, dict, list],
91
+ q: Dict,
92
+ max_len: int = 512,
93
+ head_max_len: int = 192,
94
+ option_order: Optional[List[int]] = None,
95
+ truncate_left: bool = False,
96
+ ):
97
+ """Format: [CLS] <type> instructions [SEP] [MASK] opt0 [MASK] opt1 ... [SEP] state [SEP]."""
98
+ ids, markers = build_prefix(tok, q, head_max_len, option_order)
99
+ room = max(0, max_len - len(ids) - 1)
100
+ st = tok(serialize_state(state).replace(tok.mask_token, " "), add_special_tokens=False)[
101
+ "input_ids"
102
+ ]
103
+ st = st[-room:] if truncate_left else st[:room]
104
+ ids = ids + st + [tok.sep_token_id]
105
+ return ids[:max_len], [m for m in markers if m < max_len]
106
+
107
+
108
+ def confidence_from_probs(p: np.ndarray, k: int) -> float:
109
+ """Normalized Shannon entropy confidence: 1 - H(p) / log(k)."""
110
+ if k < 2:
111
+ return 1.0
112
+ p = p[:k]
113
+ ent = -(p * np.log(np.clip(p, 1e-12, 1.0))).sum()
114
+ return float(np.clip(1.0 - ent / math.log(k), 0.0, 1.0))
115
+
116
+
117
+ def temp_bucket(qtype: int, k: int) -> str:
118
+ size = "2" if k <= 2 else "3-5" if k <= 5 else "6-10" if k <= 10 else "11+"
119
+ return "%s:%s" % (QTYPE_NAMES[int(qtype)], size)
laya_mlx/convert.py ADDED
@@ -0,0 +1,48 @@
1
+ """Export a standalone MLX checkpoint. Weight downloads stay outside Git."""
2
+
3
+ import json
4
+ import shutil
5
+ from pathlib import Path
6
+
7
+ import mlx.core as mx
8
+ from mlx.utils import tree_flatten
9
+
10
+ from .agent import Agent
11
+
12
+
13
+ def convert(
14
+ model_id_or_path, output, *, dtype="float16", revision=None, subfolder=None, token=None
15
+ ):
16
+ """Convert an upstream checkpoint to MLX parameter names and the requested dtype.
17
+
18
+ The destination must not exist. If export fails, the newly created partial
19
+ directory is removed; existing checkpoints are never overwritten.
20
+ """
21
+ output = Path(output).expanduser()
22
+ if output.exists():
23
+ raise FileExistsError(f"Output already exists: {output}")
24
+ agent = Agent(
25
+ model_id_or_path, dtype=dtype, revision=revision, subfolder=subfolder, token=token
26
+ )
27
+ output.mkdir(parents=True)
28
+ try:
29
+ shutil.copytree(agent.model_dir / "tokenizer", output / "tokenizer")
30
+ (output / "encoder").mkdir()
31
+ (output / "encoder/config.json").write_text(json.dumps(agent.encoder_cfg, indent=2) + "\n")
32
+ (output / "rl_agent_config.json").write_text(json.dumps(agent.cfg, indent=2) + "\n")
33
+ mx.save_safetensors(
34
+ str(output / "model.safetensors"), dict(tree_flatten(agent.model.parameters()))
35
+ )
36
+ metadata = {
37
+ "format": "laya-mlx",
38
+ "format_version": 1,
39
+ "dtype": dtype,
40
+ "source": str(model_id_or_path),
41
+ "revision": revision,
42
+ "subfolder": subfolder,
43
+ }
44
+ (output / "mlx_config.json").write_text(json.dumps(metadata, indent=2) + "\n")
45
+ except BaseException:
46
+ shutil.rmtree(output)
47
+ raise
48
+ return output
laya_mlx/email.py ADDED
@@ -0,0 +1,101 @@
1
+ # Derived from Laya (Apache-2.0); see NOTICE. Modified for laya-mlx.
2
+ """Email utilities for cleaning and structuring email inputs in laya."""
3
+
4
+ import re
5
+ from typing import Dict, Optional
6
+
7
+ _QUOTE_HEADERS = [
8
+ re.compile(r"^\s*On .{0,300}wrote:\s*$", re.I),
9
+ re.compile(r"^\s*-{2,}\s*(Original|Forwarded) Message\s*-{2,}", re.I),
10
+ re.compile(r"^\s*_{8,}\s*$"),
11
+ re.compile(r"^\s*From:\s.+$", re.I),
12
+ ]
13
+ _SIGNATURE_MARKERS = [
14
+ re.compile(r"^\s*--\s*$"),
15
+ re.compile(
16
+ r"^\s*(best|kind|warm|many thanks|thanks|thank you|regards|cheers|sincerely)[\w ,!.]*$",
17
+ re.I,
18
+ ),
19
+ re.compile(r"^\s*sent from my (iphone|android|mobile|ipad)", re.I),
20
+ ]
21
+ _DISCLAIMER = re.compile(
22
+ r"(confidential|intended (solely )?for the (use of the )?(named )?(addressee|recipient)|"
23
+ r"if you (have )?received this (e-?mail|message) in error)",
24
+ re.I,
25
+ )
26
+
27
+
28
+ def clean_email_body(body: str, max_chars: int = 3000) -> str:
29
+ """Remove quoted email history, signatures and disclaimers to keep input focused."""
30
+ text = (body or "").replace("\r\n", "\n").replace("\r", "\n").replace("\\n", "\n")
31
+ lines = []
32
+ for line in text.split("\n"):
33
+ if any(p.match(line) for p in _QUOTE_HEADERS) and lines:
34
+ break
35
+ if line.lstrip().startswith(">"):
36
+ continue
37
+ lines.append(line.rstrip())
38
+ cut = len(lines)
39
+ for i in range(max(1, min(int(len(lines) * 0.6), len(lines) - 8)), len(lines)):
40
+ if len(lines[i].strip()) <= 40 and any(p.match(lines[i]) for p in _SIGNATURE_MARKERS):
41
+ cut = i
42
+ break
43
+ lines = lines[:cut]
44
+ paragraphs = [p for p in re.split(r"\n\s*\n", "\n".join(lines)) if not _DISCLAIMER.search(p)]
45
+ text = re.sub(r"[ \t]+", " ", "\n\n".join(p.strip() for p in paragraphs if p.strip()))
46
+ return text[:max_chars]
47
+
48
+
49
+ def email_state(
50
+ subject: str, body: str, sender: Optional[str] = None, clean: bool = True, **extra
51
+ ) -> Dict:
52
+ """Construct a clean state dictionary for email classification."""
53
+ state = {
54
+ "subject": (subject or "").strip(),
55
+ "body": clean_email_body(body) if clean else (body or ""),
56
+ }
57
+ if sender:
58
+ state["from"] = sender
59
+ state.update({k: v for k, v in extra.items() if v is not None})
60
+ return state
61
+
62
+
63
+ def email_questions(categories: Optional[Dict[str, str]] = None) -> Dict:
64
+ """Standard pre-built questions for email triage."""
65
+ categories = categories or {
66
+ "billing": "invoices, payments, refunds",
67
+ "technical": "bugs, outages, integrations",
68
+ "sales": "pricing, demos, new purchases",
69
+ "security": "phishing, scams, account compromise",
70
+ "hr": "hiring, leave, payroll",
71
+ "other": "none of the above",
72
+ }
73
+ return {
74
+ "category": {
75
+ "type": "choice",
76
+ "instructions": "Which team should handle the email in `body`?",
77
+ "criteria": categories,
78
+ },
79
+ "is_spam": {
80
+ "type": "noul",
81
+ "instructions": "Is this email unsolicited spam or bulk marketing?",
82
+ },
83
+ "is_phishing": {
84
+ "type": "noul",
85
+ "instructions": "Is this email a phishing or scam attempt to steal money, credentials, or personal data?",
86
+ "criteria": {"true": "phishing, scam, or fraud", "false": "a legitimate email"},
87
+ },
88
+ "urgency": {
89
+ "type": "score",
90
+ "instructions": "How urgent is the request in `body`?",
91
+ "criteria": [
92
+ "no time pressure",
93
+ "needs attention soon",
94
+ "blocking issue or hard deadline",
95
+ ],
96
+ },
97
+ "needs_reply": {
98
+ "type": "noul",
99
+ "instructions": "Does the sender expect a reply?",
100
+ },
101
+ }