π‘ Live capture, thin structure. The Daily Log is GoBuild's field-capture wedge β the one ritual that gets a crew opening the app every day: what got done, who was on site, the weather, and photos of it. The capture side is real and load-bearing. The AI structuring promised in the model docstring ("Deepgram β DeepSeek β structured JSON of summary/tasks/issues/materials") is βͺ largely unwired β logs are stored raw, and the one office feature that reads structured mostly reads an empty dict. This page separates what works from what's aspirational.
Audience: field worker + office. The first half is what the crew does on their phone. The To the office and To the Client sections are for the office side. House terms: a Job is a project; a Client is the homeowner/customer.
Cross-links: The Field App Β· Jobs CC
A daily log is the day's record, filed to a Job. Model: app/models/daily_log.py:15-39.
DailyLog
ββ job_id β the Job it belongs to (required)
ββ author_id β who wrote it (SET NULL if the user is deleted)
ββ schedule_item_id β optional link to the task it reports progress on
ββ notes β typed / dictated free text
ββ voice_transcript β the raw spoken transcript (Web Speech), kept alongside notes
ββ weather β a free-text string (e.g. "Sunny, 18Β°C, light wind")
ββ photo_document_idsβ JSON list of Document ids (the photos)
ββ structured β JSON: summary/tasks/issues/materials β¦ βͺ almost never populated
It is a hub-owned (tenant) table (TenantMixin), so every log is walled to one org. There is no crew field on the model β "who was on site" is captured as free text in notes, or inferred from the timeclock, not a structured roster. See open questions.
There are two distinct ways a DailyLog row gets created, and they behave differently. This trips people up, so be precise:
| Door | Route | What it is | Sets structured? |
|---|---|---|---|
| 1. The Logs screen | POST /field/logs/create |
The explicit "write today's log" form | β No β structured stays None |
| 2. The Capture spine | POST /field/capture β field_capture.route_capture |
Snap-and-talk; AI decides it's a log | π‘ A minimal dict only |
| 3. The JSON API | POST /api/daily-logs |
Programmatic create | β No |
All three write the same table. None of them run the full DeepgramβDeepSeek structuring described in the docstring.
This is the deliberate ritual. Screen: GET /field/logs (app/routers/field.py:2051-2071); form template templates/field/logs.html.
The crew picks a Job (required), types the weather as a free-text string, writes the notes, and optionally attaches photos. Submitting hits POST /field/logs/create (app/routers/field.py:2074-2095):
_resolve_job) β a foreign or missing job redirects with ?err=nojob.notes and voice_transcript are blank, it redirects ?err=empty β a log must say something (app/routers/field.py:2087-2088).Document rows (kind="photo", tied to the Job) via _save_photos β capped at 8 photos per log (app/routers/field.py:80-98).author_id, notes, voice_transcript, weather, and the photo id list. structured is never set here.The list below the form shows the last 40 logs for active jobs, newest first, each rendering weather, notes, and a photo grid (templates/field/logs.html:37-57).
The task brief mentions crew and hours. Be honest about the data model:
notes ("Framing crew of 3 on the second floorβ¦"). There's no structured attendance on the log.So a daily log today = notes + weather + photos, plus an optional voice transcript and task link. Crew and hours are adjacent systems, not log columns.
The bigger idea behind the field app is that a worker on a roof shouldn't have to decide "is this a daily log, a punch item, or a material request?" They snap and talk; Keystone classifies and files. This is app/services/field_capture.py.
Flow: POST /field/capture (app/routers/field.py:918-1001) β field_capture.route_capture (field_capture.py:95-192):
Document rows.classify(text, has_photo) asks DeepSeek to triage the note into one of log / punch / material / issue / note and extract a title, summary, location, and weather word (field_capture.py:32-92).kind="log") so a worker's note is always filed, never dropped (field_capture.py:59-92). This is deliberate: "a worker on a roof can't retry a form."FieldCapture (app/models/field.py) β the audit record, the owner review queue, and the offline-sync target.kind is log or issue and there's a Job, a real DailyLog is created (field_capture.py:136-151).Here is the one place structured gets written β and it's thin:
structured={"source": "field_capture", "kind": kind, "by": actor_name,
"title": result.get("title"), "summary": result.get("summary")}
That's a provenance stamp plus a one-line summary. It is not the {summary, tasks, issues, materials} shape the model docstring advertises (app/models/daily_log.py:3-4). No tasks, no issues, no materials are ever produced. See The structured pipeline.
Subs' captures stay
status='new'for owner review; staff captures that file cleanly are auto-reviewed. The homeowner never sees a capture β that's the wall (app/models/field.py:1-11).
The textarea on both the Logs form and the Capture screen carries a data-mic attribute (templates/field/logs.html:28, templates/field/capture.html:48). That's the hook.
The mic is wired entirely on-device in templates/field/base.html:118-151:
<textarea data-mic> gets a π€ button injected next to it.window.SpeechRecognition || window.webkitSpeechRecognition β the browser's speech engine (base.html:122). No audio leaves the phone through GoBuild; the OS/browser does the transcription.[name=voice_transcript], that transcript is mirrored into it (base.html:139) β which is why daily logs keep the spoken words in voice_transcript alongside the edited notes.base.html:125). Graceful.So dictation works today on any device whose browser supports Web Speech (Chrome/Android, Safari/iOS). It is free, needs no key, and is fully offline-independent of GoBuild.
deepgram_api_key exists as a per-tenant credential β it's in settings (app/config.py:112) and in the tenant credential resolver (app/services/credentials.py:57, 93). But:
app/integrations/ai/ holds only deepseek.py and gemini.py. Nothing calls Deepgram anywhere in the codebase.app/routers/daily_logs.py:1-6, app/models/daily_log.py:1-4), not shipped code.Net: the credential slot is real, the server-side transcription is not. Today's voice = the browser. See open questions.
structured pipeline βͺThis is the honest core of the page. The intended design, per docstrings:
voice β Deepgram transcript β DeepSeek structuring β
structuredJSON of{summary, tasks, issues, materials}.
The reality:
| Stage | Intended | Shipped |
|---|---|---|
| Deepgram transcription | Server transcribes audio | βͺ No Deepgram code exists; transcript comes from the browser (Web Speech) |
| DeepSeek structuring into tasks/issues/materials | Parse the note into a rich object | βͺ Never runs for daily logs |
structured populated |
On every voice log | π‘ Only the Capture spine writes it, and only {source, kind, by, title, summary} |
| Logs-screen / API logs | Also structured | β structured = None |
Downstream cost of the gap: the office's Client-update composer tries to read structured issues β
for log in logs:
s = log.structured or {}
for iss in (s.get("issues") or []): # ai_assistant.py:1790-1794
...
s.get("issues") is effectively always empty, because nothing writes an issues key. That branch is dead in practice (app/services/ai_assistant.py:1789-1794). The composer still works β it falls back to raw notes/voice_transcript and schedule state β but the "AI-structured tasks/issues" story advertised in the retrieval tool description (app/services/ai_retrieval.py:668) overstates what's in the column.
Jobsite dead zones are the norm, so the field app is built offline-tolerant. This is genuinely wired.
data-offline (templates/field/logs.html:18).templates β app/routers/field.py:233-289 serves field.js, an IndexedDB queue. When navigator.onLine is false, a submit on any data-offline form is intercepted: string fields and any chosen photos (converted to data-URLs) are stored in the queue object store instead of being POSTed (field.py:270-285).online (or next page load), flushQueue() replays each queued item as a multipart/form-data POST to its original action, deleting it from the queue on a 2xx (field.py:251-255, 286-287).[data-queue-badge] shows the pending count so nothing silently vanishes.The service worker (field.py:201-230, cache gobuild-field-v5) additionally caches the app shell and CDN libs so the screens open with no signal. Photo-bearing forms β daily logs, punch, safety, warranty β all ride this same queue (field.py:277-279).
β Caveat: the offline queue replays the whole form POST. Photos are base64'd into IndexedDB, so a day of dead-zone photos sits in browser storage until reconnect β a storage/size risk on older phones. See open questions.
Once a log exists (online or flushed), it's a normal hub row and the office sees it several ways:
POST /jobs/{job_id}/daily-logs/{log_id}) or delete it (POST /logs/{log_id}/delete) β app/routers/jobextras.py:1047-1078. See Jobs CC.app/services/job_brief.py:123-125 computes log_age β days since the last log β so a Job that's gone quiet flags itself in the brief. This is the main management signal: not the content, but the freshness.app/services/ai_retrieval.py:668) when answering questions about a Job./field/review for the owner to action before they count (app/routers/field.py:783-843).Logs are crew-facing; the Client never sees a raw daily log (the wall). The bridge to the homeowner is a separate record, ClientUpdate (app/models/daily_log.py:42-57) β an AI-drafted, human-edited, human-sent progress note.
ai_assistant.compose_client_update (app/services/ai_assistant.py:1749+) reads the Job's recent daily logs since the last update (app/services/ai_assistant.py:1769-1777), the schedule %-complete, weather-delay days (counted from the log's free-text weather), and new photo count, then drafts a "Subject: β¦" + body.app/services/client_updates.py, field flow at app/routers/field.py:1114-1174). Owner/PM only β caps.can_message_client keeps subs and crew out.So the value chain is: crew logs the day β office turns recent logs into a Client update β homeowner sees curated progress, never the raw log. The weakest link is that the composer wants structured issues it rarely gets (see above), so drafts lean on raw notes.
| Piece | Status | Note |
|---|---|---|
| Daily log create (Logs screen) | π’ | Notes + weather + photos, org-scoped, empty-guarded |
| Photos on a log | π’ | Document rows, capped at 8/log |
| Weather | π’ | Free-text string on the log (separate from the dashboard forecast service) |
| Voice dictation (Web Speech) | π’ | On-device, browser engine, kept in voice_transcript |
| Deepgram server transcription | βͺ | Credential slot only; no integration code |
| Capture spine β daily log | π’ | Snap-and-talk classifies & files; AI-optional |
structured = summary/tasks/issues/materials |
βͺ | Only a thin {source,kind,by,title,summary} ever written |
| Crew roster on a log | βͺ | Free text only; no structured attendance |
| Hours on a log | π‘ | Lives in the timeclock, not the log |
| Offline capture & replay | π’ | IndexedDB queue, auto-flush on reconnect |
| Surfacing to office | π’ | Jobs CC Logs tab + log_age freshness signal |
| Client visibility | π‘ | Via ClientUpdate only; raw logs never shown |
Concrete, unresolved product/ops questions this deep-dive surfaced:
job_brief computes log_age but nothing acts on it β no reminder, no nag, no "you haven't logged Job X in 4 days" push. Should a stale-log threshold trigger a field notification, or block clock-out? Today logging is purely voluntary.structured gap. Should we wire the DeepSeek structuring step (tasks/issues/materials) that the Client-update composer already assumes exists β or delete the dead s.get("issues") branch and stop advertising "AI-structured logs" in the retrieval tool? Right now it's the worst of both: promised, half-consumed, never produced.ClientUpdate. Should there be an opt-in "auto-summarize this week's logs for the homeowner" cadence, and what's the guardrail so raw crew notes (or a sub's capture) never leak past the wall?