afpnews-mcp-server 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "afpnews-mcp-server",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "",
5
5
  "main": "src/index.ts",
6
6
  "type": "module",
@@ -4,11 +4,13 @@ import { extractRenditions, formatMediaDocument, formatMediaDocumentsAsJson, for
4
4
  // Fixture bagItem reprenant la structure réelle AFP
5
5
  const FIXTURE_BAG_ITEM = [{
6
6
  medias: [
7
- { role: 'Thumbnail', sizeInBytes: 33590, width: 320, height: 213, href: 'https://example.com/thumb.jpg', type: 'Photo' },
8
- { role: 'Preview', sizeInBytes: 340621, width: 1200, height: 800, href: 'https://example.com/prev.jpg', type: 'Photo' },
9
- { role: 'Preview_B', sizeInBytes: 596996, width: 1800, height: 1200, href: 'https://example.com/prev_b.jpg', type: 'Photo' },
10
- { role: 'HighDef', sizeInBytes: 5126566, width: 3429, height: 2286, href: 'https://example.com/hd.jpg', type: 'Photo' },
11
- { role: 'Quicklook', sizeInBytes: 14055, width: 245, height: 164, href: 'https://example.com/quick.jpg', type: 'Photo' },
7
+ { role: 'Thumbnail', sizeInBytes: 33590, width: 320, height: 213, href: 'https://example.com/thumb.jpg', type: 'Photo' },
8
+ { role: 'Preview', sizeInBytes: 340621, width: 1200, height: 800, href: 'https://example.com/prev.jpg', type: 'Photo' },
9
+ { role: 'Preview_B', sizeInBytes: 596996, width: 1800, height: 1200, href: 'https://example.com/prev_b.jpg', type: 'Photo' },
10
+ { role: 'HighDef', sizeInBytes: 5126566, width: 3429, height: 2286, href: 'https://example.com/hd.jpg', type: 'Photo' },
11
+ { role: 'Quicklook', sizeInBytes: 14055, width: 245, height: 164, href: 'https://example.com/quick.jpg', type: 'Photo' },
12
+ { role: 'Mockup', sizeInBytes: 19989, width: 512, height: 342, href: 'https://example.com/mockup.jpg', type: 'Photo' },
13
+ { role: 'Squared120', sizeInBytes: 3696, width: 120, height: 120, href: 'https://example.com/sq120.jpg', type: 'Photo' },
12
14
  ],
13
15
  }];
14
16
 
@@ -21,11 +23,14 @@ const FIXTURE_BAG_NO_PREVIEW = [{
21
23
  }];
22
24
 
23
25
  describe('MEDIA_RENDITION_ROLE_MAP', () => {
26
+ it('maps Quicklook → quicklook', () => expect(MEDIA_RENDITION_ROLE_MAP['Quicklook']).toBe('quicklook'));
24
27
  it('maps Thumbnail → thumbnail', () => expect(MEDIA_RENDITION_ROLE_MAP['Thumbnail']).toBe('thumbnail'));
25
28
  it('maps Preview → preview', () => expect(MEDIA_RENDITION_ROLE_MAP['Preview']).toBe('preview'));
26
29
  it('maps Preview_B → preview', () => expect(MEDIA_RENDITION_ROLE_MAP['Preview_B']).toBe('preview'));
27
30
  it('maps Preview_W → preview', () => expect(MEDIA_RENDITION_ROLE_MAP['Preview_W']).toBe('preview'));
28
31
  it('maps HighDef → highdef', () => expect(MEDIA_RENDITION_ROLE_MAP['HighDef']).toBe('highdef'));
32
+ it('maps Mockup → mockup', () => expect(MEDIA_RENDITION_ROLE_MAP['Mockup']).toBe('mockup'));
33
+ it('maps Squared120 → squared120', () => expect(MEDIA_RENDITION_ROLE_MAP['Squared120']).toBe('squared120'));
29
34
  });
30
35
 
31
36
  describe('extractRenditions', () => {
@@ -37,11 +42,17 @@ describe('extractRenditions', () => {
37
42
  expect(extractRenditions([{}])).toEqual({});
38
43
  });
39
44
 
40
- it('extracts thumbnail, preview, highdef from standard bagItem', () => {
45
+ it('extracts squared120, quicklook, thumbnail, mockup, preview, highdef from standard bagItem', () => {
41
46
  const r = extractRenditions(FIXTURE_BAG_ITEM);
47
+ expect(r.squared120?.href).toBe('https://example.com/sq120.jpg');
48
+ expect(r.squared120?.width).toBe(120);
49
+ expect(r.quicklook?.href).toBe('https://example.com/quick.jpg');
50
+ expect(r.quicklook?.width).toBe(245);
42
51
  expect(r.thumbnail?.href).toBe('https://example.com/thumb.jpg');
43
52
  expect(r.thumbnail?.width).toBe(320);
44
53
  expect(r.thumbnail?.sizeInBytes).toBe(33590);
54
+ expect(r.mockup?.href).toBe('https://example.com/mockup.jpg');
55
+ expect(r.mockup?.width).toBe(512);
45
56
  expect(r.preview?.href).toBe('https://example.com/prev.jpg');
46
57
  expect(r.preview?.width).toBe(1200);
47
58
  expect(r.highdef?.href).toBe('https://example.com/hd.jpg');
@@ -58,9 +69,15 @@ describe('extractRenditions', () => {
58
69
  expect(r.preview?.width).toBe(1800);
59
70
  });
60
71
 
61
- it('ignores unknown roles (Quicklook)', () => {
62
- const r = extractRenditions(FIXTURE_BAG_ITEM);
63
- expect(Object.keys(r)).not.toContain('quicklook');
72
+ it('ignores genuinely unknown roles', () => {
73
+ const bagWithUnknownRole = [{
74
+ medias: [
75
+ ...FIXTURE_BAG_ITEM[0].medias,
76
+ { role: 'SomeFutureRole', href: 'https://example.com/future.jpg', width: 999, height: 999, type: 'Photo' },
77
+ ],
78
+ }];
79
+ const r = extractRenditions(bagWithUnknownRole);
80
+ expect(Object.keys(r)).not.toContain('somefuturerole');
64
81
  });
65
82
 
66
83
  it('uses first bagItem only (index 0)', () => {
@@ -25,11 +25,26 @@ describe('inferMimeType', () => {
25
25
 
26
26
  describe('selectRenditionForEmbed', () => {
27
27
  const renditions: MediaRenditions = {
28
+ quicklook: { href: 'https://example.com/quick.jpg', width: 245, height: 164, sizeInBytes: 14055 },
28
29
  thumbnail: { href: 'https://example.com/thumb.jpg', width: 320, height: 213, sizeInBytes: 33590 },
29
30
  preview: { href: 'https://example.com/prev.jpg', width: 1200, height: 800, sizeInBytes: 340621 },
30
31
  highdef: { href: 'https://example.com/hd.jpg', width: 3429, height: 2286, sizeInBytes: 5126566 },
31
32
  };
32
33
 
34
+ it('returns quicklook when explicitly requested and available', () => {
35
+ const r = selectRenditionForEmbed(renditions, 'quicklook');
36
+ expect(r?.href).toBe('https://example.com/quick.jpg');
37
+ });
38
+
39
+ it('downgrades to quicklook when thumbnail is absent and requested rendition exceeds 5MB', () => {
40
+ const noThumbnail: MediaRenditions = {
41
+ quicklook: { href: 'https://example.com/quick.jpg', width: 245, height: 164, sizeInBytes: 14055 },
42
+ highdef: { href: 'https://example.com/hd.jpg', width: 3429, height: 2286, sizeInBytes: 5126566 },
43
+ };
44
+ const r = selectRenditionForEmbed(noThumbnail, 'highdef');
45
+ expect(r?.href).toBe('https://example.com/quick.jpg');
46
+ });
47
+
33
48
  it('returns requested rendition when available and under size limit', () => {
34
49
  const r = selectRenditionForEmbed(renditions, 'preview');
35
50
  expect(r?.href).toBe('https://example.com/prev.jpg');
@@ -62,4 +77,22 @@ describe('selectRenditionForEmbed', () => {
62
77
  const r = selectRenditionForEmbed({}, 'preview');
63
78
  expect(r).toBeUndefined();
64
79
  });
80
+
81
+ it('returns mockup when explicitly requested and available', () => {
82
+ const withMockup: MediaRenditions = {
83
+ ...renditions,
84
+ mockup: { href: 'https://example.com/mockup.jpg', width: 512, height: 342, sizeInBytes: 19989 },
85
+ };
86
+ const r = selectRenditionForEmbed(withMockup, 'mockup');
87
+ expect(r?.href).toBe('https://example.com/mockup.jpg');
88
+ });
89
+
90
+ it('downgrades to squared120 as last resort when nothing lighter is available', () => {
91
+ const squaredOnly: MediaRenditions = {
92
+ squared120: { href: 'https://example.com/sq120.jpg', width: 120, height: 120, sizeInBytes: 3696 },
93
+ highdef: { href: 'https://example.com/hd.jpg', width: 3429, height: 2286, sizeInBytes: 5126566 },
94
+ };
95
+ const r = selectRenditionForEmbed(squaredOnly, 'highdef');
96
+ expect(r?.href).toBe('https://example.com/sq120.jpg');
97
+ });
65
98
  });
@@ -0,0 +1,125 @@
1
+ import { beforeEach, describe, expect, it, mock } from 'bun:test';
2
+ import { afpSearchMediaTool, EMBED_MAX_DOCS } from '../tools/search-media.js';
3
+
4
+ const ORIGINAL_FETCH = globalThis.fetch;
5
+
6
+ function makeMediaDoc(uno: string, extraMedias: Record<string, unknown>[] = []) {
7
+ return {
8
+ uno,
9
+ title: `Photo ${uno}`,
10
+ caption: ['A caption'],
11
+ class: 'picture',
12
+ bagItem: [{
13
+ medias: [
14
+ { role: 'Quicklook', href: `https://example.com/${uno}-quick.jpg`, width: 245, height: 164, type: 'Photo' },
15
+ { role: 'Thumbnail', href: `https://example.com/${uno}-thumb.jpg`, width: 320, height: 213, type: 'Photo' },
16
+ ...extraMedias,
17
+ ],
18
+ }],
19
+ };
20
+ }
21
+
22
+ describe('afpSearchMediaTool.handler — embed', () => {
23
+ beforeEach(() => {
24
+ globalThis.fetch = ORIGINAL_FETCH;
25
+ });
26
+
27
+ it('rejects embed with a non-markdown format', async () => {
28
+ const apicore = { search: mock() };
29
+
30
+ const result = await afpSearchMediaTool.handler(apicore, { query: 'test', embed: true, format: 'json' });
31
+
32
+ expect('isError' in result && result.isError).toBe(true);
33
+ expect(apicore.search).not.toHaveBeenCalled();
34
+ });
35
+
36
+ it('caps the effective search size to EMBED_MAX_DOCS when embed is true', async () => {
37
+ const apicore = {
38
+ search: mock().mockResolvedValue({ documents: [], count: 0 }),
39
+ };
40
+
41
+ await afpSearchMediaTool.handler(apicore, { query: 'test', size: 50, embed: true });
42
+
43
+ const [request] = apicore.search.mock.calls[0]!;
44
+ expect(request.size).toBe(EMBED_MAX_DOCS);
45
+ });
46
+
47
+ it('does not cap size when embed is false', async () => {
48
+ const apicore = {
49
+ search: mock().mockResolvedValue({ documents: [], count: 0 }),
50
+ };
51
+
52
+ await afpSearchMediaTool.handler(apicore, { query: 'test', size: 50 });
53
+
54
+ const [request] = apicore.search.mock.calls[0]!;
55
+ expect(request.size).toBe(50);
56
+ });
57
+
58
+ it('embeds an image block per result, interleaved with its markdown text', async () => {
59
+ globalThis.fetch = mock().mockResolvedValue({
60
+ ok: true,
61
+ arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
62
+ headers: new Headers({ 'content-type': 'image/jpeg' }),
63
+ } as Response);
64
+
65
+ const apicore = {
66
+ search: mock().mockResolvedValue({
67
+ documents: [makeMediaDoc('MEDIA-1'), makeMediaDoc('MEDIA-2')],
68
+ count: 2,
69
+ }),
70
+ };
71
+
72
+ const result = await afpSearchMediaTool.handler(apicore, { query: 'test', embed: true });
73
+
74
+ // pagination + (text + image) per doc
75
+ expect(result.content).toHaveLength(1 + 2 * 2);
76
+ expect(result.content[1]).toEqual(expect.objectContaining({ type: 'text' }));
77
+ expect((result.content[1] as { text: string }).text).toContain('MEDIA-1');
78
+ expect(result.content[2]).toEqual(expect.objectContaining({ type: 'image', mimeType: 'image/jpeg' }));
79
+ expect((result.content[3] as { text: string }).text).toContain('MEDIA-2');
80
+ expect(result.content[4]).toEqual(expect.objectContaining({ type: 'image' }));
81
+ });
82
+
83
+ it('embeds the thumbnail (poster frame) for video results, not quicklook', async () => {
84
+ globalThis.fetch = mock().mockResolvedValue({
85
+ ok: true,
86
+ arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
87
+ headers: new Headers({ 'content-type': 'image/jpeg' }),
88
+ } as Response);
89
+
90
+ const videoDoc = {
91
+ uno: 'VIDEO-1',
92
+ title: 'A video',
93
+ class: 'video',
94
+ bagItem: [{
95
+ medias: [
96
+ { role: 'Quicklook', href: 'https://example.com/video-quick.jpg', width: 245, height: 164, type: 'Photo' },
97
+ { role: 'Thumbnail', href: 'https://example.com/video-thumb.jpg', width: 320, height: 213, type: 'Photo' },
98
+ ],
99
+ }],
100
+ };
101
+ const apicore = { search: mock().mockResolvedValue({ documents: [videoDoc], count: 1 }) };
102
+
103
+ await afpSearchMediaTool.handler(apicore, { query: 'test', class: 'video', embed: true });
104
+
105
+ const fetchMock = globalThis.fetch as unknown as ReturnType<typeof mock>;
106
+ expect(fetchMock.mock.calls[0]![0]).toBe('https://example.com/video-thumb.jpg');
107
+ });
108
+
109
+ it('skips embedding SVG graphics but still returns their metadata text', async () => {
110
+ const svgDoc = {
111
+ uno: 'GRAPHIC-1',
112
+ title: 'A graphic',
113
+ class: 'graphic',
114
+ bagItem: [{
115
+ medias: [{ role: 'Preview', href: 'https://example.com/graphic.svg', width: 1200, height: 800, type: 'Graphic' }],
116
+ }],
117
+ };
118
+ const apicore = { search: mock().mockResolvedValue({ documents: [svgDoc], count: 1 }) };
119
+
120
+ const result = await afpSearchMediaTool.handler(apicore, { query: 'test', class: 'graphic', embed: true });
121
+
122
+ expect(result.content.some((c: any) => c.type === 'image')).toBe(false);
123
+ expect(result.content.some((c: any) => c.type === 'text' && c.text.includes('GRAPHIC-1'))).toBe(true);
124
+ });
125
+ });
@@ -355,6 +355,40 @@ describe('MCP integration', () => {
355
355
  expect(params).toMatchObject({ langs: ['fr'], class: ['text'], provider: ['afp'], dateFrom: 'now-1d' });
356
356
  });
357
357
 
358
+ it('forwards query and date window to the API', async () => {
359
+ await client.callTool({
360
+ name: 'afp_list_facets',
361
+ arguments: { facet: 'slug', query: 'elections', dateFrom: 'now-2d', dateTo: 'now-1d', lang: 'fr' },
362
+ });
363
+ const [facet, params] = apicore.list.mock.calls.at(-1)!;
364
+ expect(facet).toBe('slug');
365
+ expect(params).toMatchObject({
366
+ query: 'elections',
367
+ dateFrom: 'now-2d',
368
+ dateTo: 'now-1d',
369
+ langs: ['fr'],
370
+ });
371
+ });
372
+
373
+ it('forwards additional facet filters', async () => {
374
+ await client.callTool({
375
+ name: 'afp_list_facets',
376
+ arguments: { facet: 'genre', facets: { country: ['usa'] } },
377
+ });
378
+ const [, params] = apicore.list.mock.calls.at(-1)!;
379
+ expect(params).toMatchObject({ country: ['usa'] });
380
+ });
381
+
382
+ it('lets explicit dates override the trending preset window', async () => {
383
+ await client.callTool({
384
+ name: 'afp_list_facets',
385
+ arguments: { preset: 'trending-topics', dateFrom: 'now-7d', dateTo: 'now' },
386
+ });
387
+ const [facet, params] = apicore.list.mock.calls.at(-1)!;
388
+ expect(facet).toBe('slug');
389
+ expect(params).toMatchObject({ dateFrom: 'now-7d', dateTo: 'now' });
390
+ });
391
+
358
392
  it('returns isError on API failure', async () => {
359
393
  apicore.list.mockRejectedValueOnce(new Error('Service unavailable'));
360
394
  const result = await client.callTool({ name: 'afp_list_facets', arguments: { facet: 'slug' } });
@@ -2,47 +2,17 @@ import { z } from 'zod';
2
2
  import type { ApiCore } from 'afpnews-api';
3
3
  import { textContent, toolError } from '../utils/format.js';
4
4
  import { extractRenditions } from '../utils/format-media.js';
5
- import type { MediaRendition, MediaRenditions, ImageContent } from '../utils/types.js';
5
+ import type { MediaRendition, MediaRenditions } from '../utils/types.js';
6
+ import { inferMimeType, selectRenditionForEmbed, isSvgRendition, embedRendition } from '../utils/embed-media.js';
6
7
  import { renditionEnum, formatErrorMessage } from './shared.js';
7
8
 
8
- // Exported for testing
9
- export function inferMimeType(afpType: string | undefined, href: string): string {
10
- if (afpType === 'Photo') return 'image/jpeg';
11
- if (afpType === 'Graphic') return 'image/png';
12
- const ext = href.split('?')[0].split('.').pop()?.toLowerCase();
13
- if (ext === 'jpg' || ext === 'jpeg') return 'image/jpeg';
14
- if (ext === 'png') return 'image/png';
15
- if (ext === 'webp') return 'image/webp';
16
- if (ext === 'gif') return 'image/gif';
17
- if (ext === 'svg') return 'image/svg+xml';
18
- return 'image/jpeg';
19
- }
20
-
21
- // Exported for testing
22
- export function selectRenditionForEmbed(
23
- renditions: MediaRenditions,
24
- requested: 'thumbnail' | 'preview' | 'highdef',
25
- ): MediaRendition | undefined {
26
- const SIZE_LIMIT = 5_000_000;
27
-
28
- const get = (key: keyof MediaRenditions): MediaRendition | undefined => renditions[key];
29
-
30
- // Try requested rendition, then fallback chain
31
- const candidate = get(requested) ?? get('preview') ?? get('thumbnail');
32
- if (!candidate) return undefined;
33
-
34
- // Downgrade if over size limit (only once)
35
- if ((candidate.sizeInBytes ?? 0) > SIZE_LIMIT) {
36
- return get('thumbnail') ?? candidate; // proceed with candidate if no thumbnail
37
- }
38
-
39
- return candidate;
40
- }
9
+ // Re-exported for testing (moved to utils/embed-media.ts for reuse by search-media.ts)
10
+ export { inferMimeType, selectRenditionForEmbed };
41
11
 
42
12
  const inputSchema = z.object({
43
13
  uno: z.string().describe('AFP document UNO identifier (e.g. newsml.afp.com.20260316T202634Z.doc-a3jc2qq)'),
44
14
  embed: z.boolean().optional().describe('When true, fetches the image and returns it as base64 for Claude vision analysis. Default: false.'),
45
- rendition: renditionEnum.optional().describe("Rendition size to embed: 'thumbnail' (320px), 'preview' (1200px, default), 'highdef' (~3400px)"),
15
+ rendition: renditionEnum.optional().describe("Rendition size to embed: 'squared120' (~120px, lightest, square crop), 'quicklook' (~245px, lightest full-frame), 'thumbnail' (320px), 'mockup' (~512px), 'preview' (1200px, default), 'highdef' (~3400px)"),
46
16
  });
47
17
 
48
18
  type GetMediaInput = z.infer<typeof inputSchema>;
@@ -65,10 +35,13 @@ function formatFullMediaText(doc: Record<string, unknown>, renditions: MediaRend
65
35
  if (doc.advisory) lines.push(`\n> ${doc.advisory}`);
66
36
 
67
37
  lines.push('\n**Renditions:**');
68
- const { thumbnail, preview, highdef } = renditions;
69
- if (thumbnail) lines.push(`- thumbnail: ${thumbnail.href} (${thumbnail.width}×${thumbnail.height})`);
70
- if (preview) lines.push(`- preview: ${preview.href} (${preview.width}×${preview.height})`);
71
- if (highdef) lines.push(`- highdef: ${highdef.href} (${highdef.width}×${highdef.height})`);
38
+ const { squared120, quicklook, thumbnail, mockup, preview, highdef } = renditions;
39
+ if (squared120) lines.push(`- squared120: ${squared120.href} (${squared120.width}×${squared120.height})`);
40
+ if (quicklook) lines.push(`- quicklook: ${quicklook.href} (${quicklook.width}×${quicklook.height})`);
41
+ if (thumbnail) lines.push(`- thumbnail: ${thumbnail.href} (${thumbnail.width}×${thumbnail.height})`);
42
+ if (mockup) lines.push(`- mockup: ${mockup.href} (${mockup.width}×${mockup.height})`);
43
+ if (preview) lines.push(`- preview: ${preview.href} (${preview.width}×${preview.height})`);
44
+ if (highdef) lines.push(`- highdef: ${highdef.href} (${highdef.width}×${highdef.height})`);
72
45
 
73
46
  if (note) lines.push(`\n*${note}*`);
74
47
 
@@ -85,8 +58,8 @@ Media classes: picture (photo), video, graphic (infographic/SVG), videography (v
85
58
  Args:
86
59
  - uno: AFP document UNO (e.g. newsml.afp.com.20260316T202634Z.doc-a3jc2qq)
87
60
  - embed: When true, fetches the image and returns it as a base64 MCP image block that Claude can see and analyse visually. Default: false.
88
- - rendition: Size to embed — 'thumbnail' (320px), 'preview' (1200px, default), 'highdef' (~3400px).
89
- Files > 5 MB are automatically downgraded to thumbnail.
61
+ - rendition: Size to embed — 'squared120' (~120px, square crop, lightest), 'quicklook' (~245px, lightest full-frame), 'thumbnail' (320px), 'mockup' (~512px), 'preview' (1200px, default), 'highdef' (~3400px).
62
+ Files > 5 MB are automatically downgraded to a lighter rendition.
90
63
  Videos and videography always use thumbnail (poster frame). SVG graphics cannot be embedded.
91
64
 
92
65
  Returns:
@@ -112,9 +85,8 @@ Returns:
112
85
  }
113
86
 
114
87
  // Guard: SVG graphics (URL ends with .svg OR AFP type field is 'Graphic')
115
- const isSvg = (r: MediaRendition) => r.href.split('?')[0].endsWith('.svg') || r.afpType === 'Graphic';
116
88
  const allRenditions = Object.values(renditions).filter(Boolean) as MediaRendition[];
117
- if (doc.class === 'graphic' && allRenditions.some(isSvg)) {
89
+ if (doc.class === 'graphic' && allRenditions.some(isSvgRendition)) {
118
90
  return {
119
91
  content: [
120
92
  metadataText,
@@ -124,7 +96,7 @@ Returns:
124
96
  }
125
97
 
126
98
  // Guard: video → use thumbnail as poster frame
127
- let renditionKey: 'thumbnail' | 'preview' | 'highdef' = requestedRendition;
99
+ let renditionKey: keyof MediaRenditions = requestedRendition;
128
100
  let note: string | undefined;
129
101
  if (doc.class === 'video') {
130
102
  renditionKey = 'thumbnail';
@@ -141,46 +113,21 @@ Returns:
141
113
  };
142
114
  }
143
115
 
144
- let imageData: string;
145
- let mimeType: string;
146
-
147
- try {
148
- const response = await fetch(chosen.href);
149
- if (!response.ok) {
150
- throw new Error(`HTTP ${response.status}: ${response.statusText}`);
151
- }
152
- const bytes = new Uint8Array(await response.arrayBuffer());
153
- const chunks: string[] = [];
154
- for (let i = 0; i < bytes.length; i += 8192) {
155
- chunks.push(String.fromCharCode(...bytes.subarray(i, i + 8192)));
156
- }
157
- imageData = btoa(chunks.join(''));
158
- // MIME priority: AFP type field → URL extension → HTTP Content-Type → fallback
159
- mimeType = inferMimeType(chosen.afpType, chosen.href);
160
- // Override with Content-Type from response if more specific
161
- const ct = response.headers.get('content-type');
162
- if (ct && ct.startsWith('image/')) mimeType = ct.split(';')[0].trim();
163
- } catch (fetchErr) {
164
- const msg = fetchErr instanceof Error ? fetchErr.message : 'Unknown fetch error';
116
+ const embedded = await embedRendition(chosen);
117
+ if (!embedded.ok) {
165
118
  return {
166
119
  content: [
167
120
  metadataText,
168
- textContent(`Warning: image embed failed: ${msg}`),
121
+ textContent(`Warning: image embed failed: ${embedded.error}`),
169
122
  ],
170
123
  };
171
124
  }
172
125
 
173
- const imageContent: ImageContent = {
174
- type: 'image',
175
- data: imageData,
176
- mimeType,
177
- };
178
-
179
126
  const metaWithNote = note
180
127
  ? textContent(formatFullMediaText(doc, renditions, note))
181
128
  : metadataText;
182
129
 
183
- return { content: [metaWithNote, imageContent] };
130
+ return { content: [metaWithNote, embedded.image] };
184
131
  } catch (error) {
185
132
  return toolError(formatErrorMessage('retrieving AFP media document', error, 'Check the UNO identifier and try again.'));
186
133
  }
@@ -3,6 +3,7 @@ import type { ApiCore, SearchQueryParams } from 'afpnews-api';
3
3
  import { escapeCsvValue, textContent, toolError, truncateToLimit, truncationHint } from '../utils/format.js';
4
4
  import {
5
5
  type FacetResult,
6
+ facetParamValueSchema,
6
7
  formatErrorMessage,
7
8
  langEnum,
8
9
  listPresetEnum,
@@ -13,8 +14,12 @@ const inputSchema = z.object({
13
14
  preset: listPresetEnum.optional().describe('Optional preset for list queries. Available preset: trending-topics.'),
14
15
  facet: z.string().optional().describe("Facet to list (e.g. 'slug', 'genre', 'country'). Required when no preset is used."),
15
16
  lang: langEnum.optional().describe("Language filter (e.g. 'en', 'fr')"),
17
+ query: z.string().optional().describe("Keywords to scope the facet counts (e.g. 'climate change'). When set, counts reflect only articles matching the query."),
18
+ dateFrom: z.string().optional().describe("Start of the time window (e.g. 'now-1d', '2026-01-01'). Combine with dateTo for a bounded window (e.g. to compare two periods)."),
19
+ dateTo: z.string().optional().describe("End of the time window (e.g. 'now', 'now-1d', '2026-01-31')."),
16
20
  size: z.number().optional().describe('Number of facet values to return'),
17
21
  format: outputFormatEnum.optional().describe('Output format: markdown (default), json, or csv.'),
22
+ facets: z.record(z.string(), facetParamValueSchema).optional().describe("Additional facet filters passed to the AFP query (e.g. { country: ['usa'], genre: 'Papier général', urgency: 1 })."),
18
23
  });
19
24
 
20
25
  type ListFacetsInput = z.infer<typeof inputSchema>;
@@ -22,14 +27,17 @@ type ListFacetsInput = z.infer<typeof inputSchema>;
22
27
  export const afpListFacetsTool = {
23
28
  name: 'afp_list_facets',
24
29
  title: 'List AFP Facet Values',
25
- description: `List facet values and their article counts. Use this to discover available topics, genres, or countries, or to get trending topics.
30
+ description: `List facet values and their article counts. Use this to discover available topics, genres, or countries, get trending topics, or compare activity across time windows.
26
31
 
27
32
  Args:
28
33
  - preset: Optional preset (trending-topics) — overrides facet to 'slug' with last 24h news
29
34
  - facet: Facet to list (e.g. 'slug', 'genre', 'country'). Required when no preset is used.
30
35
  - lang: Language filter (e.g. 'en', 'fr')
36
+ - query: Keywords to scope the counts (e.g. 'climate change')
37
+ - dateFrom / dateTo: Time window for the counts (e.g. dateFrom 'now-1d'; dateFrom '2026-01-01', dateTo '2026-01-31')
31
38
  - size: Number of facet values to return
32
39
  - format: Output format — markdown (default), json, or csv.
40
+ - facets: Additional filters (country, genre, urgency, …)
33
41
 
34
42
  Returns:
35
43
  - markdown: Formatted list with labels and article counts
@@ -39,10 +47,12 @@ Returns:
39
47
  Examples:
40
48
  - Trending topics in French: { preset: "trending-topics" }
41
49
  - Trending topics in English: { preset: "trending-topics", lang: "en" }
42
- - List available genres as CSV: { facet: "genre", format: "csv" }
50
+ - Top slugs over the last 24h: { facet: "slug", dateFrom: "now-1d", format: "json" }
51
+ - Top slugs over the previous 24h: { facet: "slug", dateFrom: "now-2d", dateTo: "now-1d", format: "json" }
52
+ - Genres about a topic: { facet: "genre", query: "elections", format: "csv" }
43
53
  - List countries as JSON: { facet: "country", size: 30, format: "json" }`,
44
54
  inputSchema,
45
- handler: async (apicore: Pick<ApiCore, 'list'>, { preset, facet, lang, size, format = 'markdown' }: ListFacetsInput) => {
55
+ handler: async (apicore: Pick<ApiCore, 'list'>, { preset, facet, lang, query, dateFrom, dateTo, size, format = 'markdown', facets }: ListFacetsInput) => {
46
56
  try {
47
57
  const isTrendingTopics = preset === 'trending-topics';
48
58
  const resolvedFacet = isTrendingTopics ? 'slug' : facet;
@@ -52,9 +62,20 @@ Examples:
52
62
  }
53
63
 
54
64
  const resolvedSize = size ?? 10;
55
- const params: SearchQueryParams = isTrendingTopics
56
- ? { langs: [lang ?? 'fr'], class: ['text'], provider: ['afp'], dateFrom: 'now-1d', size: resolvedSize }
57
- : { class: ['text'], provider: ['afp'], ...(lang ? { langs: [lang] } : {}), size: resolvedSize };
65
+ // Le preset trending impose slug + fenêtre 24h, mais dateFrom/dateTo/query
66
+ // explicites restent prioritaires pour affiner ou comparer des périodes.
67
+ const resolvedLang = lang ?? (isTrendingTopics ? 'fr' : undefined);
68
+ const resolvedDateFrom = dateFrom ?? (isTrendingTopics ? 'now-1d' : undefined);
69
+ const params: SearchQueryParams = {
70
+ class: ['text'],
71
+ provider: ['afp'],
72
+ size: resolvedSize,
73
+ ...(resolvedLang ? { langs: [resolvedLang] } : {}),
74
+ ...(query ? { query } : {}),
75
+ ...(resolvedDateFrom ? { dateFrom: resolvedDateFrom } : {}),
76
+ ...(dateTo ? { dateTo } : {}),
77
+ ...(facets ?? {}),
78
+ };
58
79
 
59
80
  const { keywords } = await apicore.list(resolvedFacet, params, 1);
60
81
  const results: FacetResult[] = keywords.map(k => ({ name: k.name ?? '', count: k.count }));
@@ -3,9 +3,12 @@ import type { ApiCore, SearchQueryParams } from 'afpnews-api';
3
3
  import { textContent, toolError, buildPaginationLine } from '../utils/format.js';
4
4
  import {
5
5
  formatMediaOutput,
6
+ formatMediaDocument,
6
7
  normalizeMediaDocument,
7
8
  } from '../utils/format-media.js';
8
9
  import { DEFAULT_SEARCH_SIZE } from '../utils/types.js';
10
+ import type { AFPMediaDocument, AnyContent, MediaRendition } from '../utils/types.js';
11
+ import { selectRenditionForEmbed, isSvgRendition, embedRendition } from '../utils/embed-media.js';
9
12
  import {
10
13
  mediaClassEnum,
11
14
  outputFormatEnum,
@@ -13,6 +16,33 @@ import {
13
16
  facetParamValueSchema,
14
17
  } from './shared.js';
15
18
 
19
+ // Fetching + base64-encoding images is costly: cap how many results get embedded per call,
20
+ // regardless of the requested `size` (see handler: `size` itself is clamped when embed is true).
21
+ export const EMBED_MAX_DOCS = 5;
22
+
23
+ async function embedMediaDocuments(docs: AFPMediaDocument[]): Promise<AnyContent[]> {
24
+ const content: AnyContent[] = [];
25
+
26
+ for (const doc of docs) {
27
+ content.push(formatMediaDocument(doc));
28
+
29
+ const allRenditions = Object.values(doc.renditions).filter(Boolean) as MediaRendition[];
30
+ if (doc.class === 'graphic' && allRenditions.some(isSvgRendition)) {
31
+ content.push(textContent('_Warning: SVG graphics cannot be embedded for vision._'));
32
+ continue;
33
+ }
34
+
35
+ const renditionKey = doc.class === 'video' || doc.class === 'videography' ? 'thumbnail' : 'quicklook';
36
+ const chosen = selectRenditionForEmbed(doc.renditions, renditionKey);
37
+ if (!chosen) continue;
38
+
39
+ const embedded = await embedRendition(chosen);
40
+ if (embedded.ok) content.push(embedded.image);
41
+ }
42
+
43
+ return content;
44
+ }
45
+
16
46
  const reservedMediaFacetKeys = new Set(['class', 'format', 'query', 'size', 'sortOrder', 'offset', 'facets']);
17
47
 
18
48
  const MEDIA_API_FIELDS = [
@@ -31,6 +61,9 @@ const inputSchema = z.object({
31
61
  facets: z.record(z.string(), facetParamValueSchema).optional().describe(
32
62
  "Additional AFP facet filters (e.g. { langs: ['fr'], country: ['fra'], dateFrom: '2026-01-01' })"
33
63
  ),
64
+ embed: z.boolean().optional().describe(
65
+ `When true, fetches and embeds the lightest available image (quicklook, thumbnail poster frame for video) for each result as base64, for Claude vision analysis. Requires format: "markdown" (default). Size is capped to ${EMBED_MAX_DOCS} regardless of the requested value, since fetching/encoding images is costly. Default: false.`
66
+ ),
34
67
  }).strict().superRefine((value, ctx) => {
35
68
  for (const key of Object.keys(value.facets ?? {})) {
36
69
  if (reservedMediaFacetKeys.has(key)) {
@@ -67,6 +100,9 @@ Args:
67
100
  - format: markdown (default, with inline thumbnails), json (structured with rendition URLs), csv
68
101
  - facets: Additional AFP filters (e.g. { langs: ['fr'], country: ['fra'], dateFrom: '2026-01-01' }).
69
102
  Defaults: provider=afp. Override provider only when partner media is explicitly needed.
103
+ - embed: When true, fetches and embeds the lightest image for each result as base64 (Claude vision analysis) —
104
+ useful to visually compare or verify a handful of candidate photos before picking one.
105
+ Requires format: "markdown". Size is capped to ${EMBED_MAX_DOCS} regardless of the requested value.
70
106
 
71
107
  Pagination:
72
108
  Use \`offset\` to paginate (e.g. offset=10 to skip the first 10).
@@ -75,9 +111,10 @@ Pagination:
75
111
  Returns (json):
76
112
  { total, shown, offset, truncated, remaining, documents: [{ uno, title, caption, creditLine, creator,
77
113
  country, city, published, urgency, class, aspectRatios, advisory,
78
- renditions: { thumbnail, preview, highdef } }] }
114
+ renditions: { quicklook, thumbnail, preview, highdef } }] }
79
115
 
80
116
  Rendition sizes:
117
+ - quicklook: ~245px wide (lightest, when available)
81
118
  - thumbnail: ~320px wide (gallery grid)
82
119
  - preview: ~1200px wide (display)
83
120
  - highdef: ~3400px wide (download / analysis)
@@ -90,13 +127,18 @@ Examples:
90
127
  inputSchema,
91
128
  handler: async (
92
129
  apicore: Pick<ApiCore, 'search'>,
93
- { class: mediaClass, query, size = DEFAULT_SEARCH_SIZE, offset, sortOrder = 'desc', format = 'markdown', facets }: SearchMediaInput,
130
+ { class: mediaClass, query, size = DEFAULT_SEARCH_SIZE, offset, sortOrder = 'desc', format = 'markdown', facets, embed = false }: SearchMediaInput,
94
131
  ) => {
95
132
  try {
133
+ if (embed && format !== 'markdown') {
134
+ return toolError(`embed is only supported with format: "markdown" (got "${format}").`);
135
+ }
136
+
137
+ const effectiveSize = embed ? Math.min(size, EMBED_MAX_DOCS) : size;
96
138
  const classFilter = mediaClass ? [mediaClass] : ['picture', 'video', 'graphic', 'videography'];
97
139
  const request: SearchQueryParams = {
98
140
  query,
99
- size,
141
+ size: effectiveSize,
100
142
  sortOrder,
101
143
  startAt: offset,
102
144
  class: classFilter,
@@ -112,6 +154,18 @@ Examples:
112
154
 
113
155
  const docs = rawDocs.map(normalizeMediaDocument);
114
156
  const currentOffset = offset ?? 0;
157
+
158
+ if (embed) {
159
+ const pagination = textContent(buildPaginationLine(docs.length, count, currentOffset));
160
+ const embeddedContent = await embedMediaDocuments(docs);
161
+ return {
162
+ content: [pagination, ...embeddedContent],
163
+ shown: docs.length,
164
+ truncated: false,
165
+ remaining: count - docs.length,
166
+ };
167
+ }
168
+
115
169
  return formatMediaOutput(docs, format, {
116
170
  jsonMeta: { total: count, offset: currentOffset },
117
171
  markdownPrefix: (shown) => [textContent(buildPaginationLine(shown, count, currentOffset))],
@@ -28,7 +28,7 @@ const MEDIA_CLASS_VALUES = ['picture', 'video', 'graphic', 'videography'] as con
28
28
  export const mediaClassEnum = z.enum(MEDIA_CLASS_VALUES);
29
29
  export type MediaClass = z.infer<typeof mediaClassEnum>;
30
30
 
31
- const RENDITION_VALUES = ['thumbnail', 'preview', 'highdef'] as const;
31
+ const RENDITION_VALUES = ['squared120', 'quicklook', 'thumbnail', 'mockup', 'preview', 'highdef'] as const;
32
32
  export const renditionEnum = z.enum(RENDITION_VALUES);
33
33
  export type RenditionKey = z.infer<typeof renditionEnum>;
34
34
 
@@ -0,0 +1,65 @@
1
+ import type { MediaRendition, MediaRenditions, ImageContent } from './types.js';
2
+
3
+ // Exported for testing
4
+ export function inferMimeType(afpType: string | undefined, href: string): string {
5
+ if (afpType === 'Photo') return 'image/jpeg';
6
+ if (afpType === 'Graphic') return 'image/png';
7
+ const ext = href.split('?')[0].split('.').pop()?.toLowerCase();
8
+ if (ext === 'jpg' || ext === 'jpeg') return 'image/jpeg';
9
+ if (ext === 'png') return 'image/png';
10
+ if (ext === 'webp') return 'image/webp';
11
+ if (ext === 'gif') return 'image/gif';
12
+ if (ext === 'svg') return 'image/svg+xml';
13
+ return 'image/jpeg';
14
+ }
15
+
16
+ // Exported for testing
17
+ export function selectRenditionForEmbed(
18
+ renditions: MediaRenditions,
19
+ requested: keyof MediaRenditions,
20
+ ): MediaRendition | undefined {
21
+ const SIZE_LIMIT = 5_000_000;
22
+
23
+ const get = (key: keyof MediaRenditions): MediaRendition | undefined => renditions[key];
24
+
25
+ // Try requested rendition, then fallback chain (largest usable → lightest)
26
+ const candidate = get(requested) ?? get('preview') ?? get('thumbnail') ?? get('quicklook') ?? get('squared120');
27
+ if (!candidate) return undefined;
28
+
29
+ // Downgrade if over size limit (only once)
30
+ if ((candidate.sizeInBytes ?? 0) > SIZE_LIMIT) {
31
+ return get('thumbnail') ?? get('quicklook') ?? get('squared120') ?? candidate; // proceed with candidate if no lighter rendition
32
+ }
33
+
34
+ return candidate;
35
+ }
36
+
37
+ // SVG graphics (URL ends with .svg OR AFP type field is 'Graphic') cannot be embedded for vision.
38
+ export function isSvgRendition(r: MediaRendition): boolean {
39
+ return r.href.split('?')[0].endsWith('.svg') || r.afpType === 'Graphic';
40
+ }
41
+
42
+ export async function embedRendition(
43
+ rendition: MediaRendition,
44
+ ): Promise<{ ok: true; image: ImageContent } | { ok: false; error: string }> {
45
+ try {
46
+ const response = await fetch(rendition.href);
47
+ if (!response.ok) {
48
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
49
+ }
50
+ const bytes = new Uint8Array(await response.arrayBuffer());
51
+ const chunks: string[] = [];
52
+ for (let i = 0; i < bytes.length; i += 8192) {
53
+ chunks.push(String.fromCharCode(...bytes.subarray(i, i + 8192)));
54
+ }
55
+ const data = btoa(chunks.join(''));
56
+ // MIME priority: AFP type field → URL extension → HTTP Content-Type → fallback
57
+ let mimeType = inferMimeType(rendition.afpType, rendition.href);
58
+ const ct = response.headers.get('content-type');
59
+ if (ct && ct.startsWith('image/')) mimeType = ct.split(';')[0].trim();
60
+ return { ok: true, image: { type: 'image', data, mimeType } };
61
+ } catch (err) {
62
+ const message = err instanceof Error ? err.message : 'Unknown fetch error';
63
+ return { ok: false, error: message };
64
+ }
65
+ }
@@ -4,11 +4,14 @@ import { textContent, truncateToLimit, truncateContentItems, truncationHint } fr
4
4
  // Mapping role AFP → clé normalisée (utilise m.role, pas m.rendition)
5
5
  // Preview est prioritaire sur Preview_B/Preview_W (premier match gagne)
6
6
  export const MEDIA_RENDITION_ROLE_MAP: Record<string, keyof MediaRenditions> = {
7
- 'Thumbnail': 'thumbnail',
8
- 'Preview': 'preview',
9
- 'Preview_B': 'preview',
10
- 'Preview_W': 'preview',
11
- 'HighDef': 'highdef',
7
+ 'Squared120': 'squared120',
8
+ 'Quicklook': 'quicklook',
9
+ 'Thumbnail': 'thumbnail',
10
+ 'Mockup': 'mockup',
11
+ 'Preview': 'preview',
12
+ 'Preview_B': 'preview',
13
+ 'Preview_W': 'preview',
14
+ 'HighDef': 'highdef',
12
15
  };
13
16
 
14
17
  export function extractRenditions(bagItem: unknown): MediaRenditions {
@@ -32,7 +32,10 @@ export interface MediaRendition {
32
32
  }
33
33
 
34
34
  export interface MediaRenditions {
35
+ squared120?: MediaRendition;
36
+ quicklook?: MediaRendition;
35
37
  thumbnail?: MediaRendition;
38
+ mockup?: MediaRendition;
36
39
  preview?: MediaRendition;
37
40
  highdef?: MediaRendition;
38
41
  }