Stop silent data loss in voice capture pipeline

Empty transcripts and transcription failures previously
deleted the temp audio and returned without writing any
record to disk — violating parity-at-encode (raw content
is episodic context, not noise).

- Preserve audio in Journal/Media/YYYY-MM/ on all paths
  (success, empty, failure) instead of unlinking.
- Write a markdown entry to Journal/Captures/ on failure
  paths with status, audio_path, and error fields.
- Add status: saved to successful captures so frontmatter
  is uniform across success and failure.
- Fire SSE capture_saved events on all terminal paths,
  with status included.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-05 23:41:51 +00:00
parent 63c58b5bb3
commit 732e450d21
+79 -31
View File
@@ -690,44 +690,92 @@ async def run_dreamer(request: Request, auth: str = Depends(require_auth)):
return JSONResponse({"started": False, "error": str(e)})
def transcribe_and_save(tmp_path, timestamp, nextcloud_url, nextcloud_user, nextcloud_password):
"""Background task — transcribes audio and saves to Nextcloud after endpoint returns."""
"""Background task — transcribes audio and saves to Nextcloud after endpoint returns.
Audio is preserved in Journal/Media/ on every terminal path; failed and empty-transcript
captures still produce a markdown record in Journal/Captures/ with a status field."""
import requests as req_lib
nc_auth = (nextcloud_user, nextcloud_password)
month_dir = timestamp[:7]
audio_ext = os.path.splitext(tmp_path)[1] or ".webm"
audio_filename = f"{timestamp}-voice{audio_ext}"
audio_relpath = f"Journal/Media/{month_dir}/{audio_filename}"
def archive_audio() -> bool:
try:
with open(tmp_path, "rb") as f:
audio_bytes = f.read()
media_parent = f"{nextcloud_url}/remote.php/dav/files/{nextcloud_user}/Journal/Media"
media_dir = f"{media_parent}/{month_dir}"
req_lib.request("MKCOL", media_parent, auth=nc_auth, timeout=10)
req_lib.request("MKCOL", media_dir, auth=nc_auth, timeout=10)
req_lib.put(f"{media_dir}/{audio_filename}", data=audio_bytes, auth=nc_auth, timeout=60)
return True
except Exception as e:
print(f"Audio archival failed for {timestamp}: {e}")
return False
finally:
if os.path.exists(tmp_path):
os.unlink(tmp_path)
def write_capture(filename: str, content_md: str, status: str):
captures_dir = f"{nextcloud_url}/remote.php/dav/files/{nextcloud_user}/Journal/Captures"
try:
req_lib.request("MKCOL", captures_dir, auth=nc_auth, timeout=10)
req_lib.put(f"{captures_dir}/{filename}", data=content_md.encode("utf-8"), auth=nc_auth, timeout=30)
except Exception as e:
print(f"Capture markdown write failed for {timestamp}: {e}")
return
try:
payload = {"type": "capture_saved", "filename": filename, "timestamp": timestamp, "status": status}
req_lib.post("http://localhost:8000/api/events/notify", json=payload, timeout=3)
req_lib.post("http://localhost:8000/api/captures/events/notify", json=payload, timeout=3)
except Exception:
pass
transcript = ""
transcribe_error = None
try:
segments, _ = whisper_model.transcribe(
tmp_path, language="en", vad_filter=True, beam_size=1, initial_prompt=WHISPER_PROMPT
)
transcript = " ".join(s.text.strip() for s in segments).strip()
os.unlink(tmp_path)
if not transcript:
print(f"Async transcription empty for {timestamp} — nothing saved")
return
filename = f"{timestamp}-voice.md"
content_md = f"# Capture — {timestamp}\n\n**type:** voice\n**modality:** audio\n**status:** unprocessed\n\n---\n\n{transcript}\n"
captures_dir = f"{nextcloud_url}/remote.php/dav/files/{nextcloud_user}/Journal/Captures"
req_lib.request("MKCOL", captures_dir, auth=nc_auth, timeout=10)
url = f"{captures_dir}/{filename}"
req_lib.put(url, data=content_md.encode("utf-8"), auth=nc_auth, timeout=30)
print(f"Async transcription saved: {filename}")
# Notify SSE clients that transcription is complete
try:
import requests as _req
_req.post("http://localhost:8000/api/events/notify", json={
"type": "capture_saved",
"filename": filename,
"timestamp": timestamp,
}, timeout=3)
_req.post("http://localhost:8000/api/captures/events/notify", json={
"type": "capture_saved",
"filename": filename,
"timestamp": timestamp,
}, timeout=3)
except Exception:
pass
except Exception as e:
if os.path.exists(tmp_path):
os.unlink(tmp_path)
print(f"Async transcription failed for {timestamp}: {e}")
transcribe_error = str(e)
audio_archived = archive_audio()
audio_line = f"**audio_path:** {audio_relpath}\n" if audio_archived else "**audio_archive_failed:** true\n"
if transcribe_error is not None:
filename = f"{timestamp}-voice-failed.md"
content_md = (
f"# Capture — {timestamp}\n\n"
f"**type:** voice\n**modality:** audio\n**status:** failed_transcription\n"
f"{audio_line}"
f"**error:** {transcribe_error}\n"
)
write_capture(filename, content_md, "failed_transcription")
print(f"Async transcription failed for {timestamp}: {transcribe_error}")
return
if not transcript:
filename = f"{timestamp}-voice-empty.md"
content_md = (
f"# Capture — {timestamp}\n\n"
f"**type:** voice\n**modality:** audio\n**status:** empty_transcript\n"
f"{audio_line}"
)
write_capture(filename, content_md, "empty_transcript")
print(f"Async transcription empty for {timestamp}: audio archived")
return
filename = f"{timestamp}-voice.md"
content_md = (
f"# Capture — {timestamp}\n\n"
f"**type:** voice\n**modality:** audio\n**status:** saved\n"
f"{audio_line}\n---\n\n{transcript}\n"
)
write_capture(filename, content_md, "saved")
print(f"Async transcription saved: {filename}")
@app.post("/api/capture")
@@ -834,7 +882,7 @@ Keep the full description to 150-250 words. Do not speculate beyond what is visi
**type:** {capture_type}
**modality:** {modality}
**status:** unprocessed
**status:** saved
**media:** {media_path}
{f"**project:** {project}" if project else ""}