ProjectOpenTARS4 commited on
Commit
602b6ba
·
verified ·
1 Parent(s): 7d8cac1

Murph 1.0: request classifier from openTARS

Browse files
Files changed (4) hide show
  1. LICENSE +21 -0
  2. README.md +63 -0
  3. murph.json +0 -0
  4. tars_murph.py +178 -0
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Project TARS
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: mit
3
+ library_name: other
4
+ pipeline_tag: text-classification
5
+ tags:
6
+ - text-classification
7
+ - intent-detection
8
+ - router
9
+ - multilingual
10
+ - cpu
11
+ language:
12
+ - pt
13
+ - en
14
+ ---
15
+
16
+ # Murph
17
+
18
+ Murph is the small request classifier built into [openTARS](https://project-opentars.github.io/OpenTARS/). For each request it decides the **kind of task**, so the app can pick the right model and tools before any language model runs.
19
+
20
+ It is not a language model. It is a softmax logistic regression over word pieces (2 to 5 letters), words and word pairs, with TF-IDF weights. It runs in plain Python on the CPU in about 0.2 ms per request, uses no GPU, and the whole model is one JSON file of about 4 MB.
21
+
22
+ ## Classes
23
+
24
+ | Class | Meaning |
25
+ |---|---|
26
+ | `visao` | needs to look at the screen ("what is on this screen?") |
27
+ | `codigo` | writing or fixing code |
28
+ | `tecnico` | technical or system questions |
29
+ | `acao` | do something on the computer (open a program, create a folder) |
30
+ | `busca` | search the web |
31
+ | `simples` | greetings and simple chat |
32
+ | `geral` | everything else |
33
+
34
+ ## Files
35
+
36
+ - `murph.json`: the trained model (SHA-256 `f1752425196a67c4baa28de5f6564a8dbccb6d41d12f44a0d0459a888c078f45`).
37
+ - `tars_murph.py`: the loader and classifier. It uses only the Python standard library.
38
+
39
+ ## Use
40
+
41
+ ```python
42
+ import tars_murph
43
+
44
+ murph = tars_murph.Murph.carregar("murph.json")
45
+ tarefa, confianca = murph.classificar("abre a calculadora")
46
+ print(tarefa, round(confianca, 2)) # acao 0.98
47
+ ```
48
+
49
+ The model also stores a confidence threshold and a coverage minimum, so the app can fall back to another method when the request is unlike anything it saw in training.
50
+
51
+ ## Training and results
52
+
53
+ - Trained on 3,034 request phrases in five languages: examples from the openTARS project plus phrases written by a larger model. Phrases similar to the evaluation phrases were removed from training.
54
+ - Cross-validation accuracy stored in the file: 88.9%.
55
+ - openTARS evaluation sets, measured on 18 October 2026: 170 of 175 across languages and 85 of 86 on the check set. The evaluation phrases were seen during development, so treat these as a sanity check, not as a benchmark.
56
+
57
+ ## Limits
58
+
59
+ - Five languages only, and short requests work best.
60
+ - It picks one class per request. A request that mixes two tasks gets the more likely one.
61
+ - The class names are Portuguese because they come from the app code.
62
+
63
+ License: MIT.
murph.json ADDED
The diff for this file is too large to render. See raw diff
 
tars_murph.py ADDED
@@ -0,0 +1,178 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Murph (3.0.4): a ajudante que decide a TAREFA de cada pedido.
2
+
3
+ Regressão logística (softmax) sobre pedaços de palavra (2 a 5 letras),
4
+ palavras e pares de palavras, com peso TF-IDF. Diferente do Naive Bayes
5
+ atual, os pesos são aprendidos DISCRIMINANDO as tarefas (o que separa
6
+ "desliga o bluetooth" de "o bluetooth não funciona"), e as palavras curtas
7
+ ("isso", "that", "aí") contam: elas são pista de visão.
8
+
9
+ Treinada com os exemplos do openTARS mais ~2.300 frases novas escritas
10
+ por um modelo grande nos 5 idiomas (a receita do TinyStories: o modelo
11
+ grande escreve, o pequeno aprende). Frases parecidas com as de avaliação
12
+ ficaram de fora do treino.
13
+
14
+ Rodar só precisa de Python puro (~0,2 ms por pedido). O treino usa
15
+ scikit-learn e fica em murph/treinar_murph.py (não vai no pacote).
16
+
17
+ Entrou no lugar do Naive Bayes (tars_classificador, removido na Endurance Neo) como camada 4 da
18
+ escolha da tarefa; sem o modelos/murph.json, o openTARS volta pro NB.
19
+ """
20
+
21
+ import json
22
+ import math
23
+ import os
24
+ import re
25
+ import threading
26
+ import unicodedata
27
+
28
+ VERSAO_FORMATO = 1
29
+ VERSAO = "1.0" # helper Murph 1.0 (3.0.5): mostrado na janela e no terminal
30
+ N_GRAMAS = (2, 3, 4, 5)
31
+ _RE_PALAVRA = re.compile(r"\w+", re.UNICODE)
32
+
33
+
34
+ def normalizar(texto):
35
+ s = unicodedata.normalize("NFKD", texto or "")
36
+ s = "".join(ch for ch in s if not unicodedata.combining(ch))
37
+ return re.sub(r"\s+", " ", s.lower().replace("ß", "ss")).strip()
38
+
39
+
40
+ def contagens(texto):
41
+ """{característica: contagem} (antes do TF-IDF)."""
42
+ t = normalizar(texto)
43
+ palavras = _RE_PALAVRA.findall(t)
44
+ feats = {}
45
+ for w in palavras:
46
+ feats["w:" + w] = feats.get("w:" + w, 0) + 1
47
+ marcada = f" {w} "
48
+ for n in N_GRAMAS:
49
+ for i in range(max(0, len(marcada) - n + 1)):
50
+ k = "c:" + marcada[i:i + n]
51
+ feats[k] = feats.get(k, 0) + 1
52
+ for a, b in zip(palavras, palavras[1:]):
53
+ k = f"b:{a}_{b}"
54
+ feats[k] = feats.get(k, 0) + 1
55
+ n = len(palavras)
56
+ feats["#n" + ("1" if n <= 1 else "2" if n <= 3 else "6" if n <= 6 else "+")] = 1
57
+ return feats
58
+
59
+
60
+ def cobertura(texto, idf):
61
+ """Quanto do texto (pedaços de palavra e palavras) o modelo já viu no
62
+ treino, de 0 a 1. "asdfgh" ou uma língua que ela não conhece: baixo."""
63
+ c = contagens(texto)
64
+ total = sum(n for k, n in c.items() if not k.startswith("#"))
65
+ if not total:
66
+ return 0.0
67
+ return sum(n for k, n in c.items() if not k.startswith("#") and k in idf) / total
68
+
69
+
70
+ def vetor(texto, idf):
71
+ """Características com TF sublinear x IDF, normalizadas (L2).
72
+ Só entra o que o modelo conhece (idf)."""
73
+ v = {}
74
+ for k, c in contagens(texto).items():
75
+ peso = idf.get(k)
76
+ if peso is not None:
77
+ v[k] = (1.0 + math.log(c)) * peso
78
+ norma = math.sqrt(sum(x * x for x in v.values())) or 1.0
79
+ return {k: x / norma for k, x in v.items()}
80
+
81
+
82
+ class Murph:
83
+ def __init__(self, dados):
84
+ self.tarefas = dados["tarefas"]
85
+ self.idf = dados["idf"]
86
+ self.vies = dados["vies"]
87
+ # pesos: {característica: [peso por tarefa]}
88
+ self.pesos = dados["pesos"]
89
+ self.limite = float(dados.get("limite", 0.1))
90
+ self.cobertura_minima = float(dados.get("cobertura_minima", 0.0))
91
+ self.versao = str(dados.get("versao") or VERSAO)
92
+
93
+ @classmethod
94
+ def carregar(cls, caminho):
95
+ with open(caminho, encoding="utf-8") as f:
96
+ dados = json.load(f)
97
+ if dados.get("formato") != VERSAO_FORMATO:
98
+ raise ValueError("formato de modelo desconhecido")
99
+ return cls(dados)
100
+
101
+ def probabilidades(self, texto):
102
+ """[(probabilidade, tarefa)] da mais provável pra menos."""
103
+ notas = list(self.vies)
104
+ for k, x in vetor(texto, self.idf).items():
105
+ linha = self.pesos.get(k)
106
+ if linha:
107
+ for i, w in enumerate(linha):
108
+ notas[i] += x * w
109
+ topo = max(notas)
110
+ exps = [math.exp(n - topo) for n in notas]
111
+ soma = sum(exps)
112
+ return sorted(((e / soma, t) for e, t in zip(exps, self.tarefas)), reverse=True)
113
+
114
+ def classificar(self, texto):
115
+ """(tarefa, confiança 0..1)."""
116
+ p = self.probabilidades(texto)
117
+ return p[0][1], p[0][0]
118
+
119
+
120
+ # ------------------------------------------------------------
121
+ # Uso no openTARS (mesmo formato do antigo tars_classificador.classificar)
122
+ # ------------------------------------------------------------
123
+
124
+ CAMINHO_PADRAO = os.path.join(os.path.dirname(os.path.abspath(__file__)), "modelos", "murph.json")
125
+ _estado = {"modelo": None, "falhou": False}
126
+ _trava = threading.Lock()
127
+
128
+
129
+ def preparar(caminho=None):
130
+ """A Murph carregada (~0,3 s na primeira vez) ou None se o arquivo não
131
+ existe / está estragado (aí quem chama usa o Naive Bayes)."""
132
+ if _estado["modelo"] is not None or _estado["falhou"]:
133
+ return _estado["modelo"]
134
+ if os.environ.get("TARS_MURPH", "").lower() in ("0", "off", "no", "nao", "não"):
135
+ return None # TARS_MURPH=off: usa o Naive Bayes (pra comparar)
136
+ with _trava:
137
+ if _estado["modelo"] is None and not _estado["falhou"]:
138
+ try:
139
+ _estado["modelo"] = Murph.carregar(caminho or os.environ.get("TARS_MURPH_ARQUIVO") or CAMINHO_PADRAO)
140
+ except Exception: # arquivo faltando ou estragado: volta pro Naive Bayes
141
+ _estado["falhou"] = True
142
+ return _estado["modelo"]
143
+
144
+
145
+ def disponivel():
146
+ return preparar() is not None
147
+
148
+
149
+ def versao():
150
+ """"1.0" (a versão gravada no modelo, ou a deste arquivo)."""
151
+ m = preparar()
152
+ return getattr(m, "versao", None) or VERSAO
153
+
154
+
155
+ def classificar(texto):
156
+ """{"notas": [(nota, tarefa)...], "margem": folga da 1ª, "certo",
157
+ "limite"} — igual ao antigo tars_classificador, pra entrar no mesmo lugar.
158
+ A nota é a probabilidade menos a da 1ª (a 1ª fica com 0)."""
159
+ m = preparar()
160
+ if m is None or not normalizar(texto):
161
+ return None
162
+ p = m.probabilidades(texto)
163
+ topo = p[0][0]
164
+ folga = topo - p[1][0] if len(p) > 1 else 1.0
165
+ limite = m.limite
166
+ # Segura = folga grande E o pedido parece com o que ela aprendeu. Texto
167
+ # estranho (letras aleatórias, outra língua) fica "em dúvida" mesmo com
168
+ # folga: aí as camadas seguintes (apps, contexto) decidem.
169
+ visto = cobertura(texto, m.idf)
170
+ return {"notas": [(prob - topo, t) for prob, t in p], "margem": folga,
171
+ "certo": folga >= limite and visto >= m.cobertura_minima, "limite": limite,
172
+ "probabilidade": topo, "cobertura": visto}
173
+
174
+
175
+ def esquecer():
176
+ """Só pros testes."""
177
+ _estado["modelo"] = None
178
+ _estado["falhou"] = False