feat: add AI school recommendation foundation

This commit is contained in:
Codex
2026-06-29 21:22:53 +08:00
parent 8d4428214a
commit aeca84b260
16 changed files with 1003 additions and 12 deletions

View File

@@ -1649,6 +1649,70 @@ async function testScoreline() {
assert.ok(years.items?.includes(2026), 'scoreline years should include 2026');
}
async function testAiSchoolRecommendationRequiresSvip() {
const denied = await request('/api/ai/school-recommendations/generate', {
userId: SECOND_STUDENT_USER_ID,
method: 'POST',
body: {
regionId: ids.region,
estimatedScore: 210,
riskPreference: 'balanced',
},
expectStatus: 403,
});
assert.equal(denied.code, 'AI_SVIP_REQUIRED', 'AI school recommendation should require SVIP entitlement');
}
async function testAiSchoolRecommendationAfterSvip() {
const generated = await request('/api/ai/school-recommendations/generate', {
method: 'POST',
body: {
regionId: ids.region,
estimatedScore: 210,
riskPreference: 'balanced',
constraints: '优先考虑计算机相关专业',
recommendationLimit: 5,
},
});
assert.equal(generated.item?.status, 'generated', 'AI school recommendation should generate report');
assert.equal(generated.item?.provider, 'local_rules', 'AI recommendation should use deterministic local provider in tests');
assert.equal(generated.item?.resultPayload?.schemaVersion, 'school-recommendation-report-v1', 'AI report should use stable schema');
assert.ok(
generated.item?.resultPayload?.recommendedSchools?.some(item => item.schoolName === '烟测学院'),
'AI report should recommend from tenant scoreline context',
);
assert.ok(!JSON.stringify(generated).includes('secret'), 'AI recommendation response should not leak provider secrets');
const reports = await request('/api/ai/school-recommendations', {
query: { limit: 5 },
});
assert.ok(reports.items?.some(item => item.id === generated.item.id), 'AI report list should include generated report');
const detail = await request('/api/ai/school-recommendations/detail', {
query: { reportId: generated.item.id },
});
assert.equal(detail.item?.id, generated.item.id, 'AI report detail should load own report');
const otherStudentDenied = await request('/api/ai/school-recommendations/detail', {
userId: SECOND_STUDENT_USER_ID,
query: { reportId: generated.item.id },
expectStatus: 404,
});
assert.equal(otherStudentDenied.code, 'AI_REPORT_NOT_FOUND', 'students must not read other students AI reports');
const partnerTenantDenied = await request('/api/ai/school-recommendations/generate', {
tenantId: PARTNER_TENANT_ID,
method: 'POST',
body: {
regionId: ids.region,
estimatedScore: 210,
riskPreference: 'balanced',
},
expectStatus: 404,
});
assert.equal(partnerTenantDenied.code, 'PROFILE_NOT_FOUND', 'AI recommendation must not use a student profile from another tenant');
}
async function testVideos() {
const single = await request(`/api/questions/${ids.question}/videos`);
assert.ok(single.total >= 1, 'question should have videos');
@@ -7181,9 +7245,11 @@ async function main() {
await check('profile', testProfile);
await check('learning leaderboard', testLearningLeaderboard);
await check('scoreline', testScoreline);
await check('AI school recommendation SVIP gate', testAiSchoolRecommendationRequiresSvip);
await check('question videos', testVideos);
await check('vocabulary', testVocabulary);
await check('commerce', testCommerce);
await check('AI school recommendation', testAiSchoolRecommendationAfterSvip);
await check('tenant isolation', testTenantIsolation);
await check('tenant content admin', testTenantContentAdmin);
await check('tenant content assets and imports', testTenantContentAssetsAndImports);