Commit ·
6103e1f
0
Parent(s):
Initialize Jawbreaker hackathon scaffold
Browse files- .gitignore +16 -0
- AGENT_TRACE.md +18 -0
- FIELD_NOTES.md +17 -0
- README.md +77 -0
- SUBMISSION.md +35 -0
- app.py +104 -0
- eval/run_eval.py +25 -0
- eval/scam_eval.jsonl +7 -0
- jawbreaker/__init__.py +2 -0
- jawbreaker/prompt.py +13 -0
- jawbreaker/render.py +62 -0
- jawbreaker/schema.py +157 -0
- requirements.txt +3 -0
- style.css +194 -0
- tests/test_schema.py +21 -0
.gitignore
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.py[cod]
|
| 3 |
+
.pytest_cache/
|
| 4 |
+
.venv/
|
| 5 |
+
venv/
|
| 6 |
+
.env
|
| 7 |
+
.env.*
|
| 8 |
+
models/
|
| 9 |
+
*.gguf
|
| 10 |
+
*.safetensors
|
| 11 |
+
*.pt
|
| 12 |
+
*.pth
|
| 13 |
+
dist/
|
| 14 |
+
build/
|
| 15 |
+
.DS_Store
|
| 16 |
+
|
AGENT_TRACE.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Agent Trace
|
| 2 |
+
|
| 3 |
+
This project is being built with OpenAI Codex as the coding agent.
|
| 4 |
+
|
| 5 |
+
## 2026-06-05
|
| 6 |
+
|
| 7 |
+
- Read hackathon rules, kickoff notes, sponsor details, and peer review feedback.
|
| 8 |
+
- Selected `Jawbreaker` as the product name.
|
| 9 |
+
- Chose Backyard AI as the main track.
|
| 10 |
+
- Established model bakeoff plan before committing to a default model.
|
| 11 |
+
- Created initial project scaffold for a Gradio Space and public GitHub repo.
|
| 12 |
+
|
| 13 |
+
Open questions:
|
| 14 |
+
|
| 15 |
+
- Which model wins the latency/quality bakeoff?
|
| 16 |
+
- Whether MiniCPM4-8B is strong enough to become central for OpenBMB eligibility.
|
| 17 |
+
- Whether fine-tuning is worth the time after MVP stability.
|
| 18 |
+
|
FIELD_NOTES.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Field Notes
|
| 2 |
+
|
| 3 |
+
## 2026-06-05
|
| 4 |
+
|
| 5 |
+
Kickoff confirmed the submission shape:
|
| 6 |
+
|
| 7 |
+
- Gradio Space under the hackathon organization.
|
| 8 |
+
- Demo video and social post proof belong in the Space README.
|
| 9 |
+
- Public GitHub repo with Codex-attributed commits matters for the OpenAI Codex Track.
|
| 10 |
+
- Sponsor prizes can depend on model or infrastructure choices.
|
| 11 |
+
|
| 12 |
+
Jawbreaker is scoped for Backyard AI: one real person, one narrow safety task, one clear answer.
|
| 13 |
+
|
| 14 |
+
Current risk: a generic scam detector will not stand out. The product must show a specific person being helped and a clear small-model fit.
|
| 15 |
+
|
| 16 |
+
Current differentiator: Scam DNA, a visual breakdown of scam structure rather than a plain label.
|
| 17 |
+
|
README.md
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Jawbreaker
|
| 3 |
+
sdk: gradio
|
| 4 |
+
sdk_version: 6.16.0
|
| 5 |
+
app_file: app.py
|
| 6 |
+
license: mit
|
| 7 |
+
short_description: Local-first scam defense for someone you love.
|
| 8 |
+
tags:
|
| 9 |
+
- gradio
|
| 10 |
+
- build-small-hackathon
|
| 11 |
+
- backyard-ai
|
| 12 |
+
- local-first
|
| 13 |
+
- llama-cpp
|
| 14 |
+
---
|
| 15 |
+
|
| 16 |
+
# Jawbreaker
|
| 17 |
+
|
| 18 |
+
Scam defense for someone you love.
|
| 19 |
+
|
| 20 |
+
Jawbreaker helps a real person pause before clicking, replying, or sending money. Paste a suspicious text, email, or DM and Jawbreaker breaks it into plain-English warning signs: what the sender is pretending to be, what pressure tactic is being used, what they want, and the safest next step.
|
| 21 |
+
|
| 22 |
+
## Hackathon
|
| 23 |
+
|
| 24 |
+
- Event: Hugging Face Build Small Hackathon
|
| 25 |
+
- Track: Backyard AI
|
| 26 |
+
- App: Gradio Space under `build-small-hackathon`
|
| 27 |
+
- Status: In development
|
| 28 |
+
- Demo video: To be added before submission
|
| 29 |
+
- Social post: To be added before submission
|
| 30 |
+
- Public GitHub repo: To be added after repo creation
|
| 31 |
+
|
| 32 |
+
## Why This Is Small
|
| 33 |
+
|
| 34 |
+
Jawbreaker is deliberately narrow. It does not try to be a general assistant or chatbot. It performs one safety task:
|
| 35 |
+
|
| 36 |
+
1. Read one suspicious message.
|
| 37 |
+
2. Identify scam risk and manipulation tactics.
|
| 38 |
+
3. Give one clear safe action.
|
| 39 |
+
4. Help the user ask someone they trust.
|
| 40 |
+
|
| 41 |
+
## Model Plan
|
| 42 |
+
|
| 43 |
+
The final app will use a local small model through `llama.cpp` / `llama-cpp-python`. Candidate models will be chosen by an eval bakeoff:
|
| 44 |
+
|
| 45 |
+
- Qwen3-4B GGUF Q4_K_M
|
| 46 |
+
- Qwen3-8B GGUF Q4_K_M
|
| 47 |
+
- MiniCPM4-8B GGUF Q4_K_M
|
| 48 |
+
|
| 49 |
+
Decision criteria:
|
| 50 |
+
|
| 51 |
+
- valid JSON output
|
| 52 |
+
- low false positives on legitimate messages
|
| 53 |
+
- no dangerous scams labeled safe
|
| 54 |
+
- safe recommended actions
|
| 55 |
+
- short, clear explanations
|
| 56 |
+
- acceptable latency for judges
|
| 57 |
+
|
| 58 |
+
## Bonus Badges Targeted
|
| 59 |
+
|
| 60 |
+
- Off the Grid: local model inference, no cloud APIs for scam analysis.
|
| 61 |
+
- Llama Champion: model runs through the llama.cpp runtime.
|
| 62 |
+
- Off-Brand: custom Gradio UI beyond the default look.
|
| 63 |
+
- Well-Tuned: conditional; only if the MVP is stable early enough.
|
| 64 |
+
- Sharing is Caring: Codex/agent trace published in this repo.
|
| 65 |
+
- Field Notes: build report published before submission.
|
| 66 |
+
|
| 67 |
+
## Sponsor Eligibility Notes
|
| 68 |
+
|
| 69 |
+
- OpenAI Codex Track: public GitHub repo with Codex-attributed commits linked here.
|
| 70 |
+
- OpenBMB Awards: possible if MiniCPM becomes the central model after bakeoff.
|
| 71 |
+
- Modal Awards: possible if Modal is used for fine-tuning, evals, or deployment support and documented here.
|
| 72 |
+
- NVIDIA Nemotron Quest: only if a NeMoTron model is used; currently not planned.
|
| 73 |
+
|
| 74 |
+
## Safety Boundary
|
| 75 |
+
|
| 76 |
+
Jawbreaker is not legal, financial, or cybersecurity advice. It is a local-first safety aid that helps non-experts slow down and verify suspicious messages. The safest action should never ask the user to click the suspicious link or call a number from the suspicious message.
|
| 77 |
+
|
SUBMISSION.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Submission Checklist
|
| 2 |
+
|
| 3 |
+
## Required
|
| 4 |
+
|
| 5 |
+
- [ ] Gradio Space created under `build-small-hackathon`
|
| 6 |
+
- [ ] Space made public before June 15, 2026
|
| 7 |
+
- [ ] Demo video linked in Space README
|
| 8 |
+
- [ ] Social post linked in Space README
|
| 9 |
+
- [ ] Public GitHub repo linked in Space README
|
| 10 |
+
- [ ] Repo includes Codex-attributed commits
|
| 11 |
+
|
| 12 |
+
## Backyard AI Evidence
|
| 13 |
+
|
| 14 |
+
- [ ] Real user identified
|
| 15 |
+
- [ ] Real suspicious message collected with private details removed
|
| 16 |
+
- [ ] User quote or reaction collected
|
| 17 |
+
- [ ] Demo script shows the real-user story
|
| 18 |
+
|
| 19 |
+
## Technical Evidence
|
| 20 |
+
|
| 21 |
+
- [ ] Model name and parameter count documented
|
| 22 |
+
- [ ] Eval set included
|
| 23 |
+
- [ ] Bakeoff results documented
|
| 24 |
+
- [ ] Local inference path documented
|
| 25 |
+
- [ ] No cloud API path documented
|
| 26 |
+
|
| 27 |
+
## Bonus Badges
|
| 28 |
+
|
| 29 |
+
- [ ] Off the Grid
|
| 30 |
+
- [ ] Llama Champion
|
| 31 |
+
- [ ] Off-Brand
|
| 32 |
+
- [ ] Well-Tuned
|
| 33 |
+
- [ ] Sharing is Caring
|
| 34 |
+
- [ ] Field Notes
|
| 35 |
+
|
app.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import gradio as gr
|
| 2 |
+
|
| 3 |
+
from jawbreaker.render import render_analysis_html, render_memory_html
|
| 4 |
+
from jawbreaker.schema import ScamAnalysis
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
EXAMPLES = [
|
| 8 |
+
"USPS: Your package is held due to an unpaid fee. Verify now: http://usps-track-secure.example",
|
| 9 |
+
"Hi Grandma, I lost my phone. This is my new number. Can you send $800 for rent today? Please don't tell Mom.",
|
| 10 |
+
"Chase fraud alert: Did you attempt a $249.00 purchase at TARGET? Reply YES or NO.",
|
| 11 |
+
]
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def analyze_message(message: str, memory: list[dict] | None) -> tuple[str, str, list[dict]]:
|
| 15 |
+
memory = memory or []
|
| 16 |
+
analysis = ScamAnalysis.from_heuristics(message, memory)
|
| 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 |
+
{
|
| 28 |
+
"summary": analysis.summary,
|
| 29 |
+
"scam_type": analysis.scam_type,
|
| 30 |
+
"risk_level": analysis.risk_level,
|
| 31 |
+
"fingerprint": analysis.scam_dna,
|
| 32 |
+
"text": message[:240],
|
| 33 |
+
}
|
| 34 |
+
)
|
| 35 |
+
return "Saved this scam pattern for this session.", memory
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def build_app() -> gr.Blocks:
|
| 39 |
+
css = open("style.css", "r", encoding="utf-8").read()
|
| 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(
|
| 51 |
+
"""
|
| 52 |
+
<section class="hero">
|
| 53 |
+
<div>
|
| 54 |
+
<p class="eyebrow">Backyard AI</p>
|
| 55 |
+
<h1>Jawbreaker</h1>
|
| 56 |
+
<p class="subtitle">Scam defense for someone you love.</p>
|
| 57 |
+
</div>
|
| 58 |
+
<div class="hero-badge">Local-first small-model app</div>
|
| 59 |
+
</section>
|
| 60 |
+
"""
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
with gr.Row(elem_classes=["main-grid"]):
|
| 64 |
+
with gr.Column(scale=5, elem_classes=["scan-panel"]):
|
| 65 |
+
message = gr.Textbox(
|
| 66 |
+
label="Suspicious message",
|
| 67 |
+
placeholder="Paste a suspicious text, email, or DM here.",
|
| 68 |
+
lines=10,
|
| 69 |
+
max_lines=16,
|
| 70 |
+
)
|
| 71 |
+
with gr.Row():
|
| 72 |
+
analyze = gr.Button("Analyze", variant="primary")
|
| 73 |
+
remember = gr.Button("Remember this pattern")
|
| 74 |
+
gr.Examples(examples=EXAMPLES, inputs=message, label="Try a sample")
|
| 75 |
+
|
| 76 |
+
with gr.Column(scale=7):
|
| 77 |
+
result = gr.HTML(
|
| 78 |
+
"""
|
| 79 |
+
<div class="empty-state">
|
| 80 |
+
<h2>Paste a message to scan it.</h2>
|
| 81 |
+
<p>Jawbreaker will show the risk, the Scam DNA, and the safest next step.</p>
|
| 82 |
+
</div>
|
| 83 |
+
"""
|
| 84 |
+
)
|
| 85 |
+
memory = gr.HTML("<div class='memory-card muted'>No scam memory saved yet.</div>")
|
| 86 |
+
save_status = gr.Textbox(label="Session memory", interactive=False)
|
| 87 |
+
|
| 88 |
+
analyze.click(
|
| 89 |
+
fn=analyze_message,
|
| 90 |
+
inputs=[message, memory_state],
|
| 91 |
+
outputs=[result, memory, memory_state],
|
| 92 |
+
)
|
| 93 |
+
remember.click(
|
| 94 |
+
fn=remember_current,
|
| 95 |
+
inputs=[message, memory_state],
|
| 96 |
+
outputs=[save_status, memory_state],
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
return demo
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
if __name__ == "__main__":
|
| 103 |
+
build_app().launch()
|
| 104 |
+
|
eval/run_eval.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import sys
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 6 |
+
|
| 7 |
+
from jawbreaker.schema import ScamAnalysis
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def main() -> None:
|
| 11 |
+
path = Path(__file__).with_name("scam_eval.jsonl")
|
| 12 |
+
rows = [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
|
| 13 |
+
correct = 0
|
| 14 |
+
|
| 15 |
+
for row in rows:
|
| 16 |
+
analysis = ScamAnalysis.from_heuristics(row["input"])
|
| 17 |
+
ok = analysis.risk_level == row["expected_risk_level"]
|
| 18 |
+
correct += int(ok)
|
| 19 |
+
print(f"{row['id']}: expected={row['expected_risk_level']} actual={analysis.risk_level} ok={ok}")
|
| 20 |
+
|
| 21 |
+
print(f"risk_level_accuracy={correct}/{len(rows)}")
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
if __name__ == "__main__":
|
| 25 |
+
main()
|
eval/scam_eval.jsonl
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{"id":"package_phish_001","input":"USPS: Your package is held due to an unpaid fee. Verify now: http://usps-track-secure.example","expected_risk_level":"dangerous","expected_scam_type":"credential_theft","expected_tactics":["urgency","suspicious link","fake authority"]}
|
| 2 |
+
{"id":"family_impersonation_001","input":"Hi Grandma, I lost my phone. This is my new number. Can you send $800 for rent today? Please don't tell Mom.","expected_risk_level":"dangerous","expected_scam_type":"family_impersonation","expected_tactics":["impersonation","urgency","secrecy","payment pressure"]}
|
| 3 |
+
{"id":"bank_safe_001","input":"Chase fraud alert: Did you attempt a $249.00 purchase at TARGET? Reply YES or NO.","expected_risk_level":"needs_check","expected_scam_type":"possible_legitimate_alert","expected_tactics":["verification needed"]}
|
| 4 |
+
{"id":"gift_card_001","input":"This is your manager. I need you to buy three gift cards immediately and send me the codes. I am in a meeting.","expected_risk_level":"dangerous","expected_scam_type":"payment_request","expected_tactics":["fake authority","urgency","payment pressure"]}
|
| 5 |
+
{"id":"safe_friend_001","input":"Running ten minutes late. See you at the restaurant soon.","expected_risk_level":"safe","expected_scam_type":"none","expected_tactics":[]}
|
| 6 |
+
{"id":"password_reset_001","input":"Your email password expires today. Login now to keep access: https://mail-security-reset.example","expected_risk_level":"dangerous","expected_scam_type":"credential_theft","expected_tactics":["urgency","suspicious link","credential request"]}
|
| 7 |
+
|
jawbreaker/__init__.py
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Jawbreaker scam analysis app."""
|
| 2 |
+
|
jawbreaker/prompt.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
SYSTEM_PROMPT = """
|
| 2 |
+
You are Jawbreaker, a local-first scam defense assistant for non-experts.
|
| 3 |
+
|
| 4 |
+
Analyze one suspicious message. Return only valid JSON. Use short, plain English.
|
| 5 |
+
|
| 6 |
+
Safety rules:
|
| 7 |
+
- Never advise clicking a suspicious link.
|
| 8 |
+
- Never advise calling a phone number from the suspicious message.
|
| 9 |
+
- If uncertain, choose "needs_check" and recommend verification through a trusted route.
|
| 10 |
+
- Avoid jargon.
|
| 11 |
+
- Give one safest next step.
|
| 12 |
+
"""
|
| 13 |
+
|
jawbreaker/render.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from html import escape
|
| 4 |
+
|
| 5 |
+
from jawbreaker.schema import ScamAnalysis
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def render_analysis_html(message: str, analysis: ScamAnalysis) -> str:
|
| 9 |
+
if not message.strip():
|
| 10 |
+
return """
|
| 11 |
+
<div class="empty-state">
|
| 12 |
+
<h2>Paste a message to scan it.</h2>
|
| 13 |
+
<p>Jawbreaker will show the risk, the Scam DNA, and the safest next step.</p>
|
| 14 |
+
</div>
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
tactic_html = "".join(f"<span class='tactic'>{escape(tactic)}</span>" for tactic in analysis.tactics)
|
| 18 |
+
dna_html = "".join(
|
| 19 |
+
f"""
|
| 20 |
+
<div class="dna-item">
|
| 21 |
+
<div class="dna-label">{escape(label)}</div>
|
| 22 |
+
<div class="dna-value">{escape(value)}</div>
|
| 23 |
+
</div>
|
| 24 |
+
"""
|
| 25 |
+
for label, value in analysis.scam_dna.items()
|
| 26 |
+
)
|
| 27 |
+
memory_html = f"<p><strong>Memory:</strong> {escape(analysis.similar_memory)}</p>" if analysis.similar_memory else ""
|
| 28 |
+
|
| 29 |
+
return f"""
|
| 30 |
+
<section class="verdict-card risk-{escape(analysis.risk_level)}">
|
| 31 |
+
<span class="risk-pill">{escape(analysis.risk_level.replace("_", " "))}</span>
|
| 32 |
+
<p class="summary">{escape(analysis.summary)}</p>
|
| 33 |
+
<div class="action-card">
|
| 34 |
+
<strong>Safest next step</strong>
|
| 35 |
+
<p>{escape(analysis.safest_action)}</p>
|
| 36 |
+
</div>
|
| 37 |
+
<h3>Scam DNA</h3>
|
| 38 |
+
<div class="dna-grid">{dna_html}</div>
|
| 39 |
+
<h3>Warning signs</h3>
|
| 40 |
+
<div class="tactics">{tactic_html or "<span class='tactic'>none found</span>"}</div>
|
| 41 |
+
{memory_html}
|
| 42 |
+
<h3>Ask someone you trust</h3>
|
| 43 |
+
<p>{escape(analysis.trusted_person_message)}</p>
|
| 44 |
+
</section>
|
| 45 |
+
"""
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def render_memory_html(analysis: ScamAnalysis, memory: list[dict]) -> str:
|
| 49 |
+
if not memory:
|
| 50 |
+
return "<div class='memory-card muted'>No scam memory saved yet.</div>"
|
| 51 |
+
|
| 52 |
+
items = "".join(
|
| 53 |
+
f"<li><strong>{escape(item.get('risk_level', ''))}</strong>: {escape(item.get('summary', ''))}</li>"
|
| 54 |
+
for item in memory[-5:]
|
| 55 |
+
)
|
| 56 |
+
return f"""
|
| 57 |
+
<div class="memory-card">
|
| 58 |
+
<strong>Session scam memory</strong>
|
| 59 |
+
<ul>{items}</ul>
|
| 60 |
+
</div>
|
| 61 |
+
"""
|
| 62 |
+
|
jawbreaker/schema.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass, field
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
RISK_LEVELS = {"dangerous", "suspicious", "needs_check", "safe"}
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
@dataclass
|
| 10 |
+
class ScamAnalysis:
|
| 11 |
+
risk_level: str
|
| 12 |
+
scam_type: str
|
| 13 |
+
summary: str
|
| 14 |
+
tactics: list[str] = field(default_factory=list)
|
| 15 |
+
safest_action: str = ""
|
| 16 |
+
trusted_person_message: str = ""
|
| 17 |
+
scam_dna: dict[str, str] = field(default_factory=dict)
|
| 18 |
+
similar_memory: str = ""
|
| 19 |
+
|
| 20 |
+
def __post_init__(self) -> None:
|
| 21 |
+
if self.risk_level not in RISK_LEVELS:
|
| 22 |
+
raise ValueError(f"Invalid risk level: {self.risk_level}")
|
| 23 |
+
|
| 24 |
+
@classmethod
|
| 25 |
+
def from_heuristics(cls, message: str, memory: list[dict] | None = None) -> "ScamAnalysis":
|
| 26 |
+
text = message.lower()
|
| 27 |
+
memory = memory or []
|
| 28 |
+
|
| 29 |
+
risk_level = "safe"
|
| 30 |
+
scam_type = "none"
|
| 31 |
+
tactics: list[str] = []
|
| 32 |
+
|
| 33 |
+
if any(token in text for token in ["verify", "password", "login", "account locked", "account is locked"]):
|
| 34 |
+
risk_level = "dangerous"
|
| 35 |
+
scam_type = "credential_theft"
|
| 36 |
+
tactics.extend(["credential request", "fake authority"])
|
| 37 |
+
|
| 38 |
+
if any(token in text for token in ["urgent", "immediately", "today", "24 hours", "act now", "held"]):
|
| 39 |
+
if risk_level == "safe":
|
| 40 |
+
risk_level = "suspicious"
|
| 41 |
+
tactics.append("urgency")
|
| 42 |
+
|
| 43 |
+
if any(token in text for token in ["gift card", "zelle", "crypto", "wire", "$800", "send money"]):
|
| 44 |
+
risk_level = "dangerous"
|
| 45 |
+
scam_type = "payment_request"
|
| 46 |
+
tactics.append("payment pressure")
|
| 47 |
+
|
| 48 |
+
if any(token in text for token in ["grandma", "new number", "don't tell", "dont tell"]):
|
| 49 |
+
risk_level = "dangerous"
|
| 50 |
+
scam_type = "family_impersonation"
|
| 51 |
+
tactics.extend(["impersonation", "secrecy"])
|
| 52 |
+
|
| 53 |
+
if "http://" in text or "https://" in text:
|
| 54 |
+
if risk_level == "safe":
|
| 55 |
+
risk_level = "suspicious"
|
| 56 |
+
tactics.append("suspicious link")
|
| 57 |
+
|
| 58 |
+
if "reply yes or no" in text and "fraud alert" in text:
|
| 59 |
+
risk_level = "needs_check"
|
| 60 |
+
scam_type = "possible_legitimate_alert"
|
| 61 |
+
tactics = ["verification needed"]
|
| 62 |
+
|
| 63 |
+
tactics = sorted(set(tactics))
|
| 64 |
+
summary = _summary_for(risk_level, scam_type)
|
| 65 |
+
similar_memory = _find_similar_memory(text, memory)
|
| 66 |
+
|
| 67 |
+
return cls(
|
| 68 |
+
risk_level=risk_level,
|
| 69 |
+
scam_type=scam_type,
|
| 70 |
+
summary=summary,
|
| 71 |
+
tactics=tactics,
|
| 72 |
+
safest_action=_safe_action_for(risk_level, scam_type),
|
| 73 |
+
trusted_person_message=_trusted_message_for(risk_level, scam_type),
|
| 74 |
+
scam_dna={
|
| 75 |
+
"Impersonates": _guess_impersonation(text),
|
| 76 |
+
"Pressure": _guess_pressure(tactics),
|
| 77 |
+
"Ask": _guess_ask(text, scam_type),
|
| 78 |
+
"Risk": scam_type.replace("_", " "),
|
| 79 |
+
},
|
| 80 |
+
similar_memory=similar_memory,
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def _summary_for(risk_level: str, scam_type: str) -> str:
|
| 85 |
+
if risk_level == "dangerous":
|
| 86 |
+
return f"This looks dangerous: likely {scam_type.replace('_', ' ')}."
|
| 87 |
+
if risk_level == "suspicious":
|
| 88 |
+
return "This has warning signs and should be checked before you act."
|
| 89 |
+
if risk_level == "needs_check":
|
| 90 |
+
return "This might be legitimate, but you should verify it using a trusted route."
|
| 91 |
+
return "No strong scam pattern was found in this short scan."
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def _safe_action_for(risk_level: str, scam_type: str) -> str:
|
| 95 |
+
if risk_level == "dangerous":
|
| 96 |
+
return "Do not click links, do not reply, and do not send money. Contact the company or person using a number or app you already trust."
|
| 97 |
+
if risk_level == "suspicious":
|
| 98 |
+
return "Pause before acting. Open the official website or app yourself instead of using links from this message."
|
| 99 |
+
if risk_level == "needs_check":
|
| 100 |
+
return "Verify directly through the official app, official website, or a known phone number."
|
| 101 |
+
return "If this came from someone you know and it asks for nothing sensitive, it is probably safe. Still avoid unexpected links."
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def _trusted_message_for(risk_level: str, scam_type: str) -> str:
|
| 105 |
+
if risk_level == "safe":
|
| 106 |
+
return "Can you sanity-check this message for me? Jawbreaker did not find a strong scam pattern, but I want to be careful."
|
| 107 |
+
return f"Can you check this for me? Jawbreaker says it may be {scam_type.replace('_', ' ')} and recommends that I do not click or reply yet."
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def _guess_impersonation(text: str) -> str:
|
| 111 |
+
if "usps" in text:
|
| 112 |
+
return "USPS or package carrier"
|
| 113 |
+
if "chase" in text or "bank" in text:
|
| 114 |
+
return "Bank or financial institution"
|
| 115 |
+
if "grandma" in text or "new number" in text:
|
| 116 |
+
return "Family member"
|
| 117 |
+
return "Unknown sender"
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def _guess_pressure(tactics: list[str]) -> str:
|
| 121 |
+
if "urgency" in tactics:
|
| 122 |
+
return "Act now"
|
| 123 |
+
if "secrecy" in tactics:
|
| 124 |
+
return "Keep it secret"
|
| 125 |
+
if "payment pressure" in tactics:
|
| 126 |
+
return "Send money"
|
| 127 |
+
return "Not obvious"
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def _guess_ask(text: str, scam_type: str) -> str:
|
| 131 |
+
if scam_type == "credential_theft":
|
| 132 |
+
return "Verify account or login"
|
| 133 |
+
if scam_type == "payment_request":
|
| 134 |
+
return "Send money"
|
| 135 |
+
if scam_type == "family_impersonation":
|
| 136 |
+
return "Trust a new number"
|
| 137 |
+
if "http://" in text or "https://" in text:
|
| 138 |
+
return "Open a link"
|
| 139 |
+
return "No direct ask found"
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def _find_similar_memory(text: str, memory: list[dict]) -> str:
|
| 143 |
+
tokens = set(text.split())
|
| 144 |
+
best_score = 0.0
|
| 145 |
+
best = ""
|
| 146 |
+
for item in memory:
|
| 147 |
+
old_tokens = set(str(item.get("text", "")).lower().split())
|
| 148 |
+
if not old_tokens:
|
| 149 |
+
continue
|
| 150 |
+
score = len(tokens & old_tokens) / max(len(tokens | old_tokens), 1)
|
| 151 |
+
if score > best_score:
|
| 152 |
+
best_score = score
|
| 153 |
+
best = str(item.get("summary", "a previous saved scam"))
|
| 154 |
+
if best_score >= 0.18:
|
| 155 |
+
return f"This resembles a saved pattern: {best}"
|
| 156 |
+
return ""
|
| 157 |
+
|
requirements.txt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio==6.16.0
|
| 2 |
+
pytest==8.4.2
|
| 3 |
+
|
style.css
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.gradio-container {
|
| 2 |
+
max-width: 1180px !important;
|
| 3 |
+
margin: 0 auto !important;
|
| 4 |
+
}
|
| 5 |
+
|
| 6 |
+
.hero {
|
| 7 |
+
display: flex;
|
| 8 |
+
align-items: end;
|
| 9 |
+
justify-content: space-between;
|
| 10 |
+
gap: 24px;
|
| 11 |
+
padding: 28px 4px 18px;
|
| 12 |
+
border-bottom: 1px solid #e4e4e7;
|
| 13 |
+
margin-bottom: 18px;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
.eyebrow {
|
| 17 |
+
margin: 0 0 8px;
|
| 18 |
+
color: #9f1239;
|
| 19 |
+
font-size: 13px;
|
| 20 |
+
font-weight: 700;
|
| 21 |
+
text-transform: uppercase;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
.hero h1 {
|
| 25 |
+
margin: 0;
|
| 26 |
+
font-size: 48px;
|
| 27 |
+
line-height: 1;
|
| 28 |
+
letter-spacing: 0;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
.subtitle {
|
| 32 |
+
margin: 10px 0 0;
|
| 33 |
+
color: #3f3f46;
|
| 34 |
+
font-size: 20px;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
.hero-badge {
|
| 38 |
+
border: 1px solid #fecdd3;
|
| 39 |
+
background: #fff1f2;
|
| 40 |
+
color: #9f1239;
|
| 41 |
+
padding: 10px 12px;
|
| 42 |
+
border-radius: 8px;
|
| 43 |
+
font-weight: 700;
|
| 44 |
+
white-space: nowrap;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
.main-grid {
|
| 48 |
+
align-items: stretch;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
.scan-panel textarea {
|
| 52 |
+
font-size: 18px !important;
|
| 53 |
+
line-height: 1.45 !important;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
.verdict-card,
|
| 57 |
+
.empty-state,
|
| 58 |
+
.memory-card {
|
| 59 |
+
border: 1px solid #e4e4e7;
|
| 60 |
+
border-radius: 8px;
|
| 61 |
+
padding: 18px;
|
| 62 |
+
background: #ffffff;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
.empty-state {
|
| 66 |
+
min-height: 220px;
|
| 67 |
+
display: flex;
|
| 68 |
+
flex-direction: column;
|
| 69 |
+
justify-content: center;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
.empty-state h2,
|
| 73 |
+
.verdict-card h2 {
|
| 74 |
+
margin-top: 0;
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
.risk-dangerous {
|
| 78 |
+
border-color: #fecaca;
|
| 79 |
+
background: #fff1f2;
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
.risk-suspicious {
|
| 83 |
+
border-color: #fed7aa;
|
| 84 |
+
background: #fff7ed;
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
.risk-needs_check {
|
| 88 |
+
border-color: #fde68a;
|
| 89 |
+
background: #fffbeb;
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
.risk-safe {
|
| 93 |
+
border-color: #bbf7d0;
|
| 94 |
+
background: #f0fdf4;
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
.risk-pill {
|
| 98 |
+
display: inline-block;
|
| 99 |
+
margin-bottom: 14px;
|
| 100 |
+
padding: 8px 10px;
|
| 101 |
+
border-radius: 8px;
|
| 102 |
+
background: #18181b;
|
| 103 |
+
color: #ffffff;
|
| 104 |
+
font-size: 13px;
|
| 105 |
+
font-weight: 800;
|
| 106 |
+
text-transform: uppercase;
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
.summary {
|
| 110 |
+
font-size: 20px;
|
| 111 |
+
line-height: 1.35;
|
| 112 |
+
margin: 0 0 18px;
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
.action-card {
|
| 116 |
+
margin: 16px 0;
|
| 117 |
+
border-left: 5px solid #18181b;
|
| 118 |
+
background: #fafafa;
|
| 119 |
+
padding: 14px;
|
| 120 |
+
border-radius: 6px;
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
.dna-grid {
|
| 124 |
+
display: grid;
|
| 125 |
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
| 126 |
+
gap: 10px;
|
| 127 |
+
margin-top: 16px;
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
.dna-item {
|
| 131 |
+
border: 1px solid #e4e4e7;
|
| 132 |
+
background: rgba(255, 255, 255, 0.7);
|
| 133 |
+
border-radius: 8px;
|
| 134 |
+
padding: 12px;
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
.dna-label {
|
| 138 |
+
color: #71717a;
|
| 139 |
+
font-size: 12px;
|
| 140 |
+
font-weight: 700;
|
| 141 |
+
text-transform: uppercase;
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
.dna-value {
|
| 145 |
+
margin-top: 6px;
|
| 146 |
+
color: #18181b;
|
| 147 |
+
font-size: 16px;
|
| 148 |
+
font-weight: 700;
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
.tactics {
|
| 152 |
+
display: flex;
|
| 153 |
+
flex-wrap: wrap;
|
| 154 |
+
gap: 8px;
|
| 155 |
+
margin-top: 12px;
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
.tactic {
|
| 159 |
+
border: 1px solid #d4d4d8;
|
| 160 |
+
border-radius: 999px;
|
| 161 |
+
padding: 7px 10px;
|
| 162 |
+
background: #ffffff;
|
| 163 |
+
color: #27272a;
|
| 164 |
+
font-size: 13px;
|
| 165 |
+
font-weight: 700;
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
.memory-card {
|
| 169 |
+
margin-top: 14px;
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
.muted {
|
| 173 |
+
color: #71717a;
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
@media (max-width: 760px) {
|
| 177 |
+
.hero {
|
| 178 |
+
align-items: start;
|
| 179 |
+
flex-direction: column;
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
.hero h1 {
|
| 183 |
+
font-size: 40px;
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
.hero-badge {
|
| 187 |
+
white-space: normal;
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
.dna-grid {
|
| 191 |
+
grid-template-columns: 1fr;
|
| 192 |
+
}
|
| 193 |
+
}
|
| 194 |
+
|
tests/test_schema.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from jawbreaker.schema import ScamAnalysis
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def test_family_impersonation_is_dangerous() -> None:
|
| 5 |
+
analysis = ScamAnalysis.from_heuristics(
|
| 6 |
+
"Hi Grandma, I lost my phone. This is my new number. Can you send $800 today?"
|
| 7 |
+
)
|
| 8 |
+
|
| 9 |
+
assert analysis.risk_level == "dangerous"
|
| 10 |
+
assert analysis.scam_type == "family_impersonation"
|
| 11 |
+
assert "Trust a new number" == analysis.scam_dna["Ask"]
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def test_legitimate_fraud_alert_needs_check_not_dangerous() -> None:
|
| 15 |
+
analysis = ScamAnalysis.from_heuristics(
|
| 16 |
+
"Chase fraud alert: Did you attempt a $249.00 purchase at TARGET? Reply YES or NO."
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
assert analysis.risk_level == "needs_check"
|
| 20 |
+
assert analysis.scam_type == "possible_legitimate_alert"
|
| 21 |
+
|