Skip to content

Commit 4251172

Browse files
committed
feat(posts): added command to sync post content to RAG
1 parent 8be25df commit 4251172

9 files changed

Lines changed: 3730 additions & 1685 deletions

File tree

.env.example

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ APP_LOGO_INV=/imgs/logo-black.svg
88
APP_CONTACT_EMAIL=contact@adocasts.com
99
APP_DOMAIN=http://localhost:${PORT}
1010
APP_KEY=dYmMU1KGTXhI0cmAHHAZi-scEf17PNG-
11-
DRIVE_DISK=fs
11+
DRIVE_DISK=r2
1212
SESSION_DRIVER=cookie
1313
CACHE_VIEWS=false
1414
LOG_LEVEL=info
@@ -71,4 +71,8 @@ R2_SIGNING_KEY=dYmMU1KGTXhI0cmAHHAZi-scEf17PNG-
7171
# not needed locally
7272
DISCORD_WEBHOOK=<discord_webhook>
7373
PLAUSIBLE_API_KEY=<api_key>
74-
IDENTITY_SECRET=
74+
IDENTITY_SECRET=
75+
R2_KEY=
76+
R2_SECRET=
77+
R2_BUCKET=
78+
R2_ENDPOINT=

app/models/post.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,9 @@ export default class Post extends AppBaseModel {
145145
@column.dateTime()
146146
declare updatedContentAt: DateTime | null
147147

148+
@column.dateTime()
149+
declare ragAddedAt: DateTime | null
150+
148151
@manyToMany(() => Asset, {
149152
pivotTable: 'asset_posts',
150153
pivotColumns: ['sort_order'],

app/services/caption_service.ts

Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
/**
2+
* Ported to TypeScript Class from https://github.com/osk/node-webvtt/blob/master/lib/parser.js
3+
* See VTT Spec: https://www.w3.org/TR/webvtt1/#file-structure
4+
*/
5+
import CaptionTypes from '#enums/caption_types'
6+
import { Exception } from '@adonisjs/core/exceptions'
7+
8+
export type CaptionOptions = {
9+
meta?: boolean
10+
strict?: boolean
11+
}
12+
13+
export type CaptionCue = {
14+
identifier: string | undefined
15+
start: number | undefined
16+
end: number | undefined
17+
text: string
18+
styles: string
19+
}
20+
21+
export type CaptionResult = {
22+
valid: boolean
23+
strict: boolean
24+
cues: (CaptionCue | null)[]
25+
errors: any[] | null
26+
meta?: Record<string, string> | null
27+
}
28+
29+
export default class CaptionService {
30+
private static VTT_TIMESTAMP_REGEXP = /([0-9]+)?:?([0-9]{2}):([0-9]{2}\.[0-9]{2,3})/
31+
private static SRT_TIMESTAMP_REGEXP = /([0-9]+)?:?([0-9]{2}):([0-9]{2}\,[0-9]{2,3})/
32+
33+
static parse(input: string, options: CaptionOptions = {}) {
34+
const { meta = false, strict = true } = options
35+
36+
if (typeof input !== 'string') {
37+
throw new Exception('Input must be a string')
38+
}
39+
40+
input = input.trim()
41+
input = input.replace(/\r\n/g, '\n')
42+
input = input.replace(/\r/g, '\n')
43+
44+
const parts = input.split('\n\n')
45+
const type = parts.at(0)?.startsWith('WEBVTT') ? CaptionTypes.VTT : CaptionTypes.SRT
46+
let headerParts: string[] = []
47+
48+
if (type === CaptionTypes.VTT) {
49+
const header = parts.shift()
50+
51+
if (!header || !header.startsWith('WEBVTT')) {
52+
throw new Exception('Must start with "WEBVTT"')
53+
}
54+
55+
headerParts = header.split('\n')
56+
57+
const headerComments = headerParts[0].replace('WEBVTT', '')
58+
59+
if (headerComments.length > 0 && headerComments[0] !== ' ' && headerComments[0] !== '\t') {
60+
throw new Exception('Header comment must start with space or tab')
61+
}
62+
}
63+
64+
// nothing of interests, return early
65+
if (parts.length === 0 && headerParts.length === 1) {
66+
return { valid: true, strict, cues: [], errors: [] }
67+
}
68+
69+
if (!meta && headerParts.length > 1 && headerParts[1] !== '') {
70+
throw new Exception('Missing blank line after signature')
71+
}
72+
73+
const { cues, errors } = this.parseCues(parts, strict, type)
74+
75+
if (strict && errors.length > 0) {
76+
throw errors[0]
77+
}
78+
79+
const headerMeta = meta ? this.parseMeta(headerParts) : null
80+
81+
const result: CaptionResult = { valid: errors.length === 0, strict, cues, errors }
82+
83+
if (meta) {
84+
result.meta = headerMeta
85+
}
86+
87+
return result
88+
}
89+
90+
static parseAsParagraphs(
91+
input: string,
92+
options: CaptionOptions = {},
93+
gapThreshold: number = 1,
94+
maxParagraphLength: number = 10
95+
): CaptionResult {
96+
const baseResult = this.parse(input, options)
97+
if (!baseResult.valid) return baseResult
98+
99+
const cues = baseResult.cues.filter(Boolean) as CaptionCue[]
100+
const paragraphs: CaptionCue[] = []
101+
102+
let currentParagraph: CaptionCue | null = null
103+
104+
for (const cue of cues) {
105+
if (!cue.start || !cue.end) continue
106+
107+
if (!currentParagraph) {
108+
currentParagraph = { ...cue }
109+
paragraphs.push(currentParagraph)
110+
continue
111+
}
112+
113+
const timeGap = cue.start - currentParagraph.end!
114+
const newParagraphTooLong =
115+
maxParagraphLength !== undefined && cue.end - currentParagraph.start! > maxParagraphLength
116+
117+
if (timeGap > gapThreshold || newParagraphTooLong) {
118+
// start a new paragraph
119+
currentParagraph = { ...cue }
120+
paragraphs.push(currentParagraph)
121+
} else {
122+
// merge into current paragraph
123+
currentParagraph.text += ' ' + cue.text.trim()
124+
currentParagraph.end = cue.end
125+
}
126+
}
127+
128+
return {
129+
...baseResult,
130+
cues: paragraphs,
131+
}
132+
}
133+
134+
static parseMeta(headerParts: string[]) {
135+
const meta: Record<string, string> | null = {}
136+
headerParts.slice(1).forEach((header) => {
137+
const splitIdx = header.indexOf(':')
138+
const key = header.slice(0, splitIdx).trim()
139+
const value = header.slice(splitIdx + 1).trim()
140+
meta[key] = value
141+
})
142+
return Object.keys(meta).length > 0 ? meta : null
143+
}
144+
145+
static parseCues(cues: string[], strict: boolean, type: CaptionTypes) {
146+
const errors: any[] = []
147+
148+
const parsedCues = cues
149+
.map((cue, i) => {
150+
try {
151+
return this.parseCue(cue, i, strict, type)
152+
} catch (e) {
153+
errors.push(e)
154+
return null
155+
}
156+
})
157+
.filter(Boolean)
158+
159+
return {
160+
cues: parsedCues,
161+
errors,
162+
}
163+
}
164+
165+
/**
166+
* Parse a single cue block.
167+
*
168+
* @param {array} cue Array of content for the cue
169+
* @param {number} i Index of cue in array
170+
* @param {boolean} strict Perform stricter validations
171+
* @param {CaptionTypes} type type of caption
172+
*
173+
* @returns {object} cue Cue object with start, end, text and styles.
174+
* Null if it's a note
175+
*/
176+
static parseCue(cue: string, i: number, strict: boolean, type: CaptionTypes) {
177+
let identifier: string | undefined = ''
178+
let start: number | undefined = 0
179+
let end: number | undefined = 0.01
180+
let text = ''
181+
let styles = ''
182+
183+
// split and remove empty lines
184+
const lines = cue.split('\n').filter(Boolean)
185+
186+
if (lines.length > 0 && lines[0].trim().startsWith('NOTE')) {
187+
return null
188+
}
189+
190+
if (lines.length === 1 && !lines[0].includes('-->')) {
191+
throw new Exception(`Cue identifier cannot be standalone (cue #${i})`)
192+
}
193+
194+
if (lines.length > 1 && !(lines[0].includes('-->') || lines[1].includes('-->'))) {
195+
const msg = `Cue identifier needs to be followed by timestamp (cue #${i})`
196+
throw new Exception(msg)
197+
}
198+
199+
if (lines.length > 1 && lines[1].includes('-->')) {
200+
identifier = lines.shift()
201+
}
202+
203+
const times = typeof lines[0] === 'string' ? lines[0].split(' --> ') : []
204+
205+
if (
206+
times.length !== 2 ||
207+
!this.validTimestamp(times[0], type) ||
208+
!this.validTimestamp(times[1], type)
209+
) {
210+
throw new Exception(`Invalid cue timestamp (cue #${i})`)
211+
}
212+
213+
start = this.parseTimestamp(times[0], type)
214+
end = this.parseTimestamp(times[1], type)
215+
216+
if (strict) {
217+
if (typeof start !== 'number' || typeof end !== 'number') {
218+
return null
219+
}
220+
221+
if (start > end) {
222+
throw new Exception(`Start timestamp greater than end (cue #${i})`)
223+
}
224+
225+
if (end < start) {
226+
throw new Exception(`End must be greater than start (cue #${i})`)
227+
}
228+
}
229+
230+
if (!strict && start && end && end < start) {
231+
return null
232+
}
233+
234+
styles = times[1].replace(this.regex(type), '').trim()
235+
236+
lines.shift()
237+
238+
text = lines.join('\n')
239+
240+
if (!text) {
241+
return null
242+
}
243+
244+
return { identifier, start, end, text, styles }
245+
}
246+
247+
static validTimestamp(timestamp: string, type: CaptionTypes) {
248+
return this.regex(type).test(timestamp)
249+
}
250+
251+
static parseTimestamp(timestamp: string, type: CaptionTypes) {
252+
const matches = timestamp.match(this.regex(type))
253+
if (!matches) return
254+
let secs = Number.parseFloat(matches[1] || '0') * 60 * 60 // hours
255+
secs += Number.parseFloat(matches[2]) * 60 // mins
256+
secs += Number.parseFloat(matches[3])
257+
// secs += parseFloat(matches[4]);
258+
return secs
259+
}
260+
261+
static regex(type: CaptionTypes) {
262+
return type === CaptionTypes.VTT ? this.VTT_TIMESTAMP_REGEXP : this.SRT_TIMESTAMP_REGEXP
263+
}
264+
}

app/services/r2_service.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ class R2Service {
88
},
99
})
1010

11-
async getTranscript(videoId: string) {
12-
const { data } = await this.#api.get(`/${videoId}/en.srt`)
11+
async getTranscript(videoId: string, filename = 'en.srt') {
12+
const { data } = await this.#api.get(`/${videoId}/${filename}`)
1313
return data as string
1414
}
1515
}

commands/sync_rag_content.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import VideoTypes from '#enums/video_types'
2+
import Post from '#models/post'
3+
import CaptionService from '#services/caption_service'
4+
import { BaseCommand } from '@adonisjs/core/ace'
5+
import type { CommandOptions } from '@adonisjs/core/types/ace'
6+
import drive from '@adonisjs/drive/services/main'
7+
import { DateTime } from 'luxon'
8+
9+
export default class SyncRagContent extends BaseCommand {
10+
static commandName = 'sync:rag-content'
11+
static description = ''
12+
13+
static options: CommandOptions = {
14+
startApp: true,
15+
}
16+
17+
async run() {
18+
const posts = await Post.query()
19+
.apply((scope) => scope.published())
20+
.where('videoTypeId', VideoTypes.R2)
21+
.whereNotNull('videoUrl')
22+
.where((query) => {
23+
query.whereNull('ragAddedAt').orWhereRaw('rag_added_at < updated_content_at')
24+
})
25+
26+
for (const post of posts) {
27+
const videoText = await this.#getVideoText(post.videoId)
28+
const types: string[] = []
29+
30+
if (videoText) {
31+
await drive.use('rag').put(`posts/${post.id}/captions.txt`, videoText)
32+
types.push('captions')
33+
}
34+
35+
if (post.body) {
36+
await drive.use('rag').put(`posts/${post.id}/body.html`, post.body)
37+
types.push('body')
38+
}
39+
40+
post.ragAddedAt = DateTime.now()
41+
42+
await post.save()
43+
44+
this.logger.info(`Post: ${post.id}; Added to RAG file types: ${types.join(', ')}`)
45+
}
46+
}
47+
48+
async #getVideoText(videoId: string | null) {
49+
if (!videoId) return
50+
51+
if (await drive.use('videos').exists(`${videoId}/en.srt`)) {
52+
const srt = await drive.use('videos').get(`${videoId}/en.srt`)
53+
const captions = CaptionService.parse(srt)
54+
.cues.filter((c) => c !== null)
55+
.map((c) => c.text)
56+
.join(' ')
57+
58+
return captions
59+
}
60+
}
61+
}

config/drive.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,26 @@ const driveConfig = defineConfig({
1010
* services each using the same or a different driver.
1111
*/
1212
services: {
13+
videos: services.s3({
14+
credentials: {
15+
accessKeyId: env.get('R2_KEY'),
16+
secretAccessKey: env.get('R2_SECRET'),
17+
},
18+
region: 'auto',
19+
bucket: 'videos',
20+
endpoint: env.get('R2_ENDPOINT'),
21+
visibility: 'public',
22+
}),
23+
rag: services.s3({
24+
credentials: {
25+
accessKeyId: env.get('R2_KEY'),
26+
secretAccessKey: env.get('R2_SECRET'),
27+
},
28+
region: 'auto',
29+
bucket: 'adocasts-rag',
30+
endpoint: env.get('R2_ENDPOINT'),
31+
visibility: 'public',
32+
}),
1333
fs: services.fs({
1434
location: app.makePath('storage'),
1535
serveFiles: true,

0 commit comments

Comments
 (0)