DEV Community

shashank ms
shashank ms

Posted on

Using LLM for Semantic Role Labeling: A Step-by-Step Guide

Semantic role labeling extracts predicate-argument structures from text, answering who did what to whom. In this tutorial we will build a lightweight SRL extractor that calls an LLM to identify predicates and their roles, then returns structured JSON. It is useful for anyone annotating training data or building downstream NLP pipelines without maintaining a bespoke model.

What you'll need

I recommend exporting your key as OXLO_API_KEY so it stays out of your shell history.

Step 1: Set up the Oxlo.ai client

We initialize the OpenAI SDK pointing at Oxlo.ai and verify the connection with a quick completion. I use Oxlo.ai here because flat per-request pricing keeps costs predictable when we send long sentences or large batches. See https://oxlo.ai/pricing for details.

from openai import OpenAI
import os

client = OpenAI(
    base_url="https://api.oxlo.ai/v1",
    api_key=os.environ["OXLO_API_KEY"]
)

# quick connectivity check
response = client.chat.completions.create(
    model="llama-3.3-70b",
    messages=[{"role": "user", "content": "Say 'Connection OK'"}],
    max_tokens=10,
)
print(response.choices[0].message.content)

Step 2: Lock down the SRL schema in the system prompt

The hardest part of LLM-based SRL is getting consistent output. I fix this with a strict system prompt that defines the JSON schema and forbids markdown fences. The prompt below is the only training the pipeline needs.

SYSTEM_PROMPT = """You are a semantic role labeling engine. Given a sentence, identify the main predicate and label the semantic roles using PropBank-style labels: AGENT, PATIENT, THEME, INSTRUMENT, LOCATION, TIME, BENEFICIARY, and MODIFIER.

Rules:
- Return a single JSON object.
- The JSON must have keys: "predicate" (string), "roles" (list of objects).
- Each role object must have "label" and "text".
- If a role is absent, omit it. Do not invent information.
- Output only the JSON, no markdown fences.

Example:
Input: "John gave Mary a book yesterday in the library."
Output: {"predicate": "gave", "roles": [{"label": "AGENT", "text": "John"}, {"label": "PATIENT", "text": "a book"}, {"label": "BENEFICIARY", "text": "Mary"}, {"label": "TIME", "text": "yesterday"}, {"label": "LOCATION", "text": "in the library"}]}"""

Step 3: Build the extraction function with JSON mode

With the prompt defined, we call the model with response_format={"type": "json_object"} so the LLM is constrained to valid JSON. I keep temperature low to reduce hallucination.

import json

def extract_srl(sentence: str) -> dict:
    response = client.chat.completions.create(
        model="llama-3.3-70b",
        messages=[
            {"role": "system", "content": SYSTEM_PROMPT},
            {"role": "user", "content": sentence},
        ],
        response_format={"type": "json_object"},
        temperature=0.1,
    )
    raw = response.choices[0].message.content
    return json.loads(raw)

# sanity check
print(extract_srl("The chef cooked the soup with a wooden spoon."))

Step 4: Process a batch of sentences

In practice you will label more than one sentence. I wrap the extractor in a loop that sources a list and attaches the original text for traceability.

from typing import List

def batch_extract(sentences: List[str]) -> List[dict]:
    results = []
    for sent in sentences:
        try:
            parsed = extract_srl(sent)
            parsed["_source"] = sent
            results.append(parsed)
        except Exception as e:
            results.append({"_source": sent, "error": str(e)})
    return results

sentences = [
    "The engineer fixed the server with a screwdriver.",
    "Maria sent an email to the team before the meeting.",
    "The cat slept on the warm blanket all afternoon.",
]

for item in batch_extract(sentences):
    print(json.dumps(item, indent=2, ensure_ascii=False))

Step 5: Validate and normalize the outputs

LLMs occasionally drift on key names or return strings instead of lists. I add a small guard layer that enforces the schema and drops unsupported labels.

ALLOWED_ROLES = {"AGENT", "PATIENT", "THEME", "INSTRUMENT", "LOCATION", "TIME", "BENEFICIARY", "MODIFIER"}

def normalize(parsed: dict, source: str) -> dict:
    cleaned = {
        "predicate": parsed.get("predicate"),
        "roles": [],
        "_source": source
    }
    for r in parsed.get("roles", []):
        if isinstance(r, dict) and r.get("label") in ALLOWED_ROLES:
            cleaned["roles"].append({"label": r["label"], "text": r.get("text")})
    return cleaned

def process_sentences(sentences: List[str]) -> List[dict]:
    out = []
    for s in sentences:
        try:
            raw = extract_srl(s)
            out.append(normalize(raw, s))
        except Exception as e:
            out.append({"predicate": None, "roles": [], "_source": s, "error": str(e)})
    return out

Run it

Here is the complete script. Save it as srl.py, set OXLO_API_KEY, and run python srl.py.

import os
import json
from typing import List
from openai import OpenAI

client = OpenAI(
    base_url="https://api.oxlo.ai/v1",
    api_key=os.environ["OXLO_API_KEY"]
)

SYSTEM_PROMPT = """You are a semantic role labeling engine. Given a sentence, identify the main predicate and label the semantic roles using PropBank-style labels: AGENT, PATIENT, THEME, INSTRUMENT, LOCATION, TIME, BENEFICIARY, and MODIFIER.

Rules:
- Return a single JSON object.
- The JSON must have keys: "predicate" (string), "roles" (list of objects).
- Each role object must have "label" and "text".
- If a role is absent, omit it. Do not invent information.
- Output only the JSON, no markdown fences.

Example:
Input: "John gave Mary a book yesterday in the library."
Output: {"predicate": "gave", "roles": [{"label": "AGENT", "text": "John"}, {"label": "PATIENT", "text": "a book"}, {"label": "BENEFICIARY", "text": "Mary"}, {"label": "TIME", "text": "yesterday"}, {"label": "LOCATION", "text": "in the library"}]}"""

ALLOWED_ROLES = {"AGENT", "PATIENT", "THEME", "INSTRUMENT", "LOCATION", "TIME", "BENEFICIARY", "MODIFIER"}

def extract_srl(sentence: str) -> dict:
    response = client.chat.completions.create(
        model="llama-3.3-70b",
        messages=[
            {"role": "system", "content": SYSTEM_PROMPT},
            {"role": "user", "content": sentence},
        ],
        response_format={"type": "json_object"},
        temperature=0.1,
    )
    return json.loads(response.choices[0].message.content)

def normalize(parsed: dict, source: str) -> dict:
    cleaned = {
        "predicate": parsed.get("predicate"),
        "roles": [],
        "_source": source
    }
    for r in parsed.get("roles", []):
        if isinstance(r, dict) and r.get("label") in ALLOWED_ROLES:
            cleaned["roles"].append({"label": r["label"], "text": r.get("text")})
    return cleaned

def process_sentences(sentences: List[str]) -> List[dict]:
    out = []
    for s in sentences:
        try:
            raw = extract_srl(s)
            out.append(normalize(raw, s))
        except Exception as e:
            out.append({"predicate": None, "roles": [], "_source": s, "error": str(e)})
    return out

if __name__ == "__main__":
    sentences = [
        "The engineer fixed the server with a screwdriver.",
        "Maria sent an email to the team before the meeting.",
        "The cat slept on the warm blanket all afternoon.",
    ]
    for record in process_sentences(sentences):
        print(json.dumps(record, indent=2, ensure_ascii=False))

Expected output:

{
  "predicate": "fixed",
  "roles": [
    {"label": "AGENT", "text": "The engineer"},
    {"label": "PATIENT", "text": "the server"},
    {"label": "INSTRUMENT", "text": "with a screwdriver"}
  ],
  "_source": "The engineer fixed the server with a screwdriver."
}
{
  "predicate": "sent",
  "roles": [
    {"label": "AGENT", "text": "Maria"},
    {"label": "PATIENT", "text": "an email"},
    {"label": "BENEFICIARY", "text": "to the team"},
    {"label": "TIME", "text": "before the meeting"}
  ],
  "_source": "Maria sent an email to the team before the meeting."
}
{
  "predicate": "slept",
  "roles": [
    {"label": "AGENT", "text": "The cat"},
    {"label": "LOCATION", "text": "on the warm blanket"},
    {"label": "TIME", "text": "all afternoon"}
  ],
  "_source": "The cat slept on the warm blanket all afternoon."
}

Next steps

Swap in qwen-3-32b or kimi-k2.6 if you need stronger multilingual or reasoning performance for complex clauses. You can also persist the JSON output to a line-delimited file and feed it into an annotation tool like Label Studio for human review.

Top comments (0)