From c52c9f172b4e89e6e57da27ae1e8f115d8aeae0d Mon Sep 17 00:00:00 2001 From: Dragon <52599892+DragonnZhang@users.noreply.github.com> Date: Tue, 2 Jun 2026 10:51:15 +0800 Subject: [PATCH] fix(insight): Harden insight facet normalization and empty qualitative handling (#3557) * Harden insight facet normalization and empty qualitative handling * feat: enhance AtAGlance component to accept target sections for dynamic rendering --- .../insight/generators/DataProcessor.test.ts | 346 ++++++++++++++++- .../insight/generators/DataProcessor.ts | 277 ++++++++++++-- .../web-templates/src/insight/src/App.tsx | 192 ++++++++-- .../src/insight/src/Qualitative.tsx | 351 +++++++++++------- 4 files changed, 958 insertions(+), 208 deletions(-) diff --git a/packages/cli/src/services/insight/generators/DataProcessor.test.ts b/packages/cli/src/services/insight/generators/DataProcessor.test.ts index 771caf9d5b..ba2fa4acd3 100644 --- a/packages/cli/src/services/insight/generators/DataProcessor.test.ts +++ b/packages/cli/src/services/insight/generators/DataProcessor.test.ts @@ -12,6 +12,14 @@ import type { SessionFacets, } from '../types/StaticInsightTypes.js'; +const mockLogger = vi.hoisted(() => ({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), +})); +const mockRunSideQuery = vi.hoisted(() => vi.fn()); + // Mock dependencies vi.mock('@qwen-code/qwen-code-core', async () => { const actual = await vi.importActual< @@ -20,12 +28,8 @@ vi.mock('@qwen-code/qwen-code-core', async () => { return { ...actual, read: vi.fn(), - createDebugLogger: vi.fn(() => ({ - info: vi.fn(), - error: vi.fn(), - warn: vi.fn(), - debug: vi.fn(), - })), + createDebugLogger: vi.fn(() => mockLogger), + runSideQuery: mockRunSideQuery, }; }); @@ -53,6 +57,9 @@ describe('DataProcessor', () => { vi.clearAllMocks(); mockGenerateJson = vi.fn(); + mockRunSideQuery.mockImplementation((_config, request) => + mockGenerateJson(request), + ); mockConfig = { getBaseLlmClient: vi.fn(() => ({ generateJson: mockGenerateJson, @@ -375,6 +382,76 @@ describe('DataProcessor', () => { }); }); + it('should ignore zero and non-finite count values', () => { + const facets = [ + { + session_id: 's1', + underlying_goal: 'test', + goal_categories: { coding: 0, debugging: Number.NaN, testing: 2 }, + outcome: 'fully_achieved', + user_satisfaction_counts: { satisfied: 0, happy: 1 }, + Qwen_helpfulness: 'very_helpful', + session_type: 'single_task', + friction_counts: { slow_response: Number.POSITIVE_INFINITY }, + friction_detail: '', + primary_success: 'none', + brief_summary: 'Test summary', + }, + ] as unknown as SessionFacets[]; + + const result = ( + dataProcessor as unknown as { + aggregateFacetsData(facets: SessionFacets[]): { + satisfactionAgg: Record; + frictionAgg: Record; + goalsAgg: Record; + }; + } + ).aggregateFacetsData(facets); + + expect(result.satisfactionAgg).toEqual({ happy: 1 }); + expect(result.frictionAgg).toEqual({}); + expect(result.goalsAgg).toEqual({ testing: 2 }); + }); + + it('should ignore malformed count objects when aggregating facets', () => { + const facets = [ + { + session_id: 's1', + underlying_goal: 'test', + goal_categories: null, + outcome: null, + user_satisfaction_counts: null, + Qwen_helpfulness: 'very_helpful', + session_type: 'single_task', + friction_counts: null, + friction_detail: '', + primary_success: null, + brief_summary: 'Test summary', + }, + ] as unknown as SessionFacets[]; + + const result = ( + dataProcessor as unknown as { + aggregateFacetsData(facets: SessionFacets[]): { + satisfactionAgg: Record; + frictionAgg: Record; + primarySuccessAgg: Record; + outcomesAgg: Record; + goalsAgg: Record; + }; + } + ).aggregateFacetsData(facets); + + expect(result.satisfactionAgg).toEqual({}); + expect(result.frictionAgg).toEqual({}); + expect(result.primarySuccessAgg).toEqual({}); + expect(result.goalsAgg).toEqual({}); + expect(result.outcomesAgg).toEqual({ + unclear_from_transcript: 1, + }); + }); + it('should aggregate friction counts', () => { const facets: SessionFacets[] = [ { @@ -631,6 +708,111 @@ describe('DataProcessor', () => { ); }); + it('should normalize malformed LLM facet fields', async () => { + mockGenerateJson.mockResolvedValue({ + underlying_goal: ' Test goal ', + goal_categories: { coding: 1 }, + outcome: null, + user_satisfaction_counts: null, + Qwen_helpfulness: 'invalid', + session_type: null, + friction_counts: null, + friction_detail: null, + primary_success: null, + brief_summary: ' Test summary ', + }); + + const records: ChatRecord[] = [ + { + sessionId: 'test-session', + timestamp: new Date().toISOString(), + type: 'user', + message: { + role: 'user', + parts: [{ text: 'Help me with code' }], + }, + uuid: '', + parentUuid: null, + cwd: '', + version: '', + }, + ]; + + const result = await ( + dataProcessor as unknown as { + analyzeSession(records: ChatRecord[]): Promise; + } + ).analyzeSession(records); + + expect(result).toEqual({ + session_id: 'test-session', + underlying_goal: 'Test goal', + goal_categories: { coding: 1 }, + outcome: 'unclear_from_transcript', + user_satisfaction_counts: {}, + Qwen_helpfulness: 'moderately_helpful', + session_type: 'single_task', + friction_counts: {}, + friction_detail: '', + primary_success: 'none', + brief_summary: 'Test summary', + }); + expect(mockLogger.debug).toHaveBeenCalledWith( + 'Normalized unknown insight enum value "invalid" to fallback "moderately_helpful"', + ); + }); + + it('should normalize case-variant LLM facet fields to canonical values', async () => { + mockGenerateJson.mockResolvedValue({ + underlying_goal: 'Test goal', + goal_categories: { coding: 1 }, + outcome: 'FULLY_ACHIEVED', + user_satisfaction_counts: null, + Qwen_helpfulness: 'Very_Helpful', + session_type: 'Multi_Task', + friction_counts: null, + friction_detail: null, + primary_success: null, + brief_summary: 'Test summary', + }); + + const records: ChatRecord[] = [ + { + sessionId: 'test-session', + timestamp: new Date().toISOString(), + type: 'user', + message: { + role: 'user', + parts: [{ text: 'Help me with code' }], + }, + uuid: '', + parentUuid: null, + cwd: '', + version: '', + }, + ]; + + const result = await ( + dataProcessor as unknown as { + analyzeSession(records: ChatRecord[]): Promise; + } + ).analyzeSession(records); + + expect(result).toEqual({ + session_id: 'test-session', + underlying_goal: 'Test goal', + goal_categories: { coding: 1 }, + outcome: 'fully_achieved', + user_satisfaction_counts: {}, + Qwen_helpfulness: 'very_helpful', + session_type: 'multi_task', + friction_counts: {}, + friction_detail: '', + primary_success: 'none', + brief_summary: 'Test summary', + }); + }); + it('should return null when LLM returns empty result', async () => { mockGenerateJson.mockResolvedValue({}); @@ -1233,6 +1415,25 @@ describe('DataProcessor', () => { expect(result).toBeUndefined(); }); + it('should return undefined when all qualitative sections are empty', async () => { + mockGenerateJson.mockResolvedValue({}); + + const result = await ( + dataProcessor as unknown as { + generateQualitativeInsights( + metrics: Omit, + facets: SessionFacets[], + ): Promise< + | import('../types/QualitativeInsightTypes.js').QualitativeInsights + | undefined + >; + } + ).generateQualitativeInsights(mockMetrics, mockFacets); + + expect(result).toBeUndefined(); + expect(mockGenerateJson).toHaveBeenCalledTimes(8); + }); + it('should return full qualitative data when all LLM calls succeed', async () => { mockGenerateJson.mockResolvedValue({ intro: 'test', areas: [] }); @@ -1329,5 +1530,138 @@ describe('DataProcessor', () => { expect(result).toHaveLength(1); expect(result[0].session_id).toBe('conversational'); }); + + it('should normalize cached facets before reusing them', async () => { + const conversationalRecords: ChatRecord[] = [ + { + sessionId: 'cached-session', + timestamp: '2025-01-15T10:00:00Z', + type: 'user', + message: { role: 'user', parts: [{ text: 'Hello' }] }, + uuid: '', + parentUuid: null, + cwd: '', + version: '', + }, + { + sessionId: 'cached-session', + timestamp: '2025-01-15T10:01:00Z', + type: 'assistant', + message: { role: 'assistant', parts: [{ text: 'Hi' }] }, + uuid: '', + parentUuid: null, + cwd: '', + version: '', + }, + ]; + + mockedReadJsonlFile.mockResolvedValue(conversationalRecords); + mockedFs.readFile.mockResolvedValue( + JSON.stringify({ + underlying_goal: ' Cached goal ', + goal_categories: null, + outcome: null, + user_satisfaction_counts: null, + Qwen_helpfulness: 'very_helpful', + session_type: 'single_task', + friction_counts: null, + friction_detail: null, + primary_success: null, + brief_summary: ' Cached summary ', + }), + ); + + const files = [{ path: '/test/cached-session.jsonl', mtime: 1000 }]; + + const result = await ( + dataProcessor as unknown as { + generateFacets( + files: Array<{ path: string; mtime: number }>, + facetsOutputDir?: string, + ): Promise; + } + ).generateFacets(files, '/facets'); + + expect(mockGenerateJson).not.toHaveBeenCalled(); + expect(result).toEqual([ + { + session_id: 'cached-session', + underlying_goal: 'Cached goal', + goal_categories: {}, + outcome: 'unclear_from_transcript', + user_satisfaction_counts: {}, + Qwen_helpfulness: 'very_helpful', + session_type: 'single_task', + friction_counts: {}, + friction_detail: '', + primary_success: 'none', + brief_summary: 'Cached summary', + }, + ]); + expect(mockedFs.writeFile).toHaveBeenCalledWith( + expect.stringMatching(/[\\/]facets[\\/]cached-session\.json$/), + JSON.stringify(result[0], null, 2), + 'utf-8', + ); + }); + + it('should reuse normalized cached facets when writeback fails', async () => { + const conversationalRecords: ChatRecord[] = [ + { + sessionId: 'cached-session', + timestamp: '2025-01-15T10:00:00Z', + type: 'user', + message: { role: 'user', parts: [{ text: 'Hello' }] }, + uuid: '', + parentUuid: null, + cwd: '', + version: '', + }, + { + sessionId: 'cached-session', + timestamp: '2025-01-15T10:01:00Z', + type: 'assistant', + message: { role: 'assistant', parts: [{ text: 'Hi' }] }, + uuid: '', + parentUuid: null, + cwd: '', + version: '', + }, + ]; + const writeError = new Error('disk full'); + + mockedReadJsonlFile.mockResolvedValue(conversationalRecords); + mockedFs.readFile.mockResolvedValue( + JSON.stringify({ + underlying_goal: 'Cached goal', + brief_summary: 'Cached summary', + }), + ); + mockedFs.writeFile.mockRejectedValueOnce(writeError); + + const files = [{ path: '/test/cached-session.jsonl', mtime: 1000 }]; + + const result = await ( + dataProcessor as unknown as { + generateFacets( + files: Array<{ path: string; mtime: number }>, + facetsOutputDir?: string, + ): Promise; + } + ).generateFacets(files, '/facets'); + + expect(mockGenerateJson).not.toHaveBeenCalled(); + expect(result).toEqual([ + expect.objectContaining({ + session_id: 'cached-session', + underlying_goal: 'Cached goal', + brief_summary: 'Cached summary', + }), + ]); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Failed to write back normalized facet for cached-session:', + writeError, + ); + }); }); }); diff --git a/packages/cli/src/services/insight/generators/DataProcessor.ts b/packages/cli/src/services/insight/generators/DataProcessor.ts index f302ee08e2..2a8a2c11f7 100644 --- a/packages/cli/src/services/insight/generators/DataProcessor.ts +++ b/packages/cli/src/services/insight/generators/DataProcessor.ts @@ -39,6 +39,167 @@ import { const logger = createDebugLogger('DataProcessor'); const CONCURRENCY_LIMIT = 4; +const SESSION_OUTCOMES = [ + 'fully_achieved', + 'mostly_achieved', + 'partially_achieved', + 'not_achieved', + 'unclear_from_transcript', +] as const; +const OUTCOME_FALLBACK = 'unclear_from_transcript'; +const QWEN_HELPFULNESS_LEVELS = [ + 'unhelpful', + 'slightly_helpful', + 'moderately_helpful', + 'very_helpful', + 'essential', +] as const; +const SESSION_TYPES = [ + 'single_task', + 'multi_task', + 'iterative_refinement', + 'exploration', + 'quick_question', +] as const; +const PRIMARY_SUCCESS_VALUES = [ + 'none', + 'fast_accurate_search', + 'correct_code_edits', + 'good_explanations', + 'proactive_help', + 'multi_file_changes', + 'good_debugging', +] as const; +const PRIMARY_SUCCESS_FALLBACK = 'none'; + +// Keep in sync with packages/web-templates/src/insight/src/App.tsx. +function hasMeaningfulInsightValue(value: unknown): boolean { + if (typeof value === 'string') { + return value.trim().length > 0; + } + + if (typeof value === 'number') { + return Number.isFinite(value) && value !== 0; + } + + if (typeof value === 'boolean') { + return value; + } + + if (Array.isArray(value)) { + return value.some((item) => hasMeaningfulInsightValue(item)); + } + + if (value && typeof value === 'object') { + return Object.values(value).some((item) => hasMeaningfulInsightValue(item)); + } + + return false; +} + +function normalizeInsightText(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function normalizeInsightCountRecord(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {}; + } + + return Object.entries(value).reduce>( + (acc, [key, count]) => { + if (typeof count === 'number' && Number.isFinite(count) && count > 0) { + acc[key] = count; + } + return acc; + }, + {}, + ); +} + +function getInsightCountEntries(value: unknown): Array<[string, number]> { + return Object.entries(normalizeInsightCountRecord(value)); +} + +function normalizeInsightEnum( + value: unknown, + allowed: readonly T[], + fallback: T, +): T { + const trimmed = typeof value === 'string' ? value.trim() : ''; + if (trimmed) { + const match = allowed.find( + (item) => String(item).toLowerCase() === trimmed.toLowerCase(), + ); + if (match) return match; + } + + logger.debug( + `Normalized unknown insight enum value "${String(value)}" to fallback "${fallback}"`, + ); + return fallback; +} + +function normalizeSessionFacet( + facet: unknown, + sessionId: string, +): SessionFacets | null { + if (!facet || typeof facet !== 'object' || Array.isArray(facet)) { + return null; + } + + const rawFacet = facet as Record; + const normalizedFacet: SessionFacets = { + session_id: sessionId, + underlying_goal: normalizeInsightText(rawFacet['underlying_goal']), + goal_categories: normalizeInsightCountRecord(rawFacet['goal_categories']), + outcome: normalizeInsightEnum( + rawFacet['outcome'], + SESSION_OUTCOMES, + OUTCOME_FALLBACK, + ), + user_satisfaction_counts: normalizeInsightCountRecord( + rawFacet['user_satisfaction_counts'], + ), + Qwen_helpfulness: normalizeInsightEnum( + rawFacet['Qwen_helpfulness'], + QWEN_HELPFULNESS_LEVELS, + 'moderately_helpful', + ), + session_type: normalizeInsightEnum( + rawFacet['session_type'], + SESSION_TYPES, + 'single_task', + ), + friction_counts: normalizeInsightCountRecord(rawFacet['friction_counts']), + friction_detail: normalizeInsightText(rawFacet['friction_detail']), + primary_success: normalizeInsightEnum( + rawFacet['primary_success'], + PRIMARY_SUCCESS_VALUES, + PRIMARY_SUCCESS_FALLBACK, + ), + brief_summary: normalizeInsightText(rawFacet['brief_summary']), + }; + + const meaningfulContent = { + underlying_goal: normalizedFacet.underlying_goal, + goal_categories: normalizedFacet.goal_categories, + outcome: + normalizedFacet.outcome === OUTCOME_FALLBACK + ? '' + : normalizedFacet.outcome, + user_satisfaction_counts: normalizedFacet.user_satisfaction_counts, + friction_counts: normalizedFacet.friction_counts, + friction_detail: normalizedFacet.friction_detail, + primary_success: + normalizedFacet.primary_success === PRIMARY_SUCCESS_FALLBACK + ? '' + : normalizedFacet.primary_success, + brief_summary: normalizedFacet.brief_summary, + }; + + return hasMeaningfulInsightValue(meaningfulContent) ? normalizedFacet : null; +} export class DataProcessor { constructor(private config: Config) {} @@ -213,10 +374,17 @@ export class DataProcessor { return null; } - return { - ...(result as unknown as SessionFacets), - session_id: records[0].sessionId, - }; + const sessionId = records[0].sessionId; + const normalizedFacet = normalizeSessionFacet(result, sessionId); + + if (!normalizedFacet) { + logger.warn( + `Ignoring malformed insight facet for session ${sessionId}`, + ); + return null; + } + + return normalizedFacet; } catch (error) { logger.error( `Failed to analyze session ${records[0]?.sessionId}:`, @@ -343,28 +511,38 @@ export class DataProcessor { facets.forEach((facet) => { // Aggregate satisfaction - Object.entries(facet.user_satisfaction_counts).forEach(([sat, count]) => { - satisfactionAgg[sat] = (satisfactionAgg[sat] || 0) + count; - }); + getInsightCountEntries(facet.user_satisfaction_counts).forEach( + ([sat, count]) => { + satisfactionAgg[sat] = (satisfactionAgg[sat] || 0) + count; + }, + ); // Aggregate friction - Object.entries(facet.friction_counts).forEach(([fric, count]) => { + getInsightCountEntries(facet.friction_counts).forEach(([fric, count]) => { frictionAgg[fric] = (frictionAgg[fric] || 0) + count; }); // Aggregate primary success - if (facet.primary_success && facet.primary_success !== 'none') { - primarySuccessAgg[facet.primary_success] = - (primarySuccessAgg[facet.primary_success] || 0) + 1; + const primarySuccess = normalizeInsightEnum( + facet.primary_success, + PRIMARY_SUCCESS_VALUES, + PRIMARY_SUCCESS_FALLBACK, + ); + if (primarySuccess !== PRIMARY_SUCCESS_FALLBACK) { + primarySuccessAgg[primarySuccess] = + (primarySuccessAgg[primarySuccess] || 0) + 1; } // Aggregate outcomes - if (facet.outcome) { - outcomesAgg[facet.outcome] = (outcomesAgg[facet.outcome] || 0) + 1; - } + const outcome = normalizeInsightEnum( + facet.outcome, + SESSION_OUTCOMES, + OUTCOME_FALLBACK, + ); + outcomesAgg[outcome] = (outcomesAgg[outcome] || 0) + 1; // Aggregate goals - Object.entries(facet.goal_categories).forEach(([goal, count]) => { + getInsightCountEntries(facet.goal_categories).forEach(([goal, count]) => { goalsAgg[goal] = (goalsAgg[goal] || 0) + count; }); }); @@ -671,7 +849,7 @@ export class DataProcessor { ), ); - return { + const qualitative = { impressiveWorkflows, projectAreas, futureOpportunities, @@ -681,6 +859,8 @@ export class DataProcessor { interactionStyle, atAGlance, }; + + return hasMeaningfulInsightValue(qualitative) ? qualitative : undefined; } catch (e) { logger.error('Error generating qualitative insights:', e); return undefined; @@ -700,27 +880,38 @@ export class DataProcessor { facets.forEach((facet) => { // Aggregate goals - Object.entries(facet.goal_categories).forEach(([goal, count]) => { + getInsightCountEntries(facet.goal_categories).forEach(([goal, count]) => { goalsAgg[goal] = (goalsAgg[goal] || 0) + count; }); // Aggregate outcomes - outcomesAgg[facet.outcome] = (outcomesAgg[facet.outcome] || 0) + 1; + const outcome = normalizeInsightEnum( + facet.outcome, + SESSION_OUTCOMES, + OUTCOME_FALLBACK, + ); + outcomesAgg[outcome] = (outcomesAgg[outcome] || 0) + 1; // Aggregate satisfaction - Object.entries(facet.user_satisfaction_counts).forEach(([sat, count]) => { - satisfactionAgg[sat] = (satisfactionAgg[sat] || 0) + count; - }); + getInsightCountEntries(facet.user_satisfaction_counts).forEach( + ([sat, count]) => { + satisfactionAgg[sat] = (satisfactionAgg[sat] || 0) + count; + }, + ); // Aggregate friction - Object.entries(facet.friction_counts).forEach(([fric, count]) => { + getInsightCountEntries(facet.friction_counts).forEach(([fric, count]) => { frictionAgg[fric] = (frictionAgg[fric] || 0) + count; }); // Aggregate success (primary_success) - if (facet.primary_success && facet.primary_success !== 'none') { - successAgg[facet.primary_success] = - (successAgg[facet.primary_success] || 0) + 1; + const primarySuccess = normalizeInsightEnum( + facet.primary_success, + PRIMARY_SUCCESS_VALUES, + PRIMARY_SUCCESS_FALLBACK, + ); + if (primarySuccess !== PRIMARY_SUCCESS_FALLBACK) { + successAgg[primarySuccess] = (successAgg[primarySuccess] || 0) + 1; } }); @@ -748,13 +939,16 @@ export class DataProcessor { // 2. SESSION SUMMARIES section const sessionSummaries = facets - .map((f) => `- ${f.brief_summary}`) + .map((f) => normalizeInsightText(f.brief_summary)) + .filter((summary) => summary.length > 0) + .map((summary) => `- ${summary}`) .join('\n'); // 3. FRICTION DETAILS section const frictionDetails = facets - .filter((f) => f.friction_detail && f.friction_detail.trim().length > 0) - .map((f) => `- ${f.friction_detail}`) + .map((f) => normalizeInsightText(f.friction_detail)) + .filter((detail) => detail.length > 0) + .map((detail) => `- ${detail}`) .join('\n'); return `DATA: @@ -1072,6 +1266,31 @@ None captured`; 'utf-8', ); const existingFacet = JSON.parse(existingData); + const normalizedFacet = normalizeSessionFacet( + existingFacet, + sessionId, + ); + if (!normalizedFacet) { + logger.warn( + `Cached insight facet for ${sessionId} is malformed, regenerating.`, + ); + throw new Error('Malformed cached insight facet'); + } + const normalizedData = JSON.stringify(normalizedFacet, null, 2); + if (existingData !== normalizedData) { + try { + await fs.writeFile( + existingFacetPath, + normalizedData, + 'utf-8', + ); + } catch (writeError) { + logger.warn( + `Failed to write back normalized facet for ${sessionId}:`, + writeError, + ); + } + } completed++; if (onProgress) { const percent = 20 + Math.round((completed / total) * 60); @@ -1081,7 +1300,7 @@ None captured`; `${completed}/${total}`, ); } - return existingFacet; + return normalizedFacet; } catch (readError) { // File doesn't exist or is invalid, proceed to analyze if ((readError as NodeJS.ErrnoException).code !== 'ENOENT') { diff --git a/packages/web-templates/src/insight/src/App.tsx b/packages/web-templates/src/insight/src/App.tsx index 5dd86bcc35..2781204ff7 100644 --- a/packages/web-templates/src/insight/src/App.tsx +++ b/packages/web-templates/src/insight/src/App.tsx @@ -4,6 +4,7 @@ import { StatsRow } from './Header'; import { AtAGlance, NavToc, + type NavTocSection, ProjectAreas, InteractionStyle, ImpressiveWorkflows, @@ -18,6 +19,48 @@ import type { InsightData } from './types'; // eslint-disable-next-line @typescript-eslint/no-unused-vars import React from 'react'; +// Keep in sync with packages/cli/src/services/insight/generators/DataProcessor.ts. +function hasMeaningfulValue(value: unknown): boolean { + if (typeof value === 'string') { + return value.trim().length > 0; + } + + if (typeof value === 'number') { + return Number.isFinite(value) && value !== 0; + } + + if (typeof value === 'boolean') { + return value; + } + + if (Array.isArray(value)) { + return value.some((item) => hasMeaningfulValue(item)); + } + + if (value && typeof value === 'object') { + return Object.values(value).some((item) => hasMeaningfulValue(item)); + } + + return false; +} + +function hasRecordEntries( + value?: Record | Array<[string, number]>, +): boolean { + if (Array.isArray(value)) { + return value.some(([, count]) => Number.isFinite(count) && count !== 0); + } + + return ( + !!value && + Object.values(value).some((count) => Number.isFinite(count) && count !== 0) + ); +} + +function hasMeaningfulArray(value: unknown): boolean { + return Array.isArray(value) && value.some((item) => hasMeaningfulValue(item)); +} + // Main App Component function InsightApp({ data }: { data: InsightData }) { const [cardTheme, setCardTheme] = useState('dark'); @@ -96,6 +139,78 @@ function InsightApp({ data }: { data: InsightData }) { dateRangeStr = `${formatDate(minDate)} to ${formatDate(maxDate)}`; } + const showAtAGlance = hasMeaningfulValue(data.qualitative?.atAGlance); + const showProjectAreas = + !!data.qualitative && + (hasMeaningfulValue(data.qualitative.projectAreas) || + hasRecordEntries(data.topGoals) || + hasRecordEntries(data.topTools)); + const showInteractionStyle = hasMeaningfulValue( + data.qualitative?.interactionStyle, + ); + const showImpressiveWorkflows = + !!data.qualitative && + (hasMeaningfulValue(data.qualitative.impressiveWorkflows) || + hasRecordEntries(data.primarySuccess) || + hasRecordEntries(data.outcomes)); + const showFrictionPoints = + !!data.qualitative && + (hasMeaningfulValue(data.qualitative.frictionPoints) || + hasRecordEntries(data.satisfaction) || + hasRecordEntries(data.friction)); + const showFeatures = + !!data.qualitative && + (hasMeaningfulArray(data.qualitative.improvements?.Qwen_md_additions) || + hasMeaningfulArray(data.qualitative.improvements?.features_to_try)); + const showPatterns = + !!data.qualitative && + hasMeaningfulArray(data.qualitative.improvements?.usage_patterns); + const showFutureOpportunities = hasMeaningfulValue( + data.qualitative?.futureOpportunities, + ); + const showMemorableMoment = hasMeaningfulValue( + data.qualitative?.memorableMoment, + ); + + const navSections: NavTocSection[] = []; + if (showProjectAreas) { + navSections.push({ href: '#section-work', label: 'What You Work On' }); + } + if (showInteractionStyle) { + navSections.push({ + href: '#section-usage', + label: 'How You Use Qwen Code', + }); + } + if (showImpressiveWorkflows) { + navSections.push({ href: '#section-wins', label: 'Impressive Things' }); + } + if (showFrictionPoints) { + navSections.push({ + href: '#section-friction', + label: 'Where Things Go Wrong', + }); + } + if (showFeatures) { + navSections.push({ href: '#section-features', label: 'Features to Try' }); + } + if (showPatterns) { + navSections.push({ + href: '#section-patterns', + label: 'New Usage Patterns', + }); + } + if (showFutureOpportunities) { + navSections.push({ href: '#section-horizon', label: 'On the Horizon' }); + } + + const atAGlanceTargetSections = { + wins: showImpressiveWorkflows, + friction: showFrictionPoints, + features: showFeatures, + horizon: showFutureOpportunities, + }; + return (
{/* Elegant Header */} @@ -115,49 +230,60 @@ function InsightApp({ data }: { data: InsightData }) {
- {data.qualitative && ( - <> - - - + {showAtAGlance && data.qualitative && ( + )} + {navSections.length > 0 && } + - {data.qualitative && ( - <> - - + {showProjectAreas && data.qualitative && ( + )} - {data.qualitative && ( - <> - - + {showInteractionStyle && data.qualitative && ( + )} - {data.qualitative && ( - <> - - - - - - + {showImpressiveWorkflows && ( + )} + {showFrictionPoints && ( + + )} + + {(showFeatures || showPatterns) && data.qualitative && ( + + )} + + {showFutureOpportunities && data.qualitative && ( + + )} + + {showMemorableMoment && data.qualitative && ( + + )} + + {/* + The share card remains available even if qualitative sections are absent. + */} ); diff --git a/packages/web-templates/src/insight/src/Qualitative.tsx b/packages/web-templates/src/insight/src/Qualitative.tsx index bc877c9682..977443e0f9 100644 --- a/packages/web-templates/src/insight/src/Qualitative.tsx +++ b/packages/web-templates/src/insight/src/Qualitative.tsx @@ -10,57 +10,99 @@ import React from 'react'; // Qualitative Insight Components // ----------------------------------------------------------------------------- -export function AtAGlance({ qualitative }: { qualitative: QualitativeData }) { +function hasMeaningfulText(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} + +export interface AtAGlanceTargetSections { + wins: boolean; + friction: boolean; + features: boolean; + horizon: boolean; +} + +export function AtAGlance({ + qualitative, + targetSections, +}: { + qualitative: QualitativeData; + targetSections: AtAGlanceTargetSections; +}) { const { atAGlance } = qualitative; if (!atAGlance) return null; + const sections = [ + { + key: 'wins', + label: "What's working:", + body: atAGlance.whats_working, + href: '#section-wins', + link: 'Impressive Things You Did →', + showLink: targetSections.wins, + }, + { + key: 'friction', + label: "What's hindering you:", + body: atAGlance.whats_hindering, + href: '#section-friction', + link: 'Where Things Go Wrong →', + showLink: targetSections.friction, + }, + { + key: 'features', + label: 'Quick wins to try:', + body: atAGlance.quick_wins, + href: '#section-features', + link: 'Features to Try →', + showLink: targetSections.features, + }, + { + key: 'horizon', + label: 'Ambitious workflows:', + body: atAGlance.ambitious_workflows, + href: '#section-horizon', + link: 'On the Horizon →', + showLink: targetSections.horizon, + }, + ].filter((section) => hasMeaningfulText(section.body)); + + if (sections.length === 0) return null; + return (
At a Glance
-
- What's working:{' '} - {atAGlance.whats_working} - - Impressive Things You Did → - -
-
- What's hindering you:{' '} - {atAGlance.whats_hindering} - - Where Things Go Wrong → - -
-
- Quick wins to try:{' '} - {atAGlance.quick_wins} - - Features to Try → - -
-
- Ambitious workflows:{' '} - {atAGlance.ambitious_workflows} - - On the Horizon → - -
+ {sections.map((section) => ( +
+ {section.label}{' '} + {section.body} + {section.showLink && ( + + {section.link} + + )} +
+ ))}
); } -export function NavToc() { +export interface NavTocSection { + href: string; + label: string; +} + +export function NavToc({ sections }: { sections: NavTocSection[] }) { + if (sections.length === 0) return null; + return ( ); } @@ -176,12 +218,11 @@ export function ImpressiveWorkflows({ primarySuccess, outcomes, }: { - qualitative: QualitativeData; + qualitative?: QualitativeData; primarySuccess: Record; outcomes: Record; }) { - const { impressiveWorkflows } = qualitative; - if (!impressiveWorkflows) return null; + const impressiveWorkflows = qualitative?.impressiveWorkflows; return ( <> @@ -191,22 +232,24 @@ export function ImpressiveWorkflows({ > Impressive Things You Did - {impressiveWorkflows.intro && ( + {impressiveWorkflows?.intro && (

{impressiveWorkflows.intro}

)} -
- {Array.isArray(impressiveWorkflows.impressive_workflows) && - impressiveWorkflows.impressive_workflows.map((win, idx) => ( -
-
{win.title}
-
- {win.description} + {impressiveWorkflows && ( +
+ {Array.isArray(impressiveWorkflows.impressive_workflows) && + impressiveWorkflows.impressive_workflows.map((win, idx) => ( +
+
{win.title}
+
+ {win.description} +
-
- ))} -
+ ))} +
+ )}
Number.isFinite(count) && count !== 0, + ); if (allowedKeys) { entries = entries.filter(([key]) => allowedKeys.includes(key)); } @@ -400,12 +445,11 @@ export function FrictionPoints({ satisfaction, friction, }: { - qualitative: QualitativeData; + qualitative?: QualitativeData; satisfaction?: Record; friction?: Record; }) { - const { frictionPoints } = qualitative; - if (!frictionPoints) return null; + const frictionPoints = qualitative?.frictionPoints; return ( <> @@ -415,31 +459,33 @@ export function FrictionPoints({ > Where Things Go Wrong - {frictionPoints.intro && ( + {frictionPoints?.intro && (

{frictionPoints.intro}

)} -
- {Array.isArray(frictionPoints.categories) && - frictionPoints.categories.map((cat, idx) => ( -
-
{cat.category}
-
- {cat.description} + {frictionPoints && ( +
+ {Array.isArray(frictionPoints.categories) && + frictionPoints.categories.map((cat, idx) => ( +
+
{cat.category}
+
+ {cat.description} +
+ {Array.isArray(cat.examples) && cat.examples.length > 0 && ( +
    + {cat.examples.map((ex, i) => ( +
  • + {ex} +
  • + ))} +
+ )}
- {Array.isArray(cat.examples) && cat.examples.length > 0 && ( -
    - {cat.examples.map((ex, i) => ( -
  • - {ex} -
  • - ))} -
- )} -
- ))} -
+ ))} +
+ )} {/* Facets Data Charts */}
0) || + (Array.isArray(improvements.features_to_try) && + improvements.features_to_try.length > 0); + const hasUsagePatterns = + Array.isArray(improvements.usage_patterns) && + improvements.usage_patterns.length > 0; + + if (!hasFeatureSuggestions && !hasUsagePatterns) return null; + return ( <> -

- Existing Qwen Code Features to Try -

+ {hasFeatureSuggestions && ( + <> +

+ Existing Qwen Code Features to Try +

- {/* QWEN.md Additions */} - {Array.isArray(improvements.Qwen_md_additions) && - improvements.Qwen_md_additions.length > 0 && ( - - )} + {/* QWEN.md Additions */} + {Array.isArray(improvements.Qwen_md_additions) && + improvements.Qwen_md_additions.length > 0 && ( + + )} -

- Just copy this into Qwen Code and it'll set it up for you. -

+

+ Just copy this into Qwen Code and it'll set it up for you. +

- {/* Features to Try */} -
- {Array.isArray(improvements.features_to_try) && - improvements.features_to_try.map((feat, idx) => ( -
-
{feat.feature}
-
- {feat.one_liner} -
-
- Why for you:{' '} - {feat.why_for_you} -
-
-
-
- {feat.example_code} - + {/* Features to Try */} +
+ {Array.isArray(improvements.features_to_try) && + improvements.features_to_try.map((feat, idx) => ( +
+
{feat.feature}
+
+ {feat.one_liner} +
+
+ Why for you:{' '} + {feat.why_for_you} +
+
+
+
+ + {feat.example_code} + + +
+
-
-
- ))} -
+ ))} +
+ + )} -

- New Ways to Use Qwen Code -

-

- Just copy this into Qwen Code and it'll walk you through it. -

+ {hasUsagePatterns && ( + <> +

+ New Ways to Use Qwen Code +

+

+ Just copy this into Qwen Code and it'll walk you through it. +

-
- {Array.isArray(improvements.usage_patterns) && - improvements.usage_patterns.map((pat, idx) => ( -
-
{pat.title}
-
- {pat.suggestion} -
-
- {pat.detail} -
-
-
Paste into Qwen Code:
-
- {pat.copyable_prompt} - +
+ {Array.isArray(improvements.usage_patterns) && + improvements.usage_patterns.map((pat, idx) => ( +
+
{pat.title}
+
+ {pat.suggestion} +
+
+ {pat.detail} +
+
+
Paste into Qwen Code:
+
+ + {pat.copyable_prompt} + + +
+
-
-
- ))} -
+ ))} +
+ + )} ); }