DEV Community

Dakshata
Dakshata

Posted on AI-assisted

LazyLora — Open-Weight Intent, Durable Escalation, Zero Babysitting

Hacktoberfest Open-Source AI Challenge Week 3: Slow and Steady Submission 🦥

This is a submission for the Hacktoberfest Open-Source AI Challenge Week 3: Slow and Steady

What I Built

On a busy ward a coordinator used to send the same shift text to six people, wait, chase the ones who didn’t reply, then escalate by phone. The replies were never clean. “yeah sure”, “nah booked”, “what time again?”. Keyword rules broke. Someone had to stay online.I built the thing that stays online instead.
LazyLore is a small FastAPI service that fans out shift offers, understands messy caregiver replies with a local open-weight model, claims the first valid accept under a lock, and escalates to voice if nobody answers. The humans only start it. The rest runs while they do something else.

Who gets to be lazier?

  • Coordinators stop manually texting and chasing people
  • Caregivers can reply casually instead of using rigid keywords
  • The system keeps running even if the model or Temporal is temporarily down (graceful fallback)
Piece What it does
Fan-out Sends SMS notifications to every caregiver with the matching role.
Intent Uses local Gemma (gemma2:2b via Ollama) to classify responses as ACCEPT, DECLINE, or UNKNOWN.
Claim The first valid acceptance wins; an asyncio lock prevents multiple caregivers from claiming the same request.
Escalation A Temporal workflow waits for a specified interval, then places phone calls if the request is still pending.
Fallback If Ollama or Temporal is unavailable, the service degrades gracefully instead of crashing.

Demo

Running it locally

VS Code showing the project, the running terminals, and the passing pytest output

The project in VS Code. The terminal list on the right shows the Temporal server (temporal), the API (uv), and two PowerShell tabs for sending requests and running tests.

The setup is three pieces running side by side:

  1. Temporal server: temporal server start-dev starts a local server and a web UI at localhost:8233.
  2. The API: uv run uvicorn app.api:create_app --factory --reload --port 8000. The Temporal worker starts inside it, so there is no separate worker to run.
  3. Ollama: it runs in the background after installation, and the app uses it to classify replies. You don't need a terminal for it.

The other two PowerShell tabs are for sending test requests with Invoke-RestMethod and running the tests.

At the bottom of the screenshot is the test run (uv run pytest -v): all 5 tests pass in under 3 seconds. They cover the intent parser, including accept, decline, unknown, fallback on error, and messy replies like "yeah sure I can take it".
Local run is three terminals: Ollama, Temporal worker, FastAPI. One curl starts the fan-out. Natural-language replies claim the shift. After the demo delay the worker escalates if nobody claimed it.

Code

LazyLore (with Local Open-Weight AI)

A Python FastAPI microservice that fills open caregiver shifts by fanning out SMS (and escalating to voice), accepting the first valid claim, and handling race conditions safely.

Hacktoberfest 2026 – Week 3 “Slow and Steady”
The service now uses a local open-weight model (via Ollama) to understand messy real-world replies instead of brittle keyword matching. This means:

  • messages never leave your machine
  • Zero ongoing LLM API cost
  • Works fully offline once the model is pulled
  • Handles natural language (“yeah sure”, “nah I’m booked”, “what time again?”) so coordinators can be lazy

Quick Start (with open-weight model)

1. Install & start Ollama

curl -fsSL https://ollama.com/install.sh | sh
ollama serve          # leave this running
ollama pull gemma2:2b # or phi3:mini / llama3.2:1b
Enter fullscreen mode Exit fullscreen mode

2. Install project dependencies

uv sync

3. Run the API

uv run uvicorn app.api:create_app --factory --reload

4. Test the intent classifier

uv run pytest tests/test_intent.py…

Core files:

app/intent.py — zero shot Gemma classifier, temperature 0, falls back to UNKNOWN
app/workflows.py + worker.py — Temporal escalation with retries
app/api.py — fan-out, claim lock, Temporal start with asyncio fallback.

How I built it

The idea

When a shift opens, LazyLora texts every caregiver with the right role. The first person to reply "yes" gets the shift. If nobody claims it in time, it escalates to phone calls. I wanted this to be reliable, so I used Temporal for the waiting and escalation instead of a plain timer.

Architecture

LazyLora system architecture

Everything the app owns runs in one Python process (FastAPI + uvicorn). Around it are two local services: a Temporal dev server and Ollama. As the image below shows that a Fan-out → natural-language accept → first claim wins → second request rejected
All intent classification runs locally with Gemma via Ollama.

Fan-out → natural-language accept → first claim wins → second request rejected<br>
All intent classification runs locally with Gemma via Ollama.

Piece Job
FastAPI routes POST /shifts/{id}/fanout opens a shift, POST /messages/inbound receives replies, GET /health checks the service
In-memory store Holds shifts, caregivers, and fanouts, loaded from sample_data.json on startup
Claim lock One asyncio.Lock per shift, so only one caregiver can claim it
Intent parser Sends each reply to a local model (gemma2:2b via Ollama) and returns ACCEPT, DECLINE, or UNKNOWN. If the model is down, it falls back to UNKNOWN instead of crashing
Notifier send_sms and place_phone_call. These are mocked for the demo, so no real messages are sent
Temporal server Stores the workflow and its timer outside the app, so the timer survives restarts
Temporal worker Runs the escalation workflow and activity. It starts inside the API process

Project layout

LazyLora/
├── app/
│   ├── api.py        # routes, claim lock, worker startup
│   ├── database.py   # in-memory store + sample data loader
│   ├── intent.py     # reply classification (Ollama)
│   ├── models.py     # InboundMessage, ShiftFanout, statuses
│   ├── notifier.py   # mocked SMS and phone calls
│   └── workflow.py   # Temporal workflow + activity
├── tests/
│   └── test_intent.py
├── worker.py         
└── sample_data.json
Enter fullscreen mode Exit fullscreen mode

Step 1: Open a shift and text the caregivers

The fanout endpoint is idempotent, so posting the same shift twice never texts anyone twice. It saves the shift as PENDING and starts a Temporal workflow named escalate-{shift_id}.

@router.post("/shifts/{shift_id}/fanout")
async def fanout_shift(shift_id: str):
    db = get_db()
    shift = db.shifts.get(shift_id)
    if shift is None:
        raise HTTPException(status_code=404, detail=f"Shift {shift_id} not found")

    if db.fanouts.get(shift_id) is not None:
        return {"status": "already_fanout"}

    caregivers = db.get_caregivers_by_role(shift.role_required)
    for c in caregivers:
        await send_sms(c.phone, "New shift available. Reply 'yes' to claim.")

    db.fanouts.put(shift_id, ShiftFanout(
        shift_id=shift_id,
        status=ShiftFanoutStatus.PENDING,
        contacted_caregiver_ids=[c.id for c in caregivers],
    ))

    client = await Client.connect("localhost:7233")
    await client.start_workflow(
        ShiftEscalationWorkflow.run,
        shift_id,
        id=f"escalate-{shift_id}",
        task_queue="shift-escalation",
    )
    return {"status": "fanout_initiated"}
Enter fullscreen mode Exit fullscreen mode

Step 2: Let the first "yes" win

Replies don't have to match a keyword. "yes I can take it" and "yes put me down" both get classified as ACCEPT. Then a lock makes sure only one person claims the shift, even if replies arrive at nearly the same time:

lock = await _get_claim_lock(fanout.shift_id)
async with lock:
    # Re-check inside the lock: someone may have claimed while we waited
    fanout = db.fanouts.get(fanout.shift_id)
    if fanout is None or fanout.status != ShiftFanoutStatus.PENDING:
        return {"status": "shift_already_claimed"}

    fanout.status = ShiftFanoutStatus.CLAIMED
    fanout.claimed_by = caregiver.id
    db.fanouts.put(fanout.shift_id, fanout)
Enter fullscreen mode Exit fullscreen mode

Step 3: Escalate with a durable timer

The workflow sleeps, then runs an activity. If the shift is still PENDING, it places phone calls. If someone claimed it, it does nothing.

@workflow.defn
class ShiftEscalationWorkflow:
    @workflow.run
    async def run(self, shift_id: str) -> str:
        await workflow.sleep(timedelta(seconds=30))  # 10 minutes in real use
        return await workflow.execute_activity(
            escalate_shift_activity,
            shift_id,
            start_to_close_timeout=timedelta(seconds=60),
            retry_policy=RetryPolicy(maximum_attempts=3),
        )
Enter fullscreen mode Exit fullscreen mode

Step 4: Run the worker inside the API

My first version ran the worker as a separate process. Because the data lives in memory, that worker had its own empty copy and never found the shift, so the escalation silently did nothing. Starting the worker in FastAPI's lifespan fixed it, since both now share the same data:

@asynccontextmanager
async def lifespan(app: FastAPI):
    load_sample_data()
    client = await Client.connect("localhost:7233")
    worker = Worker(
        client,
        task_queue="shift-escalation",
        workflows=[ShiftEscalationWorkflow],
        activities=[escalate_shift_activity],
    )
    worker_task = asyncio.create_task(worker.run())
    yield
    worker_task.cancel()


def create_app() -> FastAPI:
    app = FastAPI(lifespan=lifespan)
    app.include_router(router)
    return app
Enter fullscreen mode Exit fullscreen mode

Run it yourself

temporal server start-dev
uv run uvicorn app.api:create_app --factory --reload --port 8000
uv run pytest -v
Enter fullscreen mode Exit fullscreen mode

How the flow works

Workflow Diagram

The diagram has one starting point and two paths that run at the same time. Here's how to read it, top to bottom.

1. A shift opens (the blue box at the top)

A request hits POST /shifts/{id}/fanout. The app first checks that the shift exists and hasn't already been sent out, so sending the same request twice never texts people twice. Then it finds every caregiver with the required role (for example, an LPN) and sends each of them an SMS.

2. The shift is marked PENDING and a timer starts

The app saves the shift as PENDING and starts a Temporal workflow with a timer. From here the diagram splits into two paths that run at once.

3. The claim path (green box, left)

When a caregiver replies, the app:

  1. Checks that the phone number belongs to a known caregiver.
  2. Sends the text to a small local language model (Ollama, gemma2:2b), which labels it as accept, decline, or unknown. That means "yes I can take it" and "yes put me down" both work, with no exact keyword needed.
  3. If it's an accept, takes a per-shift lock and checks the shift is still open.
  4. The first caregiver through the lock gets the shift (CLAIMED). Anyone else gets shift_already_claimed.

The lock matters because two people can reply at nearly the same moment. Without it, both could be booked for the same shift.

4. The escalation path (purple box, right)

At the same time, Temporal is counting down a durable timer (30 seconds in my demo, 10 minutes for real use). When it ends, an activity checks the shift:

  • Still PENDING? It places a phone call to each matching caregiver and marks the shift ESCALATED.
  • Already claimed? It does nothing and returns already_claimed_or_missing.

Why Temporal for the timer?

A plain sleep inside the app disappears if the server crashes or restarts. Temporal stores the timer outside the app, so the escalation still fires after a restart. That's the main reason I used it here.

What I fixed

My first version ran the Temporal worker as a separate process. The app kept its data in memory, so that worker had its own empty copy and never found the shift. Escalation silently did nothing.

Here is what that looked like in the Temporal UI. The workflow list shows the escalation workflow as Completed, which looks healthy at first glance:

Temporal workflow detail showing the result already_claimed_or_missing and a 10 minute timer
The workflow ran and finished, so Temporal reported success. Nothing here hints at a problem.

Opening the workflow shows the real result. The activity returned already_claimed_or_missing even though nobody had claimed the shift. The worker could not see the shift at all, so it skipped the phone calls. The timeline also shows the original 10-minute timer, which is why I later shortened it to 30 seconds for demos:

Temporal UI workflow list showing one completed ShiftEscalationWorkflow
Result: already_claimed_or_missing. The workflow completed, but the escalation never happened.

The lesson: a Completed workflow only means the code ran without crashing, not that it did the right thing. Always check the result.

The fix was to start the worker inside the API's startup so both share the same data. After that, the same flow returned escalated_to_1_caregivers.

Why Does Open Innovation Matter?

Closed intent APIs would have required keys, bills, and shipping private caregiver messages off box. A local open-weight model keeps the data on the machine, costs nothing after the first pull, and still works offline. Temporal does the same for time. A plain sleep is fine until the process restarts. A workflow survives the restart. The combination of local model for understanding, durable workflow for waiting is what lets the system actually replace the human loop instead of just assisting it.

The Lazy Payoff

Before: manual texts, keyword failures, phone chases, someone watching the channel.

After: one POST starts the fan-out. Replies are understood. The first accept claims the shift under a lock. If silence continues, Temporal escalates. The coordinator’s job shrinks to “start it and, if they want, look later.”
That is the whole point of the theme. Build the thing that keeps working so the people can stop.

Prize Categories

  • Best Use of Gemma: Local gemma2:2b via Ollama classifies messy caregiver SMS into ACCEPT / DECLINE / UNKNOWN with temperature 0 and graceful UNKNOWN fallback.
  • Best Use of Temporal: Shift escalation is a Temporal workflow (sleep → activity with retries). Survives worker restarts; falls back to asyncio if Temporal is unavailable.

Top comments (0)