add ai functions
Some checks reported errors
continuous-integration/drone/push Build encountered an error

This commit is contained in:
2026-01-15 01:59:16 +03:00
parent 739a7d172d
commit dea0676169
33 changed files with 4850 additions and 104 deletions

View File

@ -0,0 +1,41 @@
import { Controller, Post, Get, Delete, Body, Param } from '@nestjs/common';
import { AiService, EstimateResult, SpecificationResult, SpecificationHistoryItem } from './ai.service';
import { EstimateIdeaDto, GenerateSpecificationDto } from './dto';
@Controller('ai')
export class AiController {
constructor(private readonly aiService: AiService) {}
@Post('estimate')
async estimateIdea(@Body() dto: EstimateIdeaDto): Promise<EstimateResult> {
return this.aiService.estimateIdea(dto.ideaId);
}
@Post('generate-specification')
async generateSpecification(
@Body() dto: GenerateSpecificationDto,
): Promise<SpecificationResult> {
return this.aiService.generateSpecification(dto.ideaId);
}
@Get('specification-history/:ideaId')
async getSpecificationHistory(
@Param('ideaId') ideaId: string,
): Promise<SpecificationHistoryItem[]> {
return this.aiService.getSpecificationHistory(ideaId);
}
@Delete('specification-history/:historyId')
async deleteSpecificationHistoryItem(
@Param('historyId') historyId: string,
): Promise<void> {
return this.aiService.deleteSpecificationHistoryItem(historyId);
}
@Post('specification-history/:historyId/restore')
async restoreSpecificationFromHistory(
@Param('historyId') historyId: string,
): Promise<SpecificationResult> {
return this.aiService.restoreSpecificationFromHistory(historyId);
}
}

View File

@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AiController } from './ai.controller';
import { AiService } from './ai.service';
import { Idea } from '../ideas/entities/idea.entity';
import { TeamMember } from '../team/entities/team-member.entity';
import { SpecificationHistory } from '../ideas/entities/specification-history.entity';
import { Comment } from '../comments/entities/comment.entity';
@Module({
imports: [TypeOrmModule.forFeature([Idea, TeamMember, SpecificationHistory, Comment])],
controllers: [AiController],
providers: [AiService],
exports: [AiService],
})
export class AiModule {}

View File

@ -0,0 +1,398 @@
import { Injectable, HttpException, HttpStatus, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Idea } from '../ideas/entities/idea.entity';
import { TeamMember } from '../team/entities/team-member.entity';
import { SpecificationHistory } from '../ideas/entities/specification-history.entity';
import { Comment } from '../comments/entities/comment.entity';
export interface RoleEstimate {
role: string;
hours: number;
}
export interface EstimateResult {
ideaId: string;
ideaTitle: string;
totalHours: number;
complexity: 'trivial' | 'simple' | 'medium' | 'complex' | 'veryComplex';
breakdown: RoleEstimate[];
recommendations: string[];
estimatedAt: Date;
}
export interface SpecificationResult {
ideaId: string;
ideaTitle: string;
specification: string;
generatedAt: Date;
}
export interface SpecificationHistoryItem {
id: string;
specification: string;
ideaDescriptionSnapshot: string | null;
createdAt: Date;
}
@Injectable()
export class AiService {
private readonly logger = new Logger(AiService.name);
private readonly aiProxyBaseUrl: string;
private readonly aiProxyApiKey: string;
constructor(
private configService: ConfigService,
@InjectRepository(Idea)
private ideaRepository: Repository<Idea>,
@InjectRepository(TeamMember)
private teamMemberRepository: Repository<TeamMember>,
@InjectRepository(SpecificationHistory)
private specificationHistoryRepository: Repository<SpecificationHistory>,
@InjectRepository(Comment)
private commentRepository: Repository<Comment>,
) {
this.aiProxyBaseUrl = this.configService.get<string>(
'AI_PROXY_BASE_URL',
'http://ai-proxy-service.ai-proxy.svc.cluster.local:3000',
);
this.aiProxyApiKey = this.configService.get<string>('AI_PROXY_API_KEY', '');
}
async generateSpecification(ideaId: string): Promise<SpecificationResult> {
// Загружаем идею
const idea = await this.ideaRepository.findOne({ where: { id: ideaId } });
if (!idea) {
throw new HttpException('Идея не найдена', HttpStatus.NOT_FOUND);
}
// Загружаем комментарии к идее
const comments = await this.commentRepository.find({
where: { ideaId },
order: { createdAt: 'ASC' },
});
// Если уже есть ТЗ — сохраняем в историю
if (idea.specification) {
await this.specificationHistoryRepository.save({
ideaId: idea.id,
specification: idea.specification,
ideaDescriptionSnapshot: idea.description,
});
}
// Формируем промпт для генерации ТЗ
const prompt = this.buildSpecificationPrompt(idea, comments);
// Отправляем запрос к AI
const specification = await this.callAiProxy(prompt);
// Сохраняем ТЗ в идею
const generatedAt = new Date();
await this.ideaRepository.update(ideaId, {
specification,
specificationGeneratedAt: generatedAt,
});
return {
ideaId: idea.id,
ideaTitle: idea.title,
specification,
generatedAt,
};
}
async getSpecificationHistory(ideaId: string): Promise<SpecificationHistoryItem[]> {
const history = await this.specificationHistoryRepository.find({
where: { ideaId },
order: { createdAt: 'DESC' },
});
return history.map((item) => ({
id: item.id,
specification: item.specification,
ideaDescriptionSnapshot: item.ideaDescriptionSnapshot,
createdAt: item.createdAt,
}));
}
async deleteSpecificationHistoryItem(historyId: string): Promise<void> {
const result = await this.specificationHistoryRepository.delete(historyId);
if (result.affected === 0) {
throw new HttpException('Запись истории не найдена', HttpStatus.NOT_FOUND);
}
}
async restoreSpecificationFromHistory(historyId: string): Promise<SpecificationResult> {
const historyItem = await this.specificationHistoryRepository.findOne({
where: { id: historyId },
relations: ['idea'],
});
if (!historyItem) {
throw new HttpException('Запись истории не найдена', HttpStatus.NOT_FOUND);
}
const idea = historyItem.idea;
// Сохраняем текущее ТЗ в историю (если есть)
if (idea.specification) {
await this.specificationHistoryRepository.save({
ideaId: idea.id,
specification: idea.specification,
ideaDescriptionSnapshot: idea.description,
});
}
// Восстанавливаем ТЗ из истории
const generatedAt = new Date();
await this.ideaRepository.update(idea.id, {
specification: historyItem.specification,
specificationGeneratedAt: generatedAt,
});
// Удаляем восстановленную запись из истории
await this.specificationHistoryRepository.delete(historyId);
return {
ideaId: idea.id,
ideaTitle: idea.title,
specification: historyItem.specification,
generatedAt,
};
}
async estimateIdea(ideaId: string): Promise<EstimateResult> {
// Загружаем идею
const idea = await this.ideaRepository.findOne({ where: { id: ideaId } });
if (!idea) {
throw new HttpException('Идея не найдена', HttpStatus.NOT_FOUND);
}
// Загружаем комментарии к идее
const comments = await this.commentRepository.find({
where: { ideaId },
order: { createdAt: 'ASC' },
});
// Загружаем состав команды
const teamMembers = await this.teamMemberRepository.find({
relations: ['role'],
});
// Формируем промпт
const prompt = this.buildPrompt(idea, teamMembers, comments);
// Отправляем запрос к AI
const aiResponse = await this.callAiProxy(prompt);
// Парсим ответ
const result = this.parseAiResponse(aiResponse, idea);
// Сохраняем оценку в идею
await this.ideaRepository.update(ideaId, {
estimatedHours: result.totalHours,
complexity: result.complexity,
estimateDetails: { breakdown: result.breakdown, recommendations: result.recommendations },
estimatedAt: result.estimatedAt,
});
return result;
}
private buildPrompt(idea: Idea, teamMembers: TeamMember[], comments: Comment[]): string {
const teamInfo = teamMembers
.map((m) => {
const prod = m.productivity;
return `- ${m.name} (${m.role.name}): производительность — trivial: ${prod.trivial}ч, simple: ${prod.simple}ч, medium: ${prod.medium}ч, complex: ${prod.complex}ч, veryComplex: ${prod.veryComplex}ч`;
})
.join('\n');
const rolesSummary = this.getRolesSummary(teamMembers);
const commentsSection = comments.length > 0
? `## Комментарии к идее
${comments.map((c, i) => `${i + 1}. ${c.author ? `**${c.author}:** ` : ''}${c.text}`).join('\n')}
`
: '';
return `Ты — эксперт по оценке трудозатрат разработки программного обеспечения.
## Задача
Оцени трудозатраты на реализацию следующей идеи с учётом состава команды.
## Идея
- **Название:** ${idea.title}
- **Описание:** ${idea.description || 'Не указано'}
- **Модуль:** ${idea.module || 'Не указан'}
- **Целевая аудитория:** ${idea.targetAudience || 'Не указана'}
- **Боль/Проблема:** ${idea.pain || 'Не указана'}
- **Роль AI:** ${idea.aiRole || 'Не указана'}
- **Способ проверки:** ${idea.verificationMethod || 'Не указан'}
- **Приоритет:** ${idea.priority}
## Техническое задание (ТЗ)
${idea.specification || 'Не указано'}
${commentsSection}## Состав команды
${teamInfo || 'Команда не указана'}
## Роли в команде
${rolesSummary}
## Требуемый формат ответа (СТРОГО JSON)
Верни ТОЛЬКО JSON без markdown-разметки:
{
"totalHours": <число — общее количество часов>,
"complexity": "<одно из: trivial, simple, medium, complex, veryComplex>",
"breakdown": [
{"role": "<название роли>", "hours": <число>}
],
"recommendations": ["<рекомендация 1>", "<рекомендация 2>"]
}
Учитывай реальную производительность каждого члена команды при оценке. Обязательно учти информацию из комментариев — там могут быть важные уточнения и особенности.`;
}
private getRolesSummary(teamMembers: TeamMember[]): string {
const rolesMap = new Map<string, number>();
for (const member of teamMembers) {
const roleName = member.role.name;
rolesMap.set(roleName, (rolesMap.get(roleName) || 0) + 1);
}
return Array.from(rolesMap.entries())
.map(([role, count]) => `- ${role}: ${count} чел.`)
.join('\n');
}
private buildSpecificationPrompt(idea: Idea, comments: Comment[]): string {
const commentsSection = comments.length > 0
? `## Комментарии к идее
${comments.map((c, i) => `${i + 1}. ${c.author ? `**${c.author}:** ` : ''}${c.text}`).join('\n')}
`
: '';
return `Ты — опытный бизнес-аналитик и технический писатель.
## Задача
Составь краткое техническое задание (мини-ТЗ) для следующей идеи. ТЗ должно быть достаточно детальным для оценки трудозатрат и понимания scope работ.
## Идея
- **Название:** ${idea.title}
- **Описание:** ${idea.description || 'Не указано'}
- **Модуль:** ${idea.module || 'Не указан'}
- **Целевая аудитория:** ${idea.targetAudience || 'Не указана'}
- **Боль/Проблема:** ${idea.pain || 'Не указана'}
- **Роль AI:** ${idea.aiRole || 'Не указана'}
- **Способ проверки:** ${idea.verificationMethod || 'Не указан'}
- **Приоритет:** ${idea.priority}
${commentsSection}## Требования к ТЗ
Мини-ТЗ должно содержать:
1. **Цель** — что должно быть достигнуто
2. **Функциональные требования** — основные функции (3-7 пунктов)
3. **Нефункциональные требования** — если применимо (производительность, безопасность)
4. **Критерии приёмки** — как понять что задача выполнена
5. **Ограничения и допущения** — что не входит в scope
**Важно:** Обязательно учти информацию из комментариев при составлении ТЗ — там могут быть важные уточнения, требования и особенности реализации.
## Формат ответа
Напиши ТЗ в формате Markdown. Будь конкретен, избегай общих фраз. Объём: 200-400 слов.`;
}
private async callAiProxy(prompt: string): Promise<string> {
if (!this.aiProxyApiKey) {
throw new HttpException(
'AI_PROXY_API_KEY не настроен',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
try {
const response = await fetch(
`${this.aiProxyBaseUrl}/api/v1/chat/completions`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': this.aiProxyApiKey,
},
body: JSON.stringify({
model: 'claude-3.7-sonnet',
messages: [
{
role: 'user',
content: prompt,
},
],
temperature: 0.3,
max_tokens: 1000,
}),
},
);
if (!response.ok) {
const errorText = await response.text();
this.logger.error(`AI Proxy error: ${response.status} - ${errorText}`);
throw new HttpException(
'Ошибка при запросе к AI сервису',
HttpStatus.BAD_GATEWAY,
);
}
const data = await response.json();
return data.choices[0].message.content;
} catch (error) {
if (error instanceof HttpException) {
throw error;
}
this.logger.error(`AI Proxy call failed: ${error.message}`);
throw new HttpException(
'Не удалось подключиться к AI сервису',
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
private parseAiResponse(aiResponse: string, idea: Idea): EstimateResult {
try {
// Удаляем возможную markdown-разметку
let cleanJson = aiResponse.trim();
if (cleanJson.startsWith('```json')) {
cleanJson = cleanJson.slice(7);
}
if (cleanJson.startsWith('```')) {
cleanJson = cleanJson.slice(3);
}
if (cleanJson.endsWith('```')) {
cleanJson = cleanJson.slice(0, -3);
}
cleanJson = cleanJson.trim();
const parsed = JSON.parse(cleanJson);
return {
ideaId: idea.id,
ideaTitle: idea.title,
totalHours: Number(parsed.totalHours) || 0,
complexity: parsed.complexity || 'medium',
breakdown: Array.isArray(parsed.breakdown) ? parsed.breakdown : [],
recommendations: Array.isArray(parsed.recommendations)
? parsed.recommendations
: [],
estimatedAt: new Date(),
};
} catch (error) {
this.logger.error(`Failed to parse AI response: ${aiResponse}`);
throw new HttpException(
'Не удалось разобрать ответ AI',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
}

View File

@ -0,0 +1,6 @@
import { IsUUID } from 'class-validator';
export class EstimateIdeaDto {
@IsUUID()
ideaId: string;
}

View File

@ -0,0 +1,6 @@
import { IsUUID } from 'class-validator';
export class GenerateSpecificationDto {
@IsUUID()
ideaId: string;
}

View File

@ -0,0 +1,2 @@
export * from './estimate-idea.dto';
export * from './generate-specification.dto';

View File

@ -8,6 +8,7 @@ import { IdeasModule } from './ideas/ideas.module';
import { CommentsModule } from './comments/comments.module';
import { TeamModule } from './team/team.module';
import { AuthModule, JwtAuthGuard } from './auth';
import { AiModule } from './ai/ai.module';
@Module({
imports: [
@ -34,6 +35,7 @@ import { AuthModule, JwtAuthGuard } from './auth';
IdeasModule,
CommentsModule,
TeamModule,
AiModule,
],
controllers: [AppController],
providers: [

View File

@ -1,5 +1,5 @@
import { PartialType } from '@nestjs/mapped-types';
import { IsOptional, IsInt, Min } from 'class-validator';
import { IsOptional, IsInt, Min, IsString } from 'class-validator';
import { CreateIdeaDto } from './create-idea.dto';
export class UpdateIdeaDto extends PartialType(CreateIdeaDto) {
@ -7,4 +7,8 @@ export class UpdateIdeaDto extends PartialType(CreateIdeaDto) {
@IsInt()
@Min(0)
order?: number;
@IsOptional()
@IsString()
specification?: string;
}

View File

@ -72,6 +72,26 @@ export class Idea {
@Column({ type: 'int', default: 0 })
order: number;
// AI-оценка
@Column({ name: 'estimated_hours', type: 'decimal', precision: 10, scale: 2, nullable: true })
estimatedHours: number | null;
@Column({ type: 'varchar', length: 20, nullable: true })
complexity: string | null;
@Column({ name: 'estimate_details', type: 'jsonb', nullable: true })
estimateDetails: Record<string, unknown> | null;
@Column({ name: 'estimated_at', type: 'timestamp', nullable: true })
estimatedAt: Date | null;
// Мини-ТЗ
@Column({ type: 'text', nullable: true })
specification: string | null;
@Column({ name: 'specification_generated_at', type: 'timestamp', nullable: true })
specificationGeneratedAt: Date | null;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;

View File

@ -0,0 +1,31 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Idea } from './idea.entity';
@Entity('specification_history')
export class SpecificationHistory {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'idea_id' })
ideaId: string;
@ManyToOne(() => Idea, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'idea_id' })
idea: Idea;
@Column({ type: 'text' })
specification: string;
@Column({ name: 'idea_description_snapshot', type: 'text', nullable: true })
ideaDescriptionSnapshot: string | null;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
}

View File

@ -3,11 +3,12 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { IdeasService } from './ideas.service';
import { IdeasController } from './ideas.controller';
import { Idea } from './entities/idea.entity';
import { SpecificationHistory } from './entities/specification-history.entity';
@Module({
imports: [TypeOrmModule.forFeature([Idea])],
imports: [TypeOrmModule.forFeature([Idea, SpecificationHistory])],
controllers: [IdeasController],
providers: [IdeasService],
exports: [IdeasService],
exports: [IdeasService, TypeOrmModule],
})
export class IdeasModule {}

View File

@ -0,0 +1,25 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddAiEstimateFields1736899500000 implements MigrationInterface {
name = 'AddAiEstimateFields1736899500000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "ideas"
ADD COLUMN "estimated_hours" DECIMAL(10, 2),
ADD COLUMN "complexity" VARCHAR(20),
ADD COLUMN "estimate_details" JSONB,
ADD COLUMN "estimated_at" TIMESTAMP
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "ideas"
DROP COLUMN "estimated_at",
DROP COLUMN "estimate_details",
DROP COLUMN "complexity",
DROP COLUMN "estimated_hours"
`);
}
}

View File

@ -0,0 +1,21 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddSpecificationField1736942400000 implements MigrationInterface {
name = 'AddSpecificationField1736942400000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "ideas"
ADD COLUMN "specification" TEXT,
ADD COLUMN "specification_generated_at" TIMESTAMP
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "ideas"
DROP COLUMN "specification_generated_at",
DROP COLUMN "specification"
`);
}
}

View File

@ -0,0 +1,29 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddSpecificationHistory1736943000000 implements MigrationInterface {
name = 'AddSpecificationHistory1736943000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE "specification_history" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"idea_id" uuid NOT NULL,
"specification" text NOT NULL,
"idea_description_snapshot" text,
"created_at" TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT "PK_specification_history" PRIMARY KEY ("id"),
CONSTRAINT "FK_specification_history_idea" FOREIGN KEY ("idea_id")
REFERENCES "ideas"("id") ON DELETE CASCADE ON UPDATE NO ACTION
)
`);
await queryRunner.query(`
CREATE INDEX "IDX_specification_history_idea_id" ON "specification_history" ("idea_id")
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX "IDX_specification_history_idea_id"`);
await queryRunner.query(`DROP TABLE "specification_history"`);
}
}