@hed-hog/lms 0.0.366 → 0.0.370
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/dist/certificate/certificate.controller.d.ts +1 -1
- package/dist/certificate/certificate.controller.d.ts.map +1 -1
- package/dist/certificate/certificate.controller.js +4 -2
- package/dist/certificate/certificate.controller.js.map +1 -1
- package/dist/certificate/certificate.service.d.ts +50 -0
- package/dist/certificate/certificate.service.d.ts.map +1 -1
- package/dist/certificate/certificate.service.js +73 -0
- package/dist/certificate/certificate.service.js.map +1 -1
- package/dist/course/course-ai-usage.service.d.ts +58 -0
- package/dist/course/course-ai-usage.service.d.ts.map +1 -0
- package/dist/course/course-ai-usage.service.js +176 -0
- package/dist/course/course-ai-usage.service.js.map +1 -0
- package/dist/course/course-audio-transcription.service.d.ts +65 -1
- package/dist/course/course-audio-transcription.service.d.ts.map +1 -1
- package/dist/course/course-audio-transcription.service.js +381 -29
- package/dist/course/course-audio-transcription.service.js.map +1 -1
- package/dist/course/course-export-scorm12.service.d.ts +3 -0
- package/dist/course/course-export-scorm12.service.d.ts.map +1 -1
- package/dist/course/course-export-scorm12.service.js +141 -6
- package/dist/course/course-export-scorm12.service.js.map +1 -1
- package/dist/course/course-export.service.d.ts.map +1 -1
- package/dist/course/course-export.service.js +2 -1
- package/dist/course/course-export.service.js.map +1 -1
- package/dist/course/course-lesson.controller.d.ts +25 -3
- package/dist/course/course-lesson.controller.d.ts.map +1 -1
- package/dist/course/course-lesson.controller.js +71 -8
- package/dist/course/course-lesson.controller.js.map +1 -1
- package/dist/course/course-structure.controller.d.ts +26 -5
- package/dist/course/course-structure.controller.d.ts.map +1 -1
- package/dist/course/course-structure.controller.js +31 -1
- package/dist/course/course-structure.controller.js.map +1 -1
- package/dist/course/course-structure.service.d.ts +37 -5
- package/dist/course/course-structure.service.d.ts.map +1 -1
- package/dist/course/course-structure.service.js +165 -20
- package/dist/course/course-structure.service.js.map +1 -1
- package/dist/course/course-transcription-translation.service.d.ts +31 -0
- package/dist/course/course-transcription-translation.service.d.ts.map +1 -0
- package/dist/course/course-transcription-translation.service.js +227 -0
- package/dist/course/course-transcription-translation.service.js.map +1 -0
- package/dist/course/course-video-agent-pipeline.service.js +7 -7
- package/dist/course/course-video-agent-pipeline.service.js.map +1 -1
- package/dist/course/course.module.d.ts.map +1 -1
- package/dist/course/course.module.js +4 -0
- package/dist/course/course.module.js.map +1 -1
- package/dist/course/dto/create-course-bulk-job.dto.d.ts +2 -1
- package/dist/course/dto/create-course-bulk-job.dto.d.ts.map +1 -1
- package/dist/course/dto/create-course-bulk-job.dto.js +6 -1
- package/dist/course/dto/create-course-bulk-job.dto.js.map +1 -1
- package/dist/course/dto/create-course-export.dto.d.ts +1 -0
- package/dist/course/dto/create-course-export.dto.d.ts.map +1 -1
- package/dist/course/dto/create-course-export.dto.js +6 -0
- package/dist/course/dto/create-course-export.dto.js.map +1 -1
- package/dist/course/lms-bulk-upload-automation.service.d.ts.map +1 -1
- package/dist/course/lms-bulk-upload-automation.service.js +26 -13
- package/dist/course/lms-bulk-upload-automation.service.js.map +1 -1
- package/dist/course/lms-bulk-upload.controller.d.ts +3 -0
- package/dist/course/lms-bulk-upload.controller.d.ts.map +1 -1
- package/dist/course/lms-bulk-upload.service.d.ts +3 -0
- package/dist/course/lms-bulk-upload.service.d.ts.map +1 -1
- package/dist/course/lms-bulk-upload.service.js +48 -29
- package/dist/course/lms-bulk-upload.service.js.map +1 -1
- package/dist/course/subtitle.util.d.ts +46 -0
- package/dist/course/subtitle.util.d.ts.map +1 -0
- package/dist/course/subtitle.util.js +206 -0
- package/dist/course/subtitle.util.js.map +1 -0
- package/dist/enterprise/training/training-student.service.d.ts +27 -0
- package/dist/enterprise/training/training-student.service.d.ts.map +1 -1
- package/dist/enterprise/training/training-student.service.js +197 -10
- package/dist/enterprise/training/training-student.service.js.map +1 -1
- package/dist/lesson-xp-map/lesson-xp-ai-calculation.service.d.ts +3 -1
- package/dist/lesson-xp-map/lesson-xp-ai-calculation.service.d.ts.map +1 -1
- package/dist/lesson-xp-map/lesson-xp-ai-calculation.service.js +19 -5
- package/dist/lesson-xp-map/lesson-xp-ai-calculation.service.js.map +1 -1
- package/dist/lesson-xp-map/lesson-xp-map.module.d.ts.map +1 -1
- package/dist/lesson-xp-map/lesson-xp-map.module.js +2 -1
- package/dist/lesson-xp-map/lesson-xp-map.module.js.map +1 -1
- package/dist/libraries/lms/tsconfig.tsbuildinfo +1 -1
- package/dist/lms.module.d.ts.map +1 -1
- package/dist/lms.module.js +4 -0
- package/dist/lms.module.js.map +1 -1
- package/dist/platforma/platforma-performance.service.js +121 -121
- package/dist/platforma/platforma-video.service.d.ts +8 -0
- package/dist/platforma/platforma-video.service.d.ts.map +1 -1
- package/dist/platforma/platforma-video.service.js +45 -2
- package/dist/platforma/platforma-video.service.js.map +1 -1
- package/dist/platforma/platforma.controller.d.ts +99 -1
- package/dist/platforma/platforma.controller.d.ts.map +1 -1
- package/dist/platforma/platforma.controller.js +111 -2
- package/dist/platforma/platforma.controller.js.map +1 -1
- package/dist/training/dto/create-training.dto.d.ts +9 -0
- package/dist/training/dto/create-training.dto.d.ts.map +1 -1
- package/dist/training/dto/create-training.dto.js +45 -1
- package/dist/training/dto/create-training.dto.js.map +1 -1
- package/dist/training/training.controller.d.ts +144 -0
- package/dist/training/training.controller.d.ts.map +1 -1
- package/dist/training/training.service.d.ts +149 -0
- package/dist/training/training.service.d.ts.map +1 -1
- package/dist/training/training.service.js +332 -167
- package/dist/training/training.service.js.map +1 -1
- package/hedhog/data/image_type.yaml +10 -0
- package/hedhog/data/route.yaml +251 -0
- package/hedhog/data/setting_group.yaml +97 -0
- package/hedhog/frontend/app/bulk-upload-sessions/page.tsx.ejs +139 -27
- package/hedhog/frontend/app/courses/[id]/_components/CourseFlagsCard.tsx.ejs +69 -57
- package/hedhog/frontend/app/courses/[id]/_components/CourseIssuedCertificatesCard.tsx.ejs +168 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/course-ai-costs-tab.tsx.ejs +191 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/course-export-sheet.tsx.ejs +81 -1
- package/hedhog/frontend/app/courses/[id]/structure/_components/course-exports-tab.tsx.ejs +12 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/detail-course.tsx.ejs +69 -1
- package/hedhog/frontend/app/courses/[id]/structure/_components/detail-lesson.tsx.ejs +267 -19
- package/hedhog/frontend/app/courses/[id]/structure/_components/editor-bulk.tsx.ejs +114 -86
- package/hedhog/frontend/app/courses/[id]/structure/_components/editor-course.tsx.ejs +239 -31
- package/hedhog/frontend/app/courses/[id]/structure/_components/editor-lesson.tsx.ejs +344 -59
- package/hedhog/frontend/app/courses/[id]/structure/_components/lesson-video-preview.tsx.ejs +200 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-lesson.tsx.ejs +1 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/types.ts.ejs +3 -0
- package/hedhog/frontend/app/courses/[id]/structure/_data/adapters/course-structure.adapter.ts.ejs +19 -7
- package/hedhog/frontend/app/courses/[id]/structure/_data/types/api-course.types.ts.ejs +4 -0
- package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-ai-costs.ts.ejs +40 -0
- package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-exports.ts.ejs +25 -0
- package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-structure-mutations.ts.ejs +148 -0
- package/hedhog/frontend/app/courses/[id]/structure/_data/use-transcription-segments.ts.ejs +157 -8
- package/hedhog/frontend/app/courses/_components/CourseRowActions.tsx.ejs +1 -22
- package/hedhog/frontend/app/courses/page.tsx.ejs +26 -4
- package/hedhog/frontend/app/paths/page.tsx.ejs +612 -164
- package/hedhog/frontend/messages/en.json +23 -12
- package/hedhog/frontend/messages/pt.json +23 -12
- package/hedhog/query/triggers.sql +33 -0
- package/hedhog/table/course_ai_usage.yaml +46 -0
- package/hedhog/table/course_lesson.yaml +3 -0
- package/hedhog/table/course_lesson_answer.yaml +37 -0
- package/hedhog/table/course_lesson_transcription_segment.yaml +8 -0
- package/hedhog/table/learning_path.yaml +6 -0
- package/hedhog/table/learning_path_module.yaml +22 -0
- package/hedhog/table/learning_path_step.yaml +9 -6
- package/hedhog/table/lesson_view_event.yaml +66 -66
- package/package.json +9 -9
- package/src/certificate/certificate.controller.ts +2 -0
- package/src/certificate/certificate.service.ts +99 -0
- package/src/course/course-ai-usage.service.ts +221 -0
- package/src/course/course-audio-transcription.service.ts +471 -43
- package/src/course/course-export-scorm12.service.ts +149 -5
- package/src/course/course-export.service.ts +1 -0
- package/src/course/course-lesson.controller.ts +59 -6
- package/src/course/course-structure.controller.ts +16 -0
- package/src/course/course-structure.service.ts +184 -10
- package/src/course/course-transcription-translation.service.ts +293 -0
- package/src/course/course-video-agent-pipeline.service.ts +471 -471
- package/src/course/course.module.ts +4 -0
- package/src/course/dto/create-course-bulk-job.dto.ts +7 -3
- package/src/course/dto/create-course-export.dto.ts +6 -0
- package/src/course/ffmpeg.util.ts +65 -65
- package/src/course/lms-bulk-upload-automation.service.ts +29 -7
- package/src/course/lms-bulk-upload.service.ts +20 -1
- package/src/course/subtitle.util.ts +220 -0
- package/src/enterprise/training/training-student.service.ts +224 -4
- package/src/lesson-xp-map/lesson-xp-ai-calculation.service.ts +14 -0
- package/src/lesson-xp-map/lesson-xp-map.module.ts +2 -1
- package/src/lms.module.ts +4 -0
- package/src/platforma/dto/heartbeat.dto.ts +30 -30
- package/src/platforma/handlers/emit-certificate.handler.ts +117 -117
- package/src/platforma/handlers/lesson-heartbeat.handler.ts +343 -343
- package/src/platforma/platforma-heartbeat.service.ts +33 -33
- package/src/platforma/platforma-performance.service.ts +606 -606
- package/src/platforma/platforma-search.service.ts +48 -48
- package/src/platforma/platforma-video.service.ts +59 -3
- package/src/platforma/platforma.controller.ts +88 -0
- package/src/training/dto/create-training.dto.ts +36 -0
- package/src/training/training.service.ts +360 -163
|
@@ -2,6 +2,7 @@ import { PrismaService } from '@hed-hog/api-prisma';
|
|
|
2
2
|
import { FileService, IntegrationDeveloperApiService } from '@hed-hog/core';
|
|
3
3
|
import {
|
|
4
4
|
BadRequestException,
|
|
5
|
+
ForbiddenException,
|
|
5
6
|
Inject,
|
|
6
7
|
Injectable,
|
|
7
8
|
NotFoundException,
|
|
@@ -18,6 +19,7 @@ type ListIssuedCertificatesParams = {
|
|
|
18
19
|
pageSize?: number;
|
|
19
20
|
search?: string;
|
|
20
21
|
type?: string;
|
|
22
|
+
courseId?: number;
|
|
21
23
|
};
|
|
22
24
|
|
|
23
25
|
type ListCertificateTemplatesParams = {
|
|
@@ -218,6 +220,10 @@ export class LmsCertificateService {
|
|
|
218
220
|
where.certificate_type = type;
|
|
219
221
|
}
|
|
220
222
|
|
|
223
|
+
if (params.courseId) {
|
|
224
|
+
where.course_id = Number(params.courseId);
|
|
225
|
+
}
|
|
226
|
+
|
|
221
227
|
if (params.search?.trim()) {
|
|
222
228
|
const search = params.search.trim();
|
|
223
229
|
where.OR = [
|
|
@@ -577,6 +583,99 @@ export class LmsCertificateService {
|
|
|
577
583
|
}
|
|
578
584
|
}
|
|
579
585
|
|
|
586
|
+
async listIssuedForStudent(
|
|
587
|
+
userId: number,
|
|
588
|
+
params: ListIssuedCertificatesParams,
|
|
589
|
+
) {
|
|
590
|
+
const personId = await this.resolvePersonIdFromUser(userId);
|
|
591
|
+
|
|
592
|
+
const page = Math.max(Number(params.page) || 1, 1);
|
|
593
|
+
const pageSize = Math.max(Number(params.pageSize) || 12, 1);
|
|
594
|
+
const skip = (page - 1) * pageSize;
|
|
595
|
+
|
|
596
|
+
const where: Record<string, any> = { student_id: personId };
|
|
597
|
+
const type = this.normalizeType(params.type);
|
|
598
|
+
if (type) {
|
|
599
|
+
where.certificate_type = type;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const [certificates, total] = await Promise.all([
|
|
603
|
+
this.prisma.certificate.findMany({
|
|
604
|
+
skip,
|
|
605
|
+
take: pageSize,
|
|
606
|
+
where,
|
|
607
|
+
orderBy: [{ issued_at: 'desc' }, { id: 'desc' }],
|
|
608
|
+
include: {
|
|
609
|
+
certificate_template: {
|
|
610
|
+
select: {
|
|
611
|
+
id: true,
|
|
612
|
+
name: true,
|
|
613
|
+
status: true,
|
|
614
|
+
template_content: true,
|
|
615
|
+
},
|
|
616
|
+
},
|
|
617
|
+
course: { select: { id: true, title: true, primary_color: true } },
|
|
618
|
+
exam: { select: { id: true, title: true } },
|
|
619
|
+
course_class_group: { select: { id: true, title: true } },
|
|
620
|
+
learning_path: { select: { id: true, title: true, primary_color: true } },
|
|
621
|
+
person: { select: { id: true, name: true } },
|
|
622
|
+
},
|
|
623
|
+
}),
|
|
624
|
+
this.prisma.certificate.count({ where }),
|
|
625
|
+
]);
|
|
626
|
+
|
|
627
|
+
return {
|
|
628
|
+
total,
|
|
629
|
+
page,
|
|
630
|
+
pageSize,
|
|
631
|
+
lastPage: Math.max(1, Math.ceil(total / pageSize)),
|
|
632
|
+
data: certificates.map((cert) => this.mapIssuedCertificateWithTemplate(cert)),
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
async updatePublicAccessForStudent(
|
|
637
|
+
userId: number,
|
|
638
|
+
certificateId: number,
|
|
639
|
+
publicAccess: boolean,
|
|
640
|
+
) {
|
|
641
|
+
const personId = await this.resolvePersonIdFromUser(userId);
|
|
642
|
+
|
|
643
|
+
const cert = await this.prisma.certificate.findUnique({
|
|
644
|
+
where: { id: certificateId },
|
|
645
|
+
select: { id: true, student_id: true },
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
if (!cert) {
|
|
649
|
+
throw new NotFoundException('Issued certificate not found');
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
if (cert.student_id !== personId) {
|
|
653
|
+
throw new ForbiddenException('Access denied');
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
return this.updatePublicAccess(certificateId, publicAccess);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
private async resolvePersonIdFromUser(userId: number) {
|
|
660
|
+
const personUser = await this.prisma.person_user.findFirst({
|
|
661
|
+
where: { user_id: userId },
|
|
662
|
+
select: { person_id: true },
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
if (!personUser) {
|
|
666
|
+
throw new NotFoundException('No person profile found for this user');
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
return personUser.person_id;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
private mapIssuedCertificateWithTemplate(certificate: any) {
|
|
673
|
+
return {
|
|
674
|
+
...this.mapIssuedCertificate(certificate),
|
|
675
|
+
templateContent: certificate.certificate_template?.template_content ?? null,
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
|
|
580
679
|
private normalizeType(value?: string) {
|
|
581
680
|
if (!value || value === 'all') {
|
|
582
681
|
return undefined;
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { PrismaService } from '@hed-hog/api-prisma';
|
|
2
|
+
import { SettingService } from '@hed-hog/core';
|
|
3
|
+
import { Inject, Injectable, Logger, forwardRef } from '@nestjs/common';
|
|
4
|
+
|
|
5
|
+
export type AiJobType = 'transcription' | 'translation' | 'xp_calculation';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Records AI usage and cost into the `course_ai_usage` ledger so the course can
|
|
9
|
+
* report how much it spent on transcription, translation and XP jobs.
|
|
10
|
+
*
|
|
11
|
+
* Token-billed models (chat: gpt-4o-mini) are priced from the existing
|
|
12
|
+
* `ai_model_pricing` table — the OpenAI API returns token counts (`usage`), not
|
|
13
|
+
* a dollar amount, so the cost is tokens × table price. Whisper is billed per
|
|
14
|
+
* minute of audio, so it uses the `lms-whisper-price-per-minute-usd` setting.
|
|
15
|
+
*
|
|
16
|
+
* Recording never throws: cost tracking must not break the underlying job.
|
|
17
|
+
*/
|
|
18
|
+
@Injectable()
|
|
19
|
+
export class CourseAiUsageService {
|
|
20
|
+
private readonly logger = new Logger(CourseAiUsageService.name);
|
|
21
|
+
|
|
22
|
+
constructor(
|
|
23
|
+
@Inject(forwardRef(() => PrismaService))
|
|
24
|
+
private readonly prisma: PrismaService,
|
|
25
|
+
@Inject(forwardRef(() => SettingService))
|
|
26
|
+
private readonly settingService: SettingService,
|
|
27
|
+
) {}
|
|
28
|
+
|
|
29
|
+
async resolveCourseIdFromLesson(lessonId: number): Promise<number | null> {
|
|
30
|
+
try {
|
|
31
|
+
const rows = await this.prisma.$queryRawUnsafe<{ course_id: number }[]>(
|
|
32
|
+
`SELECT cm.course_id
|
|
33
|
+
FROM course_lesson cl
|
|
34
|
+
JOIN course_module cm ON cm.id = cl.course_module_id
|
|
35
|
+
WHERE cl.id = $1
|
|
36
|
+
LIMIT 1`,
|
|
37
|
+
lessonId,
|
|
38
|
+
);
|
|
39
|
+
return rows[0]?.course_id ?? null;
|
|
40
|
+
} catch (error) {
|
|
41
|
+
this.logger.warn(
|
|
42
|
+
`Failed to resolve course for lesson ${lessonId}: ${error instanceof Error ? error.message : String(error)}`,
|
|
43
|
+
);
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Resolves the active per-million token price for a provider/model. */
|
|
49
|
+
private async getTokenPrice(
|
|
50
|
+
provider: string,
|
|
51
|
+
model: string,
|
|
52
|
+
): Promise<{ input: number; output: number } | null> {
|
|
53
|
+
try {
|
|
54
|
+
const row = await (this.prisma as any).ai_model_pricing.findFirst({
|
|
55
|
+
where: { provider, model, is_active: true },
|
|
56
|
+
orderBy: { valid_from: 'desc' },
|
|
57
|
+
});
|
|
58
|
+
if (!row) return null;
|
|
59
|
+
return {
|
|
60
|
+
input: Number(row.price_input_per_million ?? 0),
|
|
61
|
+
output: Number(row.price_output_per_million ?? 0),
|
|
62
|
+
};
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private async insert(params: {
|
|
69
|
+
courseId: number;
|
|
70
|
+
lessonId: number | null;
|
|
71
|
+
jobType: AiJobType;
|
|
72
|
+
provider: string;
|
|
73
|
+
model: string;
|
|
74
|
+
inputTokens: number | null;
|
|
75
|
+
outputTokens: number | null;
|
|
76
|
+
audioSeconds: number | null;
|
|
77
|
+
costUsd: number;
|
|
78
|
+
}): Promise<void> {
|
|
79
|
+
await this.prisma.$executeRawUnsafe(
|
|
80
|
+
`INSERT INTO course_ai_usage
|
|
81
|
+
(course_id, course_lesson_id, job_type, provider, model,
|
|
82
|
+
input_tokens, output_tokens, audio_seconds, cost_usd, created_at)
|
|
83
|
+
VALUES ($1, $2, $3::course_ai_usage_job_type_enum, $4, $5, $6, $7, $8, $9, NOW())`,
|
|
84
|
+
params.courseId,
|
|
85
|
+
params.lessonId,
|
|
86
|
+
params.jobType,
|
|
87
|
+
params.provider,
|
|
88
|
+
params.model,
|
|
89
|
+
params.inputTokens,
|
|
90
|
+
params.outputTokens,
|
|
91
|
+
params.audioSeconds,
|
|
92
|
+
params.costUsd,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Records usage for a token-billed chat model (translation, XP). */
|
|
97
|
+
async recordChatUsage(params: {
|
|
98
|
+
courseId?: number | null;
|
|
99
|
+
lessonId: number;
|
|
100
|
+
jobType: AiJobType;
|
|
101
|
+
provider: string;
|
|
102
|
+
model: string;
|
|
103
|
+
inputTokens: number;
|
|
104
|
+
outputTokens: number;
|
|
105
|
+
}): Promise<void> {
|
|
106
|
+
try {
|
|
107
|
+
const courseId =
|
|
108
|
+
params.courseId ?? (await this.resolveCourseIdFromLesson(params.lessonId));
|
|
109
|
+
if (!courseId) return;
|
|
110
|
+
|
|
111
|
+
const price = await this.getTokenPrice(params.provider, params.model);
|
|
112
|
+
const costUsd = price
|
|
113
|
+
? (params.inputTokens / 1_000_000) * price.input +
|
|
114
|
+
(params.outputTokens / 1_000_000) * price.output
|
|
115
|
+
: 0;
|
|
116
|
+
|
|
117
|
+
await this.insert({
|
|
118
|
+
courseId,
|
|
119
|
+
lessonId: params.lessonId,
|
|
120
|
+
jobType: params.jobType,
|
|
121
|
+
provider: params.provider,
|
|
122
|
+
model: params.model,
|
|
123
|
+
inputTokens: params.inputTokens,
|
|
124
|
+
outputTokens: params.outputTokens,
|
|
125
|
+
audioSeconds: null,
|
|
126
|
+
costUsd,
|
|
127
|
+
});
|
|
128
|
+
} catch (error) {
|
|
129
|
+
this.logger.warn(
|
|
130
|
+
`Failed to record chat AI usage for lesson ${params.lessonId}: ${error instanceof Error ? error.message : String(error)}`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Records usage for Whisper transcription (billed per minute of audio). */
|
|
136
|
+
async recordTranscriptionUsage(params: {
|
|
137
|
+
courseId: number;
|
|
138
|
+
lessonId: number;
|
|
139
|
+
model: string;
|
|
140
|
+
audioSeconds: number;
|
|
141
|
+
}): Promise<void> {
|
|
142
|
+
try {
|
|
143
|
+
const priceSlug = `lms-transcription-price-per-second-usd-${params.model}`;
|
|
144
|
+
const settings = await this.settingService.getSettingValues([priceSlug]);
|
|
145
|
+
const pricePerSecond = Number(settings[priceSlug] ?? 0.0001);
|
|
146
|
+
const costUsd = params.audioSeconds * (Number.isFinite(pricePerSecond) && pricePerSecond > 0 ? pricePerSecond : 0.0001);
|
|
147
|
+
|
|
148
|
+
await this.insert({
|
|
149
|
+
courseId: params.courseId,
|
|
150
|
+
lessonId: params.lessonId,
|
|
151
|
+
jobType: 'transcription',
|
|
152
|
+
provider: 'openai',
|
|
153
|
+
model: params.model,
|
|
154
|
+
inputTokens: null,
|
|
155
|
+
outputTokens: null,
|
|
156
|
+
audioSeconds: params.audioSeconds,
|
|
157
|
+
costUsd,
|
|
158
|
+
});
|
|
159
|
+
} catch (error) {
|
|
160
|
+
this.logger.warn(
|
|
161
|
+
`Failed to record transcription AI usage for lesson ${params.lessonId}: ${error instanceof Error ? error.message : String(error)}`,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Aggregated cost report for a course: total, by job type and by lesson. */
|
|
167
|
+
async getCourseAiCosts(courseId: number): Promise<{
|
|
168
|
+
currency: string;
|
|
169
|
+
totalCostUsd: number;
|
|
170
|
+
byJobType: Array<{ jobType: string; costUsd: number; runs: number }>;
|
|
171
|
+
byLesson: Array<{
|
|
172
|
+
lessonId: number | null;
|
|
173
|
+
lessonTitle: string | null;
|
|
174
|
+
costUsd: number;
|
|
175
|
+
runs: number;
|
|
176
|
+
}>;
|
|
177
|
+
}> {
|
|
178
|
+
const byJobType = await this.prisma.$queryRawUnsafe<
|
|
179
|
+
Array<{ job_type: string; cost_usd: number; runs: bigint }>
|
|
180
|
+
>(
|
|
181
|
+
`SELECT job_type, COALESCE(SUM(cost_usd), 0)::float8 AS cost_usd, COUNT(*) AS runs
|
|
182
|
+
FROM course_ai_usage
|
|
183
|
+
WHERE course_id = $1
|
|
184
|
+
GROUP BY job_type`,
|
|
185
|
+
courseId,
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
const byLesson = await this.prisma.$queryRawUnsafe<
|
|
189
|
+
Array<{ lesson_id: number | null; lesson_title: string | null; cost_usd: number; runs: bigint }>
|
|
190
|
+
>(
|
|
191
|
+
`SELECT u.course_lesson_id AS lesson_id,
|
|
192
|
+
cl.title AS lesson_title,
|
|
193
|
+
COALESCE(SUM(u.cost_usd), 0)::float8 AS cost_usd,
|
|
194
|
+
COUNT(*) AS runs
|
|
195
|
+
FROM course_ai_usage u
|
|
196
|
+
LEFT JOIN course_lesson cl ON cl.id = u.course_lesson_id
|
|
197
|
+
WHERE u.course_id = $1
|
|
198
|
+
GROUP BY u.course_lesson_id, cl.title
|
|
199
|
+
ORDER BY cost_usd DESC`,
|
|
200
|
+
courseId,
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
const totalCostUsd = byJobType.reduce((sum, r) => sum + Number(r.cost_usd ?? 0), 0);
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
currency: 'USD',
|
|
207
|
+
totalCostUsd,
|
|
208
|
+
byJobType: byJobType.map((r) => ({
|
|
209
|
+
jobType: r.job_type,
|
|
210
|
+
costUsd: Number(r.cost_usd ?? 0),
|
|
211
|
+
runs: Number(r.runs ?? 0),
|
|
212
|
+
})),
|
|
213
|
+
byLesson: byLesson.map((r) => ({
|
|
214
|
+
lessonId: r.lesson_id,
|
|
215
|
+
lessonTitle: r.lesson_title,
|
|
216
|
+
costUsd: Number(r.cost_usd ?? 0),
|
|
217
|
+
runs: Number(r.runs ?? 0),
|
|
218
|
+
})),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
}
|