import json
import re
from pathlib import Path
import gradio as gr
import torch
from transformers import AutoTokenizer
from granite_switch.hf import GraniteSwitchForCausalLM
MODEL_ID = "barha/granite-switch-4.0-350m-cti"
ADAPTER_NAME = "cti-technique-mapping"
# The adapter was trained on CTI sentences wrapped in this exact instruction
# format (see the ETL: USER_PROMPT + the sentence inside ... tags).
# Sending bare CTI text instead makes the model fall back to base behavior and
# emit garbage (e.g. repeated ), so we must reproduce the format here.
USER_PROMPT = "What ATT&CK technique does the following CTI procedure sentence describe?"
# MITRE ATT&CK Enterprise technique ID -> human-readable name. Built from the
# official mitre-attack/attack-stix-data Enterprise bundle (697 techniques +
# sub-techniques), bundled as a static file so the Space needs no network at
# inference and resolves any ID the adapter emits.
_ID_TO_NAME = json.loads((Path(__file__).parent / "mitre_id_to_name.json").read_text())
# Match a MITRE technique ID anywhere in a string, e.g. T1059 or T1059.001.
_TID_RE = re.compile(r"\bT\d{4}(?:\.\d{3})?\b")
# Load the model and tokenizer once at startup. The CTI adapter is activated by
# the <|cti-technique-mapping|> control token. The chat template only inserts
# that token when `adapter_name` is passed to apply_chat_template — the default
# render leaves it out, giving the plain base model. We run BOTH below.
tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
# No device_map: the Space runs on single-device CPU (cpu-basic), and
# device_map="auto" crashes here because accelerate's check_device_map can't
# place the non-float `model.adapter_token_ids` buffer. Plain load → CPU.
model = GraniteSwitchForCausalLM.from_pretrained(
MODEL_ID,
torch_dtype=torch.float32,
)
model.eval()
EXAMPLES = [
"The actor used PowerShell to download and execute a payload from a remote server.",
"The malware created a scheduled task to maintain persistence across reboots.",
"The threat actor dumped credentials from LSASS memory using a custom tool.",
"The adversary encrypted files on the victim host and dropped a ransom note.",
"The implant communicated with its command-and-control server over HTTPS on port 443.",
]
def _generate(cti_text: str, use_adapter: bool, max_new_tokens: int) -> str:
"""Run one forward pass, with or without firing the CTI adapter."""
user_content = f"{USER_PROMPT}\n\n\n{cti_text}\n"
messages = [{"role": "user", "content": user_content}]
# Passing adapter_name fires the <|cti-technique-mapping|> control token in
# the chat template; omitting it renders the plain prompt → base model.
template_kwargs = {"adapter_name": ADAPTER_NAME} if use_adapter else {}
enc = tokenizer.apply_chat_template(
messages,
add_generation_prompt=True,
return_tensors="pt",
return_dict=True,
**template_kwargs,
).to(model.device)
prompt_len = enc["input_ids"].shape[1]
with torch.no_grad():
out = model.generate(**enc, max_new_tokens=max_new_tokens, do_sample=False)
return tokenizer.decode(out[0, prompt_len:], skip_special_tokens=True).strip()
def _resolve_name(technique_id: str) -> str:
"""Look up the MITRE name for an ID; '' if not a known technique."""
return _ID_TO_NAME.get(technique_id, "")
def compare(cti_text: str):
"""Run the base model and the adapter on the same input, side by side.
Returns (base_output, adapter_output) markdown strings.
"""
cti_text = (cti_text or "").strip()
if not cti_text:
msg = "_Enter some CTI text describing adversary behavior._"
return msg, msg
# Base model: no adapter. It tends to answer in prose, not a clean ID — give
# it room to do so. This is the "before" half of the demo.
base_raw = _generate(cti_text, use_adapter=False, max_new_tokens=48)
base_md = base_raw or "_(no output)_"
# Adapter: fires the control token; trained to emit exactly one technique ID.
adapter_raw = _generate(cti_text, use_adapter=True, max_new_tokens=16)
match = _TID_RE.search(adapter_raw)
if match:
tid = match.group(0)
name = _resolve_name(tid)
if name:
adapter_md = f"### `{tid}`\n**{name}**"
else:
adapter_md = f"### `{tid}`\n_(name not in MITRE map)_"
else:
adapter_md = f"_(no technique ID returned)_\n\n```\n{adapter_raw}\n```"
return base_md, adapter_md
with gr.Blocks(title="Granite Switch · CTI Technique Mapping") as demo:
gr.Markdown(
"""
# 🛡️ Granite Switch 4.0 350M — CTI Technique Mapping
The **same base model** answers a **cyber threat intelligence (CTI)** prompt
two ways. Without the adapter it rambles in prose; flip on the embedded
**`cti-technique-mapping`** LoRA (via a single control token) and it emits the
one matching **MITRE ATT&CK** technique ID — which we resolve to its name.
Model: [`barha/granite-switch-4.0-350m-cti`](https://huggingface.co/barha/granite-switch-4.0-350m-cti)
· Base: `ibm-granite/granite-4.0-350m` · Adapter: `cti-technique-mapping` (LoRA, attention + MLP)
"""
)
cti_input = gr.Textbox(
label="CTI text",
placeholder="Describe the observed adversary behavior…",
lines=4,
)
submit = gr.Button("Compare base vs adapter", variant="primary")
with gr.Row():
with gr.Column():
gr.Markdown("#### 🟡 Base model (adapter OFF)")
base_out = gr.Markdown()
with gr.Column():
gr.Markdown("#### 🟢 Granite Switch (adapter ON)")
adapter_out = gr.Markdown()
gr.Examples(
examples=[[e] for e in EXAMPLES],
inputs=cti_input,
outputs=[base_out, adapter_out],
fn=compare,
cache_examples=False,
)
submit.click(compare, inputs=cti_input, outputs=[base_out, adapter_out])
cti_input.submit(compare, inputs=cti_input, outputs=[base_out, adapter_out])
if __name__ == "__main__":
demo.launch()