Phase 21: Intent-first pipeline, visible ack, KV-stable system prompt

Pipeline changes (generation_task.py, intent.py):
- Remove optimistic streaming queue/race (_drain_queue deleted)
- Remove _generate_acknowledgment — ack now embedded in intent JSON
- Round 0: await intent (~400ms), stream ack immediately as TTFT,
  then execute tool sequentially; chat-only streams directly
- IntentResult.ack: one-sentence acknowledgment, intent max_tokens 200→350
- _parse_intent extracts and trims ack field

KV cache stability (llm.py, generation_buffer.py, generation_task.py):
- build_context: replace cached_note_ids with include_note_ids
- Auto-found notes populate context_meta["auto_notes"] for sidebar but
  are NOT injected into system prompt (--- Related Notes --- removed)
- Explicitly included notes injected as --- Included Notes ---
- _conv_note_cache dict + get/set/clear functions removed from generation_buffer.py
- All clear_conv_note_cache() calls removed

Cold model retry (llm.py):
- generate_completion (used by classify_intent) retries on HTTP 500:
  3 attempts with 3s/6s delays — prevents intent failure during cold load

API + frontend (routes/chat.py, stores/chat.ts, views/ChatView.vue, components/ChatPanel.vue):
- exclude_note_ids → include_note_ids throughout
- ChatView sidebar: Suggested (auto-found, + to include) + In Context (× to remove)
- ChatPanel: remove exclude button from context pills; no IDs passed to sendMessage

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-26 22:34:54 -05:00
parent 316a85e13b
commit e119331645
9 changed files with 237 additions and 353 deletions
+1 -19
View File
@@ -29,8 +29,6 @@ const noteSearchResults = ref<{ id: number; title: string }[]>([]);
const noteSearchLoading = ref(false);
let noteSearchTimer: ReturnType<typeof setTimeout> | null = null;
// Exclude tracking
const excludedNoteIds = ref<Set<number>>(new Set());
const streamingRendered = computed(() => {
if (!store.streamingContent) return "";
@@ -65,11 +63,7 @@ async function sendMessage() {
messageInput.value = "";
resetTextareaHeight();
scrollToBottom();
await store.sendMessage(
content,
contextNoteId,
excludedNoteIds.value.size ? [...excludedNoteIds.value] : undefined,
);
await store.sendMessage(content, contextNoteId);
scrollToBottom();
}
@@ -151,10 +145,6 @@ function removeAttachedNote() {
function promoteAutoNote(note: { id: number; title: string }) {
attachedNote.value = note;
}
function excludeAutoNote(noteId: number) {
excludedNoteIds.value = new Set([...excludedNoteIds.value, noteId]);
}
</script>
<template>
@@ -198,11 +188,6 @@ function excludeAutoNote(noteId: number) {
@click="promoteAutoNote(note)"
title="Attach for next message"
>+</button>
<button
class="context-pill-btn exclude"
@click="excludeAutoNote(note.id)"
title="Exclude from auto-search"
>&times;</button>
</span>
</div>
@@ -463,9 +448,6 @@ function excludeAutoNote(noteId: number) {
.context-pill-btn.promote:hover {
color: var(--color-primary);
}
.context-pill-btn.exclude:hover {
color: var(--color-danger, #e74c3c);
}
/* Input wrapper */
.panel-input-wrapper {
+2 -2
View File
@@ -119,7 +119,7 @@ export const useChatStore = defineStore("chat", () => {
async function sendMessage(
content: string,
contextNoteId?: number | null,
excludeNoteIds?: number[],
includeNoteIds?: number[],
think = false,
contextNoteTitle?: string,
) {
@@ -167,7 +167,7 @@ export const useChatStore = defineStore("chat", () => {
{
content,
context_note_id: contextNoteId,
exclude_note_ids: excludeNoteIds?.length ? excludeNoteIds : undefined,
include_note_ids: includeNoteIds?.length ? includeNoteIds : undefined,
think,
},
);
+82 -33
View File
@@ -30,11 +30,12 @@ const noteSearchResults = ref<{ id: number; title: string }[]>([]);
const noteSearchLoading = ref(false);
let noteSearchTimer: ReturnType<typeof setTimeout> | null = null;
// Exclude tracking (session-scoped per conversation)
const excludedNoteIds = ref<Set<number>>(new Set());
// Explicitly included notes (user clicked "+ include" in sidebar)
const includedNoteIds = ref<Set<number>>(new Set());
const includedNotes = ref<{ id: number; title: string }[]>([]);
// Persistent context notes — populated as assistant responds, cleared on conv change
const contextNotes = ref<{ id: number; title: string }[]>([]);
// Suggested notes — auto-found by search, not yet included
const suggestedNotes = ref<{ id: number; title: string }[]>([]);
let prevConvId: number | null = null;
@@ -91,8 +92,9 @@ watch(convId, async (newId) => {
}
prevConvId = newId ?? null;
excludedNoteIds.value = new Set();
contextNotes.value = [];
includedNoteIds.value = new Set();
includedNotes.value = [];
suggestedNotes.value = [];
attachedNote.value = null;
store.lastContextMeta = null;
if (newId) {
@@ -117,12 +119,11 @@ watch(
() => store.lastContextMeta?.auto_notes,
(newNotes) => {
if (!newNotes) return;
const excluded = excludedNoteIds.value;
const existing = new Set(contextNotes.value.map((n) => n.id));
const alreadyIncluded = includedNoteIds.value;
const alreadySuggested = new Set(suggestedNotes.value.map((n) => n.id));
for (const note of newNotes) {
if (!excluded.has(note.id) && !existing.has(note.id)) {
contextNotes.value.push(note);
existing.add(note.id);
if (!alreadyIncluded.has(note.id) && !alreadySuggested.has(note.id)) {
suggestedNotes.value.push(note);
}
}
}
@@ -180,7 +181,7 @@ async function sendMessage() {
await store.sendMessage(
content,
contextNoteId,
excludedNoteIds.value.size ? [...excludedNoteIds.value] : undefined,
includedNoteIds.value.size ? [...includedNoteIds.value] : undefined,
true, // enable thinking in the full chat view
contextNoteTitle,
);
@@ -282,9 +283,20 @@ function removeAttachedNote() {
attachedNote.value = null;
}
function excludeAutoNote(noteId: number) {
excludedNoteIds.value = new Set([...excludedNoteIds.value, noteId]);
contextNotes.value = contextNotes.value.filter((n) => n.id !== noteId);
function includeNote(note: { id: number; title: string }) {
if (includedNoteIds.value.has(note.id)) return;
includedNoteIds.value = new Set([...includedNoteIds.value, note.id]);
includedNotes.value.push(note);
suggestedNotes.value = suggestedNotes.value.filter((n) => n.id !== note.id);
}
function removeIncludedNote(noteId: number) {
includedNoteIds.value = new Set([...includedNoteIds.value].filter((id) => id !== noteId));
const removed = includedNotes.value.find((n) => n.id === noteId);
includedNotes.value = includedNotes.value.filter((n) => n.id !== noteId);
if (removed && !suggestedNotes.value.some((n) => n.id === noteId)) {
suggestedNotes.value.push(removed);
}
}
// Keyboard shortcuts
@@ -420,26 +432,40 @@ onUnmounted(() => {
</div>
</div>
<!-- Persistent context sidebar -->
<aside v-if="contextNotes.length || attachedNote" class="context-sidebar">
<div class="context-sidebar-header">In Context</div>
<!-- Context sidebar -->
<aside v-if="includedNotes.length || suggestedNotes.length || attachedNote" class="context-sidebar">
<!-- IN CONTEXT section -->
<template v-if="attachedNote || includedNotes.length">
<div class="context-sidebar-header">In Context</div>
<!-- Manually attached note pinned until sent -->
<div v-if="attachedNote" class="context-note context-note-pinned">
<span class="context-note-icon" title="Attached for this message">📌</span>
<router-link :to="`/notes/${attachedNote.id}`" class="context-note-name">
{{ attachedNote.title }}
</router-link>
<button class="context-note-remove" @click="removeAttachedNote" title="Remove">&times;</button>
</div>
<!-- Manually attached note pinned until sent -->
<div v-if="attachedNote" class="context-note context-note-pinned">
<span class="context-note-icon" title="Attached for this message">📌</span>
<router-link :to="`/notes/${attachedNote.id}`" class="context-note-name">
{{ attachedNote.title }}
</router-link>
<button class="context-note-remove" @click="removeAttachedNote" title="Remove">&times;</button>
</div>
<!-- Auto-found notes persist across turns -->
<div v-for="note in contextNotes" :key="note.id" class="context-note">
<router-link :to="`/notes/${note.id}`" class="context-note-name">
{{ note.title }}
</router-link>
<button class="context-note-remove" @click="excludeAutoNote(note.id)" title="Remove from context">&times;</button>
</div>
<!-- Explicitly included notes -->
<div v-for="note in includedNotes" :key="note.id" class="context-note context-note-included">
<router-link :to="`/notes/${note.id}`" class="context-note-name">
{{ note.title }}
</router-link>
<button class="context-note-remove" @click="removeIncludedNote(note.id)" title="Remove from context">&times;</button>
</div>
</template>
<!-- SUGGESTED section -->
<template v-if="suggestedNotes.length">
<div class="context-sidebar-header" :class="{ 'context-sidebar-header-gap': attachedNote || includedNotes.length }">Suggested</div>
<div v-for="note in suggestedNotes" :key="note.id" class="context-note context-note-suggested">
<router-link :to="`/notes/${note.id}`" class="context-note-name">
{{ note.title }}
</router-link>
<button class="context-note-add" @click="includeNote(note)" title="Add to context">+</button>
</div>
</template>
</aside>
</div>
@@ -729,6 +755,15 @@ onUnmounted(() => {
.context-note-name:hover {
color: var(--color-primary);
}
.context-sidebar-header-gap {
margin-top: 0.6rem;
}
.context-note-included {
border-color: var(--color-primary);
}
.context-note-suggested {
opacity: 0.85;
}
.context-note-remove {
background: none;
border: none;
@@ -742,6 +777,20 @@ onUnmounted(() => {
.context-note-remove:hover {
color: var(--color-danger, #e74c3c);
}
.context-note-add {
background: none;
border: none;
cursor: pointer;
color: var(--color-primary);
font-size: 1.1rem;
font-weight: 700;
line-height: 1;
padding: 0 0.1rem;
flex-shrink: 0;
}
.context-note-add:hover {
opacity: 0.75;
}
.messages-inner {
margin-top: auto;
}