gowtham0992 commited on
Commit
6103e1f
·
0 Parent(s):

Initialize Jawbreaker hackathon scaffold

Browse files
.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
+