Files
gongxue-base/apps/server/src/ai-chat/ai-review.service.ts

189 lines
6.3 KiB
TypeScript

// aislop-ignore-file: duplicate-block -- 导入校验循环结构相似,逻辑已复用现有助手
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, In, Repository } from 'typeorm';
import { uuidV7 } from '../common/uuid-v7';
import { AiReview } from './entities/ai-review.entity';
import type {
AiReviewSection,
AiReviewSectionType,
} from './entities/ai-review.entity';
import type { ExcelSheetRows } from './ai-excel-reader.service';
import {
AiReviewStepSubmitResult,
AiReviewSubmitResult,
MAX_SECTIONS_JSON_BYTES,
withInitialSectionState,
} from './ai-review.shared';
import { buildSectionsFromWorkbookAsync, parseSections } from './ai-review.workbook';
import { validateSchema } from './ai-review.validation';
import { enrichWithIssues } from './ai-review.enrich';
import {
submitAll,
submitGroup,
submitSection,
} from './ai-review.submit';
import type { AiReviewSubmitContext } from './ai-review.submit';
/**
* A2UI batch-import review lifecycle.
*
* The model parses an uploaded workbook (students / rooms / transfers),
* calls `render_review`, and the user inspects per-table preview cards
* before confirming. Confirmation runs each section in its own
* transaction in dependency order: students → rooms → transfers →
* checkins. Per-row problems are collected as issues and the row is
* skipped instead of failing the whole import.
*/
@Injectable()
export class AiReviewService {
constructor(
@InjectRepository(AiReview)
private readonly reviews: Repository<AiReview>,
private readonly dataSource: DataSource,
) {}
private get submitContext(): AiReviewSubmitContext {
return { reviews: this.reviews, dataSource: this.dataSource };
}
/**
* Validate `render_review` arguments and persist a pending review.
* Throws BadRequestException when the schema is unsafe/invalid.
*/
async createReview(
input: { userId: number; conversationId: number; assistantMessageId: number },
rawArgs: unknown,
): Promise<AiReview> {
const schema = validateSchema(rawArgs);
const sections = (await enrichWithIssues(this.dataSource, schema.sections)).map(
withInitialSectionState,
);
const sectionsJson = JSON.stringify(sections);
if (Buffer.byteLength(sectionsJson, 'utf8') > MAX_SECTIONS_JSON_BYTES) {
throw new BadRequestException('预览数据过大');
}
return this.reviews.save(
this.reviews.create({
id: uuidV7(),
userId: input.userId,
conversationId: input.conversationId,
assistantMessageId: input.assistantMessageId,
title: schema.title,
summary: schema.summary,
sectionsJson,
status: 'pending',
resultSummary: null,
submittedAt: null,
}),
);
}
async findOwnedPending(reviewId: string, userId: number): Promise<AiReview> {
const review = await this.reviews.findOne({
where: { id: reviewId, userId, status: 'pending' },
});
if (!review) throw new NotFoundException('导入预览不存在、已确认或已失效');
return review;
}
/**
* Mark every other pending review in the same conversation as expired.
* Called after a new render_review is successfully created so older cards
* are superseded instead of silently staying confirmable.
*/
async expirePreviousReviews(
userId: number,
conversationId: number,
exceptReviewId: string,
): Promise<AiReview[]> {
const pending = await this.reviews.find({
where: { userId, conversationId, status: 'pending' },
});
const expired = pending.filter((review) => review.id !== exceptReviewId);
if (expired.length === 0) return [];
const ids = expired.map((review) => review.id);
await this.reviews.update({ id: In(ids) }, { status: 'expired' });
return expired.map((review) => ({ ...review, status: 'expired' as const }));
}
/**
* Return a review owned by the user regardless of overall status.
* Used by step confirmation so an already-completed card can respond
* with a conflict instead of a plain not-found error.
*/
async findOwned(reviewId: string, userId: number): Promise<AiReview> {
const review = await this.reviews.findOne({
where: { id: reviewId, userId },
});
if (!review) throw new NotFoundException('导入预览不存在');
return review;
}
/**
* Return the newest pending review bound to an assistant message, if any.
* Used to guarantee at most one batch-import preview card per message.
*/
async findPendingByAssistantMessage(assistantMessageId: number): Promise<AiReview | null> {
return this.reviews.findOne({
where: { assistantMessageId, status: 'pending' },
order: { createdAt: 'DESC' },
});
}
/**
* 服务端直接解析上传的 Excel 生成审阅分表:行数据来自文件原文,
* 不经过模型转抄,避免漏行/错值。表头按内置字典自动映射,
* 模型可通过 sections[].columns[].sourceHeader 显式指定映射。
*/
async buildSectionsFromWorkbook(
sheets: ExcelSheetRows[],
rawArgs: unknown,
): Promise<AiReviewSection[]> {
return await buildSectionsFromWorkbookAsync(sheets, rawArgs);
}
/** Public shape sent via `ui.review` SSE and mirrored into message metadata. */
serialize(review: AiReview): Record<string, unknown> {
return {
id: review.id,
title: review.title,
summary: review.summary,
sections: this.parseSections(review.sectionsJson),
status: review.status,
resultSummary: review.resultSummary,
};
}
parseSections(sectionsJson: string): AiReviewSection[] {
return parseSections(sectionsJson);
}
async submitSection(
reviewId: string,
userId: number,
sectionKey: string,
): Promise<AiReviewStepSubmitResult> {
return submitSection(this.submitContext, reviewId, userId, sectionKey);
}
async submitAll(reviewId: string, userId: number): Promise<{
review: AiReview;
result: AiReviewSubmitResult;
}> {
return submitAll(this.submitContext, reviewId, userId);
}
async submitGroup(
reviewId: string,
userId: number,
type: AiReviewSectionType,
): Promise<{ review: AiReview }> {
return submitGroup(this.submitContext, reviewId, userId, type);
}
}