Skip to content

Commit 35edc87

Browse files
committed
connecting redis to cms, adding ai overview generation, fixing a few post nullables
1 parent a94f2a7 commit 35edc87

16 files changed

Lines changed: 582 additions & 93 deletions

File tree

adonisrc.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ export default defineConfig({
1717
() => import('@adocasts.com/dto/commands'),
1818
() => import('@adocasts.com/actions/commands'),
1919
() => import('@tuyau/core/commands'),
20-
() => import('@adonisjs/bouncer/commands')
20+
() => import('@adonisjs/bouncer/commands'),
21+
() => import('@adonisjs/cache/commands')
2122
],
2223

2324
/*
@@ -49,7 +50,9 @@ export default defineConfig({
4950
() => import('@adonisjs/mail/mail_provider'),
5051
() => import('@tuyau/core/tuyau_provider'),
5152
() => import('@adonisjs/drive/drive_provider'),
52-
() => import('@adonisjs/bouncer/bouncer_provider')
53+
() => import('@adonisjs/bouncer/bouncer_provider'),
54+
() => import('@adonisjs/cache/cache_provider'),
55+
() => import('@adonisjs/redis/redis_provider')
5356
],
5457

5558
/*

app/actions/posts/update_post.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export default class UpdatePost {
4343

4444
post.merge(update)
4545

46+
if (!data.publishAt) post.publishAt = null
4647
if (!data.videoBunnyId) post.videoBunnyId = null
4748
if (!data.videoUrl) post.videoUrl = null
4849
if (!data.videoBunnyId && !data.videoUrl) post.videoSeconds = 0

app/controllers/ai_controller.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import aiService from '#services/ai/ai_service'
2+
import r2Service from '#services/r2_service'
3+
import { aiBodyOverviewValidator } from '#validators/ai'
4+
import cache from '@adonisjs/cache/services/main'
5+
import type { HttpContext } from '@adonisjs/core/http'
6+
7+
export default class AiController {
8+
async videoChapters({ params }: HttpContext) {
9+
const transcript = await r2Service.getTranscript(params.videoId)
10+
const chapters = await aiService.generateChapters(transcript)
11+
return chapters
12+
}
13+
14+
async bodyOverview({ request, params }: HttpContext) {
15+
const { body } = await request.validateUsing(aiBodyOverviewValidator)
16+
const force = request.input('force', false)
17+
const key = `lesson_${params.lessonId}`
18+
19+
if (force) {
20+
await cache.namespace('AI_BODY_OVERVIEW').delete({ key })
21+
}
22+
23+
const overview = await aiService.getOrGenerateBodyOverview(params.lessonId, body)
24+
25+
return overview
26+
}
27+
}

app/controllers/ai_videos_controller.ts

Lines changed: 0 additions & 11 deletions
This file was deleted.

app/controllers/posts_controller.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import TaxonomyDto from '#dtos/taxonomy'
1313
import FrameworkVersion from '#models/framework_version'
1414
import Post from '#models/post'
1515
import Taxonomy from '#models/taxonomy'
16+
import aiService from '#services/ai/ai_service'
1617
import { postIndexValidator, postSearchValidator, postValidator } from '#validators/post'
1718
import type { HttpContext } from '@adonisjs/core/http'
1819
import router from '@adonisjs/core/services/router'
@@ -78,13 +79,15 @@ export default class PostsController {
7879
const post = await GetPost.byId(params.id)
7980
const taxonomies = await Taxonomy.query().orderBy('name')
8081
const frameworkVersions = await FrameworkVersion.query().orderBy('sort')
82+
const aiOverview = await aiService.getBodyOverview(post.id)
8183

8284
await bouncer.with('PostPolicy').authorize('update', post)
8385

8486
return inertia.render('posts/form', {
8587
post: new PostFormDto(post),
8688
taxonomies: TaxonomyDto.fromArray(taxonomies),
8789
frameworkVersions: FrameworkVersionDto.fromArray(frameworkVersions),
90+
aiOverview,
8891
})
8992
}
9093

app/services/ai/ai_service.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Chapter } from '#dtos/post_form'
2+
import cache from '@adonisjs/cache/services/main'
23
import { google } from '@ai-sdk/google'
34
import { generateText, Output } from 'ai'
45
import { z } from 'zod'
@@ -41,6 +42,61 @@ class AiService {
4142

4243
return output.chapters
4344
}
45+
46+
async getBodyOverview(lessonId: number) {
47+
return cache.namespace('AI_BODY_OVERVIEW').get({ key: `lesson_${lessonId}` })
48+
}
49+
50+
async getOrGenerateBodyOverview(lessonId: number, body: string) {
51+
return cache.namespace('AI_BODY_OVERVIEW').getOrSet({
52+
key: `lesson_${lessonId}`,
53+
ttl: '180d',
54+
factory: async () => this.generateBodyOverview(body),
55+
})
56+
}
57+
58+
async generateBodyOverview(body: string): Promise<AiBodyOverview> {
59+
const { output } = await generateText({
60+
model: this.model,
61+
output: Output.object({
62+
schema: z.object({
63+
summary: z
64+
.array(z.string())
65+
.describe('3 to 5 concise, action-oriented bullet points summarizing the lesson.'),
66+
metaDescription: z
67+
.string()
68+
.max(160)
69+
.describe(
70+
'A high-click-through SEO description. Must include the primary topic and a call to learning, under 160 characters.'
71+
),
72+
socialHooks: z.object({
73+
twitter: z
74+
.string()
75+
.describe(
76+
'A punchy, curiosity-gap style tweet to drive clicks. Use 1-2 relevant hashtags.'
77+
),
78+
facebook: z
79+
.string()
80+
.describe(
81+
'A conversational, community-focused post. Explain the value of the lesson and encourage comments or shares.'
82+
),
83+
}),
84+
}),
85+
}),
86+
prompt: `Analyze the following lesson body and generate metadata: ${body}`,
87+
})
88+
89+
return output
90+
}
91+
}
92+
93+
export interface AiBodyOverview {
94+
summary: string[]
95+
metaDescription: string
96+
socialHooks: {
97+
twitter: string
98+
facebook: string
99+
}
44100
}
45101

46102
const aiService = new AiService()

app/validators/ai.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import vine from '@vinejs/vine'
2+
3+
export const aiBodyOverviewValidator = vine.compile(
4+
vine.object({
5+
body: vine.string(),
6+
})
7+
)

app/validators/post.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,11 @@ export const postValidator = vine.compile(
4343
return !result
4444
})
4545
.optional(),
46-
pageTitle: vine.string().trim().maxLength(100).optional(),
47-
description: vine.string().trim().maxLength(255).optional(),
48-
metaDescription: vine.string().trim().maxLength(255).optional(),
49-
canonical: vine.string().trim().maxLength(255).url().optional(),
50-
body: vine.string().trim().optional(),
46+
pageTitle: vine.string().trim().maxLength(100).nullable(),
47+
description: vine.string().trim().maxLength(255).nullable(),
48+
metaDescription: vine.string().trim().maxLength(255).nullable(),
49+
canonical: vine.string().trim().maxLength(255).url().nullable(),
50+
body: vine.string().trim().nullable(),
5151
repositoryUrl: vine.string().trim().maxLength(255).url().optional().nullable(),
5252
repositoryAccessLevel: vine.number().enum(RepositoryAccessLevels).optional(),
5353
isFeatured: vine.boolean().optional().nullable(),
@@ -58,8 +58,8 @@ export const postValidator = vine.compile(
5858
videoBunnyId: vine.string().trim().maxLength(500).optional().nullable(),
5959
videoSeconds: vine.number().optional(),
6060
timezone: vine.string().trim().optional(),
61-
publishAtDate: vine.date().optional(),
62-
publishAtTime: vine.date({ formats: ['HH:mm'] }).optional(),
61+
publishAtDate: vine.date().nullable(),
62+
publishAtTime: vine.date({ formats: ['HH:mm'] }).nullable(),
6363
postTypeId: vine.number().enum(PostTypes).optional(),
6464
stateId: vine.number().enum(States).optional(),
6565
paywallTypeId: vine.number().enum(PaywallTypes).optional(),
@@ -112,6 +112,7 @@ export const postValidator = vine.compile(
112112
}
113113

114114
return publishAt.setZone('UTC').set({ second: 0, millisecond: 0 })
115-
}),
115+
})
116+
.nullable(),
116117
})
117118
)

config/cache.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { defineConfig, drivers, errors, store } from '@adonisjs/cache'
2+
3+
const cacheConfig = defineConfig({
4+
default: 'default',
5+
6+
onFactoryError(error) {
7+
switch (true) {
8+
case error.cause instanceof errors.E_FACTORY_ERROR:
9+
case error.cause instanceof errors.E_FACTORY_HARD_TIMEOUT:
10+
case error.cause instanceof errors.E_FACTORY_SOFT_TIMEOUT:
11+
case error.cause instanceof errors.E_L2_CACHE_ERROR:
12+
case error.cause instanceof errors.E_UNDEFINED_VALUE:
13+
return // continue with thrown error
14+
default:
15+
throw error.cause // don't swallow non-cache error, a 404 should remain a 404
16+
}
17+
},
18+
19+
stores: {
20+
memoryOnly: store().useL1Layer(drivers.memory()),
21+
22+
default: store()
23+
.useL1Layer(drivers.memory({ maxSize: 10_000 }))
24+
.useL2Layer(
25+
drivers.redis({
26+
connectionName: 'main',
27+
})
28+
),
29+
},
30+
})
31+
32+
export default cacheConfig
33+
34+
declare module '@adonisjs/cache/types' {
35+
interface CacheStores extends InferStores<typeof cacheConfig> {}
36+
}

config/redis.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import env from '#start/env'
2+
import { defineConfig } from '@adonisjs/redis'
3+
import { InferConnections } from '@adonisjs/redis/types'
4+
5+
const redisConfig = defineConfig({
6+
connection: 'main',
7+
8+
connections: {
9+
/*
10+
|--------------------------------------------------------------------------
11+
| The default connection
12+
|--------------------------------------------------------------------------
13+
|
14+
| The main connection you want to use to execute redis commands. The same
15+
| connection will be used by the session provider, if you rely on the
16+
| redis driver.
17+
|
18+
*/
19+
main: {
20+
host: env.get('REDIS_HOST'),
21+
port: env.get('REDIS_PORT'),
22+
password: env.get('REDIS_PASSWORD', ''),
23+
db: 0,
24+
keyPrefix: '',
25+
retryStrategy(times) {
26+
return times > 10 ? null : times * 50
27+
},
28+
},
29+
},
30+
})
31+
32+
export default redisConfig
33+
34+
declare module '@adonisjs/redis/types' {
35+
export interface RedisConnections extends InferConnections<typeof redisConfig> {}
36+
}

0 commit comments

Comments
 (0)