feat: add ai recommendation exports

This commit is contained in:
Codex
2026-06-30 19:04:30 +08:00
parent ba737d41f5
commit 686b3609cf
13 changed files with 380 additions and 12 deletions

View File

@@ -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],
];

View File

@@ -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, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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 };
}

View File

@@ -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 || ''}`}>

View File

@@ -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 },
});
}