diff --git a/.plans/half_question_enhancement_plan.md b/.plans/half_question_enhancement_plan.md
new file mode 100644
index 0000000..71fcc2a
--- /dev/null
+++ b/.plans/half_question_enhancement_plan.md
@@ -0,0 +1,448 @@
+# Development Plan: Half Question Enhancement
+
+**Created:** 2026-05-14
+**Status:** Draft
+**Source:** User request — three enhancements to the LTT (Live Transcript) page
+
+---
+
+## 1. Objective
+
+Three changes on the LTT page in the RAG Video Q&A app:
+
+1. **ASR text color**: Make all transcribed text appear black from the first word (currently grey italic until sentence completion)
+2. **Rename button**: "Submit" → "Final Submit"
+3. **New button**: Add "Half Question" button before "Final Submit" — sends current accumulated ASR text to decomposition only, keeps text in the querybox (does NOT clear it), ASR continues adding new text. User can click repeatedly to re-decompose with updated text.
+
+---
+
+## 2. User Flow (Half Question)
+
+```
+Video plays → ASR transcribes → text accumulates in querybox (black, real-time)
+ │
+ ┌───────────────────┴───────────────────┐
+ ▼ ▼
+ [Half Question] [Final Submit]
+ │ │
+ Send current text to Send complete text
+ DECOMPOSITION ONLY through FULL PIPELINE
+ │ (decompose→retrieve→filter→generate)
+ ┌─────────────┴─────────────┐ │
+ ▼ ▼ ▼
+ Sub-questions appear Text STAYS in Text CLEARED from
+ in ExtractedQuestionsDisplay querybox querybox
+ │ │ │
+ ▼ ▼ ▼
+ ASR continues adding User can click again Full answer appears
+ more text to querybox (or click Final Submit) in ResponsePanel
+```
+
+**Key difference from full submit**:
+- Half Question does NOT clear the querybox text
+- Half Question does NOT reset `hasUserInput` (since user didn't type)
+- Half Question only runs decomposition (Stage 1 of the 3-step pipeline)
+- Half Question result is displayed in `ExtractedQuestionsDisplay` (already on page)
+
+---
+
+## 3. Changes Overview
+
+| # | Change | Files Affected | Complexity |
+|---|--------|---------------|------------|
+| 1 | ASR grey → black | 1 file (+ tests) | Trivial |
+| 2 | Rename "Submit" → "Final Submit" | 1 file (+ tests) | Trivial |
+| 3 | Half Question button + backend | 5 files (+ tests) | Medium |
+
+---
+
+## 4. Change 1: ASR Text Color (Grey → Black)
+
+### Current Behavior
+- `QueryInput.tsx` line 23: `showPartialStyle = !hasUserInput && !!partialText`
+- Line 55: `showPartialStyle ? 'text-gray-400 italic' : ''`
+- When ASR sends interim (delta) text via `partialTranscript`, text appears grey italic
+- When ASR sends final (is_final) sentence, `partialTranscript` is cleared → text turns black
+
+### Change
+**File**: `frontend/src/components/QueryInput.tsx`, **line 55**
+
+```diff
+- showPartialStyle ? 'text-gray-400 italic' : '',
++ '',
+```
+
+This is a one-character-effective change (`''` replaces the ternary). The `showPartialStyle` variable and `partialText` prop remain (they may be useful for other purposes), but the visual distinction is removed.
+
+### Tests to Update
+**File**: `frontend/src/test/test_phase2_QueryInput_integration.test.tsx`
+- ~9 tests assert `text-gray-400` and `italic` classes are present/absent
+- Update to assert that these classes are NEVER applied
+
+---
+
+## 5. Change 2: Rename "Submit" → "Final Submit"
+
+### Change
+**File**: `frontend/src/components/QueryInput.tsx`, **line 75**
+
+```diff
+- {isLoading ? 'Processing...' : 'Submit'}
++ {isLoading ? 'Processing...' : 'Final Submit'}
+```
+
+### Tests to Update
+- `frontend/src/test/components/QueryInput.test.tsx` — tests referencing "Submit" text
+- `frontend/src/test/test_phase2_LTTPage_integration.test.tsx` — line ~298: `screen.getByRole('button', { name: /submit/i })` → `/final submit/i`
+
+---
+
+## 6. Change 3: Half Question Button + Backend
+
+### 6.1 Backend Changes
+
+#### 6.1.1 Add `stop_after_decompose` to QueryRequest
+**File**: `backend/app/models/query.py`, **line 8-9**
+
+```diff
+class QueryRequest(BaseModel):
+ question: str
++ stop_after_decompose: bool = False
+```
+
+#### 6.1.2 Modify `_query_stream` to stop after decompose
+**File**: `backend/app/routers/query.py`, **lines 206-235**
+
+After yielding the `decomposed` event (line 206), check the flag:
+
+```python
+yield _format_sse({
+ "phase": "decomposed",
+ "extracted_questions": extracted_questions,
+})
+
+# NEW: Stop after decompose for half-question requests
+if request.stop_after_decompose:
+ _schedule_history(history_service, request, extracted_questions,
+ decompose_prompt, decomposer_time_ms, 0, 0, "", "",
+ 0, 0, "", "", 0, active_profile, "",
+ "[]", int((time.perf_counter() - overall_start) * 1000))
+ yield _format_sse({
+ "phase": "completed",
+ "answer": "",
+ "half_question": True,
+ "extracted_questions": extracted_questions,
+ "sources": [],
+ })
+ return
+
+# Existing code continues (Stage 2: Retrieve)
+stage_start = time.perf_counter()
+...
+```
+
+The `half_question: True` flag in the completed event lets the frontend distinguish this from a full completion.
+
+#### 6.1.3 Update SSE event types (backend)
+**File**: `backend/app/models/query.py`
+
+Add `half_question` and `extracted_questions` fields to `CompletedEvent`:
+
+```diff
+class CompletedEvent(BaseModel):
+ phase: Literal["completed"]
+ answer: str
++ half_question: bool = False
++ extracted_questions: List[str] = []
+ sub_question_sources: List[SubQuestionSources] = []
+ sources: List[SourceMetadata] = []
+```
+
+### 6.2 Frontend Type Changes
+
+**File**: `frontend/src/types/index.ts`, **line 18-20**
+
+```diff
+export interface QueryRequest {
+ question: string
++ stop_after_decompose?: boolean
+}
+```
+
+**File**: `frontend/src/types/index.ts`, **line 35** (the `completed` event in `QueryStreamEvent`)
+
+```diff
+- | { phase: 'completed'; answer: string; sub_question_sources?: SubQuestionSources[]; sources?: SourceMetadata[] }
++ | { phase: 'completed'; answer: string; half_question?: boolean; extracted_questions?: string[]; sub_question_sources?: SubQuestionSources[]; sources?: SourceMetadata[] }
+```
+
+### 6.3 Frontend Hook Changes
+
+**File**: `frontend/src/lib/queries.tsx`
+
+Two approaches:
+
+**Option A (Recommended): Add `decomposeOnly` method to existing hook**
+
+Add a `decomposeOnly` method to `useQueryDocumentStream`:
+
+```tsx
+const decomposeOnly = useCallback(async (request: QueryRequest) => {
+ setState({
+ extractedQuestions: null,
+ answer: null,
+ sources: null,
+ subQuestionSources: null,
+ phase: 'decomposing',
+ historyId: null,
+ error: null,
+ })
+
+ abortRef.current = new AbortController()
+
+ try {
+ await queryDocumentStream(
+ { question: request.question, stop_after_decompose: true },
+ (event: QueryStreamEvent) => {
+ switch (event.phase) {
+ case 'decomposed':
+ setState(prev => ({
+ ...prev,
+ extractedQuestions: event.extracted_questions ?? null,
+ phase: 'retrieving',
+ }))
+ break
+ case 'completed':
+ // For half-question: keep extractedQuestions, clear answer
+ setState(prev => ({
+ ...prev,
+ answer: event.half_question ? null : (event.answer ?? null),
+ sources: event.sources ?? null,
+ subQuestionSources: event.sub_question_sources ?? null,
+ phase: 'completed',
+ historyId: (event as any).history_id ?? null,
+ }))
+ break
+ // ... other cases unchanged
+ }
+ },
+ abortRef.current.signal
+ )
+ } catch (err) {
+ // ... same error handling
+ }
+}, [])
+
+return { ...state, mutate, decomposeOnly, reset }
+```
+
+**Option B: Separate `useDecomposeOnly` hook** — cleaner separation but duplicates SSE handling. Only use if Option A gets too messy.
+
+### 6.4 QueryInput Component Changes
+
+**File**: `frontend/src/components/QueryInput.tsx`
+
+Changes to make:
+1. Remove `text-gray-400 italic` (Change 1, line 55)
+2. Rename "Submit" → "Final Submit" (Change 2, line 75)
+3. Add `onHalfQuestion` prop and "Half Question" button (Change 3)
+
+#### Updated interface (lines 3-8):
+
+```diff
+export interface QueryInputProps {
+ onSubmit: (question: string) => void
+ isLoading: boolean
+ partialText?: string
+ value?: string
++ onHalfQuestion?: (question: string) => void
++ isHalfQuestionLoading?: boolean
+}
+```
+
+#### New "Half Question" button (insert between lines 69 and 70):
+
+Add a gray secondary button BEFORE the Final Submit button in the `
`:
+
+```tsx
+
+ {onHalfQuestion && (
+
+ )}
+
+ ...
+
+```
+
+**Key design decisions for Half Question button**:
+- `type="button"` — NOT a form submit (form submit = Final Submit)
+- `onClick` handler calls `onHalfQuestion` with the current text
+- Does NOT clear `question` state (keeps text in querybox for ASR to continue adding)
+- Does NOT clear `hasUserInput` (user hasn't typed — ASR is filling)
+- Sets `submittedQuestion` so the "Your question:" echo appears
+- Disabled when empty or loading (same as Final Submit)
+- Gray secondary style (`bg-gray-100`) to visually distinguish from blue "Final Submit"
+- Conditionally rendered only when `onHalfQuestion` prop is provided (backward compatible)
+
+### 6.5 LTTPage Changes
+
+**File**: `frontend/src/pages/LTTPage.tsx`
+
+#### Add `handleHalfQuestion` handler (after line 45):
+
+```tsx
+const handleHalfQuestion = (question: string): void => {
+ queryStream.decomposeOnly({ question })
+ // NOTE: do NOT call setQueryText('') — keep text in querybox
+}
+```
+
+#### Update `
` rendering (lines 114-119):
+
+```diff
+
+```
+
+#### Update `isLoading` to account for half question:
+
+The current `isLoading` disables the QueryInput whenever the full pipeline is running. For half questions, we need a separate loading state:
+
+```tsx
+const isFullLoading = queryStream.phase !== 'idle' && queryStream.phase !== 'completed' && queryStream.phase !== 'error'
+```
+
+Actually, since `decomposeOnly` uses the same state machine, `isLoading` will already be `true` during half-question decomposition and `false` after completion. This works as-is. No change needed.
+
+---
+
+## 7. Test Files
+
+### 7.1 Backend Tests
+
+| File | Purpose |
+|------|---------|
+| `backend/app/test/test_phase3_half_question.py` | **NEW** — Test decompose-only endpoint: SSE yields `decomposed` then `completed`, answer is empty, `extracted_questions` populated, `half_question: true` |
+| `backend/app/test/acceptance/test_acceptance_phase3_half_question.py` | **NEW** — Acceptance test: real LLM decomposes a question, stops before retrieval |
+
+### 7.2 Frontend Tests
+
+| File | Changes |
+|------|---------|
+| `frontend/src/test/components/QueryInput.test.tsx` | Update: "Final Submit" text, new "Half Question" button, no grey class |
+| `frontend/src/test/test_phase2_QueryInput_integration.test.tsx` | Update: remove assertions for `text-gray-400`/`italic` classes |
+| `frontend/src/test/test_phase2_LTTPage_integration.test.tsx` | Update: rename `{ name: /submit/i }` → `{ name: /final submit/i }` |
+| `frontend/src/test/test_phase3_half_question.test.tsx` | **NEW** — Integration test: Half Question button click → SSE with decompose-only, text stays in querybox |
+
+---
+
+## 8. Acceptance Criteria
+
+### Change 1 — ASR Text Color
+- [ ] ASR transcribed text appears in black from the first word (no grey italic phase)
+- [ ] User-typed text still appears correctly
+- [ ] All existing QueryInput tests pass after updates
+
+### Change 2 — Rename
+- [ ] Button displays "Final Submit" (not "Submit")
+- [ ] Loading state shows "Processing..."
+- [ ] All existing tests pass after updates
+
+### Change 3 — Half Question
+- [ ] "Half Question" button visible when querybox has text
+- [ ] Clicking "Half Question" sends text to backend decomposition only
+- [ ] Decomposed sub-questions appear in `ExtractedQuestionsDisplay`
+- [ ] Querybox text remains after clicking "Half Question" (NOT cleared)
+- [ ] ASR continues adding text to querybox after half question
+- [ ] Clicking "Half Question" multiple times re-decomposes with updated text
+- [ ] Clicking "Final Submit" sends text through full pipeline and clears querybox
+- [ ] "Half Question" button is gray (secondary), "Final Submit" is blue (primary)
+- [ ] "Half Question" is disabled when querybox is empty or loading
+
+---
+
+## 9. Implementation Tasks (Test-First Order)
+
+### Phase 3.1: Backend — Decompose-Only Endpoint
+
+| # | Task | Test File | Description |
+|---|------|-----------|-------------|
+| 1 | Write backend test | `test_phase3_half_question.py` | Test that `POST /api/v1/query` with `stop_after_decompose: true` yields `decomposed` then `completed` events, answer is empty, `half_question: true` |
+| 2 | Add `stop_after_decompose` to `QueryRequest` | — | Add `bool = False` field to Pydantic model |
+| 3 | Modify `_query_stream` | — | Check flag after decompose, yield completed early |
+| 4 | Update `CompletedEvent` | — | Add `half_question` and `extracted_questions` fields |
+| 5 | Run backend tests | — | `pytest app/test/test_phase3_half_question.py -v` |
+
+### Phase 3.2: Frontend — Types & API
+
+| # | Task | Test File | Description |
+|---|------|-----------|-------------|
+| 1 | Update `QueryRequest` type | — | Add `stop_after_decompose?: boolean` |
+| 2 | Update `QueryStreamEvent` type | — | Add `half_question?` and `extracted_questions?` to completed event |
+| 3 | Add `decomposeOnly` to hook | — | New method on `useQueryDocumentStream` that passes `stop_after_decompose: true` |
+
+### Phase 3.3: Frontend — QueryInput Changes
+
+| # | Task | Test File | Description |
+|---|------|-----------|-------------|
+| 1 | Write frontend test | `test_phase3_half_question.test.tsx` | Integration test: Half Question button renders, click calls handler, text not cleared |
+| 2 | Remove grey text class | `QueryInput.tsx` line 55 | `showPartialStyle ? 'text-gray-400 italic' : ''` → `''` |
+| 3 | Rename "Submit" → "Final Submit" | `QueryInput.tsx` line 75 | String change |
+| 4 | Add `onHalfQuestion` prop | `QueryInput.tsx` lines 3-8 | Update interface |
+| 5 | Add "Half Question" button | `QueryInput.tsx` line ~69 | Insert gray secondary button before Final Submit |
+| 6 | Update LTTPage | `LTTPage.tsx` | Add `handleHalfQuestion`, pass `onHalfQuestion` prop |
+| 7 | Update existing tests | `QueryInput.test.tsx`, `test_phase2_*.tsx` | Fix assertions for renamed button, removed grey class |
+| 8 | Run frontend tests | — | `pnpm test` |
+
+### Phase 3.4: Acceptance Testing
+
+| # | Task | Test File | Description |
+|---|------|-----------|-------------|
+| 1 | Write acceptance test | `test_acceptance_phase3_half_question.py` | Real LLM decompose → stop before retrieval, verify SSE events |
+| 2 | Run acceptance tests | — | `pytest app/test/acceptance/ -v -m acceptance` |
+
+---
+
+## 10. Risk Assessment
+
+| Risk | Likelihood | Mitigation |
+|------|-----------|------------|
+| `decomposeOnly` sharing state with `mutate` causes race conditions | Low | They use same state but `decomposeOnly` completes quickly (just decompose). If user clicks "Half Question" then "Final Submit", the abort controller from `reset()` handles cleanup. |
+| `ExtractedQuestionsDisplay` not updating on half question | Low | It reads `queryStream.extractedQuestions` which is set from `decomposed` event. Same mechanism as full pipeline. |
+| ASR `partialTranscript` conflicts with half question text | Low | Half Question submits `question` (from `value` prop, set by `onFinalTranscript`), not `partialTranscript`. The partial flow is unaffected. |
+| Backward compatibility | None | `stop_after_decompose` defaults to `false`. `onHalfQuestion` is optional. Existing behavior is unchanged. |
+
+---
+
+## 11. Dependencies
+
+- No new npm packages or Python packages required
+- Uses existing `queryDocumentStream` SSE infrastructure
+- Uses existing `QueryDecomposer` service
+- Uses existing `ExtractedQuestionsDisplay` component
+- Button styling follows existing inline Tailwind conventions (no shadcn/ui)
diff --git a/backend/app/models/query.py b/backend/app/models/query.py
index beb4d35..75ac19c 100644
--- a/backend/app/models/query.py
+++ b/backend/app/models/query.py
@@ -1,12 +1,13 @@
from typing import List, Literal, Union
-from pydantic import BaseModel
+from pydantic import BaseModel, Field
from app.models.common import SourceMetadata
class QueryRequest(BaseModel):
question: str
+ stop_after_decompose: bool = False
class SubQuestionSources(BaseModel):
@@ -57,6 +58,8 @@ class CompletedEvent(BaseModel):
answer: str
sub_question_sources: List[SubQuestionSources] = []
sources: List[SourceMetadata] = []
+ half_question: bool = False
+ extracted_questions: List[str] = Field(default_factory=list)
class ErrorEvent(BaseModel):
diff --git a/backend/app/routers/query.py b/backend/app/routers/query.py
index da85a3f..187bbe3 100644
--- a/backend/app/routers/query.py
+++ b/backend/app/routers/query.py
@@ -205,6 +205,21 @@ async def _query_stream(request: QueryRequest):
"extracted_questions": extracted_questions,
})
+ # Half-question mode: stop after decomposition
+ if request.stop_after_decompose:
+ _schedule_history(history_service, request, extracted_questions,
+ decompose_prompt, decomposer_time_ms, 0, 0, "", "",
+ 0, 0, "", "", 0, active_profile, "",
+ "[]", int((time.perf_counter() - overall_start) * 1000))
+ yield _format_sse({
+ "phase": "completed",
+ "answer": "",
+ "half_question": True,
+ "extracted_questions": extracted_questions,
+ "sources": [],
+ })
+ return
+
# Stage 2: Retrieve (per sub-question)
stage_start = time.perf_counter()
retrieval_results = rag.retrieve_per_subquestion(
diff --git a/frontend/src/components/QueryInput.tsx b/frontend/src/components/QueryInput.tsx
index 82e2478..6622bf6 100644
--- a/frontend/src/components/QueryInput.tsx
+++ b/frontend/src/components/QueryInput.tsx
@@ -2,12 +2,13 @@ import React, { useState, useEffect, type FormEvent, type KeyboardEvent } from '
export interface QueryInputProps {
onSubmit: (question: string) => void
+ onHalfQuestion?: (question: string) => void
isLoading: boolean
partialText?: string
value?: string
}
-export const QueryInput: React.FC = ({ onSubmit, isLoading, partialText, value }) => {
+export const QueryInput: React.FC = ({ onSubmit, onHalfQuestion, isLoading, partialText, value }) => {
const [question, setQuestion] = useState('')
const [submittedQuestion, setSubmittedQuestion] = useState(null)
const [hasUserInput, setHasUserInput] = useState(false)
@@ -52,7 +53,7 @@ export const QueryInput: React.FC = ({ onSubmit, isLoading, par
const textareaClassName = [
'w-full rounded border border-gray-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed',
- showPartialStyle ? 'text-gray-400 italic' : '',
+ '',
].filter(Boolean).join(' ')
return (
@@ -67,12 +68,28 @@ export const QueryInput: React.FC = ({ onSubmit, isLoading, par
className={textareaClassName}
/>
+ {onHalfQuestion && (
+
+ )}
{submittedQuestion && (
diff --git a/frontend/src/lib/queries.tsx b/frontend/src/lib/queries.tsx
index cc6d36a..27ba71b 100644
--- a/frontend/src/lib/queries.tsx
+++ b/frontend/src/lib/queries.tsx
@@ -101,6 +101,73 @@ export const useQueryDocumentStream = () => {
}
}, [])
+ const decomposeOnly = useCallback(async (request: QueryRequest) => {
+ setState({
+ extractedQuestions: null,
+ answer: null,
+ sources: null,
+ subQuestionSources: null,
+ phase: 'decomposing',
+ historyId: null,
+ error: null,
+ })
+
+ abortRef.current = new AbortController()
+
+ try {
+ await queryDocumentStream({ question: request.question, stop_after_decompose: true }, (event: QueryStreamEvent) => {
+ switch (event.phase) {
+ case 'decomposed':
+ setState(prev => ({
+ ...prev,
+ extractedQuestions: event.extracted_questions ?? null,
+ phase: 'retrieving',
+ }))
+ break
+ case 'retrieving':
+ setState(prev => ({ ...prev, phase: 'retrieving' }))
+ break
+ case 'filtering':
+ setState(prev => ({ ...prev, phase: 'filtering' }))
+ break
+ case 'generating':
+ setState(prev => ({ ...prev, phase: 'generating' }))
+ break
+ case 'generating_subquestion':
+ setState(prev => ({ ...prev, phase: 'generating' }))
+ break
+ case 'completed':
+ setState(prev => ({
+ ...prev,
+ answer: event.half_question ? null : (event.answer ?? null),
+ sources: event.sources ?? null,
+ subQuestionSources: event.sub_question_sources ?? null,
+ phase: 'completed',
+ historyId: (event as any).history_id ?? null,
+ }))
+ break
+ case 'error':
+ setState(prev => ({
+ ...prev,
+ phase: 'error',
+ error: new Error(event.message ?? 'Unknown error'),
+ }))
+ break
+ }
+ }, abortRef.current.signal)
+ } catch (err) {
+ if (err instanceof Error && err.name === 'AbortError') {
+ setState(prev => ({ ...prev, phase: 'idle' }))
+ return
+ }
+ setState(prev => ({
+ ...prev,
+ phase: 'error',
+ error: err instanceof Error ? err : new Error(String(err)),
+ }))
+ }
+ }, [])
+
const reset = useCallback(() => {
abortRef.current?.abort()
setState({
@@ -114,7 +181,7 @@ export const useQueryDocumentStream = () => {
})
}, [])
- return { ...state, mutate, reset }
+ return { ...state, mutate, decomposeOnly, reset }
}
export const useIngestDocument = () => {
diff --git a/frontend/src/pages/LTTPage.tsx b/frontend/src/pages/LTTPage.tsx
index 392d6f5..de40fbb 100644
--- a/frontend/src/pages/LTTPage.tsx
+++ b/frontend/src/pages/LTTPage.tsx
@@ -44,6 +44,10 @@ export const LTTPage: React.FC = () => {
setQueryText('')
}
+ const handleHalfQuestion = (question: string): void => {
+ queryStream.decomposeOnly({ question })
+ }
+
const handleRequestFullTranscript = useCallback(() => {
ft.requestFullTranscript()
}, [ft])
@@ -113,6 +117,7 @@ export const LTTPage: React.FC = () => {