Skip to content
Merged
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
18 changes: 18 additions & 0 deletions src/server/core/domain/entities/query-log-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
export const QUERY_LOG_TIERS = [0, 1, 2, 3, 4] as const
export type QueryLogTier = (typeof QUERY_LOG_TIERS)[number]

export type TierKey = `tier${QueryLogTier}`

/** Human-readable labels for each resolution tier. */
export const QUERY_LOG_TIER_LABELS: Record<QueryLogTier, string> = {
0: 'exact cache hit',
Expand All @@ -15,6 +17,22 @@ export const QUERY_LOG_TIER_LABELS: Record<QueryLogTier, string> = {
4: 'full agentic',
}

/** Tiers considered cache hits for cache-hit-rate calculation. */
export const CACHE_TIERS = [0, 1] as const satisfies readonly QueryLogTier[]

export type ByTier = Record<TierKey, number> & {unknown: number}

// Single `as` contained here — TS cannot prove loop exhaustiveness over template literal keys.
export function emptyByTier(): ByTier {
const obj: Record<string, number> = {}
for (const t of QUERY_LOG_TIERS) {
obj[`tier${t}`] = 0
}

obj.unknown = 0
return obj as ByTier
}

/** Valid query log statuses. Add/remove here — the type updates automatically. */
export const QUERY_LOG_STATUSES = ['cancelled', 'completed', 'error', 'processing'] as const
export type QueryLogStatus = (typeof QUERY_LOG_STATUSES)[number]
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,12 @@
// Interface driven by `brv query-log summary` command (ENG-1899).
// Full implementation of data computation, text format, and JSON format in ENG-1898.
import type {ByTier} from '../../domain/entities/query-log-entry.js'

export type QueryLogSummary = {
byStatus: {
cancelled: number
completed: number
error: number
}
byTier: {
tier0: number
tier1: number
tier2: number
tier3: number
tier4: number
unknown: number
}
byTier: ByTier
cacheHitRate: number
coverageRate: number
knowledgeGaps: {
Expand Down
53 changes: 37 additions & 16 deletions src/server/infra/usecase/query-log-summary-narrative-formatter.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,20 @@
import type {QueryLogSummary} from '../../core/interfaces/usecase/i-query-log-summary-use-case.js'

const EMPTY_STATE_MESSAGE =
'No queries recorded in the last 24 hours. Your knowledge base is ready — try asking a question!'

const NARRATIVE_TOP_DOCS = 2
const NARRATIVE_TOP_GAPS = 2
const MS_PER_HOUR = 3_600_000
const MS_PER_DAY = 86_400_000

/**
* Format a QueryLogSummary as a human-readable value story.
*
* Pure formatting — no computation. Consumes the data already computed
* by QueryLogSummaryUseCase (ENG-1898) and wraps it in prose aimed at
* conveying ByteRover recall value to end users.
*/
export function formatQueryLogSummaryNarrative(summary: QueryLogSummary): string {
const periodLabel = describePeriod(summary.period)

if (summary.totalQueries === 0) {
return EMPTY_STATE_MESSAGE
return `No queries recorded ${periodLabel}. Your knowledge base is ready — try asking a question!`
}

const paragraphs: string[] = []

paragraphs.push(buildOverviewParagraph(summary))
paragraphs.push(buildOverviewParagraph(summary, periodLabel))

if (summary.totalMatchedDocs > 0 && summary.topRecalledDocs.length > 0) {
paragraphs.push(buildTopDocsParagraph(summary))
Expand All @@ -31,14 +25,41 @@ export function formatQueryLogSummaryNarrative(summary: QueryLogSummary): string
return paragraphs.join('\n\n')
}

function buildOverviewParagraph(summary: QueryLogSummary): string {
const {cacheHitRate, coverageRate, queriesWithoutMatches, responseTime, totalQueries} = summary
const answered = totalQueries - queriesWithoutMatches
function describePeriod(period: QueryLogSummary['period']): string {
if (period.from === 0 && period.to === 0) {
return 'in the selected period'
}

if (period.from > 0 && period.to > 0) {
const spanMs = period.to - period.from
return describeSpan(spanMs)
}

if (period.from > 0) {
const spanMs = Date.now() - period.from
return describeSpan(spanMs)
}

return 'in the selected period'
}

function describeSpan(spanMs: number): string {
if (spanMs <= MS_PER_DAY + MS_PER_HOUR) {
return 'in the last 24 hours'
}

const days = Math.round(spanMs / MS_PER_DAY)
return `in the last ${days} days`
}

function buildOverviewParagraph(summary: QueryLogSummary, periodLabel: string): string {
const {byStatus, cacheHitRate, coverageRate, queriesWithoutMatches, responseTime, totalQueries} = summary
const answered = byStatus.completed - queriesWithoutMatches
const coveragePct = Math.round(coverageRate * 100)
const cachePct = Math.round(cacheHitRate * 100)

return (
`Your team asked ${totalQueries} questions today. ` +
`Your team asked ${totalQueries} questions ${periodLabel}. ` +
`ByteRover answered ${answered} from curated knowledge ` +
`(${coveragePct}% coverage), with ${cachePct}% served from cache. ` +
`Average response time was ${formatDuration(responseTime.avgMs)}.`
Expand Down
Loading
Loading