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)})
|
return JSONResponse({"started": False, "error": str(e)})
|
||||||
|
|
||||||
def transcribe_and_save(tmp_path, timestamp, nextcloud_url, nextcloud_user, nextcloud_password):
|
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
|
import requests as req_lib
|
||||||
nc_auth = (nextcloud_user, nextcloud_password)
|
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:
|
try:
|
||||||
segments, _ = whisper_model.transcribe(
|
segments, _ = whisper_model.transcribe(
|
||||||
tmp_path, language="en", vad_filter=True, beam_size=1, initial_prompt=WHISPER_PROMPT
|
tmp_path, language="en", vad_filter=True, beam_size=1, initial_prompt=WHISPER_PROMPT
|
||||||
)
|
)
|
||||||
transcript = " ".join(s.text.strip() for s in segments).strip()
|
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:
|
except Exception as e:
|
||||||
if os.path.exists(tmp_path):
|
transcribe_error = str(e)
|
||||||
os.unlink(tmp_path)
|
|
||||||
print(f"Async transcription failed for {timestamp}: {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")
|
@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}
|
**type:** {capture_type}
|
||||||
**modality:** {modality}
|
**modality:** {modality}
|
||||||
**status:** unprocessed
|
**status:** saved
|
||||||
**media:** {media_path}
|
**media:** {media_path}
|
||||||
{f"**project:** {project}" if project else ""}
|
{f"**project:** {project}" if project else ""}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user