File size: 4,957 Bytes
dbb1bf9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
import { escapeHtml, sanitizeUrl } from '@/utils/sanitize';

export interface BriefSource {
  title: string;
  source: string;
  url: string;
  publishedAt?: string;
}

export interface BriefSourceCandidate {
  title?: unknown;
  primaryTitle?: unknown;
  source?: unknown;
  primarySource?: unknown;
  url?: unknown;
  link?: unknown;
  primaryLink?: unknown;
  publishedAt?: unknown;
  pubDate?: unknown;
}

const DEFAULT_MAX_SOURCES = 6;
const MAX_TITLE_LEN = 160;
const MAX_SOURCE_LEN = 80;
const BRIEF_SOURCES_METHODOLOGY_HREF = '/docs/methodology/news-digest-and-briefing';

function clipText(value: unknown, maxLen: number): string {
  const text = typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : '';
  return text.length > maxLen ? `${text.slice(0, maxLen - 1).trim()}...` : text;
}

export function normalizeBriefSourceUrl(value: unknown): string {
  if (typeof value !== 'string') return '';
  const trimmed = value.trim();
  if (!trimmed) return '';
  try {
    const parsed = new URL(trimmed);
    return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.toString() : '';
  } catch {
    return '';
  }
}

function normalizePublishedAt(value: unknown): string | undefined {
  if (value instanceof Date) return Number.isFinite(value.getTime()) ? value.toISOString() : undefined;
  if (typeof value === 'number' && Number.isFinite(value)) return new Date(value).toISOString();
  if (typeof value === 'string' && value.trim()) {
    const ms = new Date(value).getTime();
    return Number.isFinite(ms) ? new Date(ms).toISOString() : undefined;
  }
  return undefined;
}

export function normalizeBriefSource(candidate: BriefSourceCandidate): BriefSource | null {
  const url = normalizeBriefSourceUrl(candidate.url ?? candidate.link ?? candidate.primaryLink);
  if (!url) return null;

  const title = clipText(candidate.title ?? candidate.primaryTitle, MAX_TITLE_LEN);
  const source = clipText(candidate.source ?? candidate.primarySource, MAX_SOURCE_LEN);
  if (!title || !source) return null;

  const publishedAt = normalizePublishedAt(candidate.publishedAt ?? candidate.pubDate);
  return publishedAt ? { title, source, url, publishedAt } : { title, source, url };
}

export function collectBriefSources(
  candidates: BriefSourceCandidate[],
  maxSources = DEFAULT_MAX_SOURCES,
): BriefSource[] {
  const out: BriefSource[] = [];
  const seen = new Set<string>();
  for (const candidate of candidates) {
    const source = normalizeBriefSource(candidate);
    if (!source || seen.has(source.url)) continue;
    out.push(source);
    seen.add(source.url);
    if (out.length >= maxSources) break;
  }
  return out;
}

export function normalizeCachedBriefSources(
  cacheData: { sources?: BriefSourceCandidate[] } | undefined,
  maxSources = DEFAULT_MAX_SOURCES,
): { sources: BriefSource[]; legacySourceShape: boolean } {
  const legacySourceShape = !cacheData || !Object.prototype.hasOwnProperty.call(cacheData, 'sources');
  return {
    sources: collectBriefSources(cacheData?.sources ?? [], maxSources),
    legacySourceShape,
  };
}

export function buildBriefSourceContextLines(sources: BriefSource[]): string[] {
  return sources.map((source, index) => {
    const payload = source.publishedAt
      ? { title: source.title, source: source.source, url: source.url, publishedAt: source.publishedAt }
      : { title: source.title, source: source.source, url: source.url };
    return `Source [${index + 1}]: ${JSON.stringify(payload)}`;
  });
}

export function renderBriefSourcesFooter(
  sources: BriefSource[] | undefined,
  options: { className?: string; methodologyHref?: string; maxSources?: number } = {},
): string {
  const normalized = collectBriefSources(sources ?? [], options.maxSources ?? DEFAULT_MAX_SOURCES);
  if (normalized.length === 0) return '';

  const className = options.className ?? 'brief-sources';
  const methodologyHref = sanitizeUrl(options.methodologyHref ?? BRIEF_SOURCES_METHODOLOGY_HREF);
  const sourceWord = normalized.length === 1 ? 'source' : 'sources';
  const items = normalized.map((source) => {
    const href = sanitizeUrl(source.url);
    const when = source.publishedAt ? ` <span class="brief-source-date">${escapeHtml(source.publishedAt.slice(0, 10))}</span>` : '';
    return `
      <li>
        <a href="${href}" target="_blank" rel="noopener noreferrer">${escapeHtml(source.title)}</a>
        <span class="brief-source-meta">${escapeHtml(source.source)}${when}</span>
      </li>`;
  }).join('');

  const methodology = methodologyHref
    ? ` &middot; <a href="${methodologyHref}" target="_blank" rel="noopener noreferrer">Methodology</a>`
    : '';

  return `
    <details class="${escapeHtml(className)}">
      <summary>Sources (${normalized.length})</summary>
      <div class="brief-sources-note">AI-synthesized from ${normalized.length} ${sourceWord} &middot; may contain errors${methodology}</div>
      <ol>${items}</ol>
    </details>`;
}