• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

cofacts / rumors-api / 21223222377

21 Jan 2026 07:38PM UTC coverage: 81.938% (+0.2%) from 81.771%
21223222377

Pull #378

github

web-flow
Merge 92fab1b6f into c4b356ce0
Pull Request #378: feat: Add admin handler for generating AI transcripts for media articles

836 of 1081 branches covered (77.34%)

Branch coverage included in aggregate %.

42 of 47 new or added lines in 2 files covered. (89.36%)

5 existing lines in 1 file now uncovered.

1582 of 1870 relevant lines covered (84.6%)

18.12 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

90.66
/src/graphql/util.js
1
import { ImageAnnotatorClient } from '@google-cloud/vision';
2
import { GoogleGenAI } from '@google/genai';
3
import { GoogleAuth } from 'google-auth-library';
4
import fetch from 'node-fetch';
5
import sharp from 'sharp';
6
import {
7
  GraphQLInputObjectType,
8
  GraphQLObjectType,
9
  GraphQLString,
10
  GraphQLInt,
11
  GraphQLList,
12
  GraphQLEnumType,
13
  GraphQLFloat,
14
  GraphQLNonNull,
15
  GraphQLID,
16
  GraphQLBoolean,
17
} from 'graphql';
18
import { MediaType, variants } from '@cofacts/media-manager';
19
import mediaManager, {
20
  IMAGE_PREVIEW,
21
  IMAGE_THUMBNAIL,
22
} from 'util/mediaManager';
23

24
import Connection from './interfaces/Connection';
25
import Edge from './interfaces/Edge';
26
import PageInfo from './interfaces/PageInfo';
27
import Highlights from './models/Highlights';
28
import client from 'util/client';
29
import delayForMs from 'util/delayForMs';
30
import langfuse from 'util/langfuse';
31

32
// https://www.graph.cool/docs/tutorials/designing-powerful-apis-with-graphql-query-parameters-aing7uech3
33
//
34
// Filtering args definition & parsing
35
//
36

37
/**
38
 * @param {string} typeName
39
 * @param {GraphQLScalarType} argType
40
 * @param {string} description
41
 * @returns {GraphQLInputObjectType}
42
 */
43
function getArithmeticExpressionType(typeName, argType, description) {
44
  return new GraphQLInputObjectType({
92✔
45
    name: typeName,
46
    description,
47
    fields: {
48
      LT: { type: argType },
49
      LTE: { type: argType },
50
      GT: { type: argType },
51
      GTE: { type: argType },
52
      EQ: { type: argType },
53
    },
54
  });
55
}
56

57
export const timeRangeInput = getArithmeticExpressionType(
46✔
58
  'TimeRangeInput',
59
  GraphQLString,
60
  'List only the entries that were created between the specific time range. ' +
61
    'The time range value is in elasticsearch date format (https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-date-format.html)'
62
);
63
export const intRangeInput = getArithmeticExpressionType(
46✔
64
  'RangeInput',
65
  GraphQLInt,
66
  'List only the entries whose field match the criteria.'
67
);
68

69
/**
70
 * @param {object} arithmeticFilterObj - {LT, LTE, GT, GTE, EQ}, the structure returned by getArithmeticExpressionType
71
 * @returns {object} Elasticsearch range filter param
72
 * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-range-query.html#range-query-field-params
73
 */
74
export function getRangeFieldParamFromArithmeticExpression(
75
  arithmeticFilterObj
76
) {
77
  // EQ overrides all other operators
78
  if (typeof arithmeticFilterObj.EQ !== 'undefined') {
44✔
79
    return {
2✔
80
      gte: arithmeticFilterObj.EQ,
81
      lte: arithmeticFilterObj.EQ,
82
    };
83
  }
84

85
  const conditionEntries = Object.entries(arithmeticFilterObj);
42✔
86

87
  if (conditionEntries.length === 0) throw new Error('Invalid Expression!');
42✔
88

89
  return Object.fromEntries(
41✔
90
    conditionEntries.map(([key, value]) => [key.toLowerCase(), value])
62✔
91
  );
92
}
93

94
export const moreLikeThisInput = new GraphQLInputObjectType({
46✔
95
  name: 'MoreLikeThisInput',
96
  description:
97
    'Parameters for Elasticsearch more_like_this query.\n' +
98
    'See: https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-mlt-query.html',
99
  fields: {
100
    like: {
101
      type: GraphQLString,
102
      description: 'The text string to search for.',
103
    },
104
    minimumShouldMatch: {
105
      type: GraphQLString,
106
      description:
107
        'more_like_this query\'s "minimum_should_match" query param.\n' +
108
        'See https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-minimum-should-match.html for possible values.',
109
    },
110
  },
111
});
112

113
export const userAndExistInput = new GraphQLInputObjectType({
46✔
114
  name: 'UserAndExistInput',
115
  fields: {
116
    userId: {
117
      type: new GraphQLNonNull(GraphQLString),
118
    },
119
    exists: {
120
      type: GraphQLBoolean,
121
      defaultValue: true,
122
      description: `
123
        When true (or not specified), return only entries with the specified user's involvement.
124
        When false, return only entries that the specified user did not involve.
125
      `,
126
    },
127
  },
128
});
129

130
export function createFilterType(typeName, args) {
131
  const filterType = new GraphQLInputObjectType({
279✔
132
    name: typeName,
133
    fields: () => ({
279✔
134
      ...args,
135
      // TODO: converting nested AND / OR to elasticsearch
136
      // AND: { type: new GraphQLList(filterType) },
137
      // OR: { type: new GraphQLList(filterType) },
138
    }),
139
  });
140
  return filterType;
279✔
141
}
142

143
//
144
// Sort args definition & parsing
145
//
146

147
const SortOrderEnum = new GraphQLEnumType({
46✔
148
  name: 'SortOrderEnum',
149
  values: {
150
    ASC: { value: 'asc' },
151
    DESC: { value: 'desc' },
152
  },
153
});
154

155
/**
156
 * @param {string} typeName
157
 * @param {Array<string|{name: string, description: string}>} filterableFields
158
 * @returns {GraphQLList<GarphQLInputObjectType>} sort input type for an field input argument.
159
 */
160
export function createSortType(typeName, filterableFields = []) {
×
161
  return new GraphQLList(
372✔
162
    new GraphQLInputObjectType({
163
      name: typeName,
164
      description:
165
        'An entry of orderBy argument. Specifies field name and the sort order. Only one field name is allowd per entry.',
166
      fields: filterableFields.reduce((fields, field) => {
167
        const fieldName = typeof field === 'string' ? field : field.name;
868✔
168
        const description =
169
          typeof field === 'string' ? undefined : field.description;
868✔
170

171
        return {
868✔
172
          ...fields,
173
          [fieldName]: { type: SortOrderEnum, description },
174
        };
175
      }, {}),
176
    })
177
  );
178
}
179

180
export const pagingArgs = {
46✔
181
  first: {
182
    type: GraphQLInt,
183
    description: 'Returns only first <first> results',
184
    defaultValue: 10,
185
  },
186
  after: {
187
    type: GraphQLString,
188
    description:
189
      'Specify a cursor, returns results after this cursor. cannot be used with "before".',
190
  },
191
  before: {
192
    type: GraphQLString,
193
    description:
194
      'Specify a cursor, returns results before this cursor. cannot be used with "after".',
195
  },
196
};
197

198
/**
199
 * @param {object[]} orderBy - sort input object type
200
 * @param {{[string]: (order: object) => object}} fieldFnMap - Defines one elasticsearch sort argument entry for a field
201
 * @returns {Array<{[string]: {order: string}}>} Elasticsearch sort argument in query body
202
 */
203
export function getSortArgs(orderBy, fieldFnMap = {}) {
50✔
204
  return orderBy
120✔
205
    .map((item) => {
206
      const field = Object.keys(item)[0];
26✔
207
      const order = item[field];
26✔
208
      const defaultFieldFn = (o) => ({ [field]: { order: o } });
26✔
209

210
      return (fieldFnMap[field] || defaultFieldFn)(order);
26✔
211
    })
212
    .concat({ _id: { order: 'desc' } }); // enforce at least 1 sort order for pagination
213
}
214

215
// sort: [{fieldName: {order: 'desc'}}, {fieldName2: {order: 'desc'}}, ...]
216
// This utility function reverts the direction of each sort params.
217
//
218
function reverseSortArgs(sort) {
219
  if (!sort) return undefined;
43!
220
  return sort.map((item) => {
43✔
221
    const field = Object.keys(item)[0];
52✔
222
    const order = item[field].order === 'desc' ? 'asc' : 'desc';
52✔
223
    return {
52✔
224
      [field]: {
225
        ...item[field],
226
        order,
227
      },
228
    };
229
  });
230
}
231

232
// Export for custom resolveEdges() and resolveLastCursor()
233
//
234
export function getCursor(cursor) {
235
  return Buffer.from(JSON.stringify(cursor)).toString('base64');
398✔
236
}
237

238
export function getSearchAfterFromCursor(cursor) {
239
  if (!cursor) return undefined;
116✔
240
  return JSON.parse(Buffer.from(cursor, 'base64').toString('utf8'));
12✔
241
}
242

243
async function defaultResolveTotalCount({
244
  first, // eslint-disable-line no-unused-vars
245
  before, // eslint-disable-line no-unused-vars
246
  after, // eslint-disable-line no-unused-vars
247
  ...searchContext
248
}) {
249
  try {
77✔
250
    return (
77✔
251
      await client.count({
252
        ...searchContext,
253
        body: {
254
          // count API only supports "query"
255
          query: searchContext.body.query,
256
        },
257
      })
258
    ).body.count;
259
  } catch (e) /* istanbul ignore next */ {
260
    console.error('[defaultResolveTotalCount]', JSON.stringify(e));
261
    throw e;
262
  }
263
}
264

265
export async function defaultResolveEdges(
266
  { first, before, after, ...searchContext },
267
  args,
268
  { loaders }
269
) {
270
  if (before && after) {
117✔
271
    throw new Error('Use of before & after is prohibited.');
1✔
272
  }
273

274
  const nodes = await loaders.searchResultLoader.load({
116✔
275
    ...searchContext,
276
    body: {
277
      ...searchContext.body,
278
      size: first,
279
      search_after: getSearchAfterFromCursor(before || after),
226✔
280

281
      // if "before" is given, reverse the sort order and later reverse back
282
      //
283
      sort: before
116✔
284
        ? reverseSortArgs(searchContext.body.sort)
285
        : searchContext.body.sort,
286
      highlight: {
287
        order: 'score',
288
        fields: {
289
          text: {
290
            number_of_fragments: 1, // Return only 1 piece highlight text
291
            fragment_size: 200, // word count of highlighted fragment
292
            type: 'plain',
293
          },
294
          reference: {
295
            number_of_fragments: 1, // Return only 1 piece highlight text
296
            fragment_size: 200, // word count of highlighted fragment
297
            type: 'plain',
298
          },
299
        },
300
        pre_tags: ['<HIGHLIGHT>'],
301
        post_tags: ['</HIGHLIGHT>'],
302
      },
303
    },
304
  });
305

306
  if (before) {
116✔
307
    nodes.reverse();
6✔
308
  }
309

310
  return nodes.map(
116✔
311
    ({ _score: score, highlight, inner_hits, _cursor, ...node }) => ({
313✔
312
      node,
313
      cursor: getCursor(_cursor),
314
      score,
315
      highlight,
316
      inner_hits,
317
    })
318
  );
319
}
320

321
async function defaultResolveLastCursor(
322
  {
323
    first, // eslint-disable-line no-unused-vars
324
    before, // eslint-disable-line no-unused-vars
325
    after, // eslint-disable-line no-unused-vars
326
    ...searchContext
327
  },
328
  args,
329
  { loaders }
330
) {
331
  const lastNode = (
37✔
332
    await loaders.searchResultLoader.load({
333
      ...searchContext,
334
      body: {
335
        ...searchContext.body,
336
        sort: reverseSortArgs(searchContext.body.sort),
337
      },
338
      size: 1,
339
    })
340
  )[0];
341

342
  return lastNode && getCursor(lastNode._cursor);
37✔
343
}
344

345
async function defaultResolveFirstCursor(
346
  {
347
    first, // eslint-disable-line no-unused-vars
348
    before, // eslint-disable-line no-unused-vars
349
    after, // eslint-disable-line no-unused-vars
350
    ...searchContext
351
  },
352
  args,
353
  { loaders }
354
) {
355
  const firstNode = (
37✔
356
    await loaders.searchResultLoader.load({
357
      ...searchContext,
358
      size: 1,
359
    })
360
  )[0];
361

362
  return firstNode && getCursor(firstNode._cursor);
37✔
363
}
364

365
async function defaultResolveHighlights(edge) {
366
  const { highlight: { text, reference } = {}, inner_hits } = edge;
23✔
367

368
  const hyperlinks = inner_hits?.hyperlinks.hits.hits?.map(
23✔
369
    ({
370
      _source: { url },
371
      highlight: {
×
372
        'hyperlinks.title': title,
373
        'hyperlinks.summary': summary,
374
      } = {},
375
    }) => ({
6✔
376
      url,
377
      title: title ? title[0] : undefined,
6✔
378
      summary: summary ? summary[0] : undefined,
6!
379
    })
380
  );
381

382
  // Elasticsearch highlight returns an array because it can be multiple fragments,
383
  // We directly returns first element(text, title, summary) here because we set number_of_fragments to 1.
384
  return {
23✔
385
    text: text ? text[0] : undefined,
23✔
386
    reference: reference ? reference[0] : undefined,
23✔
387
    hyperlinks,
388
  };
389
}
390

391
// All search
392
//
393
export function createConnectionType(
394
  typeName,
395
  nodeType,
396
  {
275✔
397
    // Default resolvers
398
    resolveTotalCount = defaultResolveTotalCount,
349✔
399
    resolveEdges = defaultResolveEdges,
312✔
400
    resolveLastCursor = defaultResolveLastCursor,
349✔
401
    resolveFirstCursor = defaultResolveFirstCursor,
349✔
402
    resolveHighlights = defaultResolveHighlights,
349✔
403
    extraEdgeFields = {},
312✔
404
  } = {}
405
) {
406
  return new GraphQLObjectType({
349✔
407
    name: typeName,
408
    interfaces: [Connection],
409
    fields: () => ({
310✔
410
      totalCount: {
411
        type: new GraphQLNonNull(GraphQLInt),
412
        description:
413
          'The total count of the entire collection, regardless of "before", "after".',
414
        resolve: resolveTotalCount,
415
      },
416
      edges: {
417
        type: new GraphQLNonNull(
418
          new GraphQLList(
419
            new GraphQLNonNull(
420
              new GraphQLObjectType({
421
                name: `${typeName}Edge`,
422
                interfaces: [Edge],
423
                fields: {
424
                  node: { type: new GraphQLNonNull(nodeType) },
425
                  cursor: { type: new GraphQLNonNull(GraphQLString) },
426
                  score: { type: GraphQLFloat },
427
                  highlight: {
428
                    type: Highlights,
429
                    resolve: resolveHighlights,
430
                  },
431
                  ...extraEdgeFields,
432
                },
433
              })
434
            )
435
          )
436
        ),
437
        resolve: resolveEdges,
438
      },
439
      pageInfo: {
440
        type: new GraphQLNonNull(
441
          new GraphQLObjectType({
442
            name: `${typeName}PageInfo`,
443
            interfaces: [PageInfo],
444
            fields: {
445
              lastCursor: {
446
                type: GraphQLString,
447
                resolve: resolveLastCursor,
448
              },
449
              firstCursor: {
450
                type: GraphQLString,
451
                resolve: resolveFirstCursor,
452
              },
453
            },
454
          })
455
        ),
456
        resolve: (params) => params,
37✔
457
      },
458
    }),
459
  });
460
}
461

462
/**
463
 * @param {{status: T}[]} entriesWithStatus - list of objects with "status" field
464
 * @param {T[]} statuses - list of status to keep
465
 * @returns {Object[]}
466
 */
467
export function filterByStatuses(entriesWithStatus, statuses) {
468
  return entriesWithStatus
55✔
469
    .filter(Boolean) // Ensure no null inside
470
    .filter(({ status }) => statuses.includes(status));
147✔
471
}
472

473
export const DEFAULT_ARTICLE_STATUSES = ['NORMAL'];
46✔
474
export const DEFAULT_ARTICLE_REPLY_STATUSES = ['NORMAL'];
46✔
475
export const DEFAULT_ARTICLE_CATEGORY_STATUSES = ['NORMAL'];
46✔
476
export const DEFAULT_REPLY_REQUEST_STATUSES = ['NORMAL'];
46✔
477
export const DEFAULT_ARTICLE_REPLY_FEEDBACK_STATUSES = ['NORMAL'];
46✔
478

479
/**
480
 * @param {string} pluralEntityName - the name to display on argument description
481
 * @returns {object} GraphQL args for common list filters
482
 */
483
export function createCommonListFilter(pluralEntityName) {
484
  return {
223✔
485
    appId: {
486
      type: GraphQLString,
487
      description: `Show only ${pluralEntityName} created by a specific app.`,
488
    },
489
    userId: {
490
      type: GraphQLString,
491
      description: `Show only ${pluralEntityName} created by the specific user.`,
492
    },
493
    userIds: {
494
      type: new GraphQLList(new GraphQLNonNull(GraphQLString)),
495
      description: `Show only ${pluralEntityName} created by the specified users.`,
496
    },
497
    createdAt: {
498
      type: timeRangeInput,
499
      description: `List only the ${pluralEntityName} that were created between the specific time range.`,
500
    },
501
    ids: {
502
      type: new GraphQLList(new GraphQLNonNull(GraphQLID)),
503
      description: `If given, only list out ${pluralEntityName} with specific IDs`,
504
    },
505
    selfOnly: {
506
      type: GraphQLBoolean,
507
      description: `Only list the ${pluralEntityName} created by the currently logged in user`,
508
    },
509
  };
510
}
511

512
/**
513
 * Attach (mutates) filterQueries with Elasticsearch query objects by args.filter in GraphQL resolver
514
 *
515
 * @param {Array<Object>} filterQueries - list of filter queries of Elasticsearch bool query
516
 * @param {object} filter - args.filter in resolver
517
 * @param {string} userId - userId for the currently logged in user
518
 * @param {string} appid - appId for the currently logged in user
519
 * @param {string?} fieldPrefix - If given, filters fields will be prefixed with the given string. Disables handling of `ids`.
520
 */
521
export function attachCommonListFilter(
522
  filterQueries,
523
  filter,
524
  userId,
525
  appId,
526
  fieldPrefix = ''
93✔
527
) {
528
  ['userId', 'appId'].forEach((field) => {
96✔
529
    if (!filter[field]) return;
192✔
530
    filterQueries.push({ term: { [`${fieldPrefix}${field}`]: filter[field] } });
9✔
531
  });
532

533
  if (filter.userIds) {
96✔
534
    filterQueries.push({ terms: { [`${fieldPrefix}userId`]: filter.userIds } });
1✔
535
  }
536

537
  if (filter.createdAt) {
96✔
538
    filterQueries.push({
11✔
539
      range: {
540
        [`${fieldPrefix}createdAt`]: getRangeFieldParamFromArithmeticExpression(
541
          filter.createdAt
542
        ),
543
      },
544
    });
545
  }
546

547
  if (!fieldPrefix && filter.ids) {
96✔
548
    filterQueries.push({ ids: { values: filter.ids } });
2✔
549
  }
550

551
  if (filter.selfOnly) {
96✔
552
    if (!userId) throw new Error('selfOnly can be set only after log in');
2✔
553
    filterQueries.push(
1✔
554
      { term: { [`${fieldPrefix}userId`]: userId } },
555
      { term: { [`${fieldPrefix}appId`]: appId } }
556
    );
557
  }
558
}
559

560
/**
561
 * Read a successful AI response of a given `type` and `docId`.
562
 * If not, it tries to wait for the latest (within 1min) loading AI response.
563
 * Returns null if there is no successful nor latest loading AI response.
564
 *
565
 * @param {object} param
566
 * @param {'AI_REPLY' | 'TRANSCRIPT'} param.type
567
 * @param {string} param.docId
568
 * @returns {AIReponse | null}
569
 */
570
export async function getAIResponse({ type, docId }) {
571
  // Try reading successful AI response.
572
  //
573
  //
574
  for (;;) {
13✔
575
    // First, find latest successful airesponse. Return if found.
576
    //
577
    const {
578
      body: {
579
        hits: {
580
          hits: [successfulAiResponse],
581
        },
582
      },
583
    } = await client.search({
14✔
584
      index: 'airesponses',
585
      type: 'doc',
586
      body: {
587
        query: {
588
          bool: {
589
            must: [
590
              { term: { type } },
591
              { term: { docId } },
592
              { term: { status: 'SUCCESS' } },
593
            ],
594
          },
595
        },
596
        sort: {
597
          createdAt: 'desc',
598
        },
599
        size: 1,
600
      },
601
    });
602

603
    if (successfulAiResponse) {
14✔
604
      return {
3✔
605
        id: successfulAiResponse._id,
606
        ...successfulAiResponse._source,
607
      };
608
    }
609

610
    // If no successful AI responses, find loading responses created within 1 min.
611
    //
612
    const {
613
      body: { count },
614
    } = await client.count({
11✔
615
      index: 'airesponses',
616
      type: 'doc',
617
      body: {
618
        query: {
619
          bool: {
620
            must: [
621
              { term: { type } },
622
              { term: { docId } },
623
              { term: { status: 'LOADING' } },
624
              {
625
                // loading document created within 1 min
626
                range: {
627
                  createdAt: {
628
                    gte: 'now-1m',
629
                  },
630
                },
631
              },
632
            ],
633
          },
634
        },
635
      },
636
    });
637

638
    // No AI response available now, break the loop
639
    //
640
    if (count === 0) {
11✔
641
      break;
10✔
642
    }
643

644
    // Wait a bit to search for successful AI response again.
645
    // If there are any loading AI response becomes successful during the wait,
646
    // it will be picked up when the loop is re-entered.
647
    await delayForMs(1000);
1✔
648
  }
649

650
  // Nothing is found
651
  return null;
10✔
652
}
653

654
/**
655
 * Creates a loading AI Response.
656
 * Returns an updater function that can be used to record real AI response.
657
 *
658
 *
659
 * @param {object} loadingResponseBody
660
 * @param {string} loadingResponseBody.request
661
 * @param {string} loadingResponseBody.type
662
 * @param {string} loadingResponseBody.docId
663
 * @param {object} loadingResponseBody.user
664
 *
665
 * @returns {(responseBody) => Promise<AIResponse>} updater function that updates the created AI
666
 *   response and returns the updated result
667
 */
668
export function createAIResponse({ user, ...loadingResponseBody }) {
669
  const newResponse = {
9✔
670
    userId: user.id,
671
    appId: user.appId,
672
    status: 'LOADING',
673
    createdAt: new Date(),
674
    ...loadingResponseBody,
675
  };
676

677
  // Resolves to loading AI Response.
678
  const newResponseIdPromise = client
9✔
679
    .index({
680
      index: 'airesponses',
681
      type: 'doc',
682
      body: newResponse,
683
    })
684
    .then(({ body: { result, _id } }) => {
685
      /* istanbul ignore if */
686
      if (result !== 'created') {
9✔
687
        throw new Error(`Cannot create AI response: ${result}`);
688
      }
689
      return _id;
9✔
690
    });
691

692
  // Update using aiResponse._id according to apiResult
693
  async function update(responseBody) {
694
    const aiResponseId = await newResponseIdPromise;
9✔
695

696
    const {
697
      body: {
698
        get: { _source },
699
      },
700
    } = await client.update({
9✔
701
      index: 'airesponses',
702
      type: 'doc',
703
      id: aiResponseId,
704
      _source: true,
705
      body: {
706
        doc: {
707
          updatedAt: new Date(),
708
          ...responseBody,
709
        },
710
      },
711
    });
712

713
    return {
9✔
714
      id: aiResponseId,
715
      ..._source,
716
    };
717
  }
718

719
  return {
9✔
720
    /**
721
     * Updates the AI response with new data
722
     * @param {Object} responseBody - The response data to update
723
     * @param {string} [responseBody.status] - New status (SUCCESS/ERROR)
724
     * @param {string} [responseBody.text] - Response text content
725
     * @param {Object} [responseBody.usage] - Token usage statistics
726
     * @returns {Promise<Object>} Updated AI response object
727
     */
728
    update,
729

730
    /**
731
     * Gets the ID of the created AI response
732
     * @returns {Promise<string>} Promise that resolves to the AI response ID
733
     */
734
    getAIResponseId() {
735
      return newResponseIdPromise;
7✔
736
    },
737
  };
738
}
739

740
const METADATA = {
46✔
741
  cacheControl: 'public, max-age=31536000, immutable',
742
};
743

744
const VALID_ARTICLE_TYPE_TO_MEDIA_TYPE = {
46✔
745
  IMAGE: MediaType.image,
746
  VIDEO: MediaType.video,
747
  AUDIO: MediaType.audio,
748
};
749

750
/**
751
 * Upload media of specified article type from the given mediaUrl
752
 *
753
 * @param {object} param
754
 * @param {string} param.mediaUrl
755
 * @param {ArticleTypeEnum} param.articleType
756
 * @param {undefined | (error: Error | null) => void} param.onUploadStop
757
 * @returns {Promise<import('@cofacts/media-manager').MediaEntry>}
758
 */
759
export async function uploadMedia({ mediaUrl, articleType, onUploadStop }) {
760
  const mappedMediaType = VALID_ARTICLE_TYPE_TO_MEDIA_TYPE[articleType];
11✔
761
  const mediaEntry = await mediaManager.insert({
11✔
762
    url: mediaUrl,
763
    getVariantSettings(options) {
764
      const { type, contentType } = options;
7✔
765

766
      // Abort if articleType does not match mediaUrl's file type
767
      //
768
      if (!mappedMediaType || mappedMediaType !== type) {
7✔
769
        throw new Error(
1✔
770
          `Specified article type is "${articleType}", but the media file is a ${type}.`
771
        );
772
      }
773

774
      switch (type) {
6✔
775
        case MediaType.image:
776
          return [
3✔
777
            variants.original(contentType),
778
            {
779
              name: IMAGE_THUMBNAIL,
780
              contentType: 'image/jpeg',
781
              transform: sharp()
782
                .resize({ height: 240, withoutEnlargement: true })
783
                .jpeg({ quality: 60 }),
784
            },
785
            {
786
              name: IMAGE_PREVIEW,
787
              contentType: 'image/webp',
788
              transform: sharp()
789
                .resize({ width: 600, withoutEnlargement: true })
790
                .webp({ quality: 30 }),
791
            },
792
          ];
793

794
        default:
795
          return variants.defaultGetVariantSettings(options);
3✔
796
      }
797
    },
798
    onUploadStop(error) {
799
      /* istanbul ignore if */
800
      if (error) {
5✔
801
        console.error(`[createNewMediaArticle] onUploadStop error:`, error);
802
      } else {
803
        mediaEntry.variants.forEach((variant) =>
4✔
804
          mediaEntry.getFile(variant).setMetadata(METADATA)
6✔
805
        );
806
      }
807

808
      if (onUploadStop) {
5✔
809
        onUploadStop(error);
3✔
810
      }
811
    },
812
  });
813

814
  return mediaEntry;
9✔
815
}
816

817
const imageAnnotator = new ImageAnnotatorClient();
46✔
818
const OCR_CONFIDENCE_THRESHOLD = 0.75;
46✔
819

820
/**
821
 * @param {ITextAnnotation} fullTextAnnotation - The fullTextAnnotation returned by client.documentTextDetection
822
 * @returns {string} The extracted text that is comprised of paragraphs passing OCR_CONFIDENCE_THRESHOLD
823
 */
824
function extractTextFromFullTextAnnotation(fullTextAnnotation) {
825
  const {
826
    pages: [{ blocks }],
827
  } = fullTextAnnotation;
1✔
828

829
  // Hierarchy described in https://cloud.google.com/vision/docs/fulltext-annotations#annotating_an_image_using_document_text_ocr
830
  //
831
  return blocks
1✔
832
    .flatMap(({ paragraphs }) =>
833
      paragraphs
21✔
834
        .filter(({ confidence }) => confidence >= OCR_CONFIDENCE_THRESHOLD)
24✔
835
        .flatMap(({ words }) =>
836
          words.flatMap(({ symbols }) =>
22✔
837
            symbols.map(({ text, property }) => {
214✔
838
              if (!property || !property.detectedBreak) return text;
365✔
839

840
              // Word break type described in
841
              // http://googleapis.github.io/googleapis/java/grpc-google-cloud-vision-v1/0.1.5/apidocs/com/google/cloud/vision/v1/TextAnnotation.DetectedBreak.BreakType.html#UNKNOWN
842
              const breakStr = [
41✔
843
                'EOL_SURE_SPACE',
844
                'HYPHEN',
845
                'LINE_BREAK',
846
              ].includes(property.detectedBreak.type)
847
                ? '\n'
848
                : ' ';
849
              return property.detectedBreak.isPrefix
41!
850
                ? `${breakStr}${text}`
851
                : `${text}${breakStr}`;
852
            })
853
          )
854
        )
855
    )
856
    .join('');
857
}
858

859
/**
860
 * Transcribes audio/video content using Gemini model
861
 *
862
 * @param {object} params
863
 * @param {string} params.fileUri - The URI starting with gs://
864
 * @param {string} params.mimeType - The mime type of the file
865
 * @param {import('@langfuse/langfuse').Trace} params.langfuseTrace - Langfuse trace object
866
 * @param {string} params.modelName - Name of the Gemini model
867
 * @param {string} params.location - Location of the model
868
 * @returns {Promise<{text: string, usage: {promptTokens?: number, completionTokens?: number, totalTokens?: number}}>}
869
 */
870
export async function transcribeAV({
871
  fileUri,
872
  mimeType,
873
  langfuseTrace,
874
  modelName,
875
  location,
876
}) {
877
  const project = await new GoogleAuth().getProjectId();
4✔
878
  // Use the new GoogleGenAI SDK with vertexai option
879
  const genAI = new GoogleGenAI({
4✔
880
    vertexai: true,
881
    project,
882
    location,
883
  });
884

885
  /**@type {import('@google/genai').GenerateContentParameters} */
886
  const generateContentArgs = {
4✔
887
    model: modelName,
888
    contents: [
889
      {
890
        role: 'user',
891
        parts: [
892
          { fileData: { fileUri, mimeType } },
893
          {
894
            text: `
895
Your job is to transcribe the given media file accurately.
896
Please watch the media file as well as listen to the audio from start to end.
897
Your text will be used for indexing these media files, so please follow these rules carefully:
898
- Only output the exact text that appears on the media file visually or said verbally.
899
- Try your best to recognize and include every word said or any piece of text displayed. Don't miss any text.
900
- Please output transcript only -- no timestamps, no explanation.
901
- Your output text should follow the language used in the media file.
902
  - When choosing between Traditional Chinese and Simplified Chinese, use the variant that is used in displayed text.
903
  - If there is no displayed text, then prefer Traditional Chinese over the Simplified variant.
904
- To maximize readability, sentenses should be grouped into paragraphs with appropriate punctuations whenever applicable.
905
            `.trim(),
906
          },
907
        ],
908
      },
909
    ],
910
    config: {
911
      systemInstruction:
912
        'You are a transcriber that provide precise transcript to video and audio content.',
913
      responseModalities: ['TEXT'],
914
      temperature: 0.5, // Raise a bit to reduce looping (repeated text) error
915
      maxOutputTokens: 2048, // Stop looping output early
916
      thinkingConfig: { thinkingBudget: 0 }, // Thinking may somehow introduce more looping
917
      safetySettings: [
918
        {
919
          category: 'HARM_CATEGORY_HATE_SPEECH',
920
          threshold: 'OFF',
921
        },
922
        {
923
          category: 'HARM_CATEGORY_DANGEROUS_CONTENT',
924
          threshold: 'OFF',
925
        },
926
        {
927
          category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT',
928
          threshold: 'OFF',
929
        },
930
        {
931
          category: 'HARM_CATEGORY_HARASSMENT',
932
          threshold: 'OFF',
933
        },
934
      ],
935
    },
936
  };
937

938
  const generation = langfuseTrace.generation({
4✔
939
    name: 'gemini-transcript',
940
    modelParameters: {
941
      temperature: generateContentArgs.config.temperature,
942
      maxOutputTokens: generateContentArgs.config.maxOutputTokens,
943
      thinkingBudget: generateContentArgs.config.thinkingConfig?.thinkingBudget,
944
      safetySettings: JSON.stringify(generateContentArgs.config.safetySettings),
945
    },
946
    input: JSON.stringify({
947
      systemInstruction: generateContentArgs.config.systemInstruction,
948
      contents: generateContentArgs.contents,
949
    }),
950
  });
951

952
  const response = await genAI.models.generateContent(generateContentArgs);
4✔
953
  console.log('[transcribeAV]', JSON.stringify(response));
4✔
954

955
  const output = response.candidates[0].content.parts[0].text;
4✔
956
  const usage = {
4✔
957
    promptTokens: response.usageMetadata?.promptTokenCount,
958
    completionTokens: response.usageMetadata?.candidatesTokenCount,
959
    totalTokens:
960
      (response.usageMetadata?.promptTokenCount || 0) +
4!
961
      (response.usageMetadata?.candidatesTokenCount || 0),
4!
962
  };
963

964
  langfuseTrace.update({ output });
4✔
965
  generation.end({
4✔
966
    output: JSON.stringify(response),
967
    usage,
968
    model: modelName,
969
    modelParameters: { location },
970
  });
971

972
  return { text: output, usage };
4✔
973
}
974

975
const TRANSCRIPT_MODELS = [
46✔
976
  // Combinations that are faster than gemini-2.0-flash-001 @ us
977
  { model: 'gemini-2.5-flash', location: 'global' },
978
  { model: 'gemini-2.5-flash-lite-preview-06-17', location: 'global' },
979
];
980

981
/**
982
 * @param {object} queryInfo - contains type and media entry ID of contents after fileUrl
983
 * @param {string|object} fileUrlOrMediaEntry - the audio, image or video file to process, or the MediaEntry object
984
 * @param {object} user - the user who requested the transcription
985
 */
986
export async function createTranscript(queryInfo, fileUrlOrMediaEntry, user) {
987
  if (!user) throw new Error('[createTranscript] user is required');
6!
988

989
  const { update, getAIResponseId } = createAIResponse({
6✔
990
    user,
991
    type: 'TRANSCRIPT',
992
    docId: queryInfo.id,
993
  });
994

995
  try {
6✔
996
    switch (queryInfo.type) {
6✔
997
      case 'image': {
998
        const fileUrl =
999
          typeof fileUrlOrMediaEntry === 'string'
1!
1000
            ? fileUrlOrMediaEntry
1001
            : fileUrlOrMediaEntry.url;
1002

1003
        const [{ fullTextAnnotation }] =
1004
          await imageAnnotator.documentTextDetection(fileUrl);
1✔
1005

1006
        console.log('[createTranscript]', queryInfo.id, fullTextAnnotation);
1✔
1007

1008
        // This should not happen, but just in case
1009
        //
1010
        if (
1!
1011
          !fullTextAnnotation ||
3✔
1012
          !fullTextAnnotation.pages ||
1013
          fullTextAnnotation.pages.length === 0
1014
        ) {
1015
          return update({
×
1016
            status: 'SUCCESS',
1017
            // No text detected
1018
            text: '',
1019
          });
1020
        }
1021

1022
        return update({
1✔
1023
          status: 'SUCCESS',
1024
          // Write '' if no text detected
1025
          text: extractTextFromFullTextAnnotation(fullTextAnnotation),
1026
        });
1027
      }
1028

1029
      case 'video':
1030
      case 'audio': {
1031
        const aiResponseId = await getAIResponseId();
4✔
1032
        const fileUrl =
1033
          typeof fileUrlOrMediaEntry === 'string'
4✔
1034
            ? fileUrlOrMediaEntry
1035
            : fileUrlOrMediaEntry.url;
1036

1037
        const mimeTypePromise = fetch(fileUrl, { method: 'HEAD' })
4✔
1038
          .then((res) => res.headers.get('content-type'))
4✔
1039
          .catch(() =>
NEW
UNCOV
1040
            queryInfo.type === 'video' ? 'video/mp4' : 'audio/mpeg'
×
1041
          );
1042

1043
        let mediaEntry;
1044
        let mimeType;
1045
        if (typeof fileUrlOrMediaEntry !== 'string') {
4✔
1046
          mediaEntry = fileUrlOrMediaEntry;
1✔
1047
          mimeType = await mimeTypePromise;
1✔
1048
        } else {
1049
          [mediaEntry, mimeType] = await Promise.all([
3✔
1050
            // Upload to GCS first and get the file.
1051
            // Here we wait until the file is fully uploaded, so that LLM can read the file without error.
1052
            new Promise((resolve) => {
1053
              let isUploadStopped = false;
3✔
1054
              let mediaEntryToResolve = undefined;
3✔
1055
              uploadMedia({
3✔
1056
                mediaUrl: fileUrl,
1057
                articleType: queryInfo.type.toUpperCase(),
1058
                onUploadStop: () => {
1059
                  isUploadStopped = true;
3✔
1060
                  if (mediaEntryToResolve) resolve(mediaEntryToResolve);
3!
1061
                },
1062
              }).then((mediaEntry) => {
1063
                if (isUploadStopped) {
3!
NEW
1064
                  resolve(mediaEntry);
×
1065
                } else {
1066
                  // Initialize mediaEntry so that we can pick it up when upload stop
1067
                  mediaEntryToResolve = mediaEntry;
3✔
1068
                }
1069
              });
1070
            }),
1071
            mimeTypePromise,
1072
          ]);
1073
        }
1074

1075
        // The URI starting with gs://
1076
        const fileUri = mediaEntry.getFile().cloudStorageURI.href;
4✔
1077

1078
        const trace = langfuse.trace({
4✔
1079
          id: aiResponseId,
1080
          name: `Transcript for ${queryInfo.id}`,
1081
          input: fileUri,
1082
        });
1083

1084
        for (const { model, location } of TRANSCRIPT_MODELS) {
4✔
1085
          try {
4✔
1086
            const { text, usage } = await transcribeAV({
4✔
1087
              fileUri,
1088
              mimeType,
1089
              langfuseTrace: trace,
1090
              modelName: model,
1091
              location,
1092
            });
1093

1094
            return update({ status: 'SUCCESS', text, usage });
4✔
1095
          } catch (e) {
1096
            console.error('[createTranscript]', e);
×
1097

1098
            if (
×
1099
              e.message.includes('429') &&
×
1100
              e.message.includes('RESOURCE_EXHAUSTED')
1101
            ) {
UNCOV
1102
              console.warn(
×
1103
                `[createTranscript] Model ${model} @ ${location} quota exceeded, trying next model.`
1104
              );
UNCOV
1105
              continue; // Try the next model
×
1106
            }
1107

UNCOV
1108
            throw e; // Re-throw other errors
×
1109
          }
1110
        }
1111

1112
        // If all models fail
UNCOV
1113
        return update({
×
1114
          status: 'ERROR',
1115
          text: 'All models failed due to quota limits.',
1116
        });
1117
      }
1118
      default:
1119
        throw new Error(`Type ${queryInfo.type} not supported`);
1✔
1120
    }
1121
  } catch (e) {
1122
    console.error('[createTranscript]', e);
1✔
1123
    return update({
1✔
1124
      status: 'ERROR',
1125
      text: e.toString(),
1126
    });
1127
  }
1128
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc