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:
+79
-31
@@ -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 ""}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user