From 17db487dbbbc9a9efba015c503e3e4b70d0927c5 Mon Sep 17 00:00:00 2001 From: Woody Date: Thu, 14 May 2026 21:27:21 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=203=20=E2=80=94=20Half=20Question?= =?UTF-8?q?=20button,=20Final=20Submit=20rename,=20ASR=20text=20always=20b?= =?UTF-8?q?lack?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Backend: add stop_after_decompose flag to QueryRequest, early-return after decomposition in SSE stream with half_question:true event - Frontend: add decomposeOnly method to useQueryDocumentStream hook - QueryInput: remove grey italic from ASR partial text, rename Submit to Final Submit, add gray Half Question button that decomposes without clearing querybox text - LTTPage: wire handleHalfQuestion to decomposeOnly --- .plans/half_question_enhancement_plan.md | 448 +++++++++++++++++++++++ backend/app/models/query.py | 5 +- backend/app/routers/query.py | 15 + frontend/src/components/QueryInput.tsx | 23 +- frontend/src/lib/queries.tsx | 69 +++- frontend/src/pages/LTTPage.tsx | 5 + frontend/src/types/index.ts | 3 +- 7 files changed, 562 insertions(+), 6 deletions(-) create mode 100644 .plans/half_question_enhancement_plan.md 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 = () => {