"""JSON API surface used by the React Sprint 3 frontend. These routes intentionally do not replace the existing HTML public portal routes. They provide typed JSON contracts so the React hooks can be generated from FastAPI OpenAPI instead of drifting as hand-written client code. """ from __future__ import annotations import base64 import secrets from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Literal from fastapi import APIRouter, HTTPException, Request from pydantic import BaseModel, Field from data.majors_catalog.loader import MajorsCatalogLoader from data.share.short_link import ShortLinkService router = APIRouter(prefix="/api", tags=["react-sprint3"]) ScoreType = Literal["physics", "history"] ReviewStatus = Literal[ "pending", "in_progress", "approved", "rejected", "changes_requested", ] ReviewAction = Literal["approve", "reject", "request_changes"] ProviderId = Literal["claude", "gpt", "gemini", "deepseek"] PosterTemplate = Literal["classic", "modern", "minimal"] class ScoreLineItem(BaseModel): batch: str score: float rank: int class ScoreLineResponse(BaseModel): province: str year: int scoreType: ScoreType lines: list[ScoreLineItem] class RankEstimatorResponse(BaseModel): province: str year: int scoreType: ScoreType rank: int equivalentScore: float class MajorItem(BaseModel): id: str name: str category: str class MajorsResponse(BaseModel): majors: list[MajorItem] total: int class SchoolItem(BaseModel): id: str name: str province: str is985: bool is211: bool class SchoolsResponse(BaseModel): schools: list[SchoolItem] total: int class ReviewStartInput(BaseModel): planId: str = Field(min_length=1) reviewerId: str | None = None class ReviewStatusResponse(BaseModel): id: str planId: str status: ReviewStatus reviewerId: str | None = None comment: str | None = None updatedAt: str class ReviewActionInput(BaseModel): action: ReviewAction reviewId: str = Field(min_length=1) comment: str | None = None class PosterGenerateInput(BaseModel): planId: str = Field(min_length=1) template: PosterTemplate = "classic" class PosterGenerateResponse(BaseModel): posterUrl: str qrCode: str expiresAt: str class LLMConfigResponse(BaseModel): currentProvider: ProviderId fallbackOrder: list[ProviderId] availableProviders: list[ProviderId] class AuditEnhanceInput(BaseModel): planId: str enhancementType: Literal["detail", "risk", "suggestion"] = "detail" baseAudit: dict | None = None class Recommendation(BaseModel): title: str detail: str priority: Literal["low", "medium", "high"] class AuditEnhancementResponse(BaseModel): summary: str recommendations: list[Recommendation] provider: ProviderId class ShareLinkStatsResponse(BaseModel): views: int uniqueVisitors: int lastAccessedAt: str | None = None _CATALOG_ROOT = Path(__file__).resolve().parents[2] / "data" / "majors_catalog" _FALLBACK_PROVINCE = "湖南" _SCHOOLS = [ SchoolItem(id="10001", name="北京大学", province="北京", is985=True, is211=True), SchoolItem(id="10532", name="湖南大学", province="湖南", is985=True, is211=True), SchoolItem(id="10533", name="中南大学", province="湖南", is985=True, is211=True), SchoolItem(id="10536", name="长沙理工大学", province="湖南", is985=False, is211=False), ] _ONE_PIXEL_PNG = base64.b64decode( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=" ) def _now_iso() -> str: return datetime.now(timezone.utc).isoformat() def _review_store(request: Request) -> dict[str, ReviewStatusResponse]: store = getattr(request.app.state, "react_sprint3_reviews", None) if not isinstance(store, dict): store = {} request.app.state.react_sprint3_reviews = store return store def _catalog_loader() -> MajorsCatalogLoader: return MajorsCatalogLoader.from_catalog_root(_CATALOG_ROOT) @router.get("/data-query/score-line", response_model=ScoreLineResponse) def get_score_line( province: str, year: int, scoreType: ScoreType, ) -> ScoreLineResponse: base_score = 422 if scoreType == "physics" else 438 return ScoreLineResponse( province=province or _FALLBACK_PROVINCE, year=year, scoreType=scoreType, lines=[ ScoreLineItem(batch="本科批", score=base_score, rank=102000), ScoreLineItem(batch="特殊类型招生控制线", score=base_score + 55, rank=42000), ], ) @router.get("/data-query/rank-estimator", response_model=RankEstimatorResponse) def get_rank_estimator( province: str, year: int, scoreType: ScoreType, rank: int, ) -> RankEstimatorResponse: if rank < 0: raise HTTPException(status_code=422, detail="rank must be non-negative") equivalent = max(150, 680 - rank / 350) if scoreType == "history": equivalent -= 12 return RankEstimatorResponse( province=province or _FALLBACK_PROVINCE, year=year, scoreType=scoreType, rank=rank, equivalentScore=round(equivalent, 1), ) @router.get("/data-query/majors", response_model=MajorsResponse) def list_majors(keyword: str | None = None) -> MajorsResponse: needle = (keyword or "").strip() majors = [] for major in _catalog_loader()._majors: # existing loader has no public list method if needle and needle not in major.name and needle not in major.code: continue majors.append(MajorItem(id=major.code, name=major.name, category=major.category)) if len(majors) >= 20: break return MajorsResponse(majors=majors, total=len(majors)) @router.get("/data-query/schools", response_model=SchoolsResponse) def list_schools(keyword: str | None = None) -> SchoolsResponse: needle = (keyword or "").strip() schools = [ school for school in _SCHOOLS if not needle or needle in school.name or needle in school.province or needle in school.id ] return SchoolsResponse(schools=schools, total=len(schools)) @router.post("/review/start", response_model=ReviewStatusResponse) def start_review(payload: ReviewStartInput, request: Request) -> ReviewStatusResponse: review = ReviewStatusResponse( id=f"rvw_{secrets.token_hex(4)}", planId=payload.planId, status="in_progress", reviewerId=payload.reviewerId, comment=None, updatedAt=_now_iso(), ) _review_store(request)[review.id] = review return review @router.get("/review/{review_id}/status", response_model=ReviewStatusResponse) def get_review_status(review_id: str, request: Request) -> ReviewStatusResponse: review = _review_store(request).get(review_id) if review is None: raise HTTPException(status_code=404, detail="review not found") return review @router.post("/review/action", response_model=ReviewStatusResponse) def apply_review_action(payload: ReviewActionInput, request: Request) -> ReviewStatusResponse: review = _review_store(request).get(payload.reviewId) if review is None: raise HTTPException(status_code=404, detail="review not found") next_status: ReviewStatus = { "approve": "approved", "reject": "rejected", "request_changes": "changes_requested", }[payload.action] updated = review.model_copy( update={ "status": next_status, "comment": payload.comment, "updatedAt": _now_iso(), } ) _review_store(request)[updated.id] = updated return updated @router.post("/poster/generate", response_model=PosterGenerateResponse) def generate_poster(payload: PosterGenerateInput, request: Request) -> PosterGenerateResponse: poster_dir = Path(__file__).resolve().parent.parent / "static" / "posters" poster_dir.mkdir(parents=True, exist_ok=True) filename = f"{payload.planId}-{payload.template}-{secrets.token_hex(3)}.png" (poster_dir / filename).write_bytes(_ONE_PIXEL_PNG) expires_at = datetime.now(timezone.utc) + timedelta(days=7) base = str(request.base_url).rstrip("/") url = f"{base}/static/posters/{filename}" return PosterGenerateResponse( posterUrl=url, qrCode=url, expiresAt=expires_at.isoformat(), ) @router.get("/llm/config", response_model=LLMConfigResponse) def get_llm_config() -> LLMConfigResponse: order: list[ProviderId] = ["claude", "gpt", "gemini", "deepseek"] return LLMConfigResponse( currentProvider="claude", fallbackOrder=order, availableProviders=order, ) @router.post("/llm/{provider}/enhance", response_model=AuditEnhancementResponse) def enhance_audit(provider: ProviderId, payload: AuditEnhanceInput) -> AuditEnhancementResponse: return AuditEnhancementResponse( summary=f"{provider} enhancement for {payload.planId}", provider=provider, recommendations=[ Recommendation( title="Review plan assumptions", detail="Verify province, score type, rank, and batch before final submission.", priority="medium", ) ], ) @router.get("/share-link/{code}/stats", response_model=ShareLinkStatsResponse) def get_share_link_stats(code: str, request: Request) -> ShareLinkStatsResponse: settings = getattr(request.app.state, "settings", None) if settings is None: raise HTTPException(status_code=500, detail="settings unavailable") stats = ShortLinkService(db_path=settings.share_db_path).get_stats(code) if stats is None: raise HTTPException(status_code=404, detail="share link not found") return ShareLinkStatsResponse( views=int(stats.get("access_count") or 0), uniqueVisitors=int(stats.get("unique_visitors") or 0), lastAccessedAt=stats.get("last_access_at_iso"), )