Compare commits

...

4 Commits

Author SHA1 Message Date
bvandeusen 027fbec606 Merge pull request 'Release v26.04.14.3 — Live amplitude mic pulse' (#34) from dev into main 2026-04-15 02:54:42 +00:00
bvandeusen 730dbfaf7b feat(voice): pulse mic button with live amplitude
The mic button now scales and glows proportional to the silence
detector's RMS amplitude instead of sitting static. Gives obvious
feedback that audio is actually being picked up — silence still
breathes via a 0.1 floor, loud input caps at ~1.18x scale so it
doesn't punch through the input bar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 22:46:37 -04:00
bvandeusen 267a975455 Merge pull request 'Release v26.04.14.2 — Discuss failure mode fixes' (#33) from dev into main 2026-04-15 01:45:36 +00:00
bvandeusen 7bd1548f71 fix(discuss): hard-fail empty articles and skip RAG on seed turn
Discuss flow was hallucinating unrelated content when article
extraction returned empty or RAG pulled in orphan notes that looked
more relevant than the generic seed prompt.

- seed_article_discussion raises EmptyArticleError on empty body;
  briefing and /news routes return 422 instead of staging an empty
  synthetic tool result.
- build_context skips RAG auto-injection when user_message matches
  ARTICLE_DISCUSS_SEED so the article IS the context on turn one;
  follow-up turns keep RAG on.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 18:13:17 -04:00
5 changed files with 93 additions and 22 deletions
+21 -1
View File
@@ -114,6 +114,18 @@ const transcribingVoice = ref(false)
const recorder = useVoiceRecorder() const recorder = useVoiceRecorder()
const silenceDetector = useSilenceDetector() const silenceDetector = useSilenceDetector()
// Live mic pulse. `silenceDetector.amplitude` is 0..1 RMS; we floor at 0.1
// so the button still breathes on silence and cap the scale growth so loud
// input doesn't punch through the input bar.
const micStyle = computed(() => {
if (!recorder.recording.value) return {}
const pulse = 0.1 + Math.min(silenceDetector.amplitude.value, 1) * 0.9
return {
transform: `scale(${1 + pulse * 0.18})`,
boxShadow: `0 0 ${6 + pulse * 14}px ${2 + pulse * 4}px rgba(239, 68, 68, ${0.2 + pulse * 0.3})`,
}
})
async function toggleVoice() { async function toggleVoice() {
if (transcribingVoice.value) return if (transcribingVoice.value) return
if (recorder.recording.value) { if (recorder.recording.value) {
@@ -235,6 +247,7 @@ defineExpose({ focus, prefill })
v-if="voiceEnabled" v-if="voiceEnabled"
class="btn-icon btn-mic" class="btn-icon btn-mic"
:class="{ 'mic-recording': recorder.recording.value, 'mic-transcribing': transcribingVoice }" :class="{ 'mic-recording': recorder.recording.value, 'mic-transcribing': transcribingVoice }"
:style="micStyle"
@click.prevent="toggleVoice" @click.prevent="toggleVoice"
:disabled="transcribingVoice || !store.chatReady" :disabled="transcribingVoice || !store.chatReady"
:title="recorder.recording.value ? 'Click to stop (or wait for silence)' : 'Click to speak'" :title="recorder.recording.value ? 'Click to stop (or wait for silence)' : 'Click to speak'"
@@ -343,7 +356,14 @@ defineExpose({ focus, prefill })
.btn-icon:hover { opacity: 1; } .btn-icon:hover { opacity: 1; }
.btn-icon:disabled { opacity: 0.3; cursor: default; } .btn-icon:disabled { opacity: 0.3; cursor: default; }
.btn-mic.mic-recording { opacity: 1; color: #ef4444; } .btn-mic.mic-recording {
opacity: 1;
color: #ef4444;
border-radius: 50%;
/* Smooth between amplitude samples (~100ms) so the pulse feels continuous
rather than stepping. */
transition: transform 0.12s ease-out, box-shadow 0.12s ease-out;
}
.btn-mic.mic-transcribing { opacity: 0.5; } .btn-mic.mic-transcribing { opacity: 0.5; }
.note-picker-wrapper { position: relative; } .note-picker-wrapper { position: relative; }
+8 -2
View File
@@ -537,10 +537,16 @@ async def discuss_article(item_id: int):
# exchange and the conversational seed user prompt into the conversation. # exchange and the conversational seed user prompt into the conversation.
# The /news from-article route calls the same helper so behavior stays # The /news from-article route calls the same helper so behavior stays
# byte-identical across entry points. # byte-identical across entry points.
from fabledassistant.services.article_context import seed_article_discussion from fabledassistant.services.article_context import (
EmptyArticleError,
seed_article_discussion,
)
model = await get_setting(uid, "default_model", "") or "" model = await get_setting(uid, "default_model", "") or ""
discuss_prompt = await seed_article_discussion(conv_id, item, model) try:
discuss_prompt = await seed_article_discussion(conv_id, item, model)
except EmptyArticleError as e:
return jsonify({"error": str(e)}), 422
# Reload conversation with fresh messages to build history # Reload conversation with fresh messages to build history
conv = await get_conversation(uid, conv_id) conv = await get_conversation(uid, conv_id)
+14 -2
View File
@@ -521,7 +521,10 @@ async def create_conversation_from_article(item_id: int):
from sqlalchemy import select as _select from sqlalchemy import select as _select
from fabledassistant.models import async_session as _async_session from fabledassistant.models import async_session as _async_session
from fabledassistant.models.rss_feed import RssItem, RssFeed from fabledassistant.models.rss_feed import RssItem, RssFeed
from fabledassistant.services.article_context import seed_article_discussion from fabledassistant.services.article_context import (
EmptyArticleError,
seed_article_discussion,
)
uid = get_current_user_id() uid = get_current_user_id()
@@ -540,7 +543,16 @@ async def create_conversation_from_article(item_id: int):
conv = await create_conversation(uid, title=conv_title, conversation_type="chat") conv = await create_conversation(uid, title=conv_title, conversation_type="chat")
model = await get_setting(uid, "default_model", "") or Config.OLLAMA_MODEL model = await get_setting(uid, "default_model", "") or Config.OLLAMA_MODEL
discuss_prompt = await seed_article_discussion(conv.id, item, model) try:
discuss_prompt = await seed_article_discussion(conv.id, item, model)
except EmptyArticleError as e:
# Roll back the empty conversation so the user doesn't end up with a
# phantom entry in their chat list.
try:
await delete_conversation(uid, conv.id)
except Exception:
logger.warning("Failed to clean up empty article conversation %s", conv.id)
return jsonify({"error": str(e)}), 422
# Reload conversation so we see the two messages the helper just added. # Reload conversation so we see the two messages the helper just added.
conv = await get_conversation(uid, conv.id) conv = await get_conversation(uid, conv.id)
@@ -182,6 +182,15 @@ ARTICLE_DISCUSS_SEED = (
) )
class EmptyArticleError(Exception):
"""Raised when an article has no extractable body text.
Callers (the briefing and /news discuss routes) map this to a 422 so the
user sees a clear error instead of a hallucinated summary built from an
empty synthetic tool result.
"""
async def seed_article_discussion( async def seed_article_discussion(
conv_id: int, conv_id: int,
item: RssItem, item: RssItem,
@@ -213,15 +222,30 @@ async def seed_article_discussion(
article_content = item.context_prepared article_content = item.context_prepared
else: else:
raw_body = await get_or_fetch_full_article(item) or item.content or "" raw_body = await get_or_fetch_full_article(item) or item.content or ""
if not raw_body.strip():
# Hard-fail rather than stage an empty synthetic tool result.
# An empty `content` field silently tells the model "the article
# has nothing in it" and it confabulates from RAG/history. Better
# to surface a clean error to the user.
logger.warning(
"Article discussion aborted: empty body for rss_item %s (%s)",
item.id, item.url,
)
raise EmptyArticleError(
"Couldn't extract any readable text from this article."
)
article_content = await prepare_article_context( article_content = await prepare_article_context(
item.title or "", item.url, raw_body, model, item.title or "", item.url, raw_body, model,
) )
if article_content: if not article_content.strip():
async with async_session() as session: raise EmptyArticleError(
fresh = await session.get(RssItem, item.id) "Couldn't extract any readable text from this article."
if fresh is not None: )
fresh.context_prepared = article_content async with async_session() as session:
await session.commit() fresh = await session.get(RssItem, item.id)
if fresh is not None:
fresh.context_prepared = article_content
await session.commit()
synthetic_tool_calls = [{ synthetic_tool_calls = [{
"function": "read_article", "function": "read_article",
+20 -11
View File
@@ -724,18 +724,27 @@ async def build_context(
orphan_only = rag_project_id is None orphan_only = rag_project_id is None
effective_project_id = rag_project_id if (rag_project_id is not None and rag_project_id != -1) else None effective_project_id = rag_project_id if (rag_project_id is not None and rag_project_id != -1) else None
try: # Skip RAG auto-injection on the first turn of a seeded article discussion.
from fabledassistant.services.embeddings import semantic_search_notes # The article body is already the sole context the user wants — pulling in
for score, note in await semantic_search_notes( # unrelated orphan notes tricks the model into summarizing those instead.
user_id, user_message, exclude_ids=search_exclude or None, limit=8, # Follow-up turns keep RAG on because by then the user's own messages drive
project_id=effective_project_id, # the query rather than the generic seed prompt.
orphan_only=orphan_only, from fabledassistant.services.article_context import ARTICLE_DISCUSS_SEED
): _skip_rag_for_article_seed = user_message.strip() == ARTICLE_DISCUSS_SEED
found_scored.append((score, note))
except Exception:
logger.warning("Semantic note search failed, falling back to keyword search", exc_info=True)
if not found_scored: if not _skip_rag_for_article_seed:
try:
from fabledassistant.services.embeddings import semantic_search_notes
for score, note in await semantic_search_notes(
user_id, user_message, exclude_ids=search_exclude or None, limit=8,
project_id=effective_project_id,
orphan_only=orphan_only,
):
found_scored.append((score, note))
except Exception:
logger.warning("Semantic note search failed, falling back to keyword search", exc_info=True)
if not found_scored and not _skip_rag_for_article_seed:
keywords = _extract_keywords(user_message) keywords = _extract_keywords(user_message)
if keywords: if keywords:
try: try: