How to Extract Contract Obligations With Source Citations
Extract every obligation from a services agreement with Unsiloed, including the one hidden in a schedule at the back, then use Python to turn them into dates for each company. Each duty keeps the page and passage it came from.


A services contract commits each company to tasks with deadlines, such as delivering a plan by a set date, paying every month, or returning keys when the contract ends. These duties are scattered across clauses and can be easy to miss.
We use the Unsiloed extraction API to pull obligations with the page and passage each came from. Then we use Python to turn that list into dates for each company, keeping duties that depend on a future event separate.

What Counts as an Obligation
An obligation is something a contract requires someone to do or not do. A party is a person or company that enters the agreement. For each obligation, keep who is responsible, what they must do, when it is due, and any condition that starts it.
The four-page services agreement names Morrow Facilities LLC as the Provider and Bracken Works LLC as the Customer. Morrow maintains equipment at Bracken's workshop. The effective date, when the agreement starts to apply, is January 15, 2027.
A deadline is the last date for completing a duty. Section 3.1 sets a fixed deadline on page 1.
The Provider shall deliver the baseline maintenance plan to the Customer by January 22, 2027.
Other duties start only after an event. That event is the condition, or trigger. Section 6.1 on page 3 ties the deadline to receipt of a notice, a formal communication about something covered by the agreement.
The Provider shall correct the service defect within 10 days after receiving a Defect Notice from the Customer.
The ten days are a cure period, the time allowed to put the problem right. The clock starts when the Provider receives the notice.
A recurring obligation repeats at a set interval. Schedule A, an attachment listing the equipment, contains this duty on page 4.
The Customer is responsible for reviewing the equipment register on every quarterly anniversary of the Effective Date, starting three months after the Effective Date and continuing during the term.
This requires a review every three months, even though it never says "shall." Searching for that word alone would miss it. A permission isn't an obligation. Section 6.2 lets the Customer end the agreement after an unresolved defect but doesn't require it to, so it stays off the list.
If the contract was amended, reconcile the original and its changes before building the obligation list.
Extract Obligations as a List
A schema describes the fields and data types we want the API to return. Ask for every duty in document order, including duties in definitions and schedules. The full four-page PDF fits one request here.
These fields keep the action separate from the rule used to date it.
| Field | Why Keep It |
|---|---|
party, action |
Identify who must do what, including amounts and recipients. |
timing_type |
Distinguish fixed dates, recurring duties, triggered duties, and vague timing. |
due_date |
Preserve an explicit deadline without inventing one for a future event. |
interval_months |
Store the frequency of monthly or quarterly anniversary duties. |
trigger, days_after |
Keep the event and the number of calendar days allowed after it. |
section, wording |
Retain the clause number and full passage for review. |
We also extract the effective date and the end of the term, the period the agreement runs, to limit the recurring dates. Empty values use null, so a triggered duty does not acquire a fixed date by accident.
Citations come from a request setting rather than fields we add to the schema.
Save the PDF as agreement.pdf in a working folder. The extraction snippets need Python with requests installed and an Unsiloed API key in the UNSILOED_API_KEY environment variable.
To start with saved results, download obligations.json into that folder and skip to "Use the Citations."
Create extract.py in the folder with this schema.
def field(kind, description):
return {"type": kind, "description": description}
def obj(properties):
return {"type": "object", "properties": properties,
"required": list(properties), "additionalProperties": False}
obligation = obj({
"party": field(["string", "null"], "Responsible party: Provider or Customer. Null if unclear."),
"action": field("string", "Short action phrase, preserving amounts and recipients"),
"timing_type": {"type": "string", "enum": ["fixed", "recurring", "triggered", "vague"]},
"due_date": field(["string", "null"], "Explicit fixed deadline as YYYY-MM-DD, otherwise null"),
"interval_months": field(["integer", "null"], "Months between anniversaries for recurring duties, otherwise null"),
"trigger": field(["string", "null"], "Event that starts the duty, otherwise null"),
"days_after": field(["integer", "null"], "Calendar days after the trigger, otherwise null"),
"section": field("string", "Clause number, including schedule prefix"),
"wording": field("string", "Complete obligation clause verbatim, without heading"),
})
schema = obj({
"effective_date": field("string", "Agreement effective date as YYYY-MM-DD"),
"term_end": field("string", "End of the initial term as YYYY-MM-DD"),
"obligations": {"type": "array", "items": obligation,
"description": "Every duty of either party, once each in document order, including definitions and schedules. Exclude permissions, definitions without duties, and statements of scope. Use vague for timing such as promptly even when a trigger is present."},
})
The recurrence fields cover the anniversary rules in this agreement. For other contracts, include their actual timing rules, such as month-end dates or business days, rather than forcing them into a number of months.
Append this request to extract.py. The extraction endpoint accepts the PDF and schema and returns a job ID. We choose the gamma model and set enable_citations to request source locations.
import json, os, time
import requests
BASE = "https://prod.visionapi.unsiloed.ai"
headers = {"api-key": os.environ["UNSILOED_API_KEY"]}
with open("agreement.pdf", "rb") as f:
response = requests.post(f"{BASE}/v2/extract", headers=headers,
files={"pdf_file": ("agreement.pdf", f, "application/pdf")},
data={"schema_data": json.dumps(schema), "model": "gamma",
"enable_citations": "true"})
response.raise_for_status()
job_id = response.json()["job_id"]
Append this to wait for completion and save the returned result object. We check the job every three seconds while it is processing.
while True:
response = requests.get(f"{BASE}/extract/{job_id}", headers=headers)
response.raise_for_status()
job = response.json()
if job["status"] in ("failed", "review"):
raise SystemExit(f"Check extraction job: {job['status']}")
if job["status"] == "completed":
break
time.sleep(3)
with open("obligations.json", "w") as f:
json.dump(job["result"], f, indent=2)
Run the file from the folder containing the PDF.
python3 extract.py
The saved result contains nine obligations under obligations["value"], in document order, ending with Schedule A.3. Each field has a value, a score, and a citation.
The grounding_score expresses confidence in locating a value in the document. The extraction_score expresses confidence in the extracted value. Both run from zero to one and help prioritize review. Neither proves that a value is correct.
Use the Citations
A citation gives a reviewer a place to check the extracted passage. Here is the wording field for Section 6.1 from the saved response.
{
"score": {
"grounding_score": 0.98,
"extraction_score": 0.98
},
"value": "The Provider shall correct the service defect within 10 days after receiving a Defect Notice from the Customer. This is the cure period, the time allowed to put the service defect right.",
"citation": {
"bbox": [
54,
202,
548,
231
],
"page": 3,
"page_width": 612.0,
"page_height": 792.0
}
}
The bbox is a bounding box, a rectangle given as left, top, right, and bottom coordinates from the page's top-left corner. Page numbers start at one. A PDF viewer can open page 3 and scale the rectangle using page_width and page_height to highlight the clause. Inferred labels such as triggered can have a null citation, so use the cited wording for review. Keep the original PDF and saved response together as part of an extraction audit trail, so later dates remain traceable to this wording.
Turn the List Into Dates With Python
Fixed dates can be used as written. Recurring dates need a starting point and a stopping point. Triggered duties stay as "starts when" rules until the event happens. We use Python to compute the anniversary dates from January 15, 2027, through April 15, 2027, without extending past the contract's end.
Create obligations.py beside obligations.json. Before assigning dates, we check for missing fields, vague timing, and low extraction scores. The 0.9 cutoff is a review setting for this example. Fields that are deliberately null, such as a recurring duty's fixed date, do not count as missing.
Values converted into a standard form, such as 3 for "three months," can have low grounding scores. Check these against the cited clause before using them to schedule work. The check below uses extraction scores and does not replace that source review.
import json
from datetime import date
with open("obligations.json") as f:
data = json.load(f)
def v(field):
return field["value"]
rows = v(data["obligations"])
effective = date.fromisoformat(v(data["effective_date"]))
term_end = date.fromisoformat(v(data["term_end"]))
horizon = min(date(2027, 4, 15), term_end)
def review(row):
kind = v(row["timing_type"])
needed = ["party", "action", "section", "wording", "timing_type"]
needed += {"fixed": ["due_date"], "recurring": ["interval_months"],
"triggered": ["trigger", "days_after"]}.get(kind, [])
if any(v(row[k]) in (None, "") for k in needed):
return "missing required value"
if kind == "vague":
return "vague timing"
if any(field["score"]["extraction_score"] < 0.9
for field in row.values() if v(field) is not None):
return "low extraction score"
return None
Append this date function to obligations.py. It preserves explicit deadlines and expands monthly or quarterly anniversaries. Each recurring duty here starts one interval after the effective date. The function sends dates after the 28th to review because not every month has the same number of days.
def dates(row):
kind = v(row["timing_type"])
if kind == "fixed":
return v(row["due_date"])
if kind == "triggered":
return f"starts when {v(row['trigger'])} | due {v(row['days_after'])} calendar days later"
interval = v(row["interval_months"])
if kind != "recurring" or interval <= 0 or effective.day > 28:
return "REVIEW: unsupported recurrence"
due_dates = []
month = effective.year * 12 + effective.month - 1 + interval
while True:
year, zero_month = divmod(month, 12)
due = date(year, zero_month + 1, effective.day)
if due > horizon:
break
due_dates.append(due.isoformat())
month += interval
return ", ".join(due_dates) or "none within reporting window"
The agreement counts calendar days, including weekends and public holidays. A business-day rule would need a holiday calendar and the contract's counting rules. The contract ends on January 14, 2028, and does not renew automatically. Renewal means extending it for another term.
Append this to check the two date fields and print a list for each party. Each row keeps its section and source page alongside the action and timing.
for key in ("effective_date", "term_end"):
if data[key]["score"]["extraction_score"] < 0.9:
raise SystemExit(f"Review {key} before building dates")
for party in sorted({v(row["party"]) or "Unassigned" for row in rows}):
print(party)
for row in rows:
if (v(row["party"]) or "Unassigned") != party:
continue
reason = review(row)
timing = f"REVIEW: {reason}" if reason else dates(row)
citation = row["wording"].get("citation") or {}
page = citation.get("page", "?")
print(f" {v(row['section'])} | p.{page} | {v(row['action'])}")
print(f" {timing}")
Run the file from the same folder.
python3 obligations.py
The saved extraction produces this list.
Customer
3.2 | p.1 | provide two workshop access keys to the Provider
2027-01-20
5.1 | p.2 | pay the Provider USD 600
2027-02-15, 2027-03-15, 2027-04-15
A.3 | p.4 | reviewing the equipment register
2027-04-15
Provider
3.1 | p.1 | deliver the baseline maintenance plan to the Customer
2027-01-22
4.1 | p.2 | complete an inspection of the Equipment
2027-02-15, 2027-03-15, 2027-04-15
4.2 | p.2 | deliver an inspection report to the Customer
2027-02-15, 2027-03-15, 2027-04-15
4.3 | p.2 | notify the Customer of any unsafe workshop condition it discovers
REVIEW: vague timing
6.1 | p.3 | correct the service defect
starts when receiving a Defect Notice from the Customer | due 10 calendar days later
7.1 | p.3 | return all workshop access keys to the Customer
starts when the Agreement ends | due 5 calendar days later
The key-return rule waits for the agreement to end, including an early ending.
Flag What Needs a Person
Send a reviewer the clause and its page when any of these checks fails.
- A required value has a low extraction score. Choose field-specific cutoffs using the confidence score thresholds guide.
- Timing is vague. Section 4.3 says "promptly," so it stays flagged even if the wording was extracted with a high score.
- The responsible party or required deadline field is empty. A missing
days_afteron a triggered duty needs review, while anullfixed date on that duty is expected. - A citation is missing or points to a passage that does not support the value. Read the surrounding definitions and conditions before accepting it.
The output gives a contract owner or lawyer a list to check rather than replacing their review.



