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:
Developer
2026-04-23 17:19:11 +08:00
commit 0a9588abb7
492 changed files with 112453 additions and 0 deletions
@@ -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;
}
+52
View File
@@ -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);
}
}
+18
View File
@@ -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 {}
+306
View File
@@ -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);
});
}
}