Inspiration

Caregiver shift coordination is still mostly manual. Someone sends the same text to several people, waits for replies, decodes messy answers like “yeah sure” or “nah booked”, then escalates by phone when nobody responds. Keyword rules break on real language. Someone has to stay online and babysit the thread. That is the “Slow and Steady” idea: open-weight AI and durable workflows that reward laziness.

What it does

LazyLore is a FastAPI microservice that fills open caregiver shifts. Fan-out — When a shift opens, it sends SMS to every caregiver with the matching role. Intent understanding — Incoming replies are classified by a local open-weight model (Gemma via Ollama) into ACCEPT, DECLINE, or UNKNOWN. Safe claim — The first valid ACCEPT claims the shift under an asyncio lock so two people cannot take the same shift. Durable escalation — If the shift stays unclaimed, a Temporal workflow waits and then places phone calls. The workflow survives process restarts. Graceful fallback — If Ollama or Temporal is unavailable, the service degrades instead of crashing. Coordinators start it. Caregivers reply in plain English. The system does the rest.

How we built it

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.

FastAPI for the HTTP API (fan-out, inbound messages, health). Local Gemma (gemma2:2b) through Ollama for zero-shot intent classification (temperature 0, short max tokens). Temporal for the escalation workflow: sleep → activity with retries → phone-call escalation only if still pending. In-memory store for shifts, caregivers, and fan-out state (with sample data loaded on startup). Asyncio locks to prevent race conditions on claim. Unit tests that mock Ollama so the suite runs fully offline. The original keyword stub was replaced with a local model call. The original asyncio.sleep escalation was replaced with a Temporal workflow, with the old path kept as a fallback.

Project Structure

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

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

Challenges we ran into

In-memory vs process boundary — Loading sample data in a separate Python process did not populate the running server. Fixed by loading data inside create_app() on startup. Temporal + Codespaces — The old temporalio/auto-setup image is deprecated; we switched to temporal server start-dev via the Temporal CLI. Import naming — workflow.py vs workflows.py caused ModuleNotFoundError until imports were aligned. Real SMS is messy — Keyword rules failed on “yeah sure”, “nah”, “what time again?”. The local model handles them; we still fall back to UNKNOWN on any error so the API never crashes. Demo timing — A 10-minute escalation is useless on video, so the workflow sleep is 30 seconds for demos and 10 minutes for real use.

Accomplishments that we're proud of

Replaced brittle keywords with a local open-weight model so caregiver messages never leave the machine. Made escalation durable with Temporal (retries, survival across worker restarts) while keeping an asyncio fallback. Race-condition-safe claiming with locks. Full offline unit tests for the intent classifier. A working end-to-end loop: fan-out → natural-language accept → claim → second claim rejected.

What we learned

Open-weight models are practical for narrow, high-value tasks (intent) when you keep the prompt tight and temperature at 0. Durability is a design choice you make before the crash. A plain sleep dies with the process; a Temporal workflow does not. Fallbacks matter. The service should keep running when the model or the workflow engine is down.

What's next for Lazy Lora

ersist state in PostgreSQL (or similar) so restarts don’t wipe shifts and claims. Real SMS/voice via Twilio (or similar) instead of stubs. Cancel the Temporal workflow when a shift is claimed (signal or cancel handle) instead of only cancelling the local task.

Built With

  • fastapi
  • gemma
  • ollama
  • python
  • temporal
Share this project:

Updates

Submission history