forked from hangshuo652/aurak
feat: implement QuestionBank CRUD with pagination and template query
- Add pagination support to findAll (page, limit query params) - Add findByTemplateId method to service - Add GET /by-template/:templateId endpoint to controller - Service already includes CRUD for QuestionBank and QuestionBankItem
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../user/user.entity';
|
||||
import { KnowledgeGroup } from '../../knowledge-group/knowledge-group.entity';
|
||||
import { Tenant } from '../../tenant/tenant.entity';
|
||||
|
||||
export enum PodcastStatus {
|
||||
PENDING = 'pending',
|
||||
PROCESSING = 'processing',
|
||||
COMPLETED = 'completed',
|
||||
FAILED = 'failed',
|
||||
}
|
||||
|
||||
@Entity('podcast_episodes')
|
||||
export class PodcastEpisode {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column()
|
||||
title: string;
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
briefing: string; // The instruction/prompt used
|
||||
|
||||
@Column({ nullable: true })
|
||||
audioUrl: string;
|
||||
|
||||
@Column('simple-json', { nullable: true })
|
||||
transcript: any; // The dialogue script
|
||||
|
||||
@Column({
|
||||
type: 'simple-enum',
|
||||
enum: PodcastStatus,
|
||||
default: PodcastStatus.PENDING,
|
||||
})
|
||||
status: PodcastStatus;
|
||||
|
||||
@Column({ name: 'user_id' })
|
||||
userId: string;
|
||||
|
||||
@Column({ name: 'group_id', nullable: true })
|
||||
groupId: string;
|
||||
|
||||
@Column({ name: 'tenant_id', nullable: true, type: 'text' })
|
||||
tenantId: string;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
|
||||
@ManyToOne(() => User)
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
user: User;
|
||||
|
||||
@ManyToOne(() => KnowledgeGroup)
|
||||
@JoinColumn({ name: 'group_id' })
|
||||
group: KnowledgeGroup;
|
||||
|
||||
@ManyToOne(() => Tenant)
|
||||
@JoinColumn({ name: 'tenant_id' })
|
||||
tenant: Tenant;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Param,
|
||||
Delete,
|
||||
UseGuards,
|
||||
Req,
|
||||
Query,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import { CombinedAuthGuard } from '../auth/combined-auth.guard';
|
||||
import { PodcastService } from './podcast.service';
|
||||
import { Response } from 'express';
|
||||
import * as path from 'path';
|
||||
|
||||
@Controller('podcasts')
|
||||
export class PodcastController {
|
||||
constructor(private readonly podcastService: PodcastService) {}
|
||||
|
||||
@Post()
|
||||
@UseGuards(CombinedAuthGuard)
|
||||
create(@Req() req, @Body() createDto: any) {
|
||||
return this.podcastService.create(req.user.id, createDto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@UseGuards(CombinedAuthGuard)
|
||||
findAll(@Req() req, @Query('groupId') groupId?: string) {
|
||||
return this.podcastService.findAll(req.user.id, groupId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@UseGuards(CombinedAuthGuard)
|
||||
findOne(@Req() req, @Param('id') id: string) {
|
||||
return this.podcastService.findOne(req.user.id, id);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(CombinedAuthGuard)
|
||||
remove(@Req() req, @Param('id') id: string) {
|
||||
return this.podcastService.delete(req.user.id, id);
|
||||
}
|
||||
|
||||
// Public route for audio streaming (or protected if preferred)
|
||||
@Get('audio/:filename')
|
||||
async streamAudio(@Param('filename') filename: string, @Res() res: Response) {
|
||||
const filePath = path.join(process.cwd(), 'uploads', 'podcasts', filename);
|
||||
res.sendFile(filePath);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { PodcastEpisode } from './entities/podcast-episode.entity';
|
||||
import { PodcastService } from './podcast.service';
|
||||
import { PodcastController } from './podcast.controller';
|
||||
import { KnowledgeGroup } from '../knowledge-group/knowledge-group.entity';
|
||||
import { ChatModule } from '../chat/chat.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([PodcastEpisode, KnowledgeGroup]),
|
||||
ChatModule, // Import ChatModule to use ChatService
|
||||
],
|
||||
controllers: [PodcastController],
|
||||
providers: [PodcastService],
|
||||
exports: [PodcastService],
|
||||
})
|
||||
export class PodcastModule {}
|
||||
@@ -0,0 +1,306 @@
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import {
|
||||
PodcastEpisode,
|
||||
PodcastStatus,
|
||||
} from './entities/podcast-episode.entity';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
// import { EdgeTTS } from 'node-edge-tts'; // Deprecated due to 403 errors
|
||||
import * as fs from 'fs-extra';
|
||||
import * as path from 'path';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { KnowledgeGroup } from '../knowledge-group/knowledge-group.entity';
|
||||
import { ChatService } from '../chat/chat.service';
|
||||
import { I18nService } from '../i18n/i18n.service';
|
||||
|
||||
@Injectable()
|
||||
export class PodcastService {
|
||||
private readonly logger = new Logger(PodcastService.name);
|
||||
private readonly outputDir: string;
|
||||
private readonly pythonPath = 'python'; // Or from config
|
||||
private readonly scriptPath = path.join(process.cwd(), 'text_to_speech.py');
|
||||
|
||||
constructor(
|
||||
@InjectRepository(PodcastEpisode)
|
||||
private podcastRepository: Repository<PodcastEpisode>,
|
||||
@InjectRepository(KnowledgeGroup)
|
||||
private groupRepository: Repository<KnowledgeGroup>,
|
||||
private configService: ConfigService,
|
||||
private chatService: ChatService, // Reusing ChatService to generate script
|
||||
private i18nService: I18nService,
|
||||
) {
|
||||
// this.tts = new EdgeTTS();
|
||||
this.outputDir = path.join(process.cwd(), 'uploads', 'podcasts');
|
||||
fs.ensureDirSync(this.outputDir);
|
||||
}
|
||||
|
||||
async create(userId: string, createDto: any): Promise<PodcastEpisode> {
|
||||
this.logger.log(`Creating podcast with DTO: ${JSON.stringify(createDto)}`);
|
||||
if (!userId) {
|
||||
throw new Error(this.i18nService.getMessage('userIdRequired'));
|
||||
}
|
||||
|
||||
const episode = this.podcastRepository.create({
|
||||
...createDto,
|
||||
briefing: createDto.content || createDto.briefing, // Map content to briefing if needed
|
||||
user: { id: userId },
|
||||
group: createDto.groupId ? { id: createDto.groupId } : undefined,
|
||||
status: PodcastStatus.PENDING,
|
||||
}) as unknown as PodcastEpisode; // Restore cast to fix TS inference issue
|
||||
|
||||
const saved = await this.podcastRepository.save(episode);
|
||||
|
||||
// Start background processing
|
||||
this.processPodcast(saved.id, userId, createDto);
|
||||
|
||||
return saved;
|
||||
}
|
||||
|
||||
async findAll(userId: string, groupId?: string): Promise<PodcastEpisode[]> {
|
||||
const query = this.podcastRepository
|
||||
.createQueryBuilder('podcast')
|
||||
.where('podcast.userId = :userId', { userId })
|
||||
.orderBy('podcast.createdAt', 'DESC');
|
||||
|
||||
if (groupId) {
|
||||
query.andWhere('podcast.groupId = :groupId', { groupId });
|
||||
}
|
||||
|
||||
return query.getMany();
|
||||
}
|
||||
|
||||
async findOne(userId: string, id: string): Promise<PodcastEpisode> {
|
||||
const episode = await this.podcastRepository.findOne({
|
||||
where: { id, userId },
|
||||
});
|
||||
if (!episode)
|
||||
throw new NotFoundException(
|
||||
this.i18nService.formatMessage('podcastNotFound', { id }),
|
||||
);
|
||||
return episode;
|
||||
}
|
||||
|
||||
async delete(userId: string, id: string): Promise<void> {
|
||||
const episode = await this.findOne(userId, id);
|
||||
|
||||
// Delete audio file if exists
|
||||
if (episode.audioUrl) {
|
||||
const filename = path.basename(episode.audioUrl);
|
||||
const filePath = path.join(this.outputDir, filename);
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
await this.podcastRepository.remove(episode);
|
||||
}
|
||||
|
||||
async processPodcast(episodeId: string, userId: string, dto: any) {
|
||||
try {
|
||||
this.logger.log(`Starting processing for podcast ${episodeId}`);
|
||||
await this.updateStatus(episodeId, PodcastStatus.PROCESSING);
|
||||
|
||||
// 1. Gather Context
|
||||
const context = dto.content || '';
|
||||
if (dto.groupId) {
|
||||
// TODO: Fetch context from group (files) if content is empty
|
||||
// For now assuming content is passed or we just use what we have
|
||||
}
|
||||
|
||||
// 2. Generate Script using ChatService (LLM)
|
||||
const fileIds = dto.fileId ? [dto.fileId] : undefined;
|
||||
const language = dto.language || 'zh';
|
||||
this.logger.log(`Generating script for language: ${language}`);
|
||||
const script = await this.generateScript(
|
||||
context,
|
||||
dto.topic || 'General Discussion',
|
||||
userId,
|
||||
language,
|
||||
dto.groupId,
|
||||
fileIds,
|
||||
);
|
||||
await this.podcastRepository.update(episodeId, { transcript: script });
|
||||
|
||||
// 3. Generate Audio using Edge TTS
|
||||
const audioFileName = `${episodeId}.mp3`;
|
||||
const audioFilePath = path.join(this.outputDir, audioFileName);
|
||||
|
||||
await this.generateAudioInternal(script, audioFilePath, language);
|
||||
|
||||
// 4. Update Episode
|
||||
await this.podcastRepository.update(episodeId, {
|
||||
status: PodcastStatus.COMPLETED,
|
||||
audioUrl: `/api/podcasts/audio/${audioFileName}`,
|
||||
});
|
||||
|
||||
this.logger.log(`Podcast ${episodeId} completed (Language: ${language})`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to process podcast ${episodeId}`, error);
|
||||
await this.updateStatus(episodeId, PodcastStatus.FAILED);
|
||||
}
|
||||
}
|
||||
|
||||
private async updateStatus(id: string, status: PodcastStatus) {
|
||||
await this.podcastRepository.update(id, { status });
|
||||
}
|
||||
|
||||
async generateScript(
|
||||
context: string,
|
||||
topic: string,
|
||||
userId: string,
|
||||
language: string = 'zh',
|
||||
groupId?: string,
|
||||
fileIds?: string[],
|
||||
): Promise<any[]> {
|
||||
// ... (RAG context logic omitted for brevity, logic remains same)
|
||||
// If groupId or fileIds are provided, try to enhance context with RAG
|
||||
if (
|
||||
(groupId || (fileIds && fileIds.length > 0)) &&
|
||||
(!context || context.length < 100)
|
||||
) {
|
||||
try {
|
||||
// tenantId is optional, we pass undefined here, groupId is string, fileIds is string[]
|
||||
const ragContext = await this.chatService.getContextForTopic(
|
||||
topic,
|
||||
userId,
|
||||
undefined,
|
||||
groupId,
|
||||
fileIds,
|
||||
);
|
||||
if (ragContext) {
|
||||
context = `Manual Context: ${context}\n\nSearch Results:\n${ragContext}`;
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Failed to fetch RAG context for podcast: ${err.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let targetLang = 'Chinese (Simplified)';
|
||||
if (language === 'en') targetLang = 'English';
|
||||
if (language === 'ja') targetLang = 'Japanese';
|
||||
|
||||
const prompt = `
|
||||
You are an expert podcast producer. Create a podcast script about the following topic: "${topic}".
|
||||
|
||||
Context information (use this to inform the discussion):
|
||||
${context ? context.substring(0, 5000) : 'No specific context provided, use general knowledge.'}
|
||||
|
||||
The podcast should be a dialogue between a Host and a Guest.
|
||||
- Host: Ask insightful questions and guide the conversation.
|
||||
- Guest: Provide expert answers and insights based on the context.
|
||||
- Tone: Professional yet conversational.
|
||||
- Length: Approximately 8-12 exchanges.
|
||||
- Language: ${targetLang}.
|
||||
|
||||
IMPORTANT: The dialogue MUST be spoken in separate ${targetLang} sentences. Even if the context is valid in another language, translate the concepts and discuss them in ${targetLang}.
|
||||
|
||||
Output the script as a valid JSON array of objects, where each object has "speaker" (Host/Guest) and "text" (the spoken content).
|
||||
Example:
|
||||
[
|
||||
// Example structure, ensure actual content is in ${targetLang}
|
||||
{"speaker": "Host", "text": "..."}
|
||||
]
|
||||
|
||||
Do not include markdown formatting like \`\`\`json. Just the raw JSON.
|
||||
`;
|
||||
|
||||
try {
|
||||
const rawContent = await this.chatService.generateSimpleChat(
|
||||
[{ role: 'user', content: prompt }],
|
||||
userId,
|
||||
);
|
||||
|
||||
// Clean up code blocks if present
|
||||
const jsonString = rawContent
|
||||
.replace(/```json/g, '')
|
||||
.replace(/```/g, '')
|
||||
.trim();
|
||||
|
||||
try {
|
||||
return JSON.parse(jsonString);
|
||||
} catch (e) {
|
||||
this.logger.error('Failed to parse podcast script JSON:', rawContent);
|
||||
throw new Error(this.i18nService.getMessage('scriptGenerationFailed'));
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to generate script:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async generateAudioInternal(
|
||||
script: any[],
|
||||
outputPath: string,
|
||||
language: string = 'zh',
|
||||
) {
|
||||
const { spawn } = await import('child_process');
|
||||
const writeStream = fs.createWriteStream(outputPath);
|
||||
|
||||
// Voice map
|
||||
const voices = {
|
||||
zh: { host: 'zh-CN-YunxiNeural', guest: 'zh-CN-XiaoxiaoNeural' },
|
||||
en: { host: 'en-US-AndrewNeural', guest: 'en-US-AvaNeural' },
|
||||
ja: { host: 'ja-JP-KeitaNeural', guest: 'ja-JP-NanamiNeural' },
|
||||
};
|
||||
|
||||
const voiceConfig = voices[language] || voices['zh'];
|
||||
|
||||
for (const line of script) {
|
||||
if (!line.text) continue;
|
||||
|
||||
// Voice selection
|
||||
const voice =
|
||||
line.speaker === 'Host' ? voiceConfig.host : voiceConfig.guest;
|
||||
const tempPath = path.join(this.outputDir, `temp_${uuidv4()}.mp3`);
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const process = spawn(this.pythonPath, [
|
||||
this.scriptPath,
|
||||
'--text',
|
||||
line.text,
|
||||
'--voice',
|
||||
voice,
|
||||
'--output',
|
||||
tempPath,
|
||||
]);
|
||||
|
||||
let errorOutput = '';
|
||||
|
||||
process.stderr.on('data', (data) => {
|
||||
errorOutput += data.toString();
|
||||
});
|
||||
|
||||
process.on('close', (code) => {
|
||||
if (code !== 0) {
|
||||
reject(
|
||||
new Error(
|
||||
`Python script exited with code ${code}: ${errorOutput}`,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (fs.existsSync(tempPath)) {
|
||||
const buffer = fs.readFileSync(tempPath);
|
||||
writeStream.write(buffer);
|
||||
fs.unlinkSync(tempPath);
|
||||
}
|
||||
} catch (e) {
|
||||
this.logger.error(`TTS Error for line: ${line.text}`, e);
|
||||
}
|
||||
}
|
||||
|
||||
writeStream.end();
|
||||
await new Promise((resolve, reject) => {
|
||||
writeStream.on('finish', resolve);
|
||||
writeStream.on('error', reject);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user