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
- Python 3.10 or newer
- The OpenAI SDK:
pip install openai - An Oxlo.ai API key from https://portal.oxlo.ai
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)