feat: improve report generation workflow
This commit is contained in:
@@ -7,8 +7,14 @@ import {
|
||||
type StudentProfileEntity
|
||||
} from '../entities/StudentProfileEntity'
|
||||
import { ClassEntitySchema, type ClassEntity } from '../entities/ClassEntity'
|
||||
import type { ChildProfile, ClassProfile, ListStudentProfilesInput } from '../types/student'
|
||||
import type {
|
||||
ChildProfile,
|
||||
ClassProfile,
|
||||
ListStudentProfilesInput,
|
||||
LoadStudentDataInput
|
||||
} from '../types/student'
|
||||
import { getAppDataSource } from './databaseService'
|
||||
import { isDataUrlImage, readImageAsDataUrl, storeImageValue } from './imageStorageService'
|
||||
import { getChatCompletionsUrl, logLargeModelRequest } from './modelService'
|
||||
import { loadSettings } from './settingsService'
|
||||
|
||||
@@ -29,16 +35,94 @@ function normalizeClassType(type: string): 'cheap' | 'noble' {
|
||||
return type === 'noble' ? 'noble' : 'cheap'
|
||||
}
|
||||
|
||||
function parseTeacherNames(value: string): string[] {
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed
|
||||
.filter((item): item is string => typeof item === 'string')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
} catch {
|
||||
// Older data may contain a single teacher name instead of a JSON array.
|
||||
}
|
||||
|
||||
return value.trim() ? [value.trim()] : []
|
||||
}
|
||||
|
||||
function stringifyTeacherNames(teacherNames: string[]): string {
|
||||
return JSON.stringify(
|
||||
teacherNames.map((teacherName) => teacherName.trim()).filter((teacherName) => teacherName)
|
||||
)
|
||||
}
|
||||
|
||||
async function normalizeStoredClassImages(entity: ClassEntity): Promise<ClassEntity> {
|
||||
if (!isDataUrlImage(entity.familyPhoto)) {
|
||||
return entity
|
||||
}
|
||||
|
||||
return {
|
||||
...entity,
|
||||
familyPhoto: await storeImageValue('classes', entity.id, 'familyPhoto', entity.familyPhoto)
|
||||
}
|
||||
}
|
||||
|
||||
async function hydrateClassImages(entity: ClassEntity): Promise<ClassEntity> {
|
||||
if (!entity.familyPhoto) {
|
||||
return entity
|
||||
}
|
||||
|
||||
return {
|
||||
...entity,
|
||||
familyPhoto: await readImageAsDataUrl(entity.familyPhoto)
|
||||
}
|
||||
}
|
||||
|
||||
async function normalizeStoredStudentImages(
|
||||
entity: StudentProfileEntity
|
||||
): Promise<StudentProfileEntity> {
|
||||
const [meImage, workImage1, workImage2] = await Promise.all([
|
||||
storeImageValue('students', entity.id, 'meImage', entity.meImage),
|
||||
storeImageValue('students', entity.id, 'workImage1', entity.workImage1),
|
||||
storeImageValue('students', entity.id, 'workImage2', entity.workImage2)
|
||||
])
|
||||
|
||||
return {
|
||||
...entity,
|
||||
meImage,
|
||||
workImage1,
|
||||
workImage2
|
||||
}
|
||||
}
|
||||
|
||||
async function hydrateStudentImages(entity: StudentProfileEntity): Promise<StudentProfileEntity> {
|
||||
const [meImage, workImage1, workImage2] = await Promise.all([
|
||||
readImageAsDataUrl(entity.meImage),
|
||||
readImageAsDataUrl(entity.workImage1),
|
||||
readImageAsDataUrl(entity.workImage2)
|
||||
])
|
||||
|
||||
return {
|
||||
...entity,
|
||||
meImage,
|
||||
workImage1,
|
||||
workImage2
|
||||
}
|
||||
}
|
||||
|
||||
function mapClassEntity(entity: ClassEntity): ClassProfile {
|
||||
return {
|
||||
id: entity.id,
|
||||
name: entity.name,
|
||||
type: normalizeClassType(entity.type),
|
||||
teacherNames: parseTeacherNames(entity.teacherNames || entity.teacherName || ''),
|
||||
familyPhoto: entity.familyPhoto ?? undefined
|
||||
}
|
||||
}
|
||||
|
||||
function mapChildEntity(entity: StudentProfileEntity): ChildProfile {
|
||||
function mapChildEntity(entity: StudentProfileEntity, options: { includeImages?: boolean } = {}): ChildProfile {
|
||||
return {
|
||||
id: entity.id,
|
||||
classId: entity.classId,
|
||||
@@ -55,21 +139,30 @@ function mapChildEntity(entity: StudentProfileEntity): ChildProfile {
|
||||
traits: entity.traits,
|
||||
comment: entity.comment ?? '',
|
||||
commentGeneratedAt: entity.commentGeneratedAt ?? '',
|
||||
reportGenerated: Boolean(entity.reportGenerated),
|
||||
meImage: options.includeImages === false ? '' : (entity.meImage ?? ''),
|
||||
workImage1: options.includeImages === false ? '' : (entity.workImage1 ?? ''),
|
||||
workImage2: options.includeImages === false ? '' : (entity.workImage2 ?? ''),
|
||||
importedAt: entity.importedAt
|
||||
}
|
||||
}
|
||||
|
||||
function mapClassProfile(profile: ClassProfile): ClassEntity {
|
||||
async function mapClassProfile(profile: ClassProfile): Promise<ClassEntity> {
|
||||
return {
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
type: profile.type,
|
||||
familyPhoto: profile.familyPhoto ?? null
|
||||
teacherName: (profile.teacherNames ?? []).join(' ') || null,
|
||||
teacherNames: stringifyTeacherNames(profile.teacherNames ?? []),
|
||||
familyPhoto: (await storeImageValue('classes', profile.id, 'familyPhoto', profile.familyPhoto)) || null
|
||||
}
|
||||
}
|
||||
|
||||
function mapChildProfile(profile: ChildProfile, classItem?: ClassProfile): StudentProfileEntity {
|
||||
return {
|
||||
async function mapChildProfile(
|
||||
profile: ChildProfile,
|
||||
classItem?: ClassProfile
|
||||
): Promise<StudentProfileEntity> {
|
||||
return normalizeStoredStudentImages({
|
||||
id: profile.id,
|
||||
classId: classItem?.id ?? profile.classId,
|
||||
className: classItem?.name ?? profile.className,
|
||||
@@ -85,8 +178,12 @@ function mapChildProfile(profile: ChildProfile, classItem?: ClassProfile): Stude
|
||||
traits: profile.traits,
|
||||
comment: profile.comment ?? '',
|
||||
commentGeneratedAt: profile.commentGeneratedAt ?? '',
|
||||
reportGenerated: profile.reportGenerated ?? false,
|
||||
meImage: profile.meImage ?? '',
|
||||
workImage1: profile.workImage1 ?? '',
|
||||
workImage2: profile.workImage2 ?? '',
|
||||
importedAt: profile.importedAt
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function buildStudentCommentPrompt(profile: ChildProfile): string {
|
||||
@@ -120,25 +217,60 @@ async function getRepositories(): Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadStudentData(): Promise<{
|
||||
export async function loadStudentData(input: LoadStudentDataInput = {}): Promise<{
|
||||
classes: ClassProfile[]
|
||||
profiles: ChildProfile[]
|
||||
}> {
|
||||
const includeProfiles = input.includeProfiles ?? true
|
||||
const includeImages = input.includeImages ?? true
|
||||
const { classRepository, childRepository } = await getRepositories()
|
||||
const classes = await classRepository.find({
|
||||
order: {
|
||||
id: 'ASC'
|
||||
}
|
||||
})
|
||||
const profiles = await childRepository.find({
|
||||
order: {
|
||||
id: 'ASC'
|
||||
const profiles = includeProfiles
|
||||
? await childRepository.find({
|
||||
order: {
|
||||
id: 'ASC'
|
||||
}
|
||||
})
|
||||
: []
|
||||
|
||||
const normalizedClasses = await Promise.all(classes.map(normalizeStoredClassImages))
|
||||
const normalizedProfiles = includeImages
|
||||
? await Promise.all(profiles.map(normalizeStoredStudentImages))
|
||||
: profiles
|
||||
const hydratedProfiles = includeImages
|
||||
? await Promise.all(normalizedProfiles.map(hydrateStudentImages))
|
||||
: normalizedProfiles
|
||||
|
||||
for (const normalizedClass of normalizedClasses) {
|
||||
const originalClass = classes.find((classItem) => classItem.id === normalizedClass.id)
|
||||
|
||||
if (originalClass && normalizedClass.familyPhoto !== originalClass.familyPhoto) {
|
||||
await classRepository.save(normalizedClass)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (includeImages) {
|
||||
for (const normalizedProfile of normalizedProfiles) {
|
||||
const originalProfile = profiles.find((profile) => profile.id === normalizedProfile.id)
|
||||
|
||||
if (
|
||||
originalProfile &&
|
||||
(normalizedProfile.meImage !== originalProfile.meImage ||
|
||||
normalizedProfile.workImage1 !== originalProfile.workImage1 ||
|
||||
normalizedProfile.workImage2 !== originalProfile.workImage2)
|
||||
) {
|
||||
await childRepository.save(normalizedProfile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
classes: classes.map(mapClassEntity),
|
||||
profiles: profiles.map(mapChildEntity)
|
||||
classes: (await Promise.all(normalizedClasses.map(hydrateClassImages))).map(mapClassEntity),
|
||||
profiles: hydratedProfiles.map((profile) => mapChildEntity(profile, { includeImages }))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,13 +320,35 @@ export async function listStudentProfiles(input: ListStudentProfilesInput): Prom
|
||||
.getManyAndCount()
|
||||
|
||||
return {
|
||||
profiles: profiles.map(mapChildEntity),
|
||||
profiles: profiles.map((profile) => mapChildEntity(profile, { includeImages: false })),
|
||||
total,
|
||||
page,
|
||||
pageSize
|
||||
}
|
||||
}
|
||||
|
||||
export async function getStudentProfile(profileId: string): Promise<ChildProfile> {
|
||||
const source = await getAppDataSource()
|
||||
const repository = source.getRepository<StudentProfileEntity>(StudentProfileEntitySchema)
|
||||
const profile = await repository.findOneBy({ id: profileId })
|
||||
|
||||
if (!profile) {
|
||||
throw new Error('学生信息不存在')
|
||||
}
|
||||
|
||||
const normalizedProfile = await normalizeStoredStudentImages(profile)
|
||||
|
||||
if (
|
||||
normalizedProfile.meImage !== profile.meImage ||
|
||||
normalizedProfile.workImage1 !== profile.workImage1 ||
|
||||
normalizedProfile.workImage2 !== profile.workImage2
|
||||
) {
|
||||
await repository.save(normalizedProfile)
|
||||
}
|
||||
|
||||
return mapChildEntity(await hydrateStudentImages(normalizedProfile))
|
||||
}
|
||||
|
||||
export async function updateStudentProfile(profile: ChildProfile): Promise<ChildProfile> {
|
||||
const source = await getAppDataSource()
|
||||
const repository = source.getRepository<StudentProfileEntity>(StudentProfileEntitySchema)
|
||||
@@ -210,8 +364,9 @@ export async function updateStudentProfile(profile: ChildProfile): Promise<Child
|
||||
commentGeneratedAt: profile.commentGeneratedAt ?? existingProfile.commentGeneratedAt ?? ''
|
||||
}
|
||||
|
||||
await repository.save(mapChildProfile(nextProfile))
|
||||
return nextProfile
|
||||
const storedProfile = await mapChildProfile(nextProfile)
|
||||
await repository.save(storedProfile)
|
||||
return mapChildEntity(await hydrateStudentImages(storedProfile))
|
||||
}
|
||||
|
||||
export async function generateStudentComment(profileId: string): Promise<ChildProfile> {
|
||||
@@ -291,8 +446,9 @@ export async function generateStudentComment(profileId: string): Promise<ChildPr
|
||||
commentGeneratedAt: new Date().toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
|
||||
await repository.save(mapChildProfile(nextProfile))
|
||||
return nextProfile
|
||||
const storedProfile = await mapChildProfile(nextProfile)
|
||||
await repository.save(storedProfile)
|
||||
return mapChildEntity(await hydrateStudentImages(storedProfile))
|
||||
}
|
||||
|
||||
export async function saveClasses(classes: ClassProfile[]): Promise<void> {
|
||||
@@ -312,7 +468,7 @@ export async function saveClasses(classes: ClassProfile[]): Promise<void> {
|
||||
}
|
||||
|
||||
for (const classItem of classes) {
|
||||
await classRepository.save(mapClassProfile(classItem))
|
||||
await classRepository.save(await mapClassProfile(classItem))
|
||||
await childRepository.update({ classId: classItem.id }, { className: classItem.name })
|
||||
}
|
||||
})
|
||||
@@ -334,8 +490,8 @@ export async function migrateLocalStudentData(
|
||||
return
|
||||
}
|
||||
|
||||
await classRepository.save(classes.map(mapClassProfile))
|
||||
await childRepository.save(profiles.map((profile) => mapChildProfile(profile)))
|
||||
await classRepository.save(await Promise.all(classes.map(mapClassProfile)))
|
||||
await childRepository.save(await Promise.all(profiles.map((profile) => mapChildProfile(profile))))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -349,7 +505,9 @@ export async function replaceProfilesForClass(
|
||||
const childRepository = manager.getRepository<StudentProfileEntity>(StudentProfileEntitySchema)
|
||||
|
||||
await childRepository.delete({ classId: classItem.id })
|
||||
await childRepository.save(profiles.map((profile) => mapChildProfile(profile, classItem)))
|
||||
await childRepository.save(
|
||||
await Promise.all(profiles.map((profile) => mapChildProfile(profile, classItem)))
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -372,3 +530,31 @@ export async function deleteStudentProfile(profileId: string): Promise<void> {
|
||||
throw new Error('学生信息不存在')
|
||||
}
|
||||
}
|
||||
|
||||
export async function migrateStoredImagesToFiles(): Promise<void> {
|
||||
const source = await getAppDataSource()
|
||||
const classRepository = source.getRepository<ClassEntity>(ClassEntitySchema)
|
||||
const childRepository = source.getRepository<StudentProfileEntity>(StudentProfileEntitySchema)
|
||||
const classes = await classRepository.find()
|
||||
const profiles = await childRepository.find()
|
||||
|
||||
for (const classItem of classes) {
|
||||
const nextClassItem = await normalizeStoredClassImages(classItem)
|
||||
|
||||
if (nextClassItem.familyPhoto !== classItem.familyPhoto) {
|
||||
await classRepository.save(nextClassItem)
|
||||
}
|
||||
}
|
||||
|
||||
for (const profile of profiles) {
|
||||
const nextProfile = await normalizeStoredStudentImages(profile)
|
||||
|
||||
if (
|
||||
nextProfile.meImage !== profile.meImage ||
|
||||
nextProfile.workImage1 !== profile.workImage1 ||
|
||||
nextProfile.workImage2 !== profile.workImage2
|
||||
) {
|
||||
await childRepository.save(nextProfile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user