Commit ·
8c85b65
1
Parent(s): 5971541
Wire app to configurable model backend
Browse filesCo-authored-by: Codex <codex@openai.com>
- app.py +107 -13
- eval/run_eval.py +8 -0
- jawbreaker/analyzers.py +49 -1
- requirements.txt +2 -1
- tests/test_schema.py +24 -0
app.py
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import gradio as gr
|
| 2 |
|
|
|
|
| 3 |
from jawbreaker.render import render_analysis_html, render_memory_html
|
| 4 |
from jawbreaker.schema import ScamAnalysis
|
| 5 |
|
|
@@ -11,17 +16,115 @@ EXAMPLES = [
|
|
| 11 |
]
|
| 12 |
|
| 13 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
def analyze_message(message: str, memory: list[dict] | None) -> tuple[str, str, list[dict]]:
|
| 15 |
memory = memory or []
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
| 17 |
return render_analysis_html(message, analysis), render_memory_html(analysis, memory), memory
|
| 18 |
|
| 19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
def remember_current(message: str, memory: list[dict] | None) -> tuple[str, list[dict]]:
|
| 21 |
memory = memory or []
|
| 22 |
-
analysis = ScamAnalysis.from_heuristics(message, memory)
|
| 23 |
if not message.strip():
|
| 24 |
return "Paste a message first.", memory
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
memory.append(
|
| 27 |
{
|
|
@@ -36,15 +139,7 @@ def remember_current(message: str, memory: list[dict] | None) -> tuple[str, list
|
|
| 36 |
|
| 37 |
|
| 38 |
def build_app() -> gr.Blocks:
|
| 39 |
-
|
| 40 |
-
theme = gr.themes.Soft(
|
| 41 |
-
primary_hue="red",
|
| 42 |
-
secondary_hue="slate",
|
| 43 |
-
neutral_hue="zinc",
|
| 44 |
-
radius_size="sm",
|
| 45 |
-
)
|
| 46 |
-
|
| 47 |
-
with gr.Blocks(title="Jawbreaker", theme=theme, css=css) as demo:
|
| 48 |
memory_state = gr.State([])
|
| 49 |
|
| 50 |
gr.HTML(
|
|
@@ -100,5 +195,4 @@ def build_app() -> gr.Blocks:
|
|
| 100 |
|
| 101 |
|
| 102 |
if __name__ == "__main__":
|
| 103 |
-
build_app().launch()
|
| 104 |
-
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from functools import lru_cache
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
import gradio as gr
|
| 6 |
|
| 7 |
+
from jawbreaker.analyzers import build_llama_cpp_analyzer, heuristic_analyzer, prediction_to_analysis
|
| 8 |
from jawbreaker.render import render_analysis_html, render_memory_html
|
| 9 |
from jawbreaker.schema import ScamAnalysis
|
| 10 |
|
|
|
|
| 16 |
]
|
| 17 |
|
| 18 |
|
| 19 |
+
def app_theme() -> gr.Theme:
|
| 20 |
+
return gr.themes.Soft(
|
| 21 |
+
primary_hue="red",
|
| 22 |
+
secondary_hue="slate",
|
| 23 |
+
neutral_hue="zinc",
|
| 24 |
+
radius_size="sm",
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def app_css() -> str:
|
| 29 |
+
return Path("style.css").read_text(encoding="utf-8")
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _env_int(name: str, default: int | None = None) -> int | None:
|
| 33 |
+
value = os.getenv(name)
|
| 34 |
+
if value is None or value == "":
|
| 35 |
+
return default
|
| 36 |
+
return int(value)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _env_bool(name: str, default: bool | None = None) -> bool | None:
|
| 40 |
+
value = os.getenv(name)
|
| 41 |
+
if value is None or value == "":
|
| 42 |
+
return default
|
| 43 |
+
return value.strip().lower() in {"1", "true", "yes", "on"}
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
@lru_cache(maxsize=1)
|
| 47 |
+
def get_analyzer():
|
| 48 |
+
backend = os.getenv("JAWBREAKER_BACKEND", "heuristic").strip().lower()
|
| 49 |
+
if backend == "heuristic":
|
| 50 |
+
return heuristic_analyzer
|
| 51 |
+
|
| 52 |
+
if backend == "llama-cpp":
|
| 53 |
+
model_path = resolve_model_path()
|
| 54 |
+
return build_llama_cpp_analyzer(
|
| 55 |
+
model_path,
|
| 56 |
+
chat_format=os.getenv("JAWBREAKER_CHAT_FORMAT") or None,
|
| 57 |
+
n_ctx=_env_int("JAWBREAKER_N_CTX", 2048) or 2048,
|
| 58 |
+
n_threads=_env_int("JAWBREAKER_N_THREADS"),
|
| 59 |
+
n_gpu_layers=_env_int("JAWBREAKER_N_GPU_LAYERS", 0) or 0,
|
| 60 |
+
n_batch=_env_int("JAWBREAKER_N_BATCH", 512) or 512,
|
| 61 |
+
n_ubatch=_env_int("JAWBREAKER_N_UBATCH", 512) or 512,
|
| 62 |
+
offload_kqv=_env_bool("JAWBREAKER_OFFLOAD_KQV", True),
|
| 63 |
+
op_offload=_env_bool("JAWBREAKER_OP_OFFLOAD"),
|
| 64 |
+
max_tokens=_env_int("JAWBREAKER_MAX_TOKENS", 512) or 512,
|
| 65 |
+
temperature=float(os.getenv("JAWBREAKER_TEMPERATURE", "0")),
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
raise ValueError(f"Unsupported JAWBREAKER_BACKEND: {backend}")
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def resolve_model_path() -> Path:
|
| 72 |
+
model_path = Path(os.getenv("JAWBREAKER_MODEL_PATH", "models/qwen3-4b-gguf/Qwen3-4B-Q4_K_M.gguf"))
|
| 73 |
+
if model_path.exists():
|
| 74 |
+
return model_path
|
| 75 |
+
|
| 76 |
+
repo_id = os.getenv("JAWBREAKER_MODEL_REPO", "Qwen/Qwen3-4B-GGUF")
|
| 77 |
+
filename = os.getenv("JAWBREAKER_MODEL_FILE", "Qwen3-4B-Q4_K_M.gguf")
|
| 78 |
+
cache_dir = os.getenv("JAWBREAKER_MODEL_CACHE")
|
| 79 |
+
try:
|
| 80 |
+
from huggingface_hub import hf_hub_download
|
| 81 |
+
except ImportError as exc:
|
| 82 |
+
raise RuntimeError("huggingface_hub is required to download the configured model file.") from exc
|
| 83 |
+
|
| 84 |
+
return Path(hf_hub_download(repo_id=repo_id, filename=filename, cache_dir=cache_dir))
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def run_analysis(message: str, memory: list[dict] | None) -> ScamAnalysis:
|
| 88 |
+
memory = memory or []
|
| 89 |
+
prediction = get_analyzer()(message)
|
| 90 |
+
similar_memory = ScamAnalysis.from_heuristics(message, memory).similar_memory
|
| 91 |
+
return prediction_to_analysis(prediction, similar_memory=similar_memory)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
def analyze_message(message: str, memory: list[dict] | None) -> tuple[str, str, list[dict]]:
|
| 95 |
memory = memory or []
|
| 96 |
+
try:
|
| 97 |
+
analysis = run_analysis(message, memory)
|
| 98 |
+
except Exception as exc:
|
| 99 |
+
analysis = analysis_error(exc)
|
| 100 |
return render_analysis_html(message, analysis), render_memory_html(analysis, memory), memory
|
| 101 |
|
| 102 |
|
| 103 |
+
def analysis_error(exc: Exception) -> ScamAnalysis:
|
| 104 |
+
return ScamAnalysis(
|
| 105 |
+
risk_level="needs_check",
|
| 106 |
+
scam_type="analysis_error",
|
| 107 |
+
summary="Jawbreaker could not finish the model scan in this environment.",
|
| 108 |
+
tactics=["runtime error"],
|
| 109 |
+
safest_action="Do not click links or reply yet. Verify through an official app, website, or known phone number.",
|
| 110 |
+
trusted_person_message=f"Can you check this for me? Jawbreaker hit an analysis error: {exc}",
|
| 111 |
+
scam_dna={
|
| 112 |
+
"Impersonates": "Unknown",
|
| 113 |
+
"Pressure": "Unknown",
|
| 114 |
+
"Ask": "Unknown",
|
| 115 |
+
"Risk": "Could not analyze",
|
| 116 |
+
},
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
|
| 120 |
def remember_current(message: str, memory: list[dict] | None) -> tuple[str, list[dict]]:
|
| 121 |
memory = memory or []
|
|
|
|
| 122 |
if not message.strip():
|
| 123 |
return "Paste a message first.", memory
|
| 124 |
+
try:
|
| 125 |
+
analysis = run_analysis(message, memory)
|
| 126 |
+
except Exception as exc:
|
| 127 |
+
analysis = analysis_error(exc)
|
| 128 |
|
| 129 |
memory.append(
|
| 130 |
{
|
|
|
|
| 139 |
|
| 140 |
|
| 141 |
def build_app() -> gr.Blocks:
|
| 142 |
+
with gr.Blocks(title="Jawbreaker") as demo:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
memory_state = gr.State([])
|
| 144 |
|
| 145 |
gr.HTML(
|
|
|
|
| 195 |
|
| 196 |
|
| 197 |
if __name__ == "__main__":
|
| 198 |
+
build_app().launch(theme=app_theme(), css=app_css())
|
|
|
eval/run_eval.py
CHANGED
|
@@ -51,6 +51,10 @@ def parse_args() -> argparse.Namespace:
|
|
| 51 |
parser.add_argument("--n-ctx", type=int, default=4096)
|
| 52 |
parser.add_argument("--n-threads", type=int)
|
| 53 |
parser.add_argument("--n-gpu-layers", type=int, default=0)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
parser.add_argument("--max-tokens", type=int, default=512)
|
| 55 |
parser.add_argument("--temperature", type=float, default=0.0)
|
| 56 |
return parser.parse_args()
|
|
@@ -131,6 +135,10 @@ def build_analyzer(args: argparse.Namespace):
|
|
| 131 |
n_ctx=args.n_ctx,
|
| 132 |
n_threads=args.n_threads,
|
| 133 |
n_gpu_layers=args.n_gpu_layers,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
max_tokens=args.max_tokens,
|
| 135 |
temperature=args.temperature,
|
| 136 |
)
|
|
|
|
| 51 |
parser.add_argument("--n-ctx", type=int, default=4096)
|
| 52 |
parser.add_argument("--n-threads", type=int)
|
| 53 |
parser.add_argument("--n-gpu-layers", type=int, default=0)
|
| 54 |
+
parser.add_argument("--n-batch", type=int, default=512)
|
| 55 |
+
parser.add_argument("--n-ubatch", type=int, default=512)
|
| 56 |
+
parser.add_argument("--offload-kqv", action=argparse.BooleanOptionalAction, default=True)
|
| 57 |
+
parser.add_argument("--op-offload", action=argparse.BooleanOptionalAction)
|
| 58 |
parser.add_argument("--max-tokens", type=int, default=512)
|
| 59 |
parser.add_argument("--temperature", type=float, default=0.0)
|
| 60 |
return parser.parse_args()
|
|
|
|
| 135 |
n_ctx=args.n_ctx,
|
| 136 |
n_threads=args.n_threads,
|
| 137 |
n_gpu_layers=args.n_gpu_layers,
|
| 138 |
+
n_batch=args.n_batch,
|
| 139 |
+
n_ubatch=args.n_ubatch,
|
| 140 |
+
offload_kqv=args.offload_kqv,
|
| 141 |
+
op_offload=args.op_offload,
|
| 142 |
max_tokens=args.max_tokens,
|
| 143 |
temperature=args.temperature,
|
| 144 |
)
|
jawbreaker/analyzers.py
CHANGED
|
@@ -30,6 +30,46 @@ def analysis_to_prediction(analysis: ScamAnalysis) -> Prediction:
|
|
| 30 |
}
|
| 31 |
|
| 32 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
def heuristic_analyzer(message: str) -> Prediction:
|
| 34 |
return analysis_to_prediction(ScamAnalysis.from_heuristics(message))
|
| 35 |
|
|
@@ -61,6 +101,10 @@ def build_llama_cpp_analyzer(
|
|
| 61 |
n_ctx: int = 4096,
|
| 62 |
n_threads: int | None = None,
|
| 63 |
n_gpu_layers: int = 0,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
max_tokens: int = 512,
|
| 65 |
temperature: float = 0.0,
|
| 66 |
) -> Analyzer:
|
|
@@ -75,8 +119,13 @@ def build_llama_cpp_analyzer(
|
|
| 75 |
"model_path": str(model_path),
|
| 76 |
"n_ctx": n_ctx,
|
| 77 |
"n_gpu_layers": n_gpu_layers,
|
|
|
|
|
|
|
|
|
|
| 78 |
"verbose": False,
|
| 79 |
}
|
|
|
|
|
|
|
| 80 |
if chat_format:
|
| 81 |
kwargs["chat_format"] = chat_format
|
| 82 |
if n_threads is not None:
|
|
@@ -132,4 +181,3 @@ def write_predictions(path: Path, rows: Iterable[dict], predictions: dict[str, P
|
|
| 132 |
case_id = row["id"]
|
| 133 |
lines.append(json.dumps({"id": case_id, "prediction": predictions[case_id]}, ensure_ascii=True))
|
| 134 |
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
| 135 |
-
|
|
|
|
| 30 |
}
|
| 31 |
|
| 32 |
|
| 33 |
+
def prediction_to_analysis(prediction: Prediction, *, similar_memory: str = "") -> ScamAnalysis:
|
| 34 |
+
risk_level = prediction.get("risk_level")
|
| 35 |
+
if risk_level not in {"dangerous", "suspicious", "needs_check", "safe"}:
|
| 36 |
+
risk_level = "needs_check"
|
| 37 |
+
|
| 38 |
+
tactics = prediction.get("tactics", [])
|
| 39 |
+
if not isinstance(tactics, list):
|
| 40 |
+
tactics = []
|
| 41 |
+
|
| 42 |
+
scam_dna = prediction.get("scam_dna", {})
|
| 43 |
+
if not isinstance(scam_dna, dict):
|
| 44 |
+
scam_dna = {}
|
| 45 |
+
|
| 46 |
+
return ScamAnalysis(
|
| 47 |
+
risk_level=str(risk_level),
|
| 48 |
+
scam_type=str(prediction.get("scam_type", "unknown")),
|
| 49 |
+
summary=str(prediction.get("summary", "This message should be checked before anyone acts.")),
|
| 50 |
+
tactics=[str(tactic) for tactic in tactics],
|
| 51 |
+
safest_action=str(
|
| 52 |
+
prediction.get(
|
| 53 |
+
"safest_action",
|
| 54 |
+
"Do not click links or reply. Verify through an official app, website, or known phone number.",
|
| 55 |
+
)
|
| 56 |
+
),
|
| 57 |
+
trusted_person_message=str(
|
| 58 |
+
prediction.get(
|
| 59 |
+
"trusted_person_message",
|
| 60 |
+
"Can you check this for me before I respond or click anything?",
|
| 61 |
+
)
|
| 62 |
+
),
|
| 63 |
+
scam_dna={
|
| 64 |
+
"Impersonates": str(scam_dna.get("impersonates", "")),
|
| 65 |
+
"Pressure": str(scam_dna.get("pressure", "")),
|
| 66 |
+
"Ask": str(scam_dna.get("ask", "")),
|
| 67 |
+
"Risk": str(scam_dna.get("risk", "")),
|
| 68 |
+
},
|
| 69 |
+
similar_memory=similar_memory,
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
def heuristic_analyzer(message: str) -> Prediction:
|
| 74 |
return analysis_to_prediction(ScamAnalysis.from_heuristics(message))
|
| 75 |
|
|
|
|
| 101 |
n_ctx: int = 4096,
|
| 102 |
n_threads: int | None = None,
|
| 103 |
n_gpu_layers: int = 0,
|
| 104 |
+
n_batch: int = 512,
|
| 105 |
+
n_ubatch: int = 512,
|
| 106 |
+
offload_kqv: bool = True,
|
| 107 |
+
op_offload: bool | None = None,
|
| 108 |
max_tokens: int = 512,
|
| 109 |
temperature: float = 0.0,
|
| 110 |
) -> Analyzer:
|
|
|
|
| 119 |
"model_path": str(model_path),
|
| 120 |
"n_ctx": n_ctx,
|
| 121 |
"n_gpu_layers": n_gpu_layers,
|
| 122 |
+
"n_batch": n_batch,
|
| 123 |
+
"n_ubatch": n_ubatch,
|
| 124 |
+
"offload_kqv": offload_kqv,
|
| 125 |
"verbose": False,
|
| 126 |
}
|
| 127 |
+
if op_offload is not None:
|
| 128 |
+
kwargs["op_offload"] = op_offload
|
| 129 |
if chat_format:
|
| 130 |
kwargs["chat_format"] = chat_format
|
| 131 |
if n_threads is not None:
|
|
|
|
| 181 |
case_id = row["id"]
|
| 182 |
lines.append(json.dumps({"id": case_id, "prediction": predictions[case_id]}, ensure_ascii=True))
|
| 183 |
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
|
requirements.txt
CHANGED
|
@@ -1,3 +1,4 @@
|
|
| 1 |
gradio==6.16.0
|
|
|
|
|
|
|
| 2 |
pytest==8.4.2
|
| 3 |
-
|
|
|
|
| 1 |
gradio==6.16.0
|
| 2 |
+
huggingface-hub==1.18.0
|
| 3 |
+
llama-cpp-python==0.3.26
|
| 4 |
pytest==8.4.2
|
|
|
tests/test_schema.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
from jawbreaker.schema import ScamAnalysis
|
|
|
|
| 2 |
|
| 3 |
|
| 4 |
def test_family_impersonation_is_dangerous() -> None:
|
|
@@ -19,3 +20,26 @@ def test_legitimate_fraud_alert_needs_check_not_dangerous() -> None:
|
|
| 19 |
assert analysis.risk_level == "needs_check"
|
| 20 |
assert analysis.scam_type == "possible_legitimate_alert"
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from jawbreaker.schema import ScamAnalysis
|
| 2 |
+
from jawbreaker.analyzers import prediction_to_analysis
|
| 3 |
|
| 4 |
|
| 5 |
def test_family_impersonation_is_dangerous() -> None:
|
|
|
|
| 20 |
assert analysis.risk_level == "needs_check"
|
| 21 |
assert analysis.scam_type == "possible_legitimate_alert"
|
| 22 |
|
| 23 |
+
|
| 24 |
+
def test_prediction_to_analysis_normalizes_model_json() -> None:
|
| 25 |
+
analysis = prediction_to_analysis(
|
| 26 |
+
{
|
| 27 |
+
"risk_level": "not_valid",
|
| 28 |
+
"scam_type": "package_phishing",
|
| 29 |
+
"summary": "Fake delivery notice.",
|
| 30 |
+
"tactics": ["fake authority"],
|
| 31 |
+
"safest_action": "Open the official carrier website yourself.",
|
| 32 |
+
"trusted_person_message": "Can you check this?",
|
| 33 |
+
"scam_dna": {
|
| 34 |
+
"impersonates": "USPS",
|
| 35 |
+
"pressure": "Held package",
|
| 36 |
+
"ask": "Open link",
|
| 37 |
+
"risk": "credential theft",
|
| 38 |
+
},
|
| 39 |
+
},
|
| 40 |
+
similar_memory="This resembles a saved pattern.",
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
assert analysis.risk_level == "needs_check"
|
| 44 |
+
assert analysis.scam_dna["Impersonates"] == "USPS"
|
| 45 |
+
assert analysis.similar_memory == "This resembles a saved pattern."
|