feat: improve report generation workflow

This commit is contained in:
2026-06-26 00:11:29 +08:00
parent 1c5841833f
commit 0b2a8b0190
27 changed files with 2662 additions and 717 deletions
+209 -23
View File
@@ -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)
}
}
}