Murph 1.0: request classifier from openTARS
Browse files- LICENSE +21 -0
- README.md +63 -0
- murph.json +0 -0
- 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
|