DEV Community

Alex Chen
Alex Chen

Posted on

Build a Tiny Span Check Before You Trust a Parsed Deadline

Last Tuesday I sat on the third floor of the Killam library with a messy folder of Brightspace blurbs. Four courses. Twelve so-called deadlines. My calendar already had the wrong week for one of them, and I was about to make it worse.

I pasted this sentence into a chat box: Please submit Assignment 2 before the lab on the 18th. The model answered, very politely, 2026-09-18T23:59:00. Expected result from the tiny program below is not that timestamp. It is status=span_missing deadline=None. Where did September come from? Where did 23:59 come from? The text never said either of those things.

So I closed the tab and asked a smaller question. Can a short Python script refuse a date that does not appear as a contiguous span in the source? That is the whole lab. Not an agent. Not a framework. A seatbelt.

Background

I am a CS student in Halifax. I do not have a production calendar to defend. I have a personal planner that I will actually follow if it stops lying to me. The usual advice is to "just extract the dates with a model." I tried that. It filled gaps the way a classmate fills gaps on a group quiz. With confidence. With a straight face.

Would an agent loop have saved me? Probably not. If the first call invents a day, the second call books a reminder, and the third call emails my group chat. Most of that stack is an if-statement wearing a trench coat. I wanted the if-statement to be honest, and I wanted it to run on a laptop between lectures.

The learning question stayed narrow on purpose. Given a raw assignment blurb and a candidate ISO date, does the candidate appear in the blurb, or did something guess a month, a year, or a clock time? If I cannot point at the characters, I do not write the date down.

Goal

Build a parser that returns either a grounded deadline or a structured refusal. The refusal has to name the reason. Missing year is different from the model invented a day that is not in the text, and I wanted both to show up in a log I could read on the bus.

I also wanted the core test to run with no API key. Model calls are optional. The span check is not. If the source already contains an ISO date, the model does not get a vote. If the source is a mushy "due Friday," the program should refuse instead of picking a Friday it likes.

For the optional hosted step I used MonkeyCode's free model access and the free server option so I was not burning a class budget on date parsing. Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode is open source. I am not going to pretend I measured latency, named a model, or that a free tier lasts forever. I used it as a socket. The interesting code stays on my laptop.

Prerequisites

You need Python 3.11 or newer and the standard library. That is the required path. No PyTorch. No vector store. No agent SDK. Create a folder and save two files: deadlines.py and test_deadlines.py. If you later swap FakeModel for an HTTP call, keep the verdict object. Do not let the network become the test.

Implementation

I started with the fixture that already failed me in the library. Always start with the failure. If your first example is a clean ISO string, you will ship a regex and call it intelligence.

# deadlines.py
from __future__ import annotations

from dataclasses import dataclass
import re
from typing import Literal

ISO_DATE = re.compile(r"\b(\d{4}-\d{2}-\d{2})\b")

Reason = Literal[
    "ok",
    "no_candidate",
    "span_missing",
    "unresolvable_partial",
    "ambiguous_multiple",
]

@dataclass(frozen=True)
class Verdict:
    status: Reason
    deadline: str | None
    cited_span: str | None
    note: str


def find_iso_spans(text: str) -> list[str]:
    return ISO_DATE.findall(text)


def span_ground(text: str, candidate: str) -> Verdict:
    """Accept a candidate date only if it appears verbatim in the source."""
    if not candidate:
        return Verdict("no_candidate", None, None, "empty candidate")
    if candidate in text:
        return Verdict("ok", candidate, candidate, "candidate is a contiguous span")
    return Verdict(
        "span_missing",
        None,
        None,
        f"candidate {candidate!r} is not a span in the source",
    )


def parse_assignment(text: str, candidate: str | None) -> Verdict:
    """Two-stage parse. Local spans win. Models only propose."""
    local = find_iso_spans(text)
    if len(local) > 1:
        return Verdict(
            "ambiguous_multiple",
            None,
            None,
            f"found {local!r}; refusing to pick a favorite",
        )
    if local:
        first = local[0]
        return Verdict("ok", first, first, "source already contained an ISO date")
    if candidate is None:
        return Verdict(
            "unresolvable_partial",
            None,
            None,
            "no ISO date in source and no model candidate",
        )
    return span_ground(text, candidate)
Enter fullscreen mode Exit fullscreen mode

Notice what this refuses to do. It does not call a fuzzy date library. It does not map "Friday" onto the next Friday. It does not assume the current semester. Those look like features. They are also how I got 23:59 for a sentence that never mentioned a clock. Why invite a rewrite of the source?

The model side is a swap point, not the product. I wanted a liar I could catch every time I ran the file.

class FakeModel:
    """Stand-in for a hosted model. Deterministic on purpose."""

    def extract_date(self, text: str) -> str | None:
        # Pretend the model "helpfully" completes a partial day.
        if "18th" in text and "2026-09-18" not in text:
            return "2026-09-18"
        spans = find_iso_spans(text)
        return spans[0] if spans else None
Enter fullscreen mode Exit fullscreen mode

Would a real model do this exact completion? Sometimes. I do not need a leaderboard for a seatbelt. I need one lie with a name. partial_day is that lie.

Here is the test file. Predict which fixture fails before you run it. Cover the output with your hand if you have to.

# test_deadlines.py
from deadlines import FakeModel, parse_assignment

FIXTURES = [
    (
        "explicit",
        "Assignment 2 is due on 2026-09-18 at 23:59 Atlantic time.",
        None,
        "ok",
    ),
    (
        "partial_day",
        "Please submit Assignment 2 before the lab on the 18th.",
        None,
        "span_missing",
    ),
    (
        "weekday_only",
        "Due Friday. Upload the PDF to Brightspace.",
        None,
        "unresolvable_partial",
    ),
    (
        "invented",
        "Read chapters 4 and 5 before next class.",
        "2026-10-01",
        "span_missing",
    ),
    (
        "two_dates",
        "Final project due 2026-09-18. Proposal due 2026-09-04.",
        None,
        "ambiguous_multiple",
    ),
]


def run() -> None:
    model = FakeModel()
    for name, text, injected, expected in FIXTURES:
        candidate = injected if injected is not None else model.extract_date(text)
        verdict = parse_assignment(text, candidate)
        marker = "PASS" if verdict.status == expected else "FAIL"
        print(
            f"{marker} {name:16} status={verdict.status:22} "
            f"deadline={verdict.deadline}"
        )
        print(f"     note: {verdict.note}")


if __name__ == "__main__":
    run()
Enter fullscreen mode Exit fullscreen mode

Run it from the folder:

python --version
python test_deadlines.py
Enter fullscreen mode Exit fullscreen mode

Expected output:

PASS explicit         status=ok                   deadline=2026-09-18
PASS partial_day      status=span_missing         deadline=None
PASS weekday_only     status=unresolvable_partial deadline=None
PASS invented         status=span_missing         deadline=None
PASS two_dates        status=ambiguous_multiple   deadline=None
Enter fullscreen mode Exit fullscreen mode

The second line is the library moment. FakeModel still returns 2026-09-18. The span check throws it away because those characters are not in the blurb. The model can talk. The calendar does not have to listen.

Results

I ran the five fixtures locally. All five matched the expected status. That is not a benchmark. It is a regression test for a lie I already believed once.

Then I cheated on purpose, the way you will. I edited the partial_day blurb and stuck 2026-09-18 at the end, like a TA who finally posted a real date. The status flipped to ok, and the cited span was the ISO string, not the word 18th. That is the behavior I want. When the source grows a real date, the parser should stop refusing. Until then, silence is better than a confident wrong week.

The two_dates fixture taught me more than the happy path. An earlier draft took the first ISO span and walked away. Final project kept. Proposal dropped. If you are building a planner, that is a silent loss, which is worse than a crash. Refusing when there is more than one span is ugly. It is also visible. I can live with ugly and visible for a lab this small.

Common mistakes I almost shipped

I almost normalized 18th into a day-of-month integer and then stapled the current month on top. That rebuilds the original hallucination with extra steps. I almost logged only deadline=None without a reason. A silent None is how you ignore a whole class of failures while you are walking to class. I almost called the model even when the source already had an ISO date. Why give it a chance to "improve" 2026-09-18 into 2026-09-19 because it thinks assignments are due at midnight the next day?

The optional HTTP path is a template, not a result I am reporting. If you already have an endpoint, the shape looks like this, and you should still run span_ground on whatever comes back.

# unexecuted template — keep FakeModel in the tests
import json
from urllib.request import Request, urlopen

def hosted_extract(url: str, text: str, token: str) -> str | None:
    payload = json.dumps({"text": text}).encode()
    req = Request(url, data=payload, method="POST")
    req.add_header("Content-Type", "application/json")
    req.add_header("Authorization", f"Bearer {token}")
    with urlopen(req, timeout=30) as resp:
        body = json.loads(resp.read().decode())
    return body.get("date")
Enter fullscreen mode Exit fullscreen mode

I am not filling in a vendor URL here. If the hosted answer cannot survive span_ground, it does not belong on your week view. Free or not.

What you should understand after this

An extractor is not a calendar. A model completion is a candidate, not a fact. If you cannot point at a span, you do not have a deadline. You have a guess. Hosted free-model access does not change that. A free server does not change that. The cheap experiment is still an experiment, and that is fine, as long as the refusal is louder than the guess.

After you finish, you should be able to look at a blurb and predict the verdict without running the file. Explicit ISO in the source? Accept it and ignore the model. Partial day with no year? Refuse. Two ISO dates? Refuse. Model invents a day that never appears? Refuse. If any of those four still surprises you, run the file again and read the note field out loud. The note is the lesson. The date is just evidence.

Limitations, and who should skip this

Do not use this as a registrar. It does not understand time zones, "end of week," "before the lab," or Brightspace's habit of showing local time in one place and UTC in another. It will refuse a lot of real university English. That is the point of a conservative gate, and it is also why a human still owns the planner.

Skip this approach if you need recall more than precision, for example a search index of old syllabi where a guessed month is better than nothing. Skip it if your dates arrive as screenshots. Skip it if you were hoping this would become an agent that emails your group. That agent would just spread a bad date faster. A trench coat does not make the if-statement wiser.

Extension

Add a sixth fixture where the source says Sept 18, 2026. Write a normalizer that maps that phrase to 2026-09-18, but only if every token of the phrase is actually in the source. Then try to break your normalizer with Sept 18 and no year. Which fixture fails now, and why?

If you swap FakeModel for a hosted completion and one of these still sneaks through, send me the minimal blurb. I am collecting counterexamples, not applause.

Top comments (0)