This commit is contained in:
@ -1,5 +1,10 @@
|
||||
import { Controller, Post, Get, Delete, Body, Param } from '@nestjs/common';
|
||||
import { AiService, EstimateResult, SpecificationResult, SpecificationHistoryItem } from './ai.service';
|
||||
import {
|
||||
AiService,
|
||||
EstimateResult,
|
||||
SpecificationResult,
|
||||
SpecificationHistoryItem,
|
||||
} from './ai.service';
|
||||
import { EstimateIdeaDto, GenerateSpecificationDto } from './dto';
|
||||
|
||||
@Controller('ai')
|
||||
|
||||
@ -8,7 +8,9 @@ import { SpecificationHistory } from '../ideas/entities/specification-history.en
|
||||
import { Comment } from '../comments/entities/comment.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Idea, TeamMember, SpecificationHistory, Comment])],
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Idea, TeamMember, SpecificationHistory, Comment]),
|
||||
],
|
||||
controllers: [AiController],
|
||||
providers: [AiService],
|
||||
exports: [AiService],
|
||||
|
||||
@ -36,6 +36,21 @@ export interface SpecificationHistoryItem {
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
interface AiProxyResponse {
|
||||
choices: {
|
||||
message: {
|
||||
content: string;
|
||||
};
|
||||
}[];
|
||||
}
|
||||
|
||||
interface ParsedEstimate {
|
||||
totalHours?: number;
|
||||
complexity?: string;
|
||||
breakdown?: RoleEstimate[];
|
||||
recommendations?: string[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AiService {
|
||||
private readonly logger = new Logger(AiService.name);
|
||||
@ -103,7 +118,9 @@ export class AiService {
|
||||
};
|
||||
}
|
||||
|
||||
async getSpecificationHistory(ideaId: string): Promise<SpecificationHistoryItem[]> {
|
||||
async getSpecificationHistory(
|
||||
ideaId: string,
|
||||
): Promise<SpecificationHistoryItem[]> {
|
||||
const history = await this.specificationHistoryRepository.find({
|
||||
where: { ideaId },
|
||||
order: { createdAt: 'DESC' },
|
||||
@ -120,18 +137,26 @@ export class AiService {
|
||||
async deleteSpecificationHistoryItem(historyId: string): Promise<void> {
|
||||
const result = await this.specificationHistoryRepository.delete(historyId);
|
||||
if (result.affected === 0) {
|
||||
throw new HttpException('Запись истории не найдена', HttpStatus.NOT_FOUND);
|
||||
throw new HttpException(
|
||||
'Запись истории не найдена',
|
||||
HttpStatus.NOT_FOUND,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async restoreSpecificationFromHistory(historyId: string): Promise<SpecificationResult> {
|
||||
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);
|
||||
throw new HttpException(
|
||||
'Запись истории не найдена',
|
||||
HttpStatus.NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
const idea = historyItem.idea;
|
||||
@ -194,14 +219,21 @@ export class AiService {
|
||||
await this.ideaRepository.update(ideaId, {
|
||||
estimatedHours: result.totalHours,
|
||||
complexity: result.complexity,
|
||||
estimateDetails: { breakdown: result.breakdown, recommendations: result.recommendations },
|
||||
estimateDetails: {
|
||||
breakdown: result.breakdown,
|
||||
recommendations: result.recommendations,
|
||||
},
|
||||
estimatedAt: result.estimatedAt,
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private buildPrompt(idea: Idea, teamMembers: TeamMember[], comments: Comment[]): string {
|
||||
private buildPrompt(
|
||||
idea: Idea,
|
||||
teamMembers: TeamMember[],
|
||||
comments: Comment[],
|
||||
): string {
|
||||
const teamInfo = teamMembers
|
||||
.map((m) => {
|
||||
const prod = m.productivity;
|
||||
@ -211,12 +243,13 @@ export class AiService {
|
||||
|
||||
const rolesSummary = this.getRolesSummary(teamMembers);
|
||||
|
||||
const commentsSection = comments.length > 0
|
||||
? `## Комментарии к идее
|
||||
const commentsSection =
|
||||
comments.length > 0
|
||||
? `## Комментарии к идее
|
||||
${comments.map((c, i) => `${i + 1}. ${c.author ? `**${c.author}:** ` : ''}${c.text}`).join('\n')}
|
||||
|
||||
`
|
||||
: '';
|
||||
: '';
|
||||
|
||||
return `Ты — эксперт по оценке трудозатрат разработки программного обеспечения.
|
||||
|
||||
@ -269,12 +302,13 @@ ${rolesSummary}
|
||||
}
|
||||
|
||||
private buildSpecificationPrompt(idea: Idea, comments: Comment[]): string {
|
||||
const commentsSection = comments.length > 0
|
||||
? `## Комментарии к идее
|
||||
const commentsSection =
|
||||
comments.length > 0
|
||||
? `## Комментарии к идее
|
||||
${comments.map((c, i) => `${i + 1}. ${c.author ? `**${c.author}:** ` : ''}${c.text}`).join('\n')}
|
||||
|
||||
`
|
||||
: '';
|
||||
: '';
|
||||
|
||||
return `Ты — опытный бизнес-аналитик и технический писатель.
|
||||
|
||||
@ -345,13 +379,14 @@ ${commentsSection}## Требования к ТЗ
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const data = (await response.json()) as AiProxyResponse;
|
||||
return data.choices[0].message.content;
|
||||
} catch (error) {
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof HttpException) {
|
||||
throw error;
|
||||
}
|
||||
this.logger.error(`AI Proxy call failed: ${error.message}`);
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
this.logger.error(`AI Proxy call failed: ${message}`);
|
||||
throw new HttpException(
|
||||
'Не удалось подключиться к AI сервису',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
@ -374,20 +409,33 @@ ${commentsSection}## Требования к ТЗ
|
||||
}
|
||||
cleanJson = cleanJson.trim();
|
||||
|
||||
const parsed = JSON.parse(cleanJson);
|
||||
const parsed = JSON.parse(cleanJson) as ParsedEstimate;
|
||||
|
||||
const validComplexities = [
|
||||
'trivial',
|
||||
'simple',
|
||||
'medium',
|
||||
'complex',
|
||||
'veryComplex',
|
||||
] as const;
|
||||
const complexity = validComplexities.includes(
|
||||
parsed.complexity as (typeof validComplexities)[number],
|
||||
)
|
||||
? (parsed.complexity as EstimateResult['complexity'])
|
||||
: 'medium';
|
||||
|
||||
return {
|
||||
ideaId: idea.id,
|
||||
ideaTitle: idea.title,
|
||||
totalHours: Number(parsed.totalHours) || 0,
|
||||
complexity: parsed.complexity || 'medium',
|
||||
complexity,
|
||||
breakdown: Array.isArray(parsed.breakdown) ? parsed.breakdown : [],
|
||||
recommendations: Array.isArray(parsed.recommendations)
|
||||
? parsed.recommendations
|
||||
: [],
|
||||
estimatedAt: new Date(),
|
||||
};
|
||||
} catch (error) {
|
||||
} catch {
|
||||
this.logger.error(`Failed to parse AI response: ${aiResponse}`);
|
||||
throw new HttpException(
|
||||
'Не удалось разобрать ответ AI',
|
||||
|
||||
@ -18,7 +18,10 @@ export class CommentsService {
|
||||
});
|
||||
}
|
||||
|
||||
async create(ideaId: string, createCommentDto: CreateCommentDto): Promise<Comment> {
|
||||
async create(
|
||||
ideaId: string,
|
||||
createCommentDto: CreateCommentDto,
|
||||
): Promise<Comment> {
|
||||
const comment = this.commentsRepository.create({
|
||||
...createCommentDto,
|
||||
ideaId,
|
||||
|
||||
@ -73,7 +73,13 @@ export class Idea {
|
||||
order: number;
|
||||
|
||||
// AI-оценка
|
||||
@Column({ name: 'estimated_hours', type: 'decimal', precision: 10, scale: 2, nullable: true })
|
||||
@Column({
|
||||
name: 'estimated_hours',
|
||||
type: 'decimal',
|
||||
precision: 10,
|
||||
scale: 2,
|
||||
nullable: true,
|
||||
})
|
||||
estimatedHours: number | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 20, nullable: true })
|
||||
@ -89,7 +95,11 @@ export class Idea {
|
||||
@Column({ type: 'text', nullable: true })
|
||||
specification: string | null;
|
||||
|
||||
@Column({ name: 'specification_generated_at', type: 'timestamp', nullable: true })
|
||||
@Column({
|
||||
name: 'specification_generated_at',
|
||||
type: 'timestamp',
|
||||
nullable: true,
|
||||
})
|
||||
specificationGeneratedAt: Date | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
|
||||
@ -16,7 +16,9 @@ export class CreateCommentsTable1736899200000 implements MigrationInterface {
|
||||
CONSTRAINT "FK_comments_idea_id" FOREIGN KEY ("idea_id") REFERENCES "ideas"("id") ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_comments_idea_id" ON "comments" ("idea_id")`);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_comments_idea_id" ON "comments" ("idea_id")`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
|
||||
@ -84,7 +84,9 @@ export class CreateRolesTable1736899400000 implements MigrationInterface {
|
||||
`);
|
||||
|
||||
// 5. Удаляем foreign key и role_id
|
||||
await queryRunner.query(`ALTER TABLE "team_members" DROP CONSTRAINT "FK_team_members_role"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "team_members" DROP CONSTRAINT "FK_team_members_role"`,
|
||||
);
|
||||
await queryRunner.query(`ALTER TABLE "team_members" DROP COLUMN "role_id"`);
|
||||
|
||||
// 6. Удаляем таблицу roles
|
||||
|
||||
@ -11,10 +11,10 @@ import { Role } from './role.entity';
|
||||
|
||||
// Матрица производительности: время в часах на задачи разной сложности
|
||||
export interface ProductivityMatrix {
|
||||
trivial: number; // < 1 часа
|
||||
simple: number; // 1-4 часа
|
||||
medium: number; // 4-16 часов
|
||||
complex: number; // 16-40 часов
|
||||
trivial: number; // < 1 часа
|
||||
simple: number; // 1-4 часа
|
||||
medium: number; // 4-16 часов
|
||||
complex: number; // 16-40 часов
|
||||
veryComplex: number; // > 40 часов
|
||||
}
|
||||
|
||||
@ -33,7 +33,16 @@ export class TeamMember {
|
||||
@Column({ name: 'role_id', type: 'uuid' })
|
||||
roleId: string;
|
||||
|
||||
@Column({ type: 'jsonb', default: { trivial: 1, simple: 4, medium: 12, complex: 32, veryComplex: 60 } })
|
||||
@Column({
|
||||
type: 'jsonb',
|
||||
default: {
|
||||
trivial: 1,
|
||||
simple: 4,
|
||||
medium: 12,
|
||||
complex: 32,
|
||||
veryComplex: 60,
|
||||
},
|
||||
})
|
||||
productivity: ProductivityMatrix;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
|
||||
@ -1,4 +1,8 @@
|
||||
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ConflictException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Role } from './entities/role.entity';
|
||||
@ -31,7 +35,9 @@ export class RolesService {
|
||||
where: { name: createDto.name },
|
||||
});
|
||||
if (existing) {
|
||||
throw new ConflictException(`Role with name "${createDto.name}" already exists`);
|
||||
throw new ConflictException(
|
||||
`Role with name "${createDto.name}" already exists`,
|
||||
);
|
||||
}
|
||||
|
||||
const maxSortOrder = await this.roleRepository
|
||||
@ -54,7 +60,9 @@ export class RolesService {
|
||||
where: { name: updateDto.name },
|
||||
});
|
||||
if (existing) {
|
||||
throw new ConflictException(`Role with name "${updateDto.name}" already exists`);
|
||||
throw new ConflictException(
|
||||
`Role with name "${updateDto.name}" already exists`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -39,7 +39,9 @@ export class TeamService {
|
||||
where: { id: createDto.roleId },
|
||||
});
|
||||
if (!role) {
|
||||
throw new NotFoundException(`Role with ID "${createDto.roleId}" not found`);
|
||||
throw new NotFoundException(
|
||||
`Role with ID "${createDto.roleId}" not found`,
|
||||
);
|
||||
}
|
||||
|
||||
const member = this.teamRepository.create(createDto);
|
||||
@ -47,7 +49,10 @@ export class TeamService {
|
||||
return this.findOne(saved.id);
|
||||
}
|
||||
|
||||
async update(id: string, updateDto: UpdateTeamMemberDto): Promise<TeamMember> {
|
||||
async update(
|
||||
id: string,
|
||||
updateDto: UpdateTeamMemberDto,
|
||||
): Promise<TeamMember> {
|
||||
const member = await this.findOne(id);
|
||||
|
||||
if (updateDto.roleId) {
|
||||
@ -55,7 +60,9 @@ export class TeamService {
|
||||
where: { id: updateDto.roleId },
|
||||
});
|
||||
if (!role) {
|
||||
throw new NotFoundException(`Role with ID "${updateDto.roleId}" not found`);
|
||||
throw new NotFoundException(
|
||||
`Role with ID "${updateDto.roleId}" not found`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -69,7 +76,9 @@ export class TeamService {
|
||||
await this.teamRepository.remove(member);
|
||||
}
|
||||
|
||||
async getSummary(): Promise<{ roleId: string; label: string; count: number }[]> {
|
||||
async getSummary(): Promise<
|
||||
{ roleId: string; label: string; count: number }[]
|
||||
> {
|
||||
// Получаем все роли
|
||||
const roles = await this.roleRepository.find({
|
||||
order: { sortOrder: 'ASC' },
|
||||
@ -87,7 +96,10 @@ export class TeamService {
|
||||
return roles.map((role) => ({
|
||||
roleId: role.id,
|
||||
label: role.label,
|
||||
count: parseInt(result.find((r) => r.roleId === role.id)?.count ?? '0', 10),
|
||||
count: parseInt(
|
||||
result.find((r) => r.roleId === role.id)?.count ?? '0',
|
||||
10,
|
||||
),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user