feat: Phase 3 — Half Question button, Final Submit rename, ASR text always black
- 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
This commit is contained in:
parent
64a7a8a46b
commit
17db487dbb
|
|
@ -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 `<div className="flex items-center gap-3">`:
|
||||
|
||||
```tsx
|
||||
<div className="flex items-center gap-3">
|
||||
{onHalfQuestion && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const trimmed = question.trim()
|
||||
if (trimmed && !isLoading) {
|
||||
onHalfQuestion(trimmed)
|
||||
// NOTE: do NOT clear question text, do NOT reset hasUserInput
|
||||
setSubmittedQuestion(trimmed)
|
||||
}
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
className="shrink-0 px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 font-medium rounded-lg transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Half Question
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isDisabled}
|
||||
className="shrink-0 px-4 py-2 bg-blue-600 text-white font-medium rounded hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-blue-600 transition-all duration-200"
|
||||
>
|
||||
{isLoading ? 'Processing...' : 'Final Submit'}
|
||||
</button>
|
||||
...
|
||||
</div>
|
||||
```
|
||||
|
||||
**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 `<QueryInput>` rendering (lines 114-119):
|
||||
|
||||
```diff
|
||||
<QueryInput
|
||||
onSubmit={handleQuerySubmit}
|
||||
isLoading={isLoading}
|
||||
partialText={asr.partialTranscript}
|
||||
value={queryText}
|
||||
+ onHalfQuestion={handleHalfQuestion}
|
||||
/>
|
||||
```
|
||||
|
||||
#### 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)
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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<QueryInputProps> = ({ onSubmit, isLoading, partialText, value }) => {
|
||||
export const QueryInput: React.FC<QueryInputProps> = ({ onSubmit, onHalfQuestion, isLoading, partialText, value }) => {
|
||||
const [question, setQuestion] = useState<string>('')
|
||||
const [submittedQuestion, setSubmittedQuestion] = useState<string | null>(null)
|
||||
const [hasUserInput, setHasUserInput] = useState(false)
|
||||
|
|
@ -52,7 +53,7 @@ export const QueryInput: React.FC<QueryInputProps> = ({ 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<QueryInputProps> = ({ onSubmit, isLoading, par
|
|||
className={textareaClassName}
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
{onHalfQuestion && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const trimmed = question.trim()
|
||||
if (trimmed && !isLoading) {
|
||||
onHalfQuestion(trimmed)
|
||||
setSubmittedQuestion(trimmed)
|
||||
}
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
className="shrink-0 px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 font-medium rounded-lg transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Half Question
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isDisabled}
|
||||
className="shrink-0 px-4 py-2 bg-blue-600 text-white font-medium rounded hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-blue-600 transition-all duration-200"
|
||||
>
|
||||
{isLoading ? 'Processing...' : 'Submit'}
|
||||
{isLoading ? 'Processing...' : 'Final Submit'}
|
||||
</button>
|
||||
{submittedQuestion && (
|
||||
<p data-testid="submitted-question" className="text-sm text-gray-500 italic break-words">
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
|||
<div className="h-full p-6 flex flex-col gap-4 overflow-y-auto">
|
||||
<QueryInput
|
||||
onSubmit={handleQuerySubmit}
|
||||
onHalfQuestion={handleHalfQuestion}
|
||||
isLoading={isLoading}
|
||||
partialText={asr.partialTranscript}
|
||||
value={queryText}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ export interface SubQuestionSources {
|
|||
|
||||
export interface QueryRequest {
|
||||
question: string
|
||||
stop_after_decompose?: boolean
|
||||
}
|
||||
|
||||
export interface QueryResponse {
|
||||
|
|
@ -32,7 +33,7 @@ export type QueryStreamEvent =
|
|||
| { phase: 'filtering' }
|
||||
| { phase: 'generating' }
|
||||
| { phase: 'generating_subquestion'; sub_question_index: number; sub_question_text: string }
|
||||
| { phase: 'completed'; answer: string; sub_question_sources?: SubQuestionSources[]; sources?: SourceMetadata[] }
|
||||
| { phase: 'completed'; answer: string; sub_question_sources?: SubQuestionSources[]; sources?: SourceMetadata[]; half_question?: boolean; extracted_questions?: string[] }
|
||||
| { phase: 'error'; message: string }
|
||||
|
||||
export interface IngestResponse {
|
||||
|
|
|
|||
Loading…
Reference in New Issue