Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"./utils/pricing": "./dist/utils/pricing.js",
"./utils/telemetry": "./dist/utils/telemetry.js",
"./analysis/prompts": "./dist/analysis/prompts.js",
"./analysis/turn-citations": "./dist/analysis/turn-citations.js",
"./analysis/prompt-types": "./dist/analysis/prompt-types.js",
"./analysis/prompt-constants": "./dist/analysis/prompt-constants.js",
"./analysis/message-format": "./dist/analysis/message-format.js",
Expand Down
28 changes: 24 additions & 4 deletions cli/src/analysis/__tests__/analysis-db.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,8 @@ const PQ_RESPONSE: PromptQualityResponse = {
// ── ANALYSIS_VERSION ──────────────────────────────────────────────────────────

describe('ANALYSIS_VERSION', () => {
it('is 3.0.0', () => {
expect(ANALYSIS_VERSION).toBe('3.0.0');
it('is 3.1.0', () => {
expect(ANALYSIS_VERSION).toBe('3.1.0');
});
});

Expand Down Expand Up @@ -158,6 +158,26 @@ describe('convertToInsightRows', () => {
expect(decision!.confidence).toBeCloseTo(0.85);
});

it('persists user decision agency and explicit intent/branch point', () => {
const response: AnalysisResponse = { ...ANALYSIS_RESPONSE, decisions: [
{ ...ANALYSIS_RESPONSE.decisions[0], decided_by: 'user', intent: 'Stateless authentication', branch_point: 'Session cookies' },
] };
const row = convertToInsightRows(response, SESSION).find(r => r.type === 'decision');
expect(JSON.parse(row!.metadata!)).toMatchObject({
decided_by: 'user', intent: 'Stateless authentication', branch_point: 'Session cookies',
});
});

it('marks missing or invalid agency unknown rather than collaborative', () => {
const absent = convertToInsightRows(ANALYSIS_RESPONSE, SESSION).find(r => r.type === 'decision');
expect(JSON.parse(absent!.metadata!).decided_by).toBe('unknown');
const bad = { ...ANALYSIS_RESPONSE, decisions: [
{ ...ANALYSIS_RESPONSE.decisions[0], decided_by: 'invalid' },
] } as unknown as AnalysisResponse;
const result = convertToInsightRows(bad, SESSION).find(r => r.type === 'decision');
expect(JSON.parse(result!.metadata!).decided_by).toBe('unknown');
});

it('skips decisions with confidence < 70', () => {
const response: AnalysisResponse = {
...ANALYSIS_RESPONSE,
Expand Down Expand Up @@ -271,8 +291,8 @@ describe('saveFacetsToDb analysisVersion parameter', () => {
expect(row.analysis_version).toBe('2.5.0');
});

it('default ANALYSIS_VERSION constant is 3.0.0', () => {
it('default ANALYSIS_VERSION constant is 3.1.0', () => {
// Verifies the constant that will be used as the default
expect(ANALYSIS_VERSION).toBe('3.0.0');
expect(ANALYSIS_VERSION).toBe('3.1.0');
});
});
9 changes: 9 additions & 0 deletions cli/src/analysis/__tests__/prompts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,15 @@ describe('buildSessionAnalysisInstructions', () => {
expect(result).toContain('<output_schema>');
});

it('requires agency grounded in explicit zero-based turn evidence', () => {
const prompt = buildSessionAnalysisInstructions('proj', null);
expect(prompt).toContain('decision_attribution name="decided_by"');
expect(prompt).toContain('"decided_by": "user | agent | collaborative | unknown"');
expect(prompt).toContain('Role-specific turn numbering starts at 0');
expect(prompt).toContain('never fabricate citations');
expect(prompt).toContain('never infer collaboration');
});

it('ends with json tags instruction', () => {
const result = buildSessionAnalysisInstructions('proj', null);
expect(result).toContain('<json>...</json>');
Expand Down
28 changes: 28 additions & 0 deletions cli/src/analysis/__tests__/turn-citations.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, it, expect } from 'vitest';
import { resolveTurnReference } from '../turn-citations.js';

const messages = [
{ id: 'u0', type: 'user', content: 'Choose SQLite' },
{ id: 'a0', type: 'assistant', content: 'I propose D1' },
{ id: 'tool', type: 'user', content: '[{"type":"tool_result","content":"ok"}]' },
{ id: 'compact', type: 'user', content: 'Here is a summary of our conversation so far' },
{ id: 'slash', type: 'user', content: '/compact' },
{ id: 'u1', type: 'user', content: 'No, use SQLite' },
{ id: 'a1', type: 'assistant', content: 'Agreed' },
{ id: 'sys', type: 'system', content: 'Runtime event' },
];

describe('resolveTurnReference', () => {
it('matches human role ordinals while excluding tool/system artifacts', () => {
expect(resolveTurnReference(messages, 'User', 0)).toEqual({ messageId: 'u0', offset: 0 });
expect(resolveTurnReference(messages, 'User', 1)).toEqual({ messageId: 'u1', offset: 5 });
expect(resolveTurnReference(messages, 'Assistant', 1)).toEqual({ messageId: 'a1', offset: 6 });
expect(resolveTurnReference(messages, 'User', 2)).toBeNull();
});
it('rejects invalid, unsafe and out-of-range indices', () => {
expect(resolveTurnReference(messages, 'User', -1)).toBeNull();
expect(resolveTurnReference(messages, 'User', 1.5)).toBeNull();
expect(resolveTurnReference(messages, 'User', Number.MAX_SAFE_INTEGER + 1)).toBeNull();
expect(resolveTurnReference(messages, 'Assistant', 2)).toBeNull();
});
});
8 changes: 7 additions & 1 deletion cli/src/analysis/analysis-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import type { AnalysisResponse, PromptQualityResponse } from './prompt-types.js'
import { normalizePatternCategory } from './pattern-normalize.js';
import { normalizePromptQualityCategory } from './prompt-quality-normalize.js';

export const ANALYSIS_VERSION = '3.0.0';
export const ANALYSIS_VERSION = '3.1.0';

// Shape of a saved insight row (matches the SQLite schema)
export interface InsightRow {
Expand Down Expand Up @@ -109,6 +109,12 @@ export function convertToInsightRows(response: AnalysisResponse, session: Sessio
trade_offs: decision.trade_offs,
revisit_when: decision.revisit_when,
evidence: decision.evidence,
// Do not claim collaboration when older analyses lack explicit provenance.
decided_by: ['user', 'agent', 'collaborative'].includes(decision.decided_by ?? '')
? decision.decided_by
: 'unknown',
intent: typeof decision.intent === 'string' ? decision.intent : undefined,
branch_point: typeof decision.branch_point === 'string' ? decision.branch_point : undefined,
}),
timestamp: session.ended_at,
created_at: now,
Expand Down
4 changes: 4 additions & 0 deletions cli/src/analysis/prompt-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ export interface AnalysisResponse {
revisit_when?: string;
confidence?: number;
evidence?: string[];
/** Agency evidenced by actual turn references. */
decided_by?: 'user' | 'agent' | 'collaborative' | 'unknown';
intent?: string;
branch_point?: string;
}>;
learnings: Array<{
title: string;
Expand Down
7 changes: 6 additions & 1 deletion cli/src/analysis/prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,15 +125,17 @@ ${sessionSummary ? ` <session_summary>${sessionSummary}</session_summary>\n` :
<rules>
1. Enforce strict JSON output schema.
2. Extract insights containing ONLY concrete references (file paths, endpoints, variables, errors). Filter out generic findings automatically.
3. Include 1-3 literal quote citations per insight referencing turn labels (e.g., "User#5").
3. Include 1-3 literal quote citations per insight referencing actual transcript turns (e.g., "User#0" or "Assistant#2"). Role-specific turn numbering starts at 0; never fabricate citations.
4. Require a minimum confidence score of 70 for any decision or learning. Drop insights below this threshold.
5. Return empty arrays for categories yielding no valid findings.
6. Fill every field in the schema. Use null for unavailable data where permitted.
7. Attribute each decision to who actually selected the choice: user, agent, collaborative, or unknown. Ground decided_by in cited turn evidence; never infer collaboration merely because both parties spoke.
</rules>

<definitions>
<facet name="outcome_satisfaction">high | medium | low | abandoned</facet>
<facet name="workflow_pattern">plan-then-implement | iterative-refinement | debug-fix-verify | explore-then-build | direct-execution | null</facet>
<decision_attribution name="decided_by">user: explicit user choice | agent: autonomous AI choice | collaborative: jointly explored and agreed choice | unknown: insufficient or contradictory turn evidence</decision_attribution>
</definitions>

${FRICTION_CLASSIFICATION_GUIDANCE}
Expand Down Expand Up @@ -181,6 +183,9 @@ ${EFFECTIVE_PATTERN_CLASSIFICATION_GUIDANCE}
"situation": "Problem context",
"choice": "Chosen implementation",
"reasoning": "Decision factors",
"decided_by": "user | agent | collaborative | unknown",
"intent": "Why the chosen approach was needed",
"branch_point": "Alternative path or design explicitly rejected",
"alternatives": [{"option": "Name", "rejected_because": "Reason"}],
"trade_offs": "Accepted downsides",
"revisit_when": "Reconsideration conditions",
Expand Down
3 changes: 3 additions & 0 deletions cli/src/analysis/schemas/session-analysis.json
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,9 @@
"situation": { "type": "string" },
"choice": { "type": "string" },
"reasoning": { "type": "string" },
"decided_by": { "type": "string", "enum": ["user", "agent", "collaborative", "unknown"] },
"intent": { "type": "string" },
"branch_point": { "type": "string" },
"alternatives": {
"type": "array",
"items": {
Expand Down
31 changes: 31 additions & 0 deletions cli/src/analysis/turn-citations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { classifyStoredUserMessage } from './message-format.js';

export type TurnRole = 'User' | 'Assistant';
export interface CitableMessage {
id: string;
type: string;
content: string;
}
export interface ResolvedTurn {
messageId: string;
offset: number;
}

/** Match formatMessagesForAnalysis's zero-based role ordinals and human-only User turns. */
export function resolveTurnReference(
messages: CitableMessage[],
role: TurnRole,
ordinal: number,
): ResolvedTurn | null {
if (!Number.isSafeInteger(ordinal) || ordinal < 0 || (role !== 'User' && role !== 'Assistant')) return null;
let seen = 0;
for (let offset = 0; offset < messages.length; offset++) {
const message = messages[offset];
const matches = role === 'User'
? message.type === 'user' && classifyStoredUserMessage(message.content) === 'human'
: message.type === 'assistant';
if (!matches) continue;
if (seen++ === ordinal) return { messageId: message.id, offset };
}
return null;
}
3 changes: 3 additions & 0 deletions cli/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,9 @@ export interface InsightMetadata {
trade_offs?: string;
revisit_when?: string;
evidence?: string[];
decided_by?: 'user' | 'agent' | 'collaborative' | 'unknown';
intent?: string;
branch_point?: string;
// Learning-specific (v3.0.0 decomposed schema)
symptom?: string;
root_cause?: string;
Expand Down
36 changes: 33 additions & 3 deletions dashboard/src/components/insights/insight-metadata.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,17 +83,44 @@ export function formatAlternatives(alternatives: InsightMetadata['alternatives']
}).join('; ');
}

/** Render legacy evidence as text and canonical role refs as optional jump links. */
export function EvidenceCitations({ evidence, onTurnClick }: {
evidence: string[];
onTurnClick?: (ref: string) => void;
}) {
return (
<span className="inline-flex flex-wrap gap-x-2 gap-y-1">
{evidence.map((item, i) => {
const match = typeof item === 'string'
? /^(User|Assistant)#(0|[1-9][0-9]*)(?::\s+.*)?$/.exec(item.trim())
: null;
const ref = match ? `${match[1]}#${match[2]}` : null;
return ref && onTurnClick ? (
<button key={i} type="button" onClick={() => onTurnClick(ref)}
className="text-primary hover:underline underline-offset-2 text-left"
title={`Open ${ref} in conversation`}>{item}</button>
) : <span key={i}>{item}</span>;
})}
</span>
);
}

// --- Type-specific content components ---

export function DecisionContent({ metadata }: { metadata: InsightMetadata }) {
export function DecisionContent({ metadata, onTurnClick }: { metadata: InsightMetadata; onTurnClick?: (ref: string) => void }) {
const hasStructured = metadata.situation || metadata.choice || metadata.reasoning;
if (!hasStructured) return null;
const agencyLabels = { user: 'User decision', agent: 'Agent decision', collaborative: 'Collaborative decision', unknown: 'Agency unverified' } as const;
const agency = metadata.decided_by && agencyLabels[metadata.decided_by];

return (
<div className="space-y-2.5">
{agency && <Badge variant="outline" className="text-xs">{agency}</Badge>}
{metadata.situation && <MetadataSection label="Situation">{metadata.situation}</MetadataSection>}
{metadata.choice && <MetadataSection label="Choice" prominent>{metadata.choice}</MetadataSection>}
{metadata.reasoning && <MetadataSection label="Reasoning">{metadata.reasoning}</MetadataSection>}
{metadata.intent && <MetadataSection label="Intent">{metadata.intent}</MetadataSection>}
{metadata.branch_point && <MetadataSection label="Branch Point">{metadata.branch_point}</MetadataSection>}
{metadata.alternatives && metadata.alternatives.length > 0 && (
<div className="space-y-0.5">
<span className="flex items-center gap-1 text-xs font-medium text-muted-foreground">
Expand All @@ -119,13 +146,13 @@ export function DecisionContent({ metadata }: { metadata: InsightMetadata }) {
<MetadataSection label="Revisit When">{metadata.revisit_when}</MetadataSection>
)}
{metadata.evidence && metadata.evidence.length > 0 && (
<MetadataSection label="Evidence">{metadata.evidence.join(', ')}</MetadataSection>
<MetadataSection label="Evidence"><EvidenceCitations evidence={metadata.evidence} onTurnClick={onTurnClick} /></MetadataSection>
)}
</div>
);
}

export function LearningContent({ metadata }: { metadata: InsightMetadata }) {
export function LearningContent({ metadata, onTurnClick }: { metadata: InsightMetadata; onTurnClick?: (ref: string) => void }) {
const hasStructured = metadata.symptom || metadata.root_cause || metadata.takeaway;
if (!hasStructured) return null;

Expand All @@ -135,6 +162,9 @@ export function LearningContent({ metadata }: { metadata: InsightMetadata }) {
{metadata.root_cause && <MetadataSection label="Why">{metadata.root_cause}</MetadataSection>}
{metadata.takeaway && <MetadataSection label="Takeaway" prominent>{metadata.takeaway}</MetadataSection>}
{metadata.applies_when && <MetadataSection label="Applies When">{metadata.applies_when}</MetadataSection>}
{metadata.evidence && metadata.evidence.length > 0 && (
<MetadataSection label="Evidence"><EvidenceCitations evidence={metadata.evidence} onTurnClick={onTurnClick} /></MetadataSection>
)}
</div>
);
}
Expand Down
6 changes: 3 additions & 3 deletions dashboard/src/components/sessions/CollapsibleInsightItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { ChevronRight, ChevronDown } from 'lucide-react';

/** Per-item collapsible for learnings and decisions. Compact row with
* expand toggle to reveal full structured metadata. */
export function CollapsibleInsightItem({ insight }: { insight: Insight }) {
export function CollapsibleInsightItem({ insight, onTurnClick }: { insight: Insight; onTurnClick?: (ref: string) => void }) {
const [expanded, setExpanded] = useState(false);
const metadata = parseJsonField<InsightMetadata>(insight.metadata, {});

Expand Down Expand Up @@ -44,9 +44,9 @@ export function CollapsibleInsightItem({ insight }: { insight: Insight }) {
insight.type === 'decision' ? 'border-blue-500/40' : 'border-green-500/40'
)}>
{insight.type === 'decision' ? (
<DecisionContent metadata={metadata} />
<DecisionContent metadata={metadata} onTurnClick={onTurnClick} />
) : (
<LearningContent metadata={metadata} />
<LearningContent metadata={metadata} onTurnClick={onTurnClick} />
)}
</div>
)}
Expand Down
Loading
Loading