diff --git a/apps/client-chrome/src/App.tsx b/apps/client-chrome/src/App.tsx index d335f55..2c8e045 100644 --- a/apps/client-chrome/src/App.tsx +++ b/apps/client-chrome/src/App.tsx @@ -1,14 +1,14 @@ import { ArrowLeft, CalendarDays, Check, Cloud, Copy, Link2, Monitor, RefreshCw, Search, Send, Settings, Sparkles, Trash2 } from 'lucide-react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { getActiveTab, subscribeToActiveTab } from './chromeApi' -import { getBrowserTimeZone, getLocalDate, normalizeBrainEndpointUrl, runDailySummary } from './brainCapture' -import type { DailySummaryResult } from './brainCapture' +import { getBrowserTimeZone, getDailySummaryVariant, getLocalDate, normalizeBrainEndpointUrl, runDailySummary } from './brainCapture' +import type { DailySummaryResult, DailySummarySource, EvidenceSupport, SummaryLevel } from './brainCapture' import { DEFAULT_BRAIN_SERVER_URL } from './config' import { checkContextServerConnection, createUrlMemoryCapture, deleteRemoteCapture, listRemoteCaptures, normalizeEndpointUrl, postCapture } from './contextCapture' import type { RemoteCapture } from './contextCapture' import { filterLinkedThoughtsForUrl, LinkedThoughtsLoader } from './linkedThoughts' import { createI18n, resolveLocale } from './i18n' -import type { LocalePreference } from './i18n' +import type { LocalePreference, MessageKey } from './i18n' import { loadLocalePreference, loadStoredSettings, saveAppData, saveLocalePreference, saveStoredSettings, subscribeToLocalePreferenceChanges, subscribeToStoredSettingsChanges } from './storage' import { copyTextToClipboard, filterThoughtsForCopy, formatThoughtsForClipboard, getThoughtCopyRange } from './thoughtExport' import type { AppData, CaptureOutboxItem, ChromeSurface, DeploymentMode, SavedTab, UrlMemory } from './types' @@ -29,6 +29,87 @@ export function canCaptureOnUrl(url: string | undefined) { } } +const SUMMARY_LEVELS: SummaryLevel[] = ['quick', 'standard', 'deep'] +const SUMMARY_LEVEL_KEYS: Record = { + quick: 'summary.level.quick', + standard: 'summary.level.standard', + deep: 'summary.level.deep', +} +const SUPPORT_KEYS: Record = { + direct: 'summary.support.direct', + partial: 'summary.support.partial', + unverified: 'summary.support.unverified', + conflict: 'summary.support.conflict', +} + +type SummaryResultViewProps = { + result: DailySummaryResult + level: SummaryLevel + onLevelChange: (level: SummaryLevel) => void + onOpenSource: (source: DailySummarySource) => void + formatDate: ReturnType['formatDate'] + t: ReturnType['t'] +} + +function SummaryResultView({ result, level, onLevelChange, onOpenSource, formatDate, t }: SummaryResultViewProps) { + const sources = result.sources ?? [] + const sourcesById = new Map(sources.map((source) => [source.recordId, source])) + + return ( +
+
{result.recordCount === 0 ? t('summary.noRecords') : t('summary.recordCount', { count: result.recordCount })}{result.date} · {result.timezone}
+ {result.variants ? ( +
+ {SUMMARY_LEVELS.map((option) => ( + + ))} +
+ ) : null} +

{getDailySummaryVariant(result, level)}

+ {result.keyPoints.length > 0 ?

{t('summary.keyPoints')}

    {result.keyPoints.map((point) =>
  • {point}
  • )}
:

{t('summary.emptyHelp')}

} + {result.claims && result.claims.length > 0 ? ( +
+

{t('summary.evidenceTitle')}

{t('summary.evidenceHelp')}
+
+ {result.claims.map((claim) => ( +
+
{t(SUPPORT_KEYS[claim.support])}
+

{claim.text}

+ {claim.sourceIds.length > 0 ?
{claim.sourceIds.map((sourceId) => { + const source = sourcesById.get(sourceId) + return source ? : null + })}
: null} +
+ ))} +
+
+ ) : null} + {sources.length > 0 ? ( +
+

{t('summary.sourcesTitle', { count: sources.length })}

+
+ {sources.map((source) => ( + + ))} +
+
+ ) : null} +
+ ) +} + export function App({ surface }: { surface: ChromeSurface }) { const [localePreference, setLocalePreference] = useState('auto') const locale = resolveLocale(localePreference) @@ -51,6 +132,7 @@ export function App({ surface }: { surface: ChromeSurface }) { const [connectionChecking, setConnectionChecking] = useState(false) const [thoughts, setThoughts] = useState([]) const [thoughtQuery, setThoughtQuery] = useState('') + const [thoughtIdFilter, setThoughtIdFilter] = useState('') const [thoughtUrlFilter, setThoughtUrlFilter] = useState('') const [thoughtStatus, setThoughtStatus] = useState('') const [copyStart, setCopyStart] = useState('') @@ -59,6 +141,7 @@ export function App({ surface }: { surface: ChromeSurface }) { const [linkedThoughts, setLinkedThoughts] = useState([]) const [summaryDate, setSummaryDate] = useState(() => getLocalDate()) const [summaryResult, setSummaryResult] = useState(null) + const [summaryLevel, setSummaryLevel] = useState('standard') const [summaryStatus, setSummaryStatus] = useState('') const [summaryLoading, setSummaryLoading] = useState(false) const [settingsChangedElsewhere, setSettingsChangedElsewhere] = useState(false) @@ -485,13 +568,32 @@ export function App({ surface }: { surface: ChromeSurface }) { }, [activeTab?.url, linkedThoughtsLoader, loadLinkedThoughts, t]) const openThoughts = () => { + setThoughtIdFilter('') setThoughtUrlFilter('') + setThoughtQuery('') setScreen('thoughts') void loadThoughts() } + const openSourceRecord = (source: DailySummarySource) => { + if (data?.sync.endpointUrl && apiToken) { + setThoughtIdFilter(source.recordId) + setThoughtQuery('') + setThoughtUrlFilter('') + setScreen('thoughts') + void loadThoughts() + return + } + if (source.url) { + void openThoughtLink(source.url) + return + } + setSummaryStatus(t('summary.sourceConnectionRequired')) + } + const openSummary = () => { setSummaryDate(getLocalDate()) + setSummaryLevel('standard') setSummaryStatus('') setScreen('summary') } @@ -514,8 +616,9 @@ export function App({ surface }: { surface: ChromeSurface }) { } } - const openLinkedThoughts = () => { +const openLinkedThoughts = () => { if (!activeTab?.url) return + setThoughtIdFilter('') setThoughtQuery('') setThoughtUrlFilter(activeTab.url) setScreen('thoughts') @@ -602,6 +705,7 @@ export function App({ surface }: { surface: ChromeSurface }) { const canCapture = !activeTab || canCaptureOnUrl(activeTab.url) const visibleThoughts = thoughts.filter((thought) => thought.data.content.toLocaleLowerCase().includes(thoughtQuery.trim().toLocaleLowerCase()) && + (!thoughtIdFilter || thought.id === thoughtIdFilter) && (!thoughtUrlFilter || thought.data.context?.browser?.url === thoughtUrlFilter) ) @@ -725,11 +829,14 @@ export function App({ surface }: { surface: ChromeSurface }) { {summaryStatus ?

{summaryStatus}

: null} {summaryResult ? ( -
-
{summaryResult.recordCount === 0 ? t('summary.noRecords') : t('summary.recordCount', { count: summaryResult.recordCount })}{summaryResult.date} · {summaryResult.timezone}
-

{summaryResult.summary}

- {summaryResult.keyPoints.length > 0 ?

{t('summary.keyPoints')}

    {summaryResult.keyPoints.map((point) =>
  • {point}
  • )}
:

{t('summary.emptyHelp')}

} -
+ ) : null} ) : screen === 'thoughts' ? ( @@ -744,6 +851,7 @@ export function App({ surface }: { surface: ChromeSurface }) {
{thoughtUrlFilter ?
{t('thoughts.filteredByLink')}
: null} + {thoughtIdFilter ?
{t('thoughts.filteredByRecord')}
: null}

{t('thoughts.externalUse')}

{t('thoughts.copyTitle')}

{t('thoughts.copyDescription')}

diff --git a/apps/client-chrome/src/brainCapture.ts b/apps/client-chrome/src/brainCapture.ts index 1248167..ebc608a 100644 --- a/apps/client-chrome/src/brainCapture.ts +++ b/apps/client-chrome/src/brainCapture.ts @@ -8,12 +8,33 @@ export type DailySummaryInput = { timezone: string } +export type SummaryLevel = 'quick' | 'standard' | 'deep' +export type EvidenceSupport = 'direct' | 'partial' | 'unverified' | 'conflict' +export type DailySummaryVariants = Record +export type DailySummaryClaim = { + id: string + text: string + sourceIds: string[] + support: EvidenceSupport +} +export type DailySummarySource = { + recordId: string + preview: string + recordedAt: string + client: string + title?: string + url?: string +} + export type DailySummaryResult = { date: string timezone: string recordCount: number summary: string keyPoints: string[] + variants?: DailySummaryVariants + claims?: DailySummaryClaim[] + sources?: DailySummarySource[] } export class BrainRequestError extends Error {} @@ -38,12 +59,45 @@ function headers(apiToken: string) { function isDailySummaryResult(value: unknown): value is DailySummaryResult { if (!value || typeof value !== 'object') return false const result = value as Partial - return typeof result.date === 'string' && + if (!(typeof result.date === 'string' && typeof result.timezone === 'string' && typeof result.recordCount === 'number' && typeof result.summary === 'string' && Array.isArray(result.keyPoints) && - result.keyPoints.every((point) => typeof point === 'string') + result.keyPoints.every((point) => typeof point === 'string'))) return false + + if (result.variants !== undefined) { + const variants = result.variants + if (!variants || typeof variants !== 'object' || Array.isArray(variants)) return false + if (!['quick', 'standard', 'deep'].every((level) => typeof variants[level as SummaryLevel] === 'string')) return false + } + + if (result.claims !== undefined) { + if (!Array.isArray(result.claims) || !result.claims.every((claim) => { + if (!claim || typeof claim !== 'object') return false + const item = claim as Partial + return typeof item.id === 'string' && typeof item.text === 'string' && Array.isArray(item.sourceIds) && + item.sourceIds.every((sourceId) => typeof sourceId === 'string') && + typeof item.support === 'string' && ['direct', 'partial', 'unverified', 'conflict'].includes(item.support) + })) return false + } + + if (result.sources !== undefined) { + if (!Array.isArray(result.sources) || !result.sources.every((source) => { + if (!source || typeof source !== 'object') return false + const item = source as Partial + return typeof item.recordId === 'string' && typeof item.preview === 'string' && + typeof item.recordedAt === 'string' && typeof item.client === 'string' && + (item.title === undefined || typeof item.title === 'string') && + (item.url === undefined || typeof item.url === 'string') + })) return false + } + + return true +} + +export function getDailySummaryVariant(result: DailySummaryResult, level: SummaryLevel): string { + return result.variants?.[level] ?? result.summary } function errorMessage(status: number, body: unknown) { diff --git a/apps/client-chrome/src/i18n.ts b/apps/client-chrome/src/i18n.ts index e350ba8..8dc97d1 100644 --- a/apps/client-chrome/src/i18n.ts +++ b/apps/client-chrome/src/i18n.ts @@ -129,11 +129,24 @@ const messages = { 'summary.recordCount': 'Context 기록 {count}개', 'summary.keyPoints': '핵심 포인트', 'summary.emptyHelp': '먼저 생각을 남기면 이 날짜의 요약이 여기에 나타납니다.', + 'summary.levelLabel': '답변 깊이', + 'summary.level.quick': '빠른 답변', + 'summary.level.standard': '일반 답변', + 'summary.level.deep': '자세한 답변', + 'summary.evidenceTitle': '주장과 근거', + 'summary.evidenceHelp': '근거 상태를 쉽게 확인할 수 있습니다.', + 'summary.support.direct': '직접 확인됨', + 'summary.support.partial': '부분 확인', + 'summary.support.unverified': '미확인', + 'summary.support.conflict': '충돌 있음', + 'summary.sourcesTitle': '근거 기록 {count}개', + 'summary.sourceConnectionRequired': '원문 기록을 열려면 Context Server 연결이 필요합니다.', 'thoughts.title': '생각 전체 조회', 'thoughts.refresh': '생각 새로고침', 'thoughts.search': '생각 검색', 'thoughts.searchPlaceholder': '기억나는 단어를 입력하세요', 'thoughts.filteredByLink': '현재 링크의 기록만 표시 중', + 'thoughts.filteredByRecord': '선택한 기록만 표시 중', 'thoughts.showAll': '전체 보기', 'thoughts.externalUse': 'External use', 'thoughts.copyTitle': '생각 복사', @@ -287,11 +300,24 @@ const messages = { 'summary.recordCount': '{count} Context record(s)', 'summary.keyPoints': 'Key points', 'summary.emptyHelp': 'Leave a thought first and its summary for this date will appear here.', + 'summary.levelLabel': 'Answer depth', + 'summary.level.quick': 'Quick', + 'summary.level.standard': 'Standard', + 'summary.level.deep': 'Deep', + 'summary.evidenceTitle': 'Claims and evidence', + 'summary.evidenceHelp': 'See how each claim is supported.', + 'summary.support.direct': 'Directly supported', + 'summary.support.partial': 'Partially supported', + 'summary.support.unverified': 'Unverified', + 'summary.support.conflict': 'Conflicting sources', + 'summary.sourcesTitle': '{count} source record(s)', + 'summary.sourceConnectionRequired': 'Connect Context Server to open the original record.', 'thoughts.title': 'All thoughts', 'thoughts.refresh': 'Refresh thoughts', 'thoughts.search': 'Search thoughts', 'thoughts.searchPlaceholder': 'Enter a word you remember', 'thoughts.filteredByLink': 'Showing only records for the current link', + 'thoughts.filteredByRecord': 'Showing the selected record only', 'thoughts.showAll': 'Show all', 'thoughts.externalUse': 'External use', 'thoughts.copyTitle': 'Copy thoughts', diff --git a/apps/client-chrome/src/styles.css b/apps/client-chrome/src/styles.css index fc694b9..ec6c63f 100644 --- a/apps/client-chrome/src/styles.css +++ b/apps/client-chrome/src/styles.css @@ -77,12 +77,37 @@ button { cursor: pointer; } .summary-result.is-empty { border-color: #4c5664; background: #272a2e; } .summary-result-meta { display: flex; justify-content: space-between; gap: 12px; color: #9fc4ff; font-size: 12px; font-weight: 700; } .summary-result-meta span:last-child { color: #9b9d9f; font-weight: 500; text-align: right; } +.summary-levels { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; margin-top: 14px; padding: 4px; border: 1px solid #465469; border-radius: 10px; background: #20252c; } +.summary-levels button { min-height: 32px; border: 0; border-radius: 7px; background: transparent; color: #aeb7c4; font-size: 12px; font-weight: 700; } +.summary-levels button:hover, .summary-levels button.is-selected { background: #354256; color: #edf4ff; } .summary-text { margin: 16px 0 0; color: #f0f2f5; font-size: 17px; line-height: 1.55; white-space: pre-wrap; } .summary-points { margin-top: 18px; padding-top: 15px; border-top: 1px solid #424b58; } .summary-points h2 { margin: 0; color: #dce6f8; font-size: 13px; } .summary-points ul { display: grid; gap: 9px; margin: 11px 0 0; padding-left: 20px; color: #d6dbe3; font-size: 13px; line-height: 1.5; } .summary-points li::marker { color: #9fc4ff; } .summary-empty-help { margin: 14px 0 0; color: #aeb2b5; font-size: 12px; line-height: 1.5; } +.summary-evidence, .summary-sources { margin-top: 18px; padding-top: 15px; border-top: 1px solid #424b58; } +.summary-evidence-heading { display: grid; gap: 4px; } +.summary-evidence-heading h2, .summary-sources h2 { margin: 0; color: #dce6f8; font-size: 13px; } +.summary-evidence-heading span { color: #9b9d9f; font-size: 11px; line-height: 1.45; } +.summary-claims { display: grid; gap: 9px; margin-top: 11px; } +.summary-claim { padding: 11px 12px; border: 1px solid #4b5665; border-radius: 9px; background: #20252c; } +.summary-claim.support-direct { border-color: #496d62; } +.summary-claim.support-partial { border-color: #75643d; } +.summary-claim.support-unverified, .summary-claim.support-conflict { border-color: #765050; } +.summary-claim-status { color: #b8d4ff; font-size: 11px; font-weight: 750; } +.support-partial .summary-claim-status { color: #ecd18a; } +.support-unverified .summary-claim-status, .support-conflict .summary-claim-status { color: #f0b5b5; } +.summary-claim p { margin: 6px 0 0; color: #e7e9ec; font-size: 13px; line-height: 1.45; } +.summary-claim-sources { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 9px; } +.summary-source-chip { max-width: 100%; overflow: hidden; padding: 5px 8px; border: 1px solid #536278; border-radius: 7px; background: #2b3544; color: #cfe0f7; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; } +.summary-source-chip:hover { border-color: #9fc4ff; } +.summary-source-list { display: grid; gap: 7px; margin-top: 10px; } +.summary-source { display: grid; gap: 4px; width: 100%; min-width: 0; padding: 9px 10px; border: 1px solid #465469; border-radius: 8px; background: #20252c; color: #c8d5e6; text-align: left; } +.summary-source:hover { border-color: #9fc4ff; background: #293241; } +.summary-source strong { overflow: hidden; color: #cfe0f7; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; } +.summary-source span { display: -webkit-box; overflow: hidden; color: #d6dbe3; font-size: 12px; line-height: 1.4; -webkit-box-orient: vertical; -webkit-line-clamp: 2; } +.summary-source:focus-visible, .summary-source-chip:focus-visible, .summary-levels button:focus-visible { outline: 2px solid #fff; outline-offset: 2px; } .spin { animation: summary-spin 1s linear infinite; } @keyframes summary-spin { to { transform: rotate(360deg); } } .search-field > div { position: relative; } diff --git a/apps/client-chrome/test/brainCapture.test.ts b/apps/client-chrome/test/brainCapture.test.ts index 85898cd..08e1ad7 100644 --- a/apps/client-chrome/test/brainCapture.test.ts +++ b/apps/client-chrome/test/brainCapture.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { getLocalDate, runDailySummary } from '../src/brainCapture' +import { getDailySummaryVariant, getLocalDate, runDailySummary } from '../src/brainCapture' describe('runDailySummary', () => { it('sends the daily-summary action and returns the structured result', async () => { @@ -45,6 +45,27 @@ describe('runDailySummary', () => { }, fetchImpl)).resolves.toMatchObject({ recordCount: 0 }) }) + it('accepts evidence-linked variants and falls back for legacy responses', async () => { + const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ result: { + date: '2026-08-13', + timezone: 'Asia/Seoul', + recordCount: 1, + summary: '기본 요약', + keyPoints: ['핵심'], + variants: { quick: '짧게', standard: '일반적으로', deep: '자세히' }, + claims: [{ id: 'claim-1', text: '주장', sourceIds: ['record-1'], support: 'direct' }], + sources: [{ recordId: 'record-1', preview: '원문 기록', recordedAt: '2026-08-13T00:00:00.000Z', client: 'chrome' }] + } }))) + + const result = await runDailySummary({ endpointUrl: 'http://brain.test', apiToken: '' }, { + date: '2026-08-13', timezone: 'Asia/Seoul' + }, fetchImpl) + + expect(result.sources?.[0].recordId).toBe('record-1') + expect(getDailySummaryVariant(result, 'quick')).toBe('짧게') + expect(getDailySummaryVariant({ ...result, variants: undefined }, 'deep')).toBe('기본 요약') + }) + it('maps server errors into a user-facing request error', async () => { const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ error: 'context unavailable' }), { status: 502 })) await expect(runDailySummary({ endpointUrl: 'http://brain.test', apiToken: '' }, { diff --git a/apps/server-brain/src/actions/daily-summary.ts b/apps/server-brain/src/actions/daily-summary.ts index 8010c40..43dd510 100644 --- a/apps/server-brain/src/actions/daily-summary.ts +++ b/apps/server-brain/src/actions/daily-summary.ts @@ -1,10 +1,13 @@ import { dailySummaryInputSchema, + dailySummaryModelOutputSchema, dailySummaryOutputSchema, + parseDailySummaryModelOutput, parseDailySummaryInput, parseDailySummaryOutput, - parseSummarizeOutput, - summarizeOutputSchema, + type DailySummaryClaim, + type DailySummarySource, + type DailySummaryVariants, } from "../schemas/validation.js"; import { MAX_DAILY_SUMMARY_CONTEXT_BYTES, @@ -22,6 +25,9 @@ export interface DailySummaryOutput { recordCount: number; summary: string; keyPoints: string[]; + variants?: DailySummaryVariants; + claims?: DailySummaryClaim[]; + sources?: DailySummarySource[]; } const NO_RECORDS_SUMMARY = "No context records found for the selected date."; @@ -30,8 +36,11 @@ const encoder = new TextEncoder(); const systemPrompt = [ "You are the Context OS daily-summary action.", "Summarize only the supplied Context OS records for the requested local date.", - "Return only a JSON object with exactly these fields:", - '{"summary":"string","keyPoints":["string"]}', + "Return only a JSON object with summary, keyPoints, variants, and claims fields.", + "The variants object must contain quick, standard, and deep answers that use the same facts and sources but different explanation depth.", + "Each claim must contain an id, text, sourceIds, and support. sourceIds may contain only record IDs supplied in the records. support must be one of direct, partial, unverified, or conflict.", + '{"summary":"string","keyPoints":["string"],"variants":{"quick":"string","standard":"string","deep":"string"},"claims":[{"id":"string","text":"string","sourceIds":["record-id"],"support":"direct|partial|unverified|conflict"}]}', + "If a claim cannot be directly supported by the supplied records, mark it unverified and use an empty sourceIds array.", "Do not add markdown fences or any other fields.", ].join(" "); @@ -41,6 +50,7 @@ function byteLength(value: string): number { function recordPayload(record: ContextRecord, content: string, includeContext: boolean): string { return JSON.stringify({ + id: record.id, recordedAt: record.recordedAt, client: record.data.source.client, content, @@ -71,6 +81,40 @@ function fitRecordToBytes(record: ContextRecord, byteBudget: number): string | u return undefined; } +function createFallbackVariants(summary: string, keyPoints: string[]): DailySummaryVariants { + const deep = keyPoints.length > 0 ? `${summary}\n\n${keyPoints.map((point) => `- ${point}`).join("\n")}` : summary; + return { quick: summary, standard: summary, deep }; +} + +function normalizeClaims(claims: DailySummaryClaim[], validSourceIds: Set): DailySummaryClaim[] { + return claims.map((claim) => { + const sourceIds = [...new Set(claim.sourceIds)].filter((sourceId) => validSourceIds.has(sourceId)); + return { + ...claim, + sourceIds, + support: sourceIds.length === 0 ? "unverified" : claim.support, + }; + }); +} + +function toSource(record: ContextRecord): DailySummarySource { + const browser = record.data.context?.browser; + const browserContext = browser && typeof browser === "object" && !Array.isArray(browser) + ? browser as Record + : undefined; + const title = typeof browserContext?.title === "string" ? browserContext.title.trim() : undefined; + const url = typeof browserContext?.url === "string" ? browserContext.url.trim() : undefined; + const preview = record.data.content.trim().slice(0, 240) || "(empty record)"; + return { + recordId: record.id, + preview, + recordedAt: record.recordedAt, + client: record.data.source.client, + ...(title ? { title } : {}), + ...(url ? { url } : {}), + }; +} + function buildUserPrompt(input: DailySummaryInput, records: ContextRecord[]): { prompt: string; recordCount: number } { const lines: string[] = []; let usedBytes = 0; @@ -112,15 +156,25 @@ export const dailySummaryAction: ActionDefinition source.recordId)); + return { + ...metadata, + summary: content.summary, + keyPoints: content.keyPoints, + variants: content.variants ?? createFallbackVariants(content.summary, content.keyPoints), + claims: normalizeClaims(content.claims ?? [], validSourceIds), + sources, + }; }, }; diff --git a/apps/server-brain/src/schemas/validation.ts b/apps/server-brain/src/schemas/validation.ts index fd876e5..82aae9d 100644 --- a/apps/server-brain/src/schemas/validation.ts +++ b/apps/server-brain/src/schemas/validation.ts @@ -6,6 +6,44 @@ export const MAX_SUMMARIZE_CONTENT_BYTES = 64 * 1024; export const MAX_VOICE_TRANSCRIPT_BYTES = 96 * 1024; export const MAX_VOICE_DRAFT_FIELD_BYTES = 16 * 1024; export const MAX_DAILY_SUMMARY_TIMEZONE_BYTES = 128; +export const MAX_DAILY_SUMMARY_CLAIMS = 20; +export const MAX_DAILY_SUMMARY_SOURCES = 100; +export const MAX_DAILY_SUMMARY_SOURCE_IDS = 20; +export const MAX_DAILY_SUMMARY_SOURCE_PREVIEW_BYTES = 1024; + +export const DAILY_SUMMARY_LEVELS = ["quick", "standard", "deep"] as const; +export type DailySummaryLevel = (typeof DAILY_SUMMARY_LEVELS)[number]; +export const DAILY_SUMMARY_SUPPORT_STATUSES = ["direct", "partial", "unverified", "conflict"] as const; +export type DailySummarySupportStatus = (typeof DAILY_SUMMARY_SUPPORT_STATUSES)[number]; + +export interface DailySummaryVariants { + quick: string; + standard: string; + deep: string; +} + +export interface DailySummaryClaim { + id: string; + text: string; + sourceIds: string[]; + support: DailySummarySupportStatus; +} + +export interface DailySummarySource { + recordId: string; + preview: string; + recordedAt: string; + client: string; + title?: string; + url?: string; +} + +export interface DailySummaryModelOutput { + summary: string; + keyPoints: string[]; + variants?: DailySummaryVariants; + claims?: DailySummaryClaim[]; +} const encoder = new TextEncoder(); @@ -84,15 +122,87 @@ export function parseSummarizeOutput(value: unknown): { summary: string; keyPoin return { summary, keyPoints }; } +function parseDailySummaryVariants(value: unknown, name: string): DailySummaryVariants { + const variants = object(value, name); + onlyKeys(variants, [...DAILY_SUMMARY_LEVELS], name); + return { + quick: requiredString(variants.quick, `${name}.quick`, MAX_SUMMARIZE_CONTENT_BYTES), + standard: requiredString(variants.standard, `${name}.standard`, MAX_SUMMARIZE_CONTENT_BYTES), + deep: requiredString(variants.deep, `${name}.deep`, MAX_SUMMARIZE_CONTENT_BYTES), + }; +} + +function parseDailySummaryClaims(value: unknown, name: string): DailySummaryClaim[] { + if (!Array.isArray(value)) throw new BrainValidationError(`${name} must be an array`); + if (value.length > MAX_DAILY_SUMMARY_CLAIMS) throw new BrainValidationError(`${name} must contain at most ${MAX_DAILY_SUMMARY_CLAIMS} items`); + return value.map((item, index) => { + const claim = object(item, `${name}[${index}]`); + onlyKeys(claim, ["id", "text", "sourceIds", "support"], `${name}[${index}]`); + if (!Array.isArray(claim.sourceIds)) throw new BrainValidationError(`${name}[${index}].sourceIds must be an array`); + if (claim.sourceIds.length > MAX_DAILY_SUMMARY_SOURCE_IDS) { + throw new BrainValidationError(`${name}[${index}].sourceIds must contain at most ${MAX_DAILY_SUMMARY_SOURCE_IDS} items`); + } + const support = claim.support; + if (!DAILY_SUMMARY_SUPPORT_STATUSES.includes(support as DailySummarySupportStatus)) { + throw new BrainValidationError(`${name}[${index}].support is invalid`); + } + return { + id: requiredString(claim.id, `${name}[${index}].id`, 128).trim(), + text: requiredString(claim.text, `${name}[${index}].text`, 4096).trim(), + sourceIds: claim.sourceIds.map((sourceId, sourceIndex) => requiredString(sourceId, `${name}[${index}].sourceIds[${sourceIndex}]`, 256).trim()), + support: support as DailySummarySupportStatus, + }; + }); +} + +function parseDailySummarySources(value: unknown, name: string): DailySummarySource[] { + if (!Array.isArray(value)) throw new BrainValidationError(`${name} must be an array`); + if (value.length > MAX_DAILY_SUMMARY_SOURCES) throw new BrainValidationError(`${name} must contain at most ${MAX_DAILY_SUMMARY_SOURCES} items`); + return value.map((item, index) => { + const source = object(item, `${name}[${index}]`); + onlyKeys(source, ["recordId", "preview", "recordedAt", "client", "title", "url"], `${name}[${index}]`); + return { + recordId: requiredString(source.recordId, `${name}[${index}].recordId`, 256).trim(), + preview: requiredString(source.preview, `${name}[${index}].preview`, MAX_DAILY_SUMMARY_SOURCE_PREVIEW_BYTES).trim(), + recordedAt: requiredString(source.recordedAt, `${name}[${index}].recordedAt`, 64).trim(), + client: requiredString(source.client, `${name}[${index}].client`, 128).trim(), + ...(source.title === undefined ? {} : { title: requiredString(source.title, `${name}[${index}].title`, 512).trim() }), + ...(source.url === undefined ? {} : { url: requiredString(source.url, `${name}[${index}].url`, 2048).trim() }), + }; + }); +} + +function parseDailySummaryContent(value: unknown, name: string): DailySummaryModelOutput { + const output = object(value, name); + onlyKeys(output, ["summary", "keyPoints", "variants", "claims"], name); + const summary = requiredString(output.summary, `${name}.summary`, MAX_SUMMARIZE_CONTENT_BYTES); + if (!Array.isArray(output.keyPoints)) throw new BrainValidationError(`${name}.keyPoints must be an array`); + if (output.keyPoints.length > 20) throw new BrainValidationError(`${name}.keyPoints must contain at most 20 items`); + const keyPoints = output.keyPoints.map((point, index) => requiredString(point, `${name}.keyPoints[${index}]`, 4096)); + return { + summary, + keyPoints, + ...(output.variants === undefined ? {} : { variants: parseDailySummaryVariants(output.variants, `${name}.variants`) }), + ...(output.claims === undefined ? {} : { claims: parseDailySummaryClaims(output.claims, `${name}.claims`) }), + }; +} + +export function parseDailySummaryModelOutput(value: unknown): DailySummaryModelOutput { + return parseDailySummaryContent(value, "output"); +} + export function parseDailySummaryOutput(value: unknown): { date: string; timezone: string; recordCount: number; summary: string; keyPoints: string[]; + variants?: DailySummaryVariants; + claims?: DailySummaryClaim[]; + sources?: DailySummarySource[]; } { const output = object(value, "output"); - onlyKeys(output, ["date", "timezone", "recordCount", "summary", "keyPoints"], "output"); + onlyKeys(output, ["date", "timezone", "recordCount", "summary", "keyPoints", "variants", "claims", "sources"], "output"); const date = requiredString(output.date, "output.date", 10); if (!isValidCalendarDate(date)) throw new BrainValidationError("output.date must be valid YYYY-MM-DD calendar date"); const timezone = requiredString(output.timezone, "output.timezone", MAX_DAILY_SUMMARY_TIMEZONE_BYTES).trim(); @@ -100,11 +210,19 @@ export function parseDailySummaryOutput(value: unknown): { if (!Number.isInteger(output.recordCount) || (output.recordCount as number) < 0 || (output.recordCount as number) > 100) { throw new BrainValidationError("output.recordCount must be an integer from 0 to 100"); } - const summary = requiredString(output.summary, "output.summary", MAX_SUMMARIZE_CONTENT_BYTES); - if (!Array.isArray(output.keyPoints)) throw new BrainValidationError("output.keyPoints must be an array"); - if (output.keyPoints.length > 20) throw new BrainValidationError("output.keyPoints must contain at most 20 items"); - const keyPoints = output.keyPoints.map((point, index) => requiredString(point, `output.keyPoints[${index}]`, 4096)); - return { date, timezone, recordCount: output.recordCount as number, summary, keyPoints }; + const content = parseDailySummaryContent({ + summary: output.summary, + keyPoints: output.keyPoints, + ...(output.variants === undefined ? {} : { variants: output.variants }), + ...(output.claims === undefined ? {} : { claims: output.claims }), + }, "output"); + return { + date, + timezone, + recordCount: output.recordCount as number, + ...content, + ...(output.sources === undefined ? {} : { sources: parseDailySummarySources(output.sources, "output.sources") }), + }; } type VoiceContextType = "work" | "decision" | "insight" | "plan" | "question" | "reflection"; @@ -174,11 +292,65 @@ export const dailySummaryOutputSchema = { recordCount: { type: "integer", minimum: 0, maximum: 100 }, summary: { type: "string", minLength: 1, maxBytes: MAX_SUMMARIZE_CONTENT_BYTES }, keyPoints: { type: "array", items: { type: "string" }, maxItems: 20 }, + variants: { + type: "object", + properties: { + quick: { type: "string", minLength: 1, maxBytes: MAX_SUMMARIZE_CONTENT_BYTES }, + standard: { type: "string", minLength: 1, maxBytes: MAX_SUMMARIZE_CONTENT_BYTES }, + deep: { type: "string", minLength: 1, maxBytes: MAX_SUMMARIZE_CONTENT_BYTES }, + }, + required: ["quick", "standard", "deep"], + additionalProperties: false, + }, + claims: { + type: "array", + maxItems: MAX_DAILY_SUMMARY_CLAIMS, + items: { + type: "object", + properties: { + id: { type: "string", minLength: 1, maxBytes: 128 }, + text: { type: "string", minLength: 1, maxBytes: 4096 }, + sourceIds: { type: "array", items: { type: "string", minLength: 1, maxBytes: 256 }, maxItems: MAX_DAILY_SUMMARY_SOURCE_IDS }, + support: { type: "string", enum: [...DAILY_SUMMARY_SUPPORT_STATUSES] }, + }, + required: ["id", "text", "sourceIds", "support"], + additionalProperties: false, + }, + }, + sources: { + type: "array", + maxItems: MAX_DAILY_SUMMARY_SOURCES, + items: { + type: "object", + properties: { + recordId: { type: "string", minLength: 1, maxBytes: 256 }, + preview: { type: "string", minLength: 1, maxBytes: MAX_DAILY_SUMMARY_SOURCE_PREVIEW_BYTES }, + recordedAt: { type: "string", minLength: 1, maxBytes: 64 }, + client: { type: "string", minLength: 1, maxBytes: 128 }, + title: { type: "string", minLength: 1, maxBytes: 512 }, + url: { type: "string", minLength: 1, maxBytes: 2048 }, + }, + required: ["recordId", "preview", "recordedAt", "client"], + additionalProperties: false, + }, + }, }, required: ["date", "timezone", "recordCount", "summary", "keyPoints"], additionalProperties: false, }; +export const dailySummaryModelOutputSchema = { + type: "object", + properties: { + summary: { type: "string", minLength: 1, maxBytes: MAX_SUMMARIZE_CONTENT_BYTES }, + keyPoints: { type: "array", items: { type: "string" }, maxItems: 20 }, + variants: dailySummaryOutputSchema.properties.variants, + claims: dailySummaryOutputSchema.properties.claims, + }, + required: ["summary", "keyPoints"], + additionalProperties: false, +}; + export const voiceContextDraftInputSchema = { type: "object", properties: { diff --git a/apps/server-brain/test/daily-summary.test.ts b/apps/server-brain/test/daily-summary.test.ts index e21f2a4..863f139 100644 --- a/apps/server-brain/test/daily-summary.test.ts +++ b/apps/server-brain/test/daily-summary.test.ts @@ -97,6 +97,26 @@ describe("daily-summary context resolution", () => { recordCount: 2, summary: "Two notes", keyPoints: ["morning", "evening"], + variants: { + quick: "Two notes", + standard: "Two notes", + deep: "Two notes\n\n- morning\n- evening", + }, + claims: [], + sources: [ + { + recordId: "2026-08-13T14:00:00.000Z", + preview: "evening note", + recordedAt: "2026-08-13T14:00:00.000Z", + client: "desktop", + }, + { + recordId: "2026-08-12T15:00:00.000Z", + preview: "morning note", + recordedAt: "2026-08-12T15:00:00.000Z", + client: "desktop", + }, + ], }); expect(listRecords).toHaveBeenNthCalledWith(1, { limit: MAX_DAILY_SUMMARY_RECORDS }); expect(listRecords).toHaveBeenNthCalledWith(2, { limit: MAX_DAILY_SUMMARY_RECORDS, cursor: "next-page" }); @@ -104,6 +124,33 @@ describe("daily-summary context resolution", () => { expect(mock.generateStructured.mock.calls[0][0].userPrompt).toContain("evening note"); }); + it("returns shared answer variants and removes unsupported source IDs", async () => { + const mock = provider({ + summary: "Supported summary", + keyPoints: ["One point"], + variants: { quick: "Quick", standard: "Standard", deep: "Deep" }, + claims: [ + { id: "claim-1", text: "Supported claim", sourceIds: ["source-a", "forged-id"], support: "direct" }, + { id: "claim-2", text: "Unknown claim", sourceIds: ["forged-id"], support: "direct" }, + ], + }); + const execution = await runner(mock.value, { + listRecords: vi.fn(async () => ({ + records: [record("2026-08-13T10:00:00.000Z", "source text", "source-a")], + nextCursor: null, + })), + }).run("daily-summary", { date: "2026-08-13", timezone: "Asia/Seoul" }); + + expect(execution.result).toMatchObject({ + variants: { quick: "Quick", standard: "Standard", deep: "Deep" }, + claims: [ + { id: "claim-1", sourceIds: ["source-a"], support: "direct" }, + { id: "claim-2", sourceIds: [], support: "unverified" }, + ], + sources: [{ recordId: "source-a", preview: "source text" }], + }); + }); + it("does not call the provider when no records match", async () => { const listRecords = vi.fn(async () => ({ records: [record("2026-08-12T14:59:59.999Z", "outside note")],