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
This commit is contained in:
Dragon 2026-06-02 10:51:15 +08:00 committed by GitHub
parent 4e1b3827e9
commit c52c9f172b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 958 additions and 208 deletions

View file

@ -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<string, number>;
frictionAgg: Record<string, number>;
goalsAgg: Record<string, number>;
};
}
).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<string, number>;
frictionAgg: Record<string, number>;
primarySuccessAgg: Record<string, number>;
outcomesAgg: Record<string, number>;
goalsAgg: Record<string, number>;
};
}
).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<SessionFacets | null>;
}
).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<SessionFacets | null>;
}
).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<InsightData, 'facets' | 'qualitative'>,
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<SessionFacets[]>;
}
).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<SessionFacets[]>;
}
).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,
);
});
});
});

View file

@ -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<string, number> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {};
}
return Object.entries(value).reduce<Record<string, number>>(
(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<T extends string>(
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<string, unknown>;
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') {

View file

@ -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<string, number> | 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<Theme>('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 (
<div>
{/* Elegant Header */}
@ -115,49 +230,60 @@ function InsightApp({ data }: { data: InsightData }) {
</div>
</header>
{data.qualitative && (
<>
<AtAGlance qualitative={data.qualitative} />
<NavToc />
</>
{showAtAGlance && data.qualitative && (
<AtAGlance
qualitative={data.qualitative}
targetSections={atAGlanceTargetSections}
/>
)}
{navSections.length > 0 && <NavToc sections={navSections} />}
<StatsRow data={data} />
{data.qualitative && (
<>
<ProjectAreas
qualitative={data.qualitative}
topGoals={data.topGoals}
topTools={data.topTools}
/>
</>
{showProjectAreas && data.qualitative && (
<ProjectAreas
qualitative={data.qualitative}
topGoals={data.topGoals}
topTools={data.topTools}
/>
)}
{data.qualitative && (
<>
<InteractionStyle qualitative={data.qualitative} insights={data} />
</>
{showInteractionStyle && data.qualitative && (
<InteractionStyle qualitative={data.qualitative} insights={data} />
)}
{data.qualitative && (
<>
<ImpressiveWorkflows
qualitative={data.qualitative}
primarySuccess={data.primarySuccess!}
outcomes={data.outcomes!}
/>
<FrictionPoints
qualitative={data.qualitative}
satisfaction={data.satisfaction}
friction={data.friction}
/>
<Improvements qualitative={data.qualitative} />
<FutureOpportunities qualitative={data.qualitative} />
<MemorableMoment qualitative={data.qualitative} />
</>
{showImpressiveWorkflows && (
<ImpressiveWorkflows
qualitative={data.qualitative}
primarySuccess={data.primarySuccess ?? {}}
outcomes={data.outcomes ?? {}}
/>
)}
{showFrictionPoints && (
<FrictionPoints
qualitative={data.qualitative}
satisfaction={data.satisfaction ?? {}}
friction={data.friction ?? {}}
/>
)}
{(showFeatures || showPatterns) && data.qualitative && (
<Improvements qualitative={data.qualitative} />
)}
{showFutureOpportunities && data.qualitative && (
<FutureOpportunities qualitative={data.qualitative} />
)}
{showMemorableMoment && data.qualitative && (
<MemorableMoment qualitative={data.qualitative} />
)}
{/*
The share card remains available even if qualitative sections are absent.
*/}
<ShareCard data={data} theme={cardTheme} />
</div>
);

View file

@ -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 (
<div className="at-a-glance">
<div className="glance-title">At a Glance</div>
<div className="glance-sections">
<div className="glance-section">
<strong>What&apos;s working:</strong>{' '}
<MarkdownText>{atAGlance.whats_working}</MarkdownText>
<a href="#section-wins" className="see-more">
Impressive Things You Did
</a>
</div>
<div className="glance-section">
<strong>What&apos;s hindering you:</strong>{' '}
<MarkdownText>{atAGlance.whats_hindering}</MarkdownText>
<a href="#section-friction" className="see-more">
Where Things Go Wrong
</a>
</div>
<div className="glance-section">
<strong>Quick wins to try:</strong>{' '}
<MarkdownText>{atAGlance.quick_wins}</MarkdownText>
<a href="#section-features" className="see-more">
Features to Try
</a>
</div>
<div className="glance-section">
<strong>Ambitious workflows:</strong>{' '}
<MarkdownText>{atAGlance.ambitious_workflows}</MarkdownText>
<a href="#section-horizon" className="see-more">
On the Horizon
</a>
</div>
{sections.map((section) => (
<div key={section.key} className="glance-section">
<strong>{section.label}</strong>{' '}
<MarkdownText>{section.body}</MarkdownText>
{section.showLink && (
<a href={section.href} className="see-more">
{section.link}
</a>
)}
</div>
))}
</div>
</div>
);
}
export function NavToc() {
export interface NavTocSection {
href: string;
label: string;
}
export function NavToc({ sections }: { sections: NavTocSection[] }) {
if (sections.length === 0) return null;
return (
<nav className="nav-toc">
<a href="#section-work">What You Work On</a>
<a href="#section-usage">How You Use Qwen Code</a>
<a href="#section-wins">Impressive Things</a>
<a href="#section-friction">Where Things Go Wrong</a>
<a href="#section-features">Features to Try</a>
<a href="#section-patterns">New Usage Patterns</a>
<a href="#section-horizon">On the Horizon</a>
{sections.map((section) => (
<a key={section.href} href={section.href}>
{section.label}
</a>
))}
</nav>
);
}
@ -176,12 +218,11 @@ export function ImpressiveWorkflows({
primarySuccess,
outcomes,
}: {
qualitative: QualitativeData;
qualitative?: QualitativeData;
primarySuccess: Record<string, number>;
outcomes: Record<string, number>;
}) {
const { impressiveWorkflows } = qualitative;
if (!impressiveWorkflows) return null;
const impressiveWorkflows = qualitative?.impressiveWorkflows;
return (
<>
@ -191,22 +232,24 @@ export function ImpressiveWorkflows({
>
Impressive Things You Did
</h2>
{impressiveWorkflows.intro && (
{impressiveWorkflows?.intro && (
<p className="section-intro">
<MarkdownText>{impressiveWorkflows.intro}</MarkdownText>
</p>
)}
<div className="big-wins">
{Array.isArray(impressiveWorkflows.impressive_workflows) &&
impressiveWorkflows.impressive_workflows.map((win, idx) => (
<div key={idx} className="big-win">
<div className="big-win-title">{win.title}</div>
<div className="big-win-desc">
<MarkdownText>{win.description}</MarkdownText>
{impressiveWorkflows && (
<div className="big-wins">
{Array.isArray(impressiveWorkflows.impressive_workflows) &&
impressiveWorkflows.impressive_workflows.map((win, idx) => (
<div key={idx} className="big-win">
<div className="big-win-title">{win.title}</div>
<div className="big-win-desc">
<MarkdownText>{win.description}</MarkdownText>
</div>
</div>
</div>
))}
</div>
))}
</div>
)}
<div
style={{
@ -277,7 +320,9 @@ function HorizontalBarChart({
if (!data || Object.keys(data).length === 0) return null;
// Filter and sort entries
let entries = Object.entries(data);
let entries = Object.entries(data).filter(
([, count]) => 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<string, number>;
friction?: Record<string, number>;
}) {
const { frictionPoints } = qualitative;
if (!frictionPoints) return null;
const frictionPoints = qualitative?.frictionPoints;
return (
<>
@ -415,31 +459,33 @@ export function FrictionPoints({
>
Where Things Go Wrong
</h2>
{frictionPoints.intro && (
{frictionPoints?.intro && (
<p className="section-intro">
<MarkdownText>{frictionPoints.intro}</MarkdownText>
</p>
)}
<div className="friction-categories">
{Array.isArray(frictionPoints.categories) &&
frictionPoints.categories.map((cat, idx) => (
<div key={idx} className="friction-category">
<div className="friction-title">{cat.category}</div>
<div className="friction-desc">
<MarkdownText>{cat.description}</MarkdownText>
{frictionPoints && (
<div className="friction-categories">
{Array.isArray(frictionPoints.categories) &&
frictionPoints.categories.map((cat, idx) => (
<div key={idx} className="friction-category">
<div className="friction-title">{cat.category}</div>
<div className="friction-desc">
<MarkdownText>{cat.description}</MarkdownText>
</div>
{Array.isArray(cat.examples) && cat.examples.length > 0 && (
<ul className="friction-examples">
{cat.examples.map((ex, i) => (
<li key={i}>
<MarkdownText>{ex}</MarkdownText>
</li>
))}
</ul>
)}
</div>
{Array.isArray(cat.examples) && cat.examples.length > 0 && (
<ul className="friction-examples">
{cat.examples.map((ex, i) => (
<li key={i}>
<MarkdownText>{ex}</MarkdownText>
</li>
))}
</ul>
)}
</div>
))}
</div>
))}
</div>
)}
{/* Facets Data Charts */}
<div
@ -566,81 +612,106 @@ export function Improvements({
const { improvements } = qualitative;
if (!improvements) return null;
const hasFeatureSuggestions =
(Array.isArray(improvements.Qwen_md_additions) &&
improvements.Qwen_md_additions.length > 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 (
<>
<h2
id="section-features"
className="text-xl font-semibold text-slate-900 mt-8 mb-4"
>
Existing Qwen Code Features to Try
</h2>
{hasFeatureSuggestions && (
<>
<h2
id="section-features"
className="text-xl font-semibold text-slate-900 mt-8 mb-4"
>
Existing Qwen Code Features to Try
</h2>
{/* QWEN.md Additions */}
{Array.isArray(improvements.Qwen_md_additions) &&
improvements.Qwen_md_additions.length > 0 && (
<QwenMdAdditionsSection additions={improvements.Qwen_md_additions} />
)}
{/* QWEN.md Additions */}
{Array.isArray(improvements.Qwen_md_additions) &&
improvements.Qwen_md_additions.length > 0 && (
<QwenMdAdditionsSection
additions={improvements.Qwen_md_additions}
/>
)}
<p className="text-xs text-slate-500 mb-3">
Just copy this into Qwen Code and it&apos;ll set it up for you.
</p>
<p className="text-xs text-slate-500 mb-3">
Just copy this into Qwen Code and it&apos;ll set it up for you.
</p>
{/* Features to Try */}
<div className="features-section">
{Array.isArray(improvements.features_to_try) &&
improvements.features_to_try.map((feat, idx) => (
<div key={idx} className="feature-card">
<div className="feature-title">{feat.feature}</div>
<div className="feature-oneliner">
<MarkdownText>{feat.one_liner}</MarkdownText>
</div>
<div className="feature-why">
<strong>Why for you:</strong>{' '}
<MarkdownText>{feat.why_for_you}</MarkdownText>
</div>
<div className="feature-examples">
<div className="feature-example">
<div className="example-code-row">
<code className="example-code">{feat.example_code}</code>
<CopyButton text={feat.example_code} />
{/* Features to Try */}
<div className="features-section">
{Array.isArray(improvements.features_to_try) &&
improvements.features_to_try.map((feat, idx) => (
<div key={idx} className="feature-card">
<div className="feature-title">{feat.feature}</div>
<div className="feature-oneliner">
<MarkdownText>{feat.one_liner}</MarkdownText>
</div>
<div className="feature-why">
<strong>Why for you:</strong>{' '}
<MarkdownText>{feat.why_for_you}</MarkdownText>
</div>
<div className="feature-examples">
<div className="feature-example">
<div className="example-code-row">
<code className="example-code">
{feat.example_code}
</code>
<CopyButton text={feat.example_code} />
</div>
</div>
</div>
</div>
</div>
</div>
))}
</div>
))}
</div>
</>
)}
<h2
id="section-patterns"
className="text-xl font-semibold text-slate-900 mt-8 mb-4"
>
New Ways to Use Qwen Code
</h2>
<p className="text-xs text-slate-500 mb-3">
Just copy this into Qwen Code and it&apos;ll walk you through it.
</p>
{hasUsagePatterns && (
<>
<h2
id="section-patterns"
className="text-xl font-semibold text-slate-900 mt-8 mb-4"
>
New Ways to Use Qwen Code
</h2>
<p className="text-xs text-slate-500 mb-3">
Just copy this into Qwen Code and it&apos;ll walk you through it.
</p>
<div className="patterns-section">
{Array.isArray(improvements.usage_patterns) &&
improvements.usage_patterns.map((pat, idx) => (
<div key={idx} className="pattern-card">
<div className="pattern-title">{pat.title}</div>
<div className="pattern-summary">
<MarkdownText>{pat.suggestion}</MarkdownText>
</div>
<div className="pattern-detail">
<MarkdownText>{pat.detail}</MarkdownText>
</div>
<div className="copyable-prompt-section">
<div className="prompt-label">Paste into Qwen Code:</div>
<div className="copyable-prompt-row">
<code className="copyable-prompt">{pat.copyable_prompt}</code>
<CopyButton text={pat.copyable_prompt} />
<div className="patterns-section">
{Array.isArray(improvements.usage_patterns) &&
improvements.usage_patterns.map((pat, idx) => (
<div key={idx} className="pattern-card">
<div className="pattern-title">{pat.title}</div>
<div className="pattern-summary">
<MarkdownText>{pat.suggestion}</MarkdownText>
</div>
<div className="pattern-detail">
<MarkdownText>{pat.detail}</MarkdownText>
</div>
<div className="copyable-prompt-section">
<div className="prompt-label">Paste into Qwen Code:</div>
<div className="copyable-prompt-row">
<code className="copyable-prompt">
{pat.copyable_prompt}
</code>
<CopyButton text={pat.copyable_prompt} />
</div>
</div>
</div>
</div>
</div>
))}
</div>
))}
</div>
</>
)}
</>
);
}