189 lines
6.3 KiB
TypeScript
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);
|
|
}
|
|
}
|