From aeca84b2605c79821428edd437d74c586a9783c6 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 29 Jun 2026 21:22:53 +0800 Subject: [PATCH] feat: add AI school recommendation foundation --- README.md | 5 +- apps/api/src/core/router.ts | 2 + apps/api/src/features/ai/index.ts | 12 + apps/api/src/features/ai/routes.ts | 546 ++++++++++++++++++ apps/taro/src/app.config.ts | 1 + .../pages/student/ai-school/index.config.ts | 3 + .../src/pages/student/ai-school/index.tsx | 154 +++++ apps/taro/src/pages/student/home/index.tsx | 1 + apps/taro/src/services/ai.ts | 83 +++ docs/refactor/backend-capability-status.md | 1 + docs/refactor/blueprint-coverage.md | 2 +- docs/refactor/legacy-feature-gap-matrix.md | 9 +- docs/refactor/next-development-todo.md | 6 +- docs/refactor/taro-frontend-integration.md | 87 ++- scripts/api-integration-test.js | 66 +++ ...202606290028_ai_school_recommendations.sql | 37 ++ 16 files changed, 1003 insertions(+), 12 deletions(-) create mode 100644 apps/api/src/features/ai/index.ts create mode 100644 apps/api/src/features/ai/routes.ts create mode 100644 apps/taro/src/pages/student/ai-school/index.config.ts create mode 100644 apps/taro/src/pages/student/ai-school/index.tsx create mode 100644 apps/taro/src/services/ai.ts create mode 100644 supabase/migrations/202606290028_ai_school_recommendations.sql diff --git a/README.md b/README.md index 1bf33faa..f31ddcde 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ - `apps/api` 独立业务 API,后续供 H5、Taro 小程序、管理后台统一调用;已支持 Supabase Auth JWT 和迁移期 `tk_` session 双入口。 - 租户后台能力:品牌、主题模板/草稿/发布、域名、公开设置、支付账户、登录配置、私密密钥掩码、活动内容、考试日期、题目反馈处理、激活码、优惠券、勋章管理/发放、成员权限、自定义角色模板、班级/教师/学生范围权限、学生批量导入、批量分班、学生备注、跟进任务、审计日志。 - 租户内容能力:可配置题库入口、任意深度分类树、考试意向标记、题目集合、顺序/随机/全真模拟蓝图、题目录入/更新、视频绑定、分数线、单词、知识手册、资料资源台账、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 批量导入。 -- 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词进度、个人中心、勋章、考试倒计时、签到积分、题目反馈、排行榜、分数线、题目视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载。 +- 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词进度、个人中心、勋章、考试倒计时、签到积分、题目反馈、排行榜、分数线、AI 择校推荐、题目视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载。 - 平台后台能力:租户管理、SaaS 套餐、订阅、账单、服务费收款、用量记录、公共题库授权。 - 公共题库商业化能力:租户可采纳平台授权题库为本租户副本,并可手动或由 worker 自动同步平台新增/更新题目;同步会保护租户自改题目,返回冲突而不覆盖,后台可查询冲突明细。 - 题库导出能力:租户内容编辑可按题目集合、内容入口或分类节点导出 JSON、`paper_json`、打印 payload、PDF、Word 和每日一练图片 ZIP 素材包,后端强制租户隔离、答案/解析开关、复合题子题脱敏、导出 job 和审计;PDF/Word/ZIP 由 exports worker 生成水印文件或运营素材并发布到 `content_assets`;`daily_practice` 支持每日一练九宫格 metadata、PDF/Word 版式、9 张 PNG/SVG 卡片和拼图包。 @@ -34,7 +34,7 @@ - Excel/CSV 导入解析已完成并复用 `content_import_jobs/items/issues` 管线;大批量异步导入 worker 基础已接入,支持 queued job 消费、重试和审计;导入后复检、模板下载和字段映射 API 已完成,前端 UI 待接。 - 题库导出已完成服务端结构化 payload、PDF/Word 二进制 worker、每日一练基础导出和每日一练 ZIP 图片素材包;后续还要补更精细试卷模板、多模板排版和导出操作台体验。 - 勋章管理/手动发放已可联调;自动发放规则、积分活动联动、分佣真实打款 provider、发票、批量凭证上传、CRM 富卡片模板、失败告警、死信运营台、销售转化看板、公共题库版本通知和冲突处理操作台还没完成。 -- `apps/taro` 已建立 Taro 4 React 跨端前端地基,包含 H5 学生端、租户后台、平台后台三套构建入口、租户解析、统一 API client 和 Supabase Auth client 初始化;学生端第一批页面已接入登录、首页、题库、练习、背单词、知识手册、分数线、资料和个人中心;租户后台第一批页面已接入工作台、数据看板、学生/班级、题库内容、营销中心和租户设置,设置页已接主题模板、草稿预览/发布、角色模板和成员绑定第一版;平台后台已接入工作台、租户管理、账务中心、公共题库授权,以及创建租户、状态变更、订阅、账单、收款、用量和题库授权第一版写操作。 +- `apps/taro` 已建立 Taro 4 React 跨端前端地基,包含 H5 学生端、租户后台、平台后台三套构建入口、租户解析、统一 API client 和 Supabase Auth client 初始化;学生端第一批页面已接入登录、首页、题库、练习、背单词、知识手册、分数线、AI 择校推荐、资料和个人中心;租户后台第一批页面已接入工作台、数据看板、学生/班级、题库内容、营销中心和租户设置,设置页已接主题模板、草稿预览/发布、角色模板和成员绑定第一版;平台后台已接入工作台、租户管理、账务中心、公共题库授权,以及创建租户、状态变更、订阅、账单、收款、用量和题库授权第一版写操作。 - 根目录已清理为新 Supabase SaaS monorepo 编排层;旧 PocketBase/React 项目和旧构建产物仅保留在 `参考/` 目录作为迁移参考,不进入 Git 提交。 更完整的进度看这些文档: @@ -128,6 +128,7 @@ apps/taro/src/pages/student/practice apps/taro/src/pages/student/vocabulary apps/taro/src/pages/student/handbook apps/taro/src/pages/student/scoreline +apps/taro/src/pages/student/ai-school apps/taro/src/pages/student/assets apps/taro/src/pages/student/profile ``` diff --git a/apps/api/src/core/router.ts b/apps/api/src/core/router.ts index efc84065..e0e4102e 100644 --- a/apps/api/src/core/router.ts +++ b/apps/api/src/core/router.ts @@ -1,5 +1,6 @@ import type { Handler } from './http.js'; import { routeKey } from './http.js'; +import { aiRoutes } from '../features/ai/index.js'; import { authRoutes } from '../features/auth/index.js'; import { catalogRoutes } from '../features/catalog/index.js'; import { commerceRoutes } from '../features/commerce/index.js'; @@ -31,6 +32,7 @@ const allRoutes: RouteDefinition[] = [ ...healthRoutes, ...authRoutes, ...tenantRoutes, + ...aiRoutes, ...catalogRoutes, ...learningRoutes, ...profileRoutes, diff --git a/apps/api/src/features/ai/index.ts b/apps/api/src/features/ai/index.ts new file mode 100644 index 00000000..44db5471 --- /dev/null +++ b/apps/api/src/features/ai/index.ts @@ -0,0 +1,12 @@ +import type { RouteDefinition } from '../../core/router.js'; +import { + generateSchoolRecommendationRoute, + schoolRecommendationReportDetailRoute, + schoolRecommendationReportsRoute, +} from './routes.js'; + +export const aiRoutes: RouteDefinition[] = [ + ['GET', '/api/ai/school-recommendations', schoolRecommendationReportsRoute], + ['GET', '/api/ai/school-recommendations/detail', schoolRecommendationReportDetailRoute], + ['POST', '/api/ai/school-recommendations/generate', generateSchoolRecommendationRoute], +]; diff --git a/apps/api/src/features/ai/routes.ts b/apps/api/src/features/ai/routes.ts new file mode 100644 index 00000000..0b789689 --- /dev/null +++ b/apps/api/src/features/ai/routes.ts @@ -0,0 +1,546 @@ +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'; + +type JsonObject = Record; + +interface StudentProfileContext { + regionId: string | null; + regionName: string | null; + selectedSchoolId: string | null; + selectedSchoolName: string | null; + selectedMajorId: string | null; + selectedMajorName: string | null; + stats: JsonObject; +} + +interface RegionRow { + id: string; + name: string; + code: string | null; +} + +interface EntitlementRow { + id: string; + scopeType: string; + scopeId: string | null; + expiresAt: string | null; +} + +interface ScorelineFieldRow { + fieldKey: string; + fieldName: string; + fieldType: string | null; + unit: string | null; + isTrend: boolean; + sortOrder: number; +} + +interface ScorelineRecordRow { + id: string; + year: number; + schoolId: string | null; + schoolName: string | null; + majorId: string | null; + majorName: string | null; + fieldValues: JsonObject; +} + +interface RecommendationCandidate { + schoolId: string | null; + schoolName: string; + majorId: string | null; + majorName: string | null; + latestYear: number | null; + latestScore: number | null; + averageScore: number | null; + scoreGap: number | null; + riskLevel: 'safe' | 'balanced' | 'sprint' | 'unknown'; + confidence: number; + reason: string; + scorelineTrend: { + years: number[]; + scores: (number | null)[]; + direction: 'up' | 'down' | 'flat' | 'unknown'; + }; + tags: string[]; +} + +const RISK_PREFERENCES = new Set(['safe', 'balanced', 'sprint']); +const PROMPT_VERSION = 'school-recommendation-v1'; +const LOCAL_MODEL = 'local-scoreline-rules-v1'; +const DISCLAIMER = [ + '推荐结果仅用于择校和备考规划参考,不构成录取承诺。', + '分数线、招生计划和考试政策可能变化,正式报考前应以院校和考试院官方信息为准。', + '当地区或院校数据覆盖不足时,应结合人工咨询和最新招生简章复核。', +]; + +function objectValue(value: unknown): JsonObject { + return value && typeof value === 'object' && !Array.isArray(value) ? value as JsonObject : {}; +} + +function nullableString(value: unknown) { + return typeof value === 'string' && value.trim() ? value.trim() : null; +} + +function boundedNumber(value: unknown, min: number, max: number) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return null; + return Math.min(Math.max(parsed, min), max); +} + +function boundedText(value: unknown, maxLength: number) { + const text = nullableString(value); + if (!text) return null; + return text.slice(0, maxLength); +} + +function normalizeRiskPreference(value: unknown) { + const riskPreference = nullableString(value) || 'balanced'; + if (!RISK_PREFERENCES.has(riskPreference)) { + throw new HttpError(400, 'riskPreference must be safe, balanced, or sprint', 'INVALID_AI_RISK_PREFERENCE'); + } + return riskPreference as 'safe' | 'balanced' | 'sprint'; +} + +function numericField(value: unknown) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function pickScoreValue(values: JsonObject) { + const preferredKeys = [ + 'minScore', + 'minimumScore', + 'score', + 'admissionScore', + 'lowestScore', + '投档线', + '最低分', + ]; + for (const key of preferredKeys) { + const score = numericField(values[key]); + if (score !== null) return score; + } + for (const [key, value] of Object.entries(values)) { + if (/score|分|线/i.test(key)) { + const score = numericField(value); + if (score !== null) return score; + } + } + return null; +} + +function riskFromGap(gap: number | null, preference: 'safe' | 'balanced' | 'sprint') { + if (gap === null) return 'unknown'; + const safeFloor = preference === 'safe' ? 18 : preference === 'sprint' ? 8 : 12; + const sprintFloor = preference === 'safe' ? -2 : preference === 'sprint' ? -15 : -8; + if (gap >= safeFloor) return 'safe'; + if (gap >= sprintFloor) return 'balanced'; + return 'sprint'; +} + +function confidenceFromGap(gap: number | null, recordsCount: number, riskLevel: string) { + if (gap === null) return recordsCount > 1 ? 0.45 : 0.35; + const base = riskLevel === 'safe' ? 0.78 : riskLevel === 'balanced' ? 0.62 : 0.42; + const gapBonus = Math.min(Math.abs(gap) / 100, 0.12); + const coverageBonus = Math.min(recordsCount * 0.025, 0.1); + return Number(Math.min(base + gapBonus + coverageBonus, 0.95).toFixed(2)); +} + +function trendDirection(scores: (number | null)[]) { + const numericScores = scores.filter((score): score is number => typeof score === 'number'); + if (numericScores.length < 2) return 'unknown'; + const first = numericScores[0]; + const last = numericScores[numericScores.length - 1]; + const diff = last - first; + if (Math.abs(diff) <= 3) return 'flat'; + return diff > 0 ? 'up' : 'down'; +} + +function groupKey(record: ScorelineRecordRow) { + return `${record.schoolId || record.schoolName || 'unknown'}:${record.majorId || record.majorName || 'unknown'}`; +} + +function buildCandidates( + records: ScorelineRecordRow[], + input: ReturnType, +) { + const groups = new Map(); + for (const record of records) { + const key = groupKey(record); + groups.set(key, [...(groups.get(key) || []), record]); + } + + const candidates = [...groups.values()].map(group => { + const sorted = group.slice().sort((left, right) => left.year - right.year); + const latest = sorted.at(-1) || null; + const scores = sorted.map(record => pickScoreValue(record.fieldValues)); + const numericScores = scores.filter((score): score is number => typeof score === 'number'); + const latestScore = latest ? pickScoreValue(latest.fieldValues) : null; + const averageScore = numericScores.length + ? Number((numericScores.reduce((sum, score) => sum + score, 0) / numericScores.length).toFixed(1)) + : null; + const baseline = latestScore ?? averageScore; + const scoreGap = baseline === null || input.estimatedScore === null + ? null + : Number((input.estimatedScore - baseline).toFixed(1)); + const riskLevel = riskFromGap(scoreGap, input.riskPreference); + const direction = trendDirection(scores); + const confidence = confidenceFromGap(scoreGap, sorted.length, riskLevel); + const tags = [ + riskLevel === 'safe' ? '稳妥' : riskLevel === 'balanced' ? '匹配' : riskLevel === 'sprint' ? '冲刺' : '数据不足', + direction === 'up' ? '分数线上升' : direction === 'down' ? '分数线下降' : direction === 'flat' ? '分数线稳定' : '趋势不足', + sorted.length >= 3 ? '多年数据' : '样本较少', + ]; + const schoolName = latest?.schoolName || group[0]?.schoolName || '未知院校'; + const majorName = latest?.majorName || group[0]?.majorName || null; + const reasonParts = [ + input.estimatedScore === null + ? '未提供预估分,按历年分数线和数据覆盖度排序。' + : `预估分与最新参考线差值约 ${scoreGap ?? '未知'} 分。`, + direction === 'up' + ? '近年参考线有上升趋势,建议预留安全分差。' + : direction === 'down' + ? '近年参考线略有下降,可作为匹配或冲刺备选。' + : direction === 'flat' + ? '近年参考线相对稳定,适合纳入重点比较。' + : '历史数据不足,建议结合招生计划复核。', + ]; + + return { + schoolId: latest?.schoolId || group[0]?.schoolId || null, + schoolName, + majorId: latest?.majorId || group[0]?.majorId || null, + majorName, + latestYear: latest?.year || null, + latestScore, + averageScore, + scoreGap, + riskLevel, + confidence, + reason: reasonParts.join(' '), + scorelineTrend: { + years: sorted.map(record => record.year), + scores, + direction, + }, + tags, + } satisfies RecommendationCandidate; + }); + + const riskOrder = { + safe: input.riskPreference === 'safe' ? 0 : 1, + balanced: input.riskPreference === 'balanced' ? 0 : 2, + sprint: input.riskPreference === 'sprint' ? 0 : 3, + unknown: 4, + }; + + return candidates + .sort((left, right) => { + const riskDiff = riskOrder[left.riskLevel] - riskOrder[right.riskLevel]; + if (riskDiff !== 0) return riskDiff; + if (right.confidence !== left.confidence) return right.confidence - left.confidence; + return (right.latestYear || 0) - (left.latestYear || 0); + }) + .slice(0, input.recommendationLimit); +} + +function normalizeRecommendationInput(body: JsonObject) { + const estimatedScore = boundedNumber(body.estimatedScore, 0, 1000); + const recommendationLimit = Math.trunc(boundedNumber(body.recommendationLimit, 1, 12) || 5); + return { + regionId: nullableString(body.regionId), + estimatedScore, + examTrack: boundedText(body.examTrack, 80), + preferredCity: boundedText(body.preferredCity, 80), + targetSchoolId: nullableString(body.targetSchoolId), + targetMajorId: nullableString(body.targetMajorId), + riskPreference: normalizeRiskPreference(body.riskPreference), + constraints: boundedText(body.constraints, 500), + notes: boundedText(body.notes, 500), + recommendationLimit, + }; +} + +async function loadStudentProfile(tenantId: string, userId: string) { + return queryOne( + ` + select sp.region_id as "regionId", r.name as "regionName", + sp.selected_school_id as "selectedSchoolId", s.name as "selectedSchoolName", + sp.selected_major_id as "selectedMajorId", m.name as "selectedMajorName", + sp.stats + from public.student_profiles sp + left join public.regions r on r.id = sp.region_id and r.tenant_id = sp.tenant_id + left join public.schools s on s.id = sp.selected_school_id and s.tenant_id = sp.tenant_id + left join public.majors m on m.id = sp.selected_major_id and m.tenant_id = sp.tenant_id + where sp.tenant_id = $1 and sp.user_id = $2 + limit 1 + `, + [tenantId, userId], + ); +} + +async function assertRegionAccess(tenantId: string, regionId: string) { + const region = await queryOne( + ` + select id, name, code + from public.regions + where tenant_id = $1 and id = $2 and is_active = true + limit 1 + `, + [tenantId, regionId], + ); + if (!region) throw new HttpError(404, 'Region not found for this tenant', 'AI_REGION_NOT_FOUND'); + return region; +} + +async function activeSvipEntitlement(tenantId: string, userId: string, regionId: string | null) { + const now = new Date().toISOString(); + return queryOne( + ` + select id, scope_type as "scopeType", scope_id as "scopeId", expires_at as "expiresAt" + from public.entitlements + where tenant_id = $1 + and user_id = $2 + and entitlement_type = 'svip' + and status = 'active' + and starts_at <= $3::timestamptz + and (expires_at is null or expires_at > $3::timestamptz) + and ( + scope_type = 'tenant' + or ($4::uuid is not null and scope_type = 'region' and scope_id = $4::uuid) + ) + order by case when scope_type = 'region' then 0 else 1 end, expires_at desc nulls first + limit 1 + `, + [tenantId, userId, now, regionId], + ); +} + +async function loadScorelineFields(tenantId: string, regionId: string) { + return query( + ` + select field_key as "fieldKey", field_name as "fieldName", + field_type as "fieldType", unit, is_trend as "isTrend", + sort_order as "sortOrder" + from public.scoreline_fields + where tenant_id = $1 + and (region_id is null or region_id = $2::uuid) + and is_visible = true + order by sort_order asc, field_name asc + `, + [tenantId, regionId], + ); +} + +async function loadScorelineRecords(tenantId: string, regionId: string, input: ReturnType) { + const params: unknown[] = [tenantId, regionId, input.targetSchoolId, input.targetMajorId, 180]; + return query( + ` + select id, year, school_id as "schoolId", school_name as "schoolName", + major_id as "majorId", major_name as "majorName", + field_values as "fieldValues" + from public.scoreline_records + where tenant_id = $1 + and region_id = $2::uuid + and ($3::uuid is null or school_id = $3::uuid) + and ($4::uuid is null or major_id = $4::uuid) + order by year desc, school_name asc nulls last, major_name asc nulls last + limit $5 + `, + params, + ); +} + +function buildReportResult(input: ReturnType, context: JsonObject, candidates: RecommendationCandidate[]) { + const region = objectValue(context.region); + const dataCoverage = objectValue(context.dataCoverage); + const safeCount = candidates.filter(candidate => candidate.riskLevel === 'safe').length; + const balancedCount = candidates.filter(candidate => candidate.riskLevel === 'balanced').length; + const sprintCount = candidates.filter(candidate => candidate.riskLevel === 'sprint').length; + const top = candidates[0] || null; + const riskLevel = + safeCount >= 2 ? 'safe' : + balancedCount >= 2 || top?.riskLevel === 'balanced' ? 'balanced' : + sprintCount ? 'sprint' : 'unknown'; + + return { + schemaVersion: 'school-recommendation-report-v1', + summary: top + ? `基于当前地区历年分数线,优先推荐 ${top.schoolName}${top.majorName ? `-${top.majorName}` : ''} 等 ${candidates.length} 个方案。` + : '当前地区分数线数据不足,暂无法生成可靠院校推荐。', + riskLevel, + recommendedSchools: candidates, + actionPlan: [ + '先确认目标地区、考试类别和预估分是否准确。', + '重点比较推荐院校近三年分数线、招生计划和专业限制。', + '把稳妥、匹配、冲刺院校分别保留 2-3 个备选,并跟进最新招生简章。', + input.constraints ? '结合个人限制条件逐项排除不符合报考条件的院校或专业。' : '补充个人限制条件后可再次生成更精细的推荐。', + ], + disclaimers: DISCLAIMER, + dataCoverage: { + regionId: region.id || null, + regionName: region.name || null, + scorelineRecordCount: dataCoverage.scorelineRecordCount || 0, + schoolMajorGroupCount: candidates.length, + years: dataCoverage.years || [], + fields: dataCoverage.fields || [], + provider: 'local_rules', + }, + }; +} + +function reportSelectSql() { + return ` + select id, tenant_id as "tenantId", user_id as "userId", region_id as "regionId", + status, provider, model, prompt_version as "promptVersion", + input_payload as "inputPayload", context_payload as "contextPayload", + result_payload as "resultPayload", error_message as "errorMessage", + generated_at as "generatedAt", created_at as "createdAt", updated_at as "updatedAt" + from public.ai_recommendation_reports + `; +} + +export async function generateSchoolRecommendationRoute(ctx: RequestContext) { + const body = await readJsonBody(ctx); + const tenantId = await tenantIdFrom(ctx); + const userId = await userIdFrom(ctx, body); + const input = normalizeRecommendationInput(body); + const profile = await loadStudentProfile(tenantId, userId); + if (!profile) throw new HttpError(404, 'Student profile not found', 'PROFILE_NOT_FOUND'); + + const regionId = input.regionId || profile.regionId; + if (!regionId) throw new HttpError(400, 'regionId is required before generating a recommendation', 'AI_REGION_REQUIRED'); + const region = await assertRegionAccess(tenantId, regionId); + const entitlement = await activeSvipEntitlement(tenantId, userId, regionId); + if (!entitlement) { + throw new HttpError(403, 'SVIP entitlement is required for AI school recommendation', 'AI_SVIP_REQUIRED'); + } + + const [fields, records] = await Promise.all([ + loadScorelineFields(tenantId, regionId), + loadScorelineRecords(tenantId, regionId, input), + ]); + const years = [...new Set(records.map(record => record.year))].sort((left, right) => right - left); + const candidates = buildCandidates(records, input); + const contextPayload = { + region, + studentProfile: { + regionId: profile.regionId, + regionName: profile.regionName, + selectedSchoolId: profile.selectedSchoolId, + selectedSchoolName: profile.selectedSchoolName, + selectedMajorId: profile.selectedMajorId, + selectedMajorName: profile.selectedMajorName, + }, + entitlement: { + id: entitlement.id, + scopeType: entitlement.scopeType, + scopeId: entitlement.scopeId, + expiresAt: entitlement.expiresAt, + }, + dataCoverage: { + scorelineRecordCount: records.length, + years, + fields: fields.map(field => ({ + fieldKey: field.fieldKey, + fieldName: field.fieldName, + fieldType: field.fieldType, + unit: field.unit, + isTrend: field.isTrend, + })), + }, + }; + const resultPayload = buildReportResult(input, contextPayload, candidates); + + const item = await transaction(async client => { + const result = await client.query( + ` + insert into public.ai_recommendation_reports ( + tenant_id, user_id, region_id, status, provider, model, prompt_version, + input_payload, context_payload, result_payload, generated_at + ) + values ( + $1, $2, $3::uuid, 'generated', 'local_rules', $4, $5, + $6::jsonb, $7::jsonb, $8::jsonb, now() + ) + returning id, tenant_id as "tenantId", user_id as "userId", region_id as "regionId", + status, provider, model, prompt_version as "promptVersion", + input_payload as "inputPayload", context_payload as "contextPayload", + result_payload as "resultPayload", error_message as "errorMessage", + generated_at as "generatedAt", created_at as "createdAt", updated_at as "updatedAt" + `, + [ + tenantId, + userId, + regionId, + LOCAL_MODEL, + PROMPT_VERSION, + JSON.stringify(input), + JSON.stringify(contextPayload), + JSON.stringify(resultPayload), + ], + ); + await client.query( + ` + insert into public.audit_logs (tenant_id, actor_user_id, action, target_type, target_id, details) + values ($1, $2, 'ai.school_recommendation.generated', 'ai_recommendation_report', $3, $4::jsonb) + `, + [ + tenantId, + userId, + result.rows[0].id, + JSON.stringify({ + provider: 'local_rules', + model: LOCAL_MODEL, + promptVersion: PROMPT_VERSION, + regionId, + scorelineRecordCount: records.length, + recommendationCount: candidates.length, + }), + ], + ); + return result.rows[0]; + }); + + return { item }; +} + +export async function schoolRecommendationReportsRoute(ctx: RequestContext) { + const tenantId = await tenantIdFrom(ctx); + const userId = await userIdFrom(ctx); + const limit = intParam(ctx, 'limit', 20, 100); + const regionId = stringParam(ctx, 'regionId'); + + const items = await query( + ` + ${reportSelectSql()} + where tenant_id = $1 + and user_id = $2 + and ($3::uuid is null or region_id = $3::uuid) + order by created_at desc + limit $4 + `, + [tenantId, userId, regionId || null, limit], + ); + return { items }; +} + +export async function schoolRecommendationReportDetailRoute(ctx: RequestContext) { + const tenantId = await tenantIdFrom(ctx); + const userId = await userIdFrom(ctx); + const reportId = stringParam(ctx, 'reportId'); + if (!reportId) throw new HttpError(400, 'reportId is required', 'AI_REPORT_ID_REQUIRED'); + + const item = await queryOne( + ` + ${reportSelectSql()} + where tenant_id = $1 and user_id = $2 and id = $3::uuid + limit 1 + `, + [tenantId, userId, reportId], + ); + if (!item) throw new HttpError(404, 'AI recommendation report not found', 'AI_REPORT_NOT_FOUND'); + return { item }; +} diff --git a/apps/taro/src/app.config.ts b/apps/taro/src/app.config.ts index 217d518f..3d0397ef 100644 --- a/apps/taro/src/app.config.ts +++ b/apps/taro/src/app.config.ts @@ -14,6 +14,7 @@ export default defineAppConfig({ 'pages/student/vocabulary/index', 'pages/student/handbook/index', 'pages/student/scoreline/index', + 'pages/student/ai-school/index', 'pages/student/assets/index', 'pages/student/profile/index', 'pages/tenant-admin/workbench/index', diff --git a/apps/taro/src/pages/student/ai-school/index.config.ts b/apps/taro/src/pages/student/ai-school/index.config.ts new file mode 100644 index 00000000..3bf259b9 --- /dev/null +++ b/apps/taro/src/pages/student/ai-school/index.config.ts @@ -0,0 +1,3 @@ +export default definePageConfig({ + navigationBarTitleText: 'AI择校推荐', +}); diff --git a/apps/taro/src/pages/student/ai-school/index.tsx b/apps/taro/src/pages/student/ai-school/index.tsx new file mode 100644 index 00000000..15eb96fc --- /dev/null +++ b/apps/taro/src/pages/student/ai-school/index.tsx @@ -0,0 +1,154 @@ +import { useEffect, useState } from 'react'; +import { Button, Input, Picker, Text, Textarea, View } from '@tarojs/components'; +import { + generateSchoolRecommendation, + loadSchoolRecommendationReports, + type SchoolRecommendationReport, +} from '@/services/ai'; +import { loadProfile, type StudentProfile } from '@/services/profile'; +import '../student.css'; + +const riskOptions = [ + { label: '均衡', value: 'balanced' }, + { label: '稳妥', value: 'safe' }, + { label: '冲刺', value: 'sprint' }, +] as const; + +function riskLabel(value?: string) { + return riskOptions.find(item => item.value === value)?.label || '未知'; +} + +function recommendationRows(report: SchoolRecommendationReport | null) { + return report?.resultPayload?.recommendedSchools || []; +} + +export default function StudentAiSchoolPage() { + const [profile, setProfile] = useState(null); + const [reports, setReports] = useState([]); + const [current, setCurrent] = useState(null); + const [estimatedScore, setEstimatedScore] = useState(''); + const [constraints, setConstraints] = useState(''); + const [riskIndex, setRiskIndex] = useState(0); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + useEffect(() => { + loadProfile().then(payload => setProfile(payload.item || null)).catch(() => setProfile(null)); + loadSchoolRecommendationReports({ limit: 5 }) + .then(payload => { + const items = payload.items || []; + setReports(items); + setCurrent(items[0] || null); + }) + .catch(() => setReports([])); + }, []); + + async function handleGenerate() { + setLoading(true); + setError(''); + try { + const payload = await generateSchoolRecommendation({ + regionId: profile?.target?.regionId || undefined, + estimatedScore: estimatedScore ? Number(estimatedScore) : undefined, + riskPreference: riskOptions[riskIndex].value, + constraints: constraints || undefined, + recommendationLimit: 5, + }); + if (payload.item) { + setCurrent(payload.item); + setReports(previous => [payload.item!, ...previous.filter(item => item.id !== payload.item!.id)].slice(0, 5)); + } + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : '生成失败'); + } finally { + setLoading(false); + } + } + + const rows = recommendationRows(current); + + return ( + + + + AI Advisor + AI择校推荐 + + {profile?.target?.regionName ? `${profile.target.regionName} · ${profile.membership?.isSvip ? 'SVIP' : '需SVIP'}` : '先在个人中心选择目标地区'} + + + + + + 生成报告 + + + 预估分 + setEstimatedScore(String(event.detail.value || ''))} + /> + + + 偏好 + item.label)} + value={riskIndex} + onChange={event => setRiskIndex(Number(event.detail.value || 0))} + > + {riskOptions[riskIndex].label} + + + + 限制条件 +