forked from wangziqi/gongxue-base
feat: add ai recommendation exports
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import type { RouteDefinition } from '../../core/router.js';
|
||||
import {
|
||||
generateSchoolRecommendationRoute,
|
||||
schoolRecommendationReportExportRoute,
|
||||
schoolRecommendationReportDetailRoute,
|
||||
schoolRecommendationReportsRoute,
|
||||
} from './routes.js';
|
||||
@@ -8,5 +9,6 @@ import {
|
||||
export const aiRoutes: RouteDefinition[] = [
|
||||
['GET', '/api/ai/school-recommendations', schoolRecommendationReportsRoute],
|
||||
['GET', '/api/ai/school-recommendations/detail', schoolRecommendationReportDetailRoute],
|
||||
['GET', '/api/ai/school-recommendations/export', schoolRecommendationReportExportRoute],
|
||||
['POST', '/api/ai/school-recommendations/generate', generateSchoolRecommendationRoute],
|
||||
];
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { HttpError, type RequestContext } from '../../core/http.js';
|
||||
import { intParam, readJsonBody, stringParam, tenantIdFrom, userIdFrom } from '../../core/request.js';
|
||||
import { query, queryOne, transaction } from '../../core/db.js';
|
||||
@@ -66,6 +67,24 @@ interface RecommendationCandidate {
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
interface SchoolRecommendationReportRow {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
userId: string;
|
||||
regionId: string | null;
|
||||
status: string;
|
||||
provider: string;
|
||||
model: string | null;
|
||||
promptVersion: string;
|
||||
inputPayload: JsonObject;
|
||||
contextPayload: JsonObject;
|
||||
resultPayload: JsonObject;
|
||||
errorMessage: string | null;
|
||||
generatedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
const RISK_PREFERENCES = new Set(['safe', 'balanced', 'sprint']);
|
||||
const PROMPT_VERSION = 'school-recommendation-v1';
|
||||
const LOCAL_MODEL = 'local-scoreline-rules-v1';
|
||||
@@ -95,6 +114,51 @@ function boundedText(value: unknown, maxLength: number) {
|
||||
return text.slice(0, maxLength);
|
||||
}
|
||||
|
||||
function textValue(value: unknown, fallback = '') {
|
||||
if (value === null || value === undefined) return fallback;
|
||||
if (typeof value === 'object') return JSON.stringify(value);
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function numberValue(value: unknown) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function arrayValue(value: unknown) {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
function htmlEscape(value: unknown) {
|
||||
return textValue(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function markdownText(value: unknown) {
|
||||
return textValue(value).replace(/[<>]/g, '');
|
||||
}
|
||||
|
||||
function contentBase64AndHash(content: string) {
|
||||
const buffer = Buffer.from(content, 'utf8');
|
||||
return {
|
||||
contentBase64: buffer.toString('base64'),
|
||||
sha256: crypto.createHash('sha256').update(buffer).digest('hex'),
|
||||
sizeBytes: buffer.length,
|
||||
};
|
||||
}
|
||||
|
||||
function reportExportFormat(value: string) {
|
||||
const format = value || 'markdown';
|
||||
if (!['markdown', 'html'].includes(format)) {
|
||||
throw new HttpError(400, 'format must be markdown or html', 'AI_REPORT_EXPORT_FORMAT_INVALID');
|
||||
}
|
||||
return format as 'markdown' | 'html';
|
||||
}
|
||||
|
||||
function normalizeRiskPreference(value: unknown) {
|
||||
const riskPreference = nullableString(value) || 'balanced';
|
||||
if (!RISK_PREFERENCES.has(riskPreference)) {
|
||||
@@ -402,6 +466,176 @@ function reportSelectSql() {
|
||||
`;
|
||||
}
|
||||
|
||||
function riskLabel(value: unknown) {
|
||||
const labels: Record<string, string> = {
|
||||
safe: '稳妥',
|
||||
balanced: '均衡',
|
||||
sprint: '冲刺',
|
||||
unknown: '未知',
|
||||
};
|
||||
return labels[textValue(value)] || '未知';
|
||||
}
|
||||
|
||||
function directionLabel(value: unknown) {
|
||||
const labels: Record<string, string> = {
|
||||
up: '上升',
|
||||
down: '下降',
|
||||
flat: '稳定',
|
||||
unknown: '样本不足',
|
||||
};
|
||||
return labels[textValue(value)] || '样本不足';
|
||||
}
|
||||
|
||||
function reportFileTimestamp(value: string | null | undefined) {
|
||||
const source = value ? new Date(value) : new Date();
|
||||
const valid = Number.isFinite(source.getTime()) ? source : new Date();
|
||||
return valid.toISOString().slice(0, 19).replace(/[-:T]/g, '');
|
||||
}
|
||||
|
||||
function buildReportMarkdown(report: SchoolRecommendationReportRow) {
|
||||
const result = objectValue(report.resultPayload);
|
||||
const input = objectValue(report.inputPayload);
|
||||
const coverage = objectValue(result.dataCoverage);
|
||||
const recommendations = arrayValue(result.recommendedSchools).map(objectValue);
|
||||
const actionPlan = arrayValue(result.actionPlan);
|
||||
const disclaimers = arrayValue(result.disclaimers);
|
||||
|
||||
const lines = [
|
||||
'# AI 择校推荐报告',
|
||||
'',
|
||||
`生成时间:${markdownText(report.generatedAt || report.createdAt)}`,
|
||||
`报告编号:${markdownText(report.id)}`,
|
||||
`推荐偏好:${riskLabel(input.riskPreference)}`,
|
||||
input.estimatedScore !== undefined ? `预估分:${markdownText(input.estimatedScore)}` : '',
|
||||
input.constraints ? `限制条件:${markdownText(input.constraints)}` : '',
|
||||
'',
|
||||
'## 结论摘要',
|
||||
markdownText(result.summary || '暂无摘要'),
|
||||
'',
|
||||
`整体方案:${riskLabel(result.riskLevel)}`,
|
||||
'',
|
||||
'## 推荐院校',
|
||||
].filter(Boolean);
|
||||
|
||||
recommendations.forEach((candidate, index) => {
|
||||
const trend = objectValue(candidate.scorelineTrend);
|
||||
const tags = arrayValue(candidate.tags).map(markdownText).filter(Boolean);
|
||||
lines.push(
|
||||
'',
|
||||
`### ${index + 1}. ${markdownText(candidate.schoolName || '未知院校')}${candidate.majorName ? ` · ${markdownText(candidate.majorName)}` : ''}`,
|
||||
`风险档位:${riskLabel(candidate.riskLevel)} · 置信度:${Math.round((numberValue(candidate.confidence) || 0) * 100)}%`,
|
||||
`最新年份:${markdownText(candidate.latestYear ?? '未知')} · 最新参考线:${markdownText(candidate.latestScore ?? '未知')} · 差值:${markdownText(candidate.scoreGap ?? '未知')}`,
|
||||
`均值参考:${markdownText(candidate.averageScore ?? '未知')} · 趋势:${directionLabel(trend.direction)}`,
|
||||
`理由:${markdownText(candidate.reason || '暂无理由')}`,
|
||||
tags.length ? `标签:${tags.join('、')}` : '',
|
||||
);
|
||||
});
|
||||
|
||||
if (!recommendations.length) {
|
||||
lines.push('', '暂无可推荐院校。');
|
||||
}
|
||||
|
||||
lines.push('', '## 行动计划');
|
||||
actionPlan.forEach((item, index) => {
|
||||
lines.push(`${index + 1}. ${markdownText(item)}`);
|
||||
});
|
||||
if (!actionPlan.length) lines.push('暂无行动计划。');
|
||||
|
||||
lines.push('', '## 数据覆盖');
|
||||
lines.push(`地区:${markdownText(coverage.regionName || coverage.regionId || report.regionId || '未知')}`);
|
||||
lines.push(`分数线样本:${markdownText(coverage.scorelineRecordCount ?? 0)}`);
|
||||
lines.push(`候选组数:${markdownText(coverage.schoolMajorGroupCount ?? recommendations.length)}`);
|
||||
const years = arrayValue(coverage.years).map(markdownText).filter(Boolean);
|
||||
if (years.length) lines.push(`覆盖年份:${years.join('、')}`);
|
||||
|
||||
lines.push('', '## 免责声明');
|
||||
disclaimers.forEach(item => {
|
||||
lines.push(`- ${markdownText(item)}`);
|
||||
});
|
||||
if (!disclaimers.length) {
|
||||
DISCLAIMER.forEach(item => lines.push(`- ${markdownText(item)}`));
|
||||
}
|
||||
|
||||
return `${lines.filter(line => line !== '').join('\n')}\n`;
|
||||
}
|
||||
|
||||
function buildReportHtml(report: SchoolRecommendationReportRow) {
|
||||
const result = objectValue(report.resultPayload);
|
||||
const input = objectValue(report.inputPayload);
|
||||
const coverage = objectValue(result.dataCoverage);
|
||||
const recommendations = arrayValue(result.recommendedSchools).map(objectValue);
|
||||
const actionPlan = arrayValue(result.actionPlan);
|
||||
const disclaimers = arrayValue(result.disclaimers).length ? arrayValue(result.disclaimers) : DISCLAIMER;
|
||||
const rows = recommendations.map((candidate, index) => {
|
||||
const trend = objectValue(candidate.scorelineTrend);
|
||||
const tags = arrayValue(candidate.tags).map(htmlEscape).filter(Boolean);
|
||||
return `
|
||||
<section class="candidate">
|
||||
<h3>${index + 1}. ${htmlEscape(candidate.schoolName || '未知院校')}${candidate.majorName ? ` · ${htmlEscape(candidate.majorName)}` : ''}</h3>
|
||||
<p><strong>${riskLabel(candidate.riskLevel)}</strong> · 置信度 ${Math.round((numberValue(candidate.confidence) || 0) * 100)}%</p>
|
||||
<p>最新年份:${htmlEscape(candidate.latestYear ?? '未知')} · 最新参考线:${htmlEscape(candidate.latestScore ?? '未知')} · 差值:${htmlEscape(candidate.scoreGap ?? '未知')}</p>
|
||||
<p>均值参考:${htmlEscape(candidate.averageScore ?? '未知')} · 趋势:${directionLabel(trend.direction)}</p>
|
||||
<p>${htmlEscape(candidate.reason || '暂无理由')}</p>
|
||||
${tags.length ? `<p class="tags">${tags.join(' / ')}</p>` : ''}
|
||||
</section>
|
||||
`;
|
||||
}).join('\n');
|
||||
const years = arrayValue(coverage.years).map(htmlEscape).filter(Boolean);
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>AI 择校推荐报告</title>
|
||||
<style>
|
||||
body { margin: 0; padding: 32px; color: #111827; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; line-height: 1.65; background: #f8fafc; }
|
||||
main { max-width: 880px; margin: 0 auto; padding: 32px; border: 1px solid #e2e8f0; border-radius: 8px; background: #fff; }
|
||||
h1, h2, h3 { line-height: 1.25; }
|
||||
.meta, .coverage, .disclaimer { color: #475569; }
|
||||
.summary { padding: 18px; border: 1px solid #bfdbfe; border-radius: 8px; background: #eff6ff; }
|
||||
.candidate { margin-top: 18px; padding: 18px; border: 1px solid #e2e8f0; border-radius: 8px; }
|
||||
.tags { color: #1d4ed8; font-weight: 700; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>AI 择校推荐报告</h1>
|
||||
<p class="meta">生成时间:${htmlEscape(report.generatedAt || report.createdAt)} · 报告编号:${htmlEscape(report.id)}</p>
|
||||
<p class="meta">推荐偏好:${riskLabel(input.riskPreference)}${input.estimatedScore !== undefined ? ` · 预估分:${htmlEscape(input.estimatedScore)}` : ''}</p>
|
||||
${input.constraints ? `<p class="meta">限制条件:${htmlEscape(input.constraints)}</p>` : ''}
|
||||
<section class="summary">
|
||||
<h2>结论摘要</h2>
|
||||
<p>${htmlEscape(result.summary || '暂无摘要')}</p>
|
||||
<p>整体方案:${riskLabel(result.riskLevel)}</p>
|
||||
</section>
|
||||
<h2>推荐院校</h2>
|
||||
${rows || '<p>暂无可推荐院校。</p>'}
|
||||
<h2>行动计划</h2>
|
||||
<ol>${actionPlan.map(item => `<li>${htmlEscape(item)}</li>`).join('') || '<li>暂无行动计划。</li>'}</ol>
|
||||
<h2>数据覆盖</h2>
|
||||
<p class="coverage">地区:${htmlEscape(coverage.regionName || coverage.regionId || report.regionId || '未知')} · 分数线样本:${htmlEscape(coverage.scorelineRecordCount ?? 0)} · 候选组数:${htmlEscape(coverage.schoolMajorGroupCount ?? recommendations.length)}${years.length ? ` · 覆盖年份:${years.join('、')}` : ''}</p>
|
||||
<h2>免责声明</h2>
|
||||
<ul class="disclaimer">${disclaimers.map(item => `<li>${htmlEscape(item)}</li>`).join('')}</ul>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
function buildReportExport(report: SchoolRecommendationReportRow, format: 'markdown' | 'html') {
|
||||
const content = format === 'html' ? buildReportHtml(report) : buildReportMarkdown(report);
|
||||
const encoded = contentBase64AndHash(content);
|
||||
const extension = format === 'html' ? 'html' : 'md';
|
||||
return {
|
||||
reportId: report.id,
|
||||
format,
|
||||
fileName: `school-recommendation-${reportFileTimestamp(report.generatedAt || report.createdAt)}-${report.id.slice(0, 8)}.${extension}`,
|
||||
mimeType: format === 'html' ? 'text/html; charset=utf-8' : 'text/markdown; charset=utf-8',
|
||||
contentText: content,
|
||||
...encoded,
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateSchoolRecommendationRoute(ctx: RequestContext) {
|
||||
const body = await readJsonBody(ctx);
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
@@ -544,3 +778,41 @@ export async function schoolRecommendationReportDetailRoute(ctx: RequestContext)
|
||||
if (!item) throw new HttpError(404, 'AI recommendation report not found', 'AI_REPORT_NOT_FOUND');
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function schoolRecommendationReportExportRoute(ctx: RequestContext) {
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const userId = await userIdFrom(ctx);
|
||||
const reportId = stringParam(ctx, 'reportId');
|
||||
const format = reportExportFormat(stringParam(ctx, 'format'));
|
||||
if (!reportId) throw new HttpError(400, 'reportId is required', 'AI_REPORT_ID_REQUIRED');
|
||||
|
||||
const item = await queryOne<SchoolRecommendationReportRow>(
|
||||
`
|
||||
${reportSelectSql()}
|
||||
where tenant_id = $1 and user_id = $2 and id = $3::uuid and status = 'generated'
|
||||
limit 1
|
||||
`,
|
||||
[tenantId, userId, reportId],
|
||||
);
|
||||
if (!item) throw new HttpError(404, 'AI recommendation report not found', 'AI_REPORT_NOT_FOUND');
|
||||
|
||||
const exportItem = buildReportExport(item, format);
|
||||
await query(
|
||||
`
|
||||
insert into public.audit_logs (tenant_id, actor_user_id, action, target_type, target_id, details)
|
||||
values ($1, $2, 'ai.school_recommendation.exported', 'ai_recommendation_report', $3, $4::jsonb)
|
||||
`,
|
||||
[
|
||||
tenantId,
|
||||
userId,
|
||||
item.id,
|
||||
JSON.stringify({
|
||||
format,
|
||||
sha256: exportItem.sha256,
|
||||
sizeBytes: exportItem.sizeBytes,
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
return { item: exportItem };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { Button, Input, Picker, Text, Textarea, View } from '@tarojs/components';
|
||||
import {
|
||||
exportSchoolRecommendationReport,
|
||||
generateSchoolRecommendation,
|
||||
loadSchoolRecommendationReports,
|
||||
type SchoolRecommendationReport,
|
||||
@@ -22,6 +24,24 @@ function recommendationRows(report: SchoolRecommendationReport | null) {
|
||||
return report?.resultPayload?.recommendedSchools || [];
|
||||
}
|
||||
|
||||
function saveExportFile(fileName: string, content: string, mimeType: string) {
|
||||
if (process.env.TARO_ENV === 'h5' && typeof window !== 'undefined' && typeof document !== 'undefined') {
|
||||
const blob = new Blob([content], { type: mimeType });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = fileName;
|
||||
link.rel = 'noopener noreferrer';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
return;
|
||||
}
|
||||
Taro.setClipboardData({ data: content });
|
||||
Taro.showToast({ title: '报告内容已复制', icon: 'none' });
|
||||
}
|
||||
|
||||
export default function StudentAiSchoolPage() {
|
||||
const [profile, setProfile] = useState<StudentProfile | null>(null);
|
||||
const [reports, setReports] = useState<SchoolRecommendationReport[]>([]);
|
||||
@@ -30,6 +50,7 @@ export default function StudentAiSchoolPage() {
|
||||
const [constraints, setConstraints] = useState('');
|
||||
const [riskIndex, setRiskIndex] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [exporting, setExporting] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
@@ -65,6 +86,22 @@ export default function StudentAiSchoolPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExport(format: 'markdown' | 'html') {
|
||||
if (!current?.id) return;
|
||||
setExporting(format);
|
||||
setError('');
|
||||
try {
|
||||
const payload = await exportSchoolRecommendationReport(current.id, format);
|
||||
if (!payload.item?.contentText) throw new Error('后端未返回报告内容');
|
||||
saveExportFile(payload.item.fileName, payload.item.contentText, payload.item.mimeType);
|
||||
if (process.env.TARO_ENV === 'h5') Taro.showToast({ title: '报告已导出', icon: 'success' });
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : '导出失败');
|
||||
} finally {
|
||||
setExporting('');
|
||||
}
|
||||
}
|
||||
|
||||
const rows = recommendationRows(current);
|
||||
|
||||
return (
|
||||
@@ -123,6 +160,10 @@ export default function StudentAiSchoolPage() {
|
||||
<Text className='hero-title'>{riskLabel(current.resultPayload.riskLevel)}方案</Text>
|
||||
<Text className='hero-copy'>{current.resultPayload.summary}</Text>
|
||||
</View>
|
||||
<View className='toolbar wrap compact-toolbar'>
|
||||
<Button className='secondary-button' loading={exporting === 'markdown'} onClick={() => handleExport('markdown')}>导出MD</Button>
|
||||
<Button className='secondary-button' loading={exporting === 'html'} onClick={() => handleExport('html')}>导出HTML</Button>
|
||||
</View>
|
||||
<View className='list-stack'>
|
||||
{rows.map(item => (
|
||||
<View className='list-row' key={`${item.schoolId || item.schoolName}:${item.majorId || item.majorName || ''}`}>
|
||||
|
||||
@@ -63,6 +63,17 @@ export interface SchoolRecommendationReport {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface SchoolRecommendationExport {
|
||||
reportId: string;
|
||||
format: 'markdown' | 'html';
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
contentBase64: string;
|
||||
contentText?: string;
|
||||
sha256: string;
|
||||
sizeBytes: number;
|
||||
}
|
||||
|
||||
export async function generateSchoolRecommendation(input: GenerateSchoolRecommendationInput) {
|
||||
return apiRequest<{ item?: SchoolRecommendationReport }>('/api/ai/school-recommendations/generate', {
|
||||
method: 'POST',
|
||||
@@ -81,3 +92,9 @@ export async function loadSchoolRecommendationReport(reportId: string) {
|
||||
query: { reportId },
|
||||
});
|
||||
}
|
||||
|
||||
export async function exportSchoolRecommendationReport(reportId: string, format: 'markdown' | 'html' = 'markdown') {
|
||||
return apiRequest<{ item?: SchoolRecommendationExport }>('/api/ai/school-recommendations/export', {
|
||||
query: { reportId, format },
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user