diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..7971ccba981f0acf81fb8da914905c26ef0fef5d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +.git +.turbo +node_modules +apps/web/node_modules +apps/web/.next +apps/api/.venv +apps/api/venv +apps/api/__pycache__ +apps/api/src/**/__pycache__ +apps/api/alembic/**/__pycache__ +*.log +.env +.env.* +!.env.example diff --git a/.gitattributes b/.gitattributes index a6344aac8c09253b3b630fb776ae94478aa0275b..643b0b3fc6a4a5e1610505671ba831faf041bb49 100644 --- a/.gitattributes +++ b/.gitattributes @@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text *.zip filter=lfs diff=lfs merge=lfs -text *.zst filter=lfs diff=lfs merge=lfs -text *tfevents* filter=lfs diff=lfs merge=lfs -text +apps/web/public/applymap-logo.png filter=lfs diff=lfs merge=lfs -text diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..9deec7cabceafc0c1df760c8fecb013f5614fbde --- /dev/null +++ b/Dockerfile @@ -0,0 +1,49 @@ +FROM node:20-bookworm-slim + +ENV DEBIAN_FRONTEND=noninteractive \ + NEXT_TELEMETRY_DISABLED=1 \ + PORT=7860 \ + NEXT_PUBLIC_API_URL=/ \ + INTERNAL_API_URL=http://127.0.0.1:8000 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + bash \ + build-essential \ + ca-certificates \ + curl \ + libpq-dev \ + python3 \ + python3-pip \ + python3-venv \ + && rm -rf /var/lib/apt/lists/* + +RUN useradd -m -u 1000 user + +WORKDIR /home/user/app + +COPY --chown=user:user package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY --chown=user:user apps/web/package.json apps/web/package.json + +RUN corepack enable \ + && corepack prepare pnpm@8.15.0 --activate \ + && pnpm install --filter @applymap/web... --frozen-lockfile + +COPY --chown=user:user apps/api/requirements.txt apps/api/requirements.txt + +RUN python3 -m venv /home/user/venv \ + && /home/user/venv/bin/pip install --no-cache-dir --upgrade pip \ + && /home/user/venv/bin/pip install --no-cache-dir -r apps/api/requirements.txt + +COPY --chown=user:user apps/web apps/web +COPY --chown=user:user apps/api apps/api +COPY --chown=user:user start.sh start.sh + +RUN chmod +x start.sh \ + && pnpm --filter @applymap/web build + +USER user + +EXPOSE 7860 + +CMD ["./start.sh"] diff --git a/README.md b/README.md index 80f984c687fdda175c61c8e90f718c051e5bd55f..bdee8246873eba9f47b6655ecfbf51263a6e5544 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,13 @@ --- title: ApplyMap -emoji: 🐨 -colorFrom: purple -colorTo: yellow -sdk: gradio -sdk_version: 6.12.0 -app_file: app.py -pinned: false -short_description: Program designed to help students to apply for universities +emoji: 🧭 +colorFrom: blue +colorTo: green +sdk: docker +app_port: 7860 +startup_duration_timeout: 30m --- -Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference +# ApplyMap + +Accessible admissions guidance platform for Kazakhstan-focused applicants. diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..40c89ff4d811bbbd6f50fa6a23628a69e0e1b8fa --- /dev/null +++ b/apps/api/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.11-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8000 + +CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/apps/api/alembic.ini b/apps/api/alembic.ini new file mode 100644 index 0000000000000000000000000000000000000000..ff0603372c026b6607f1071b897658e3ffbb24f9 --- /dev/null +++ b/apps/api/alembic.ini @@ -0,0 +1,41 @@ +[alembic] +script_location = alembic +prepend_sys_path = . +version_path_separator = os +sqlalchemy.url = postgresql://applymap:applymap@localhost:5432/applymap_db + +[post_write_hooks] + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/apps/api/alembic/env.py b/apps/api/alembic/env.py new file mode 100644 index 0000000000000000000000000000000000000000..b03e167362aeffd529a31da0b85348c4cbcfb963 --- /dev/null +++ b/apps/api/alembic/env.py @@ -0,0 +1,57 @@ +import os +import sys +from logging.config import fileConfig + +from sqlalchemy import engine_from_config, pool +from alembic import context + +# Add src to path +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from src.database import Base +from src.models import * # noqa: F401 - import all models so metadata is populated + +config = context.config + +# Override sqlalchemy.url from env if available +database_url = os.environ.get("DATABASE_URL") +if database_url: + config.set_main_option("sqlalchemy.url", database_url) + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + ) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/apps/api/alembic/versions/001_initial.py b/apps/api/alembic/versions/001_initial.py new file mode 100644 index 0000000000000000000000000000000000000000..52e86fb94dd4c48a053fd05a713caf460d31b056 --- /dev/null +++ b/apps/api/alembic/versions/001_initial.py @@ -0,0 +1,212 @@ +"""Initial schema + +Revision ID: 001 +Revises: +Create Date: 2024-01-01 00:00:00.000000 + +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision: str = "001" +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # users + op.create_table( + "users", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("email", sa.String(255), unique=True, nullable=False), + sa.Column("password_hash", sa.String(255), nullable=True), + sa.Column("role", sa.Enum("student", "admin", name="userrole"), default="student", nullable=False), + sa.Column("full_name", sa.String(255), nullable=True), + sa.Column("country", sa.String(100), nullable=True), + sa.Column("created_at", sa.DateTime, nullable=False), + sa.Column("updated_at", sa.DateTime, nullable=False), + ) + op.create_index("ix_users_email", "users", ["email"]) + + # student_profiles + op.create_table( + "student_profiles", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), unique=True, nullable=False), + sa.Column("graduation_year", sa.Integer, nullable=True), + sa.Column("curriculum", sa.String(100), nullable=True), + sa.Column("intended_major", sa.String(255), nullable=True), + sa.Column("sat_score", sa.Integer, nullable=True), + sa.Column("act_score", sa.Integer, nullable=True), + sa.Column("ielts_score", sa.String(10), nullable=True), + sa.Column("toefl_score", sa.Integer, nullable=True), + sa.Column("budget_range", sa.String(100), nullable=True), + sa.Column("aid_needed", sa.Boolean, nullable=True), + sa.Column("created_at", sa.DateTime, nullable=False), + sa.Column("updated_at", sa.DateTime, nullable=False), + ) + + # universities + op.create_table( + "universities", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("slug", sa.String(100), unique=True, nullable=False), + sa.Column("name", sa.String(255), nullable=False), + sa.Column("country", sa.String(100), nullable=False), + sa.Column("application_system", sa.String(100), nullable=True), + sa.Column("short_description", sa.Text, nullable=True), + sa.Column("weight_preset", sa.Enum("research_heavy", "leadership_heavy", "balanced_holistic", "community_service_heavy", name="weightpreset"), nullable=False), + sa.Column("is_active", sa.Boolean, default=True, nullable=False), + sa.Column("created_at", sa.DateTime, nullable=False), + sa.Column("updated_at", sa.DateTime, nullable=False), + ) + op.create_index("ix_universities_slug", "universities", ["slug"]) + + # university_policy_entries + op.create_table( + "university_policy_entries", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("university_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("universities.id", ondelete="CASCADE"), nullable=False), + sa.Column("title", sa.String(500), nullable=False), + sa.Column("content", sa.Text, nullable=False), + sa.Column("source_url", sa.String(1000), nullable=True), + sa.Column("source_title", sa.String(500), nullable=True), + sa.Column("source_type", sa.Enum("official", "public_example", name="sourcetype"), nullable=False), + sa.Column("reliability_tier", sa.Enum("A", "B", "C", "D", name="reliabilitytier"), nullable=False), + sa.Column("excerpt", sa.Text, nullable=True), + sa.Column("created_at", sa.DateTime, nullable=False), + sa.Column("updated_at", sa.DateTime, nullable=False), + ) + op.create_index("ix_university_policy_entries_university_id", "university_policy_entries", ["university_id"]) + + # achievements + op.create_table( + "achievements", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("type", sa.Enum("activity", "honor", name="achievementtype"), nullable=False), + sa.Column("title", sa.String(500), nullable=False), + sa.Column("organization_name", sa.String(255), nullable=True), + sa.Column("role_title", sa.String(255), nullable=True), + sa.Column("description_raw", sa.Text, nullable=True), + sa.Column("category", sa.String(100), nullable=True), + sa.Column("start_date", sa.Date, nullable=True), + sa.Column("end_date", sa.Date, nullable=True), + sa.Column("hours_per_week", sa.Float, nullable=True), + sa.Column("weeks_per_year", sa.Integer, nullable=True), + sa.Column("impact_scope", sa.Enum("school", "local", "regional", "national", "international", "family", "personal", name="impactscope"), nullable=True), + sa.Column("leadership_level", sa.Enum("none", "member", "lead", "founder", "captain", name="leadershiplevel"), nullable=True), + sa.Column("major_relevance_score", sa.Float, nullable=True), + sa.Column("continuity_score", sa.Float, nullable=True), + sa.Column("selectivity_score", sa.Float, nullable=True), + sa.Column("distinctiveness_score", sa.Float, nullable=True), + sa.Column("truth_risk_flag", sa.Boolean, nullable=True), + sa.Column("created_at", sa.DateTime, nullable=False), + sa.Column("updated_at", sa.DateTime, nullable=False), + ) + op.create_index("ix_achievements_user_id", "achievements", ["user_id"]) + + # achievement_evidence_files + op.create_table( + "achievement_evidence_files", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("achievement_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("achievements.id", ondelete="CASCADE"), nullable=False), + sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("file_url", sa.String(1000), nullable=False), + sa.Column("file_name", sa.String(500), nullable=False), + sa.Column("mime_type", sa.String(100), nullable=True), + sa.Column("uploaded_at", sa.DateTime, nullable=False), + ) + + # target_universities + op.create_table( + "target_universities", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("university_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("universities.id", ondelete="CASCADE"), nullable=False), + sa.Column("priority_order", sa.Integer, nullable=True), + sa.Column("created_at", sa.DateTime, nullable=False), + ) + op.create_index("ix_target_universities_user_id", "target_universities", ["user_id"]) + + # optimization_reports + op.create_table( + "optimization_reports", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("university_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("universities.id", ondelete="CASCADE"), nullable=False), + sa.Column("status", sa.Enum("pending", "processing", "completed", "failed", name="reportstatus"), nullable=False), + sa.Column("summary_text", sa.Text, nullable=True), + sa.Column("version_number", sa.Integer, default=1, nullable=False), + sa.Column("created_at", sa.DateTime, nullable=False), + sa.Column("completed_at", sa.DateTime, nullable=True), + ) + op.create_index("ix_optimization_reports_user_id", "optimization_reports", ["user_id"]) + + # report_recommendations + op.create_table( + "report_recommendations", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("report_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("optimization_reports.id", ondelete="CASCADE"), nullable=False), + sa.Column("achievement_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("achievements.id", ondelete="CASCADE"), nullable=False), + sa.Column("recommendation_type", sa.Enum("keep", "remove", "merge", "rewrite", "reorder", name="recommendationtype"), nullable=False), + sa.Column("suggested_rank", sa.Integer, nullable=True), + sa.Column("rationale", sa.Text, nullable=True), + sa.Column("confidence_label", sa.Enum("low", "medium", "high", name="confidencelabel"), nullable=False), + sa.Column("created_at", sa.DateTime, nullable=False), + ) + + # rewrite_variants + op.create_table( + "rewrite_variants", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("achievement_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("achievements.id", ondelete="CASCADE"), nullable=False), + sa.Column("report_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("optimization_reports.id", ondelete="CASCADE"), nullable=False), + sa.Column("style_mode", sa.String(50), nullable=False), + sa.Column("text", sa.Text, nullable=False), + sa.Column("character_count", sa.Integer, nullable=False), + sa.Column("is_recommended", sa.Boolean, default=False), + sa.Column("explanation", sa.Text, nullable=True), + sa.Column("created_at", sa.DateTime, nullable=False), + ) + + # source_references + op.create_table( + "source_references", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("report_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("optimization_reports.id", ondelete="CASCADE"), nullable=False), + sa.Column("university_policy_entry_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("university_policy_entries.id", ondelete="CASCADE"), nullable=False), + sa.Column("section", sa.Enum("official_guidance", "public_examples", "recommendation_support", name="sourcesection"), nullable=False), + sa.Column("note", sa.Text, nullable=True), + sa.Column("created_at", sa.DateTime, nullable=False), + ) + + # admin_audit_logs + op.create_table( + "admin_audit_logs", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("admin_user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("action", sa.String(255), nullable=False), + sa.Column("entity_type", sa.String(100), nullable=True), + sa.Column("entity_id", sa.String(255), nullable=True), + sa.Column("metadata_json", postgresql.JSON, nullable=True), + sa.Column("created_at", sa.DateTime, nullable=False), + ) + + +def downgrade() -> None: + op.drop_table("admin_audit_logs") + op.drop_table("source_references") + op.drop_table("rewrite_variants") + op.drop_table("report_recommendations") + op.drop_table("optimization_reports") + op.drop_table("target_universities") + op.drop_table("achievement_evidence_files") + op.drop_table("achievements") + op.drop_table("university_policy_entries") + op.drop_table("universities") + op.drop_table("student_profiles") + op.drop_table("users") diff --git a/apps/api/requirements.txt b/apps/api/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..dd8934d95ea8e54ea7cfdccc0ab190830adba64c --- /dev/null +++ b/apps/api/requirements.txt @@ -0,0 +1,19 @@ +fastapi==0.111.0 +uvicorn[standard]==0.29.0 +sqlalchemy==2.0.30 +alembic==1.13.1 +psycopg2-binary==2.9.9 +pydantic==2.7.1 +pydantic-settings==2.2.1 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +bcrypt==4.0.1 +python-multipart==0.0.9 +httpx==0.27.0 +email-validator==2.1.1 +python-dotenv==1.0.1 +aiofiles==23.2.1 +Pillow==10.3.0 +boto3==1.34.100 +pdfplumber==0.11.4 +python-docx==1.1.2 diff --git a/apps/api/src/__init__.py b/apps/api/src/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/apps/api/src/config.py b/apps/api/src/config.py new file mode 100644 index 0000000000000000000000000000000000000000..90554c0465b59ff180aba56c213716aad9a1521c --- /dev/null +++ b/apps/api/src/config.py @@ -0,0 +1,30 @@ +from pydantic_settings import BaseSettings +from typing import List + + +class Settings(BaseSettings): + DATABASE_URL: str = "postgresql://applymap:applymap@localhost:5432/applymap_db" + SECRET_KEY: str = "dev-secret-key-change-in-production" + ALGORITHM: str = "HS256" + ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 + REFRESH_TOKEN_EXPIRE_DAYS: int = 30 + BACKEND_CORS_ORIGINS: List[str] = ["http://localhost:3000"] + + # AI Chancellor + GEMINI_API_KEY: str = "" + GEMINI_MODEL: str = "gemini-2.5-flash" + GOOGLE_SEARCH_API_KEY: str = "" + GOOGLE_SEARCH_ENGINE_ID: str = "" + + # S3 / Storage + S3_BUCKET_NAME: str = "" + AWS_ACCESS_KEY_ID: str = "" + AWS_SECRET_ACCESS_KEY: str = "" + AWS_REGION: str = "us-east-1" + + class Config: + env_file = ".env" + extra = "ignore" + + +settings = Settings() diff --git a/apps/api/src/database.py b/apps/api/src/database.py new file mode 100644 index 0000000000000000000000000000000000000000..9df06bb395094181c85366477187a9fe7d698555 --- /dev/null +++ b/apps/api/src/database.py @@ -0,0 +1,23 @@ +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker +from .config import settings + +engine = create_engine( + settings.DATABASE_URL, + pool_pre_ping=True, + pool_size=10, + max_overflow=20, +) + +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +Base = declarative_base() + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/apps/api/src/main.py b/apps/api/src/main.py new file mode 100644 index 0000000000000000000000000000000000000000..71ccb4d5fc9951d91405b713cc3c49ff891b7a86 --- /dev/null +++ b/apps/api/src/main.py @@ -0,0 +1,47 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse + +from .config import settings +from .database import Base, engine +from .routes import auth, profile, achievements, universities, reports, admin +from .schema_maintenance import ensure_application_schema + +# Create tables on startup (use Alembic migrations in production) +Base.metadata.create_all(bind=engine) +ensure_application_schema() + +app = FastAPI( + title="ApplyMap API", + description="Source-backed Common App optimization for international applicants", + version="0.1.0", + docs_url="/docs", + redoc_url="/redoc", +) + +# CORS +app.add_middleware( + CORSMiddleware, + allow_origins=settings.BACKEND_CORS_ORIGINS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Include routers +app.include_router(auth.router) +app.include_router(profile.router) +app.include_router(achievements.router) +app.include_router(universities.router) +app.include_router(reports.router) +app.include_router(admin.router) + + +@app.get("/health") +def health_check(): + return {"status": "ok", "service": "applymap-api"} + + +@app.get("/") +def root(): + return {"message": "ApplyMap API", "docs": "/docs"} diff --git a/apps/api/src/models/__init__.py b/apps/api/src/models/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..0e9cd67b5f68b3feb116a897a6464b40a15f76c2 --- /dev/null +++ b/apps/api/src/models/__init__.py @@ -0,0 +1,26 @@ +from .user import User, StudentProfile +from .achievement import Achievement, AchievementEvidenceFile +from .university import University, UniversityPolicyEntry +from .report import ( + TargetUniversity, + OptimizationReport, + ReportRecommendation, + RewriteVariant, + SourceReference, + AdminAuditLog, +) + +__all__ = [ + "User", + "StudentProfile", + "Achievement", + "AchievementEvidenceFile", + "University", + "UniversityPolicyEntry", + "TargetUniversity", + "OptimizationReport", + "ReportRecommendation", + "RewriteVariant", + "SourceReference", + "AdminAuditLog", +] diff --git a/apps/api/src/models/achievement.py b/apps/api/src/models/achievement.py new file mode 100644 index 0000000000000000000000000000000000000000..62dd894e9b13ed7987d3a53fa1b596f18fc0c226 --- /dev/null +++ b/apps/api/src/models/achievement.py @@ -0,0 +1,86 @@ +import uuid +from datetime import datetime, date +from sqlalchemy import Column, String, Boolean, DateTime, Date, ForeignKey, Enum, Integer, Float, Text +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship +import enum + +from ..database import Base + + +class AchievementType(str, enum.Enum): + activity = "activity" + honor = "honor" + + +class ImpactScope(str, enum.Enum): + school = "school" + local = "local" + regional = "regional" + national = "national" + international = "international" + family = "family" + personal = "personal" + + +class LeadershipLevel(str, enum.Enum): + none = "none" + member = "member" + lead = "lead" + founder = "founder" + captain = "captain" + + +class Achievement(Base): + __tablename__ = "achievements" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + + type = Column(Enum(AchievementType), nullable=False) + title = Column(String(500), nullable=False) + organization_name = Column(String(255), nullable=True) + role_title = Column(String(255), nullable=True) + description_raw = Column(Text, nullable=True) + category = Column(String(100), nullable=True) # e.g. "Science", "Arts", "Community Service" + + start_date = Column(Date, nullable=True) + end_date = Column(Date, nullable=True) + hours_per_week = Column(Float, nullable=True) + weeks_per_year = Column(Integer, nullable=True) + + impact_scope = Column(Enum(ImpactScope), nullable=True) + leadership_level = Column(Enum(LeadershipLevel), nullable=True) + + # Computed Chancellor scores (0-10) + major_relevance_score = Column(Float, nullable=True) + continuity_score = Column(Float, nullable=True) + selectivity_score = Column(Float, nullable=True) + distinctiveness_score = Column(Float, nullable=True) + + truth_risk_flag = Column(Boolean, nullable=True, default=False) + + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + # Relationships + user = relationship("User", back_populates="achievements") + evidence_files = relationship("AchievementEvidenceFile", back_populates="achievement", cascade="all, delete-orphan") + recommendations = relationship("ReportRecommendation", back_populates="achievement", cascade="all, delete-orphan") + rewrite_variants = relationship("RewriteVariant", back_populates="achievement", cascade="all, delete-orphan") + + +class AchievementEvidenceFile(Base): + __tablename__ = "achievement_evidence_files" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + achievement_id = Column(UUID(as_uuid=True), ForeignKey("achievements.id", ondelete="CASCADE"), nullable=False) + user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + file_url = Column(String(1000), nullable=False) + file_name = Column(String(500), nullable=False) + mime_type = Column(String(100), nullable=True) + uploaded_at = Column(DateTime, default=datetime.utcnow, nullable=False) + + # Relationships + achievement = relationship("Achievement", back_populates="evidence_files") + user = relationship("User", back_populates="evidence_files") diff --git a/apps/api/src/models/report.py b/apps/api/src/models/report.py new file mode 100644 index 0000000000000000000000000000000000000000..e9e0ce3080d48aad37dc5b24dad3b6fafd4e9433 --- /dev/null +++ b/apps/api/src/models/report.py @@ -0,0 +1,133 @@ +import uuid +from datetime import datetime +from sqlalchemy import Column, String, Boolean, DateTime, ForeignKey, Enum, Integer, Text, JSON +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship +import enum + +from ..database import Base + + +class ReportStatus(str, enum.Enum): + pending = "pending" + processing = "processing" + completed = "completed" + failed = "failed" + + +class RecommendationType(str, enum.Enum): + keep = "keep" + remove = "remove" + merge = "merge" + rewrite = "rewrite" + reorder = "reorder" + + +class ConfidenceLabel(str, enum.Enum): + low = "low" + medium = "medium" + high = "high" + + +class SourceSection(str, enum.Enum): + official_guidance = "official_guidance" + public_examples = "public_examples" + recommendation_support = "recommendation_support" + + +class TargetUniversity(Base): + __tablename__ = "target_universities" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + university_id = Column(UUID(as_uuid=True), ForeignKey("universities.id", ondelete="CASCADE"), nullable=False) + priority_order = Column(Integer, nullable=True) + fit_category = Column(String(20), default="target", nullable=False) + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + + # Relationships + user = relationship("User", back_populates="target_universities") + university = relationship("University", back_populates="target_universities") + + +class OptimizationReport(Base): + __tablename__ = "optimization_reports" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + university_id = Column(UUID(as_uuid=True), ForeignKey("universities.id", ondelete="CASCADE"), nullable=False) + status = Column(Enum(ReportStatus), default=ReportStatus.pending, nullable=False) + summary_text = Column(Text, nullable=True) + advisor_snapshot_json = Column(JSON, nullable=True) + version_number = Column(Integer, default=1, nullable=False) + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + completed_at = Column(DateTime, nullable=True) + + # Relationships + user = relationship("User", back_populates="reports") + university = relationship("University", back_populates="reports") + recommendations = relationship("ReportRecommendation", back_populates="report", cascade="all, delete-orphan") + rewrite_variants = relationship("RewriteVariant", back_populates="report", cascade="all, delete-orphan") + source_references = relationship("SourceReference", back_populates="report", cascade="all, delete-orphan") + + +class ReportRecommendation(Base): + __tablename__ = "report_recommendations" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + report_id = Column(UUID(as_uuid=True), ForeignKey("optimization_reports.id", ondelete="CASCADE"), nullable=False) + achievement_id = Column(UUID(as_uuid=True), ForeignKey("achievements.id", ondelete="CASCADE"), nullable=False) + recommendation_type = Column(Enum(RecommendationType), nullable=False) + suggested_rank = Column(Integer, nullable=True) + rationale = Column(Text, nullable=True) + confidence_label = Column(Enum(ConfidenceLabel), nullable=False, default=ConfidenceLabel.medium) + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + + # Relationships + report = relationship("OptimizationReport", back_populates="recommendations") + achievement = relationship("Achievement", back_populates="recommendations") + + +class RewriteVariant(Base): + __tablename__ = "rewrite_variants" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + achievement_id = Column(UUID(as_uuid=True), ForeignKey("achievements.id", ondelete="CASCADE"), nullable=False) + report_id = Column(UUID(as_uuid=True), ForeignKey("optimization_reports.id", ondelete="CASCADE"), nullable=False) + style_mode = Column(String(50), nullable=False) # factual, impact_first, understated + text = Column(Text, nullable=False) + character_count = Column(Integer, nullable=False) + is_recommended = Column(Boolean, default=False) + explanation = Column(Text, nullable=True) + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + + # Relationships + achievement = relationship("Achievement", back_populates="rewrite_variants") + report = relationship("OptimizationReport", back_populates="rewrite_variants") + + +class SourceReference(Base): + __tablename__ = "source_references" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + report_id = Column(UUID(as_uuid=True), ForeignKey("optimization_reports.id", ondelete="CASCADE"), nullable=False) + university_policy_entry_id = Column(UUID(as_uuid=True), ForeignKey("university_policy_entries.id", ondelete="CASCADE"), nullable=False) + section = Column(Enum(SourceSection), nullable=False) + note = Column(Text, nullable=True) + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + + # Relationships + report = relationship("OptimizationReport", back_populates="source_references") + policy_entry = relationship("UniversityPolicyEntry", back_populates="source_references") + + +class AdminAuditLog(Base): + __tablename__ = "admin_audit_logs" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + admin_user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + action = Column(String(255), nullable=False) + entity_type = Column(String(100), nullable=True) + entity_id = Column(String(255), nullable=True) + metadata_json = Column(JSON, nullable=True) + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) diff --git a/apps/api/src/models/university.py b/apps/api/src/models/university.py new file mode 100644 index 0000000000000000000000000000000000000000..6c07ecf4b3663355ef22fe37ae976b68167797fc --- /dev/null +++ b/apps/api/src/models/university.py @@ -0,0 +1,84 @@ +import uuid +from datetime import datetime +from sqlalchemy import Column, String, Boolean, DateTime, ForeignKey, Enum, Text, Integer, JSON +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship +import enum + +from ..database import Base + + +class WeightPreset(str, enum.Enum): + research_heavy = "research_heavy" + leadership_heavy = "leadership_heavy" + balanced_holistic = "balanced_holistic" + community_service_heavy = "community_service_heavy" + + +class SourceType(str, enum.Enum): + official = "official" + public_example = "public_example" + + +class ReliabilityTier(str, enum.Enum): + A = "A" + B = "B" + C = "C" + D = "D" + + +class University(Base): + __tablename__ = "universities" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + slug = Column(String(100), unique=True, nullable=False, index=True) + name = Column(String(255), nullable=False) + country = Column(String(100), nullable=False) + application_system = Column(String(100), nullable=True) # CommonApp, Coalition, etc. + application_source_url = Column(String(1000), nullable=True) + short_description = Column(Text, nullable=True) + weight_preset = Column(Enum(WeightPreset), nullable=False, default=WeightPreset.balanced_holistic) + region = Column(String(100), nullable=True) + city = Column(String(255), nullable=True) + is_common_app = Column(Boolean, default=False, nullable=False) + teaching_languages = Column(JSON, nullable=True) + major_strengths = Column(JSON, nullable=True) + education_years_required = Column(Integer, nullable=True) + school_years_note = Column(Text, nullable=True) + aid_type = Column(String(100), nullable=True) + aid_strength = Column(Integer, nullable=True) + selectivity_score = Column(Integer, nullable=True) + full_ride_possible = Column(Boolean, default=False, nullable=False) + full_tuition_possible = Column(Boolean, default=False, nullable=False) + aid_notes = Column(Text, nullable=True) + funding_source_url = Column(String(1000), nullable=True) + funding_source_title = Column(String(500), nullable=True) + eligibility_notes = Column(Text, nullable=True) + is_active = Column(Boolean, default=True, nullable=False) + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + # Relationships + policy_entries = relationship("UniversityPolicyEntry", back_populates="university", cascade="all, delete-orphan") + target_universities = relationship("TargetUniversity", back_populates="university") + reports = relationship("OptimizationReport", back_populates="university") + + +class UniversityPolicyEntry(Base): + __tablename__ = "university_policy_entries" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + university_id = Column(UUID(as_uuid=True), ForeignKey("universities.id", ondelete="CASCADE"), nullable=False, index=True) + title = Column(String(500), nullable=False) + content = Column(Text, nullable=False) + source_url = Column(String(1000), nullable=True) + source_title = Column(String(500), nullable=True) + source_type = Column(Enum(SourceType), nullable=False) + reliability_tier = Column(Enum(ReliabilityTier), nullable=False, default=ReliabilityTier.B) + excerpt = Column(Text, nullable=True) + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + # Relationships + university = relationship("University", back_populates="policy_entries") + source_references = relationship("SourceReference", back_populates="policy_entry") diff --git a/apps/api/src/models/user.py b/apps/api/src/models/user.py new file mode 100644 index 0000000000000000000000000000000000000000..2ea98c5b19e75989d26cb5b45998d6f9e8faa605 --- /dev/null +++ b/apps/api/src/models/user.py @@ -0,0 +1,77 @@ +import uuid +from datetime import datetime +from sqlalchemy import Column, String, Boolean, DateTime, ForeignKey, Enum, Integer, JSON +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship +import enum + +from ..database import Base + + +class UserRole(str, enum.Enum): + student = "student" + admin = "admin" + + +class User(Base): + __tablename__ = "users" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + email = Column(String(255), unique=True, nullable=False, index=True) + password_hash = Column(String(255), nullable=True) # nullable for OAuth users + role = Column(Enum(UserRole), default=UserRole.student, nullable=False) + full_name = Column(String(255), nullable=True) + country = Column(String(100), nullable=True) + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + # Relationships + profile = relationship("StudentProfile", back_populates="user", uselist=False, cascade="all, delete-orphan") + achievements = relationship("Achievement", back_populates="user", cascade="all, delete-orphan") + target_universities = relationship("TargetUniversity", back_populates="user", cascade="all, delete-orphan") + reports = relationship("OptimizationReport", back_populates="user", cascade="all, delete-orphan") + evidence_files = relationship("AchievementEvidenceFile", back_populates="user", cascade="all, delete-orphan") + + +class StudentProfile(Base): + __tablename__ = "student_profiles" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), unique=True, nullable=False) + graduation_year = Column(Integer, nullable=True) + curriculum = Column(String(100), nullable=True) # IB, AP, A-Level, etc. + intended_major = Column(String(255), nullable=True) + + # Test scores + sat_score = Column(Integer, nullable=True) + sat_math = Column(Integer, nullable=True) + sat_ebrw = Column(Integer, nullable=True) + act_score = Column(Integer, nullable=True) + ielts_score = Column(String(10), nullable=True) # e.g. "7.5" + ielts_listening = Column(String(10), nullable=True) + ielts_reading = Column(String(10), nullable=True) + ielts_writing = Column(String(10), nullable=True) + ielts_speaking = Column(String(10), nullable=True) + toefl_score = Column(Integer, nullable=True) + toefl_reading = Column(Integer, nullable=True) + toefl_listening = Column(Integer, nullable=True) + toefl_speaking = Column(Integer, nullable=True) + toefl_writing = Column(Integer, nullable=True) + duolingo_score = Column(Integer, nullable=True) + a_level_subjects = Column(String(500), nullable=True) + a_level_predicted = Column(String(255), nullable=True) + ap_subjects = Column(String(500), nullable=True) + ib_predicted_score = Column(Integer, nullable=True) + unt_score = Column(Integer, nullable=True) + nis_grade12_certificate_gpa = Column(String(50), nullable=True) + + # Financial + budget_range = Column(String(100), nullable=True) # e.g. "50k-75k" + aid_needed = Column(Boolean, nullable=True) + application_preferences_json = Column(JSON, nullable=True) + + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + # Relationships + user = relationship("User", back_populates="profile") diff --git a/apps/api/src/repositories/__init__.py b/apps/api/src/repositories/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/apps/api/src/routes/__init__.py b/apps/api/src/routes/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/apps/api/src/routes/achievements.py b/apps/api/src/routes/achievements.py new file mode 100644 index 0000000000000000000000000000000000000000..2dc4cf790d2977f49cdeff249881698d8431ce78 --- /dev/null +++ b/apps/api/src/routes/achievements.py @@ -0,0 +1,309 @@ +import json + +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, status +from sqlalchemy.orm import Session +from typing import List, Optional +from uuid import UUID + +from ..database import get_db +from ..schemas.achievement import ( + AchievementCreate, + AchievementUpdate, + AchievementOut, + EvidenceFileOut, + AchievementImportOut, +) +from ..models.achievement import Achievement, AchievementEvidenceFile, AchievementType +from ..routes.auth import get_current_user +from ..services.chancellor_analysis import estimate_chancellor_scores +from ..services.achievement_import_service import decode_import_file, parse_achievement_import + +router = APIRouter(prefix="/api/achievements", tags=["achievements"]) + + +@router.get("", response_model=dict) +def list_achievements( + type: Optional[AchievementType] = None, + current_user=Depends(get_current_user), + db: Session = Depends(get_db), +): + query = db.query(Achievement).filter(Achievement.user_id == current_user.id) + if type: + query = query.filter(Achievement.type == type) + achievements = query.order_by(Achievement.created_at.desc()).all() + return { + "data": [AchievementOut.model_validate(a).model_dump() for a in achievements], + "message": "OK", + } + + +@router.post("", response_model=dict, status_code=status.HTTP_201_CREATED) +def create_achievement( + payload: AchievementCreate, + current_user=Depends(get_current_user), + db: Session = Depends(get_db), +): + achievement_data = payload.model_dump() + achievement_data.update(estimate_chancellor_scores(payload, current_user)) + achievement = Achievement( + user_id=current_user.id, + **achievement_data, + ) + db.add(achievement) + db.commit() + db.refresh(achievement) + return { + "data": AchievementOut.model_validate(achievement).model_dump(), + "message": "Achievement created", + } + + +@router.get("/{achievement_id}", response_model=dict) +def get_achievement( + achievement_id: UUID, + current_user=Depends(get_current_user), + db: Session = Depends(get_db), +): + achievement = db.query(Achievement).filter( + Achievement.id == achievement_id, + Achievement.user_id == current_user.id, + ).first() + if not achievement: + raise HTTPException(status_code=404, detail="Achievement not found") + return { + "data": AchievementOut.model_validate(achievement).model_dump(), + "message": "OK", + } + + +@router.put("/{achievement_id}", response_model=dict) +def update_achievement( + achievement_id: UUID, + payload: AchievementUpdate, + current_user=Depends(get_current_user), + db: Session = Depends(get_db), +): + achievement = db.query(Achievement).filter( + Achievement.id == achievement_id, + Achievement.user_id == current_user.id, + ).first() + if not achievement: + raise HTTPException(status_code=404, detail="Achievement not found") + + for field, value in payload.model_dump(exclude_unset=True).items(): + setattr(achievement, field, value) + + for field, value in estimate_chancellor_scores(achievement, current_user).items(): + setattr(achievement, field, value) + + db.commit() + db.refresh(achievement) + return { + "data": AchievementOut.model_validate(achievement).model_dump(), + "message": "Achievement updated", + } + + +@router.delete("/{achievement_id}", response_model=dict) +def delete_achievement( + achievement_id: UUID, + current_user=Depends(get_current_user), + db: Session = Depends(get_db), +): + achievement = db.query(Achievement).filter( + Achievement.id == achievement_id, + Achievement.user_id == current_user.id, + ).first() + if not achievement: + raise HTTPException(status_code=404, detail="Achievement not found") + db.delete(achievement) + db.commit() + return {"data": None, "message": "Achievement deleted"} + + +@router.post("/{achievement_id}/upload", response_model=dict) +async def upload_evidence( + achievement_id: UUID, + file: UploadFile = File(...), + current_user=Depends(get_current_user), + db: Session = Depends(get_db), +): + achievement = db.query(Achievement).filter( + Achievement.id == achievement_id, + Achievement.user_id == current_user.id, + ).first() + if not achievement: + raise HTTPException(status_code=404, detail="Achievement not found") + + # In production, upload to S3; for now, store locally + file_url = f"/uploads/{achievement_id}/{file.filename}" + + evidence = AchievementEvidenceFile( + achievement_id=achievement_id, + user_id=current_user.id, + file_url=file_url, + file_name=file.filename, + mime_type=file.content_type, + ) + db.add(evidence) + db.commit() + db.refresh(evidence) + + return { + "data": EvidenceFileOut.model_validate(evidence).model_dump(), + "message": "File uploaded", + } + + +@router.post("/import-all", response_model=dict, status_code=status.HTTP_201_CREATED) +async def import_all_achievements( + file: UploadFile = File(...), + word_limit: int = Form(22), + clarification_answers: Optional[str] = Form(None), + previous_import_ids: Optional[str] = Form(None), + current_user=Depends(get_current_user), + db: Session = Depends(get_db), +): + if word_limit < 5 or word_limit > 40: + raise HTTPException(status_code=400, detail="Word limit must be between 5 and 40.") + + raw_bytes = await file.read() + try: + raw_text = decode_import_file(file.filename or "import.txt", raw_bytes) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + parsed_clarification_answers: dict[str, str] = {} + if clarification_answers: + try: + raw_answers = json.loads(clarification_answers) + if isinstance(raw_answers, dict): + parsed_clarification_answers = { + str(key): str(value).strip() + for key, value in raw_answers.items() + if str(value).strip() + } + except json.JSONDecodeError as exc: + raise HTTPException(status_code=400, detail="Invalid clarification answers JSON.") from exc + + parsed_previous_ids: list[UUID] = [] + if previous_import_ids: + try: + raw_ids = json.loads(previous_import_ids) + if isinstance(raw_ids, list): + parsed_previous_ids = [UUID(str(value)) for value in raw_ids] + except (json.JSONDecodeError, ValueError) as exc: + raise HTTPException(status_code=400, detail="Invalid previous import ids JSON.") from exc + + parsed = parse_achievement_import( + raw_text, + current_user, + word_limit, + parsed_clarification_answers, + ) + + if parsed_previous_ids: + db.query(Achievement).filter( + Achievement.user_id == current_user.id, + Achievement.id.in_(parsed_previous_ids), + ).delete(synchronize_session=False) + db.flush() + + imported_achievements: list[Achievement] = [] + selection_items: list[tuple[Achievement, dict]] = [] + + for item in parsed["items"]: + achievement = Achievement( + user_id=current_user.id, + type=AchievementType(item["type"]), + title=item["title"], + organization_name=item["organization_name"], + role_title=item["role_title"], + description_raw=item["description_raw"], + category=item["category"], + hours_per_week=item["hours_per_week"], + weeks_per_year=item["weeks_per_year"], + impact_scope=item["impact_scope"], + leadership_level=item["leadership_level"], + truth_risk_flag=item["truth_risk_flag"], + major_relevance_score=item["major_relevance_score"], + selectivity_score=item["selectivity_score"], + continuity_score=item["continuity_score"], + distinctiveness_score=item["distinctiveness_score"], + ) + db.add(achievement) + imported_achievements.append(achievement) + selection_items.append((achievement, item)) + + db.commit() + + for achievement in imported_achievements: + db.refresh(achievement) + + activity_selection = [] + honor_selection = [] + + for achievement, item in selection_items: + rank = item.get("recommended_rank") + if not rank: + continue + selection_item = { + "achievement_id": achievement.id, + "type": achievement.type, + "rank": rank, + "title": achievement.title, + "common_app_text": item["common_app_text"], + "word_count": len(item["common_app_text"].split()), + "character_count": len(item["common_app_text"]), + "common_app_position": item.get("common_app_position"), + "common_app_organization": item.get("common_app_organization"), + "common_app_activity_description": item.get("common_app_activity_description"), + "common_app_honor_description": item.get("common_app_honor_description"), + "position_character_count": len(item.get("common_app_position") or ""), + "organization_character_count": len(item.get("common_app_organization") or ""), + "activity_description_character_count": len(item.get("common_app_activity_description") or ""), + "honor_character_count": len(item.get("common_app_honor_description") or ""), + "selection_reason": item.get("selection_reason") or None, + "verification_notes": item.get("verification_notes") or [], + "missing_or_unclear_facts": item.get("missing_or_unclear_facts") or [], + } + if achievement.type == AchievementType.activity and rank <= 10: + activity_selection.append(selection_item) + if achievement.type == AchievementType.honor and rank <= 5: + honor_selection.append(selection_item) + + activity_selection.sort(key=lambda item: item["rank"]) + honor_selection.sort(key=lambda item: item["rank"]) + + return { + "data": AchievementImportOut( + file_name=file.filename or "import.txt", + word_limit=word_limit, + imported_count=len(imported_achievements), + strongest_angle=parsed["strongest_angle"], + needs_student_clarification=parsed.get("needs_student_clarification", False), + clarifying_questions=parsed.get("clarifying_questions", []), + additional_information_recommended=parsed.get("additional_information_recommended", False), + additional_information_reason=parsed.get("additional_information_reason") or None, + additional_information_draft=parsed.get("additional_information_draft") or None, + formatting_notes=parsed.get("formatting_notes", []), + extraction_notes=parsed.get("extraction_notes", []), + source_excerpts=parsed.get("source_excerpts", []), + processing_steps=[ + *parsed.get("processing_steps", []), + { + "key": "save_vault", + "label": "Save imported achievements", + "status": "complete", + "detail": f"Saved {len(imported_achievements)} extracted items to Achievement Vault.", + }, + ], + imported_achievements=[ + AchievementOut.model_validate(achievement).model_dump() + for achievement in imported_achievements + ], + top_activities=activity_selection, + top_honors=honor_selection, + ).model_dump(), + "message": "Achievements imported", + } diff --git a/apps/api/src/routes/admin.py b/apps/api/src/routes/admin.py new file mode 100644 index 0000000000000000000000000000000000000000..3418f61f16ff19094eaab83e79c558e3611109f9 --- /dev/null +++ b/apps/api/src/routes/admin.py @@ -0,0 +1,184 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from typing import List, Optional +from uuid import UUID +from datetime import datetime + +from ..database import get_db +from ..schemas.university import UniversityCreate, UniversityUpdate, UniversityOut, PolicyEntryCreate, PolicyEntryUpdate, PolicyEntryOut +from ..models.university import University, UniversityPolicyEntry +from ..models.report import AdminAuditLog +from ..routes.auth import get_admin_user + +router = APIRouter(prefix="/api/admin", tags=["admin"]) + + +def log_action(db: Session, admin_id, action: str, entity_type: str, entity_id: str, metadata: dict = None): + log = AdminAuditLog( + admin_user_id=admin_id, + action=action, + entity_type=entity_type, + entity_id=entity_id, + metadata_json=metadata or {}, + ) + db.add(log) + + +@router.post("/universities", response_model=dict, status_code=status.HTTP_201_CREATED) +def create_university( + payload: UniversityCreate, + admin=Depends(get_admin_user), + db: Session = Depends(get_db), +): + existing = db.query(University).filter(University.slug == payload.slug).first() + if existing: + raise HTTPException(status_code=400, detail="Slug already in use") + + university = University(**payload.model_dump()) + db.add(university) + db.flush() + + log_action(db, admin.id, "create_university", "university", str(university.id)) + db.commit() + db.refresh(university) + + return { + "data": UniversityOut.model_validate(university).model_dump(), + "message": "University created", + } + + +@router.put("/universities/{university_id}", response_model=dict) +def update_university( + university_id: UUID, + payload: UniversityUpdate, + admin=Depends(get_admin_user), + db: Session = Depends(get_db), +): + university = db.query(University).filter(University.id == university_id).first() + if not university: + raise HTTPException(status_code=404, detail="University not found") + + for field, value in payload.model_dump(exclude_unset=True).items(): + setattr(university, field, value) + + log_action(db, admin.id, "update_university", "university", str(university_id)) + db.commit() + db.refresh(university) + + return { + "data": UniversityOut.model_validate(university).model_dump(), + "message": "University updated", + } + + +@router.delete("/universities/{university_id}", response_model=dict) +def delete_university( + university_id: UUID, + admin=Depends(get_admin_user), + db: Session = Depends(get_db), +): + university = db.query(University).filter(University.id == university_id).first() + if not university: + raise HTTPException(status_code=404, detail="University not found") + + log_action(db, admin.id, "delete_university", "university", str(university_id)) + db.delete(university) + db.commit() + + return {"data": None, "message": "University deleted"} + + +@router.post("/universities/{university_id}/sources", response_model=dict, status_code=status.HTTP_201_CREATED) +def create_policy_entry( + university_id: UUID, + payload: PolicyEntryCreate, + admin=Depends(get_admin_user), + db: Session = Depends(get_db), +): + university = db.query(University).filter(University.id == university_id).first() + if not university: + raise HTTPException(status_code=404, detail="University not found") + + entry = UniversityPolicyEntry(university_id=university_id, **payload.model_dump()) + db.add(entry) + db.flush() + + log_action(db, admin.id, "create_policy_entry", "policy_entry", str(entry.id)) + db.commit() + db.refresh(entry) + + return { + "data": PolicyEntryOut.model_validate(entry).model_dump(), + "message": "Policy entry created", + } + + +@router.put("/sources/{entry_id}", response_model=dict) +def update_policy_entry( + entry_id: UUID, + payload: PolicyEntryUpdate, + admin=Depends(get_admin_user), + db: Session = Depends(get_db), +): + entry = db.query(UniversityPolicyEntry).filter(UniversityPolicyEntry.id == entry_id).first() + if not entry: + raise HTTPException(status_code=404, detail="Policy entry not found") + + for field, value in payload.model_dump(exclude_unset=True).items(): + setattr(entry, field, value) + + log_action(db, admin.id, "update_policy_entry", "policy_entry", str(entry_id)) + db.commit() + db.refresh(entry) + + return { + "data": PolicyEntryOut.model_validate(entry).model_dump(), + "message": "Policy entry updated", + } + + +@router.delete("/sources/{entry_id}", response_model=dict) +def delete_policy_entry( + entry_id: UUID, + admin=Depends(get_admin_user), + db: Session = Depends(get_db), +): + entry = db.query(UniversityPolicyEntry).filter(UniversityPolicyEntry.id == entry_id).first() + if not entry: + raise HTTPException(status_code=404, detail="Policy entry not found") + + log_action(db, admin.id, "delete_policy_entry", "policy_entry", str(entry_id)) + db.delete(entry) + db.commit() + + return {"data": None, "message": "Policy entry deleted"} + + +@router.get("/audit-logs", response_model=dict) +def list_audit_logs( + limit: int = 50, + offset: int = 0, + admin=Depends(get_admin_user), + db: Session = Depends(get_db), +): + logs = ( + db.query(AdminAuditLog) + .order_by(AdminAuditLog.created_at.desc()) + .offset(offset) + .limit(limit) + .all() + ) + return { + "data": [ + { + "id": str(l.id), + "action": l.action, + "entity_type": l.entity_type, + "entity_id": l.entity_id, + "created_at": l.created_at.isoformat(), + } + for l in logs + ], + "message": "OK", + } diff --git a/apps/api/src/routes/auth.py b/apps/api/src/routes/auth.py new file mode 100644 index 0000000000000000000000000000000000000000..a48e28438f386776b78dd6c5909de3079a4e7c78 --- /dev/null +++ b/apps/api/src/routes/auth.py @@ -0,0 +1,122 @@ +from fastapi import APIRouter, Depends, HTTPException, status, Response, Request +from sqlalchemy.orm import Session +from datetime import timedelta + +from ..database import get_db +from ..schemas.user import UserCreate, UserLogin, UserOut, TokenOut +from ..services.auth_service import ( + create_user, authenticate_user, create_access_token, + get_user_by_email, decode_token, get_user_by_id, +) +from ..config import settings + +router = APIRouter(prefix="/api/auth", tags=["auth"]) + + +def get_current_user(request: Request, db: Session = Depends(get_db)): + token = request.cookies.get("access_token") + if not token: + auth_header = request.headers.get("Authorization") + if auth_header and auth_header.startswith("Bearer "): + token = auth_header[7:] + + if not token: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated") + + payload = decode_token(token) + if not payload: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") + + user_id = payload.get("sub") + if not user_id: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") + + user = get_user_by_id(db, user_id) + if not user: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found") + + return user + + +def get_admin_user(current_user=Depends(get_current_user)): + if current_user.role.value != "admin": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required") + return current_user + + +@router.post("/signup", response_model=dict, status_code=status.HTTP_201_CREATED) +def signup(payload: UserCreate, response: Response, db: Session = Depends(get_db)): + existing = get_user_by_email(db, payload.email) + if existing: + raise HTTPException(status_code=400, detail="Email already registered") + + user = create_user( + db, + email=payload.email, + password=payload.password, + full_name=payload.full_name, + country=payload.country, + ) + + token = create_access_token( + data={"sub": str(user.id)}, + expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES), + ) + + response.set_cookie( + key="access_token", + value=token, + httponly=True, + samesite="lax", + max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60, + ) + + return { + "data": TokenOut( + access_token=token, + user=UserOut.model_validate(user), + ).model_dump(), + "message": "Account created successfully", + } + + +@router.post("/login", response_model=dict) +def login(payload: UserLogin, response: Response, db: Session = Depends(get_db)): + user = authenticate_user(db, payload.email, payload.password) + if not user: + raise HTTPException(status_code=401, detail="Invalid email or password") + + token = create_access_token( + data={"sub": str(user.id)}, + expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES), + ) + + response.set_cookie( + key="access_token", + value=token, + httponly=True, + samesite="lax", + max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60, + ) + + return { + "data": TokenOut( + access_token=token, + user=UserOut.model_validate(user), + ).model_dump(), + "message": "Logged in successfully", + } + + +@router.post("/logout") +def logout(response: Response): + response.delete_cookie("access_token") + return {"data": None, "message": "Logged out successfully"} + + +@router.get("/me", response_model=dict) +def me(current_user=Depends(get_current_user)): + return { + "data": UserOut.model_validate(current_user).model_dump(), + "message": "OK", + } diff --git a/apps/api/src/routes/profile.py b/apps/api/src/routes/profile.py new file mode 100644 index 0000000000000000000000000000000000000000..73dad4b46ea7cbd2a3a588fac34c76091b9c03ac --- /dev/null +++ b/apps/api/src/routes/profile.py @@ -0,0 +1,67 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from ..database import get_db +from ..schemas.user import ProfileCreate, ProfileUpdate, ProfileOut, UserOut, UserUpdate +from ..models.user import StudentProfile +from ..routes.auth import get_current_user + +router = APIRouter(prefix="/api/profile", tags=["profile"]) + + +@router.get("", response_model=dict) +def get_profile(current_user=Depends(get_current_user), db: Session = Depends(get_db)): + profile = db.query(StudentProfile).filter(StudentProfile.user_id == current_user.id).first() + if not profile: + profile = StudentProfile(user_id=current_user.id) + db.add(profile) + db.commit() + db.refresh(profile) + return { + "data": { + "user": UserOut.model_validate(current_user).model_dump(), + "profile": ProfileOut.model_validate(profile).model_dump(), + }, + "message": "OK", + } + + +@router.put("", response_model=dict) +def update_profile( + payload: ProfileUpdate, + current_user=Depends(get_current_user), + db: Session = Depends(get_db), +): + profile = db.query(StudentProfile).filter(StudentProfile.user_id == current_user.id).first() + if not profile: + profile = StudentProfile(user_id=current_user.id) + db.add(profile) + + for field, value in payload.model_dump(exclude_unset=True).items(): + setattr(profile, field, value) + + db.commit() + db.refresh(profile) + + return { + "data": ProfileOut.model_validate(profile).model_dump(), + "message": "Profile updated", + } + + +@router.put("/user", response_model=dict) +def update_user( + payload: UserUpdate, + current_user=Depends(get_current_user), + db: Session = Depends(get_db), +): + for field, value in payload.model_dump(exclude_unset=True).items(): + setattr(current_user, field, value) + + db.commit() + db.refresh(current_user) + + return { + "data": UserOut.model_validate(current_user).model_dump(), + "message": "User updated", + } diff --git a/apps/api/src/routes/reports.py b/apps/api/src/routes/reports.py new file mode 100644 index 0000000000000000000000000000000000000000..e0f0953a10c00aedbfb052e4a095f4eab222da1b --- /dev/null +++ b/apps/api/src/routes/reports.py @@ -0,0 +1,281 @@ +from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks +from sqlalchemy.orm import Session +from typing import List +from uuid import UUID +import json + +from ..database import get_db +from ..schemas.report import ReportOut, ReportDetailOut, TargetUniversityCreate, TargetUniversityOut +from ..models.report import ( + OptimizationReport, TargetUniversity, ReportStatus, +) +from ..models.achievement import Achievement +from ..models.university import University +from ..models.user import User, StudentProfile +from ..routes.auth import get_current_user +from ..services.optimization_engine import run_optimization +from ..services.rewrite_service import generate_rewrite_variants + +router = APIRouter(prefix="/api", tags=["reports"]) + + +# --- Target Universities --- + +@router.get("/targets", response_model=dict) +def list_targets(current_user=Depends(get_current_user), db: Session = Depends(get_db)): + targets = ( + db.query(TargetUniversity) + .filter(TargetUniversity.user_id == current_user.id) + .order_by(TargetUniversity.priority_order) + .all() + ) + return { + "data": [TargetUniversityOut.model_validate(t).model_dump() for t in targets], + "message": "OK", + } + + +@router.post("/targets", response_model=dict, status_code=status.HTTP_201_CREATED) +def add_target( + payload: TargetUniversityCreate, + current_user=Depends(get_current_user), + db: Session = Depends(get_db), +): + fit_category = payload.fit_category if payload.fit_category in {"dream", "target", "safe"} else "target" + # Check university exists + university = db.query(University).filter(University.id == payload.university_id).first() + if not university: + raise HTTPException(status_code=404, detail="University not found") + + # Check not already targeted + existing = db.query(TargetUniversity).filter( + TargetUniversity.user_id == current_user.id, + TargetUniversity.university_id == payload.university_id, + ).first() + if existing: + raise HTTPException(status_code=400, detail="University already in targets") + + target = TargetUniversity( + user_id=current_user.id, + university_id=payload.university_id, + priority_order=payload.priority_order, + fit_category=fit_category, + ) + db.add(target) + db.commit() + db.refresh(target) + + return { + "data": TargetUniversityOut.model_validate(target).model_dump(), + "message": "University added to targets", + } + + +@router.delete("/targets/{target_id}", response_model=dict) +def remove_target( + target_id: UUID, + current_user=Depends(get_current_user), + db: Session = Depends(get_db), +): + target = db.query(TargetUniversity).filter( + TargetUniversity.id == target_id, + TargetUniversity.user_id == current_user.id, + ).first() + if not target: + raise HTTPException(status_code=404, detail="Target not found") + db.delete(target) + db.commit() + return {"data": None, "message": "Target removed"} + + +# --- Reports --- + +def _run_report_generation(db: Session, report_id: UUID): + """Background task to generate the report.""" + report = db.query(OptimizationReport).filter(OptimizationReport.id == report_id).first() + if not report: + return + + try: + report.status = ReportStatus.processing + db.commit() + + university = db.query(University).filter(University.id == report.university_id).first() + user = db.query(User).filter(User.id == report.user_id).first() + profile = db.query(StudentProfile).filter(StudentProfile.user_id == report.user_id).first() + achievements = db.query(Achievement).filter(Achievement.user_id == report.user_id).all() + + run_optimization( + db, + report, + achievements, + university, + profile=profile, + user_country=user.country if user else None, + ) + + # Generate rewrite variants for top kept recommendations + from ..models.report import ReportRecommendation, RecommendationType + kept_recs = db.query(ReportRecommendation).filter( + ReportRecommendation.report_id == report.id, + ReportRecommendation.recommendation_type.in_([ + RecommendationType.keep, + RecommendationType.rewrite, + ]), + ).all() + + for rec in kept_recs[:15]: # Limit rewrites to top 15 + achievement = db.query(Achievement).filter(Achievement.id == rec.achievement_id).first() + if achievement: + variants = generate_rewrite_variants(db, achievement, report) + db.add_all(variants) + + db.commit() + + except Exception as e: + report.status = ReportStatus.failed + report.summary_text = f"Generation failed: {str(e)}" + db.commit() + raise + + +@router.post("/reports/generate", response_model=dict, status_code=status.HTTP_201_CREATED) +def generate_report( + university_id: UUID, + background_tasks: BackgroundTasks, + current_user=Depends(get_current_user), + db: Session = Depends(get_db), +): + university = db.query(University).filter(University.id == university_id).first() + if not university: + raise HTTPException(status_code=404, detail="University not found") + + # Check for existing pending/processing report + existing = db.query(OptimizationReport).filter( + OptimizationReport.user_id == current_user.id, + OptimizationReport.university_id == university_id, + OptimizationReport.status.in_([ReportStatus.pending, ReportStatus.processing]), + ).first() + if existing: + raise HTTPException(status_code=400, detail="A report for this university is already being processed") + + # Determine version number + prev_reports = db.query(OptimizationReport).filter( + OptimizationReport.user_id == current_user.id, + OptimizationReport.university_id == university_id, + ).count() + + report = OptimizationReport( + user_id=current_user.id, + university_id=university_id, + status=ReportStatus.pending, + version_number=prev_reports + 1, + ) + db.add(report) + db.commit() + db.refresh(report) + + # Run synchronously for MVP (can be moved to background task with proper async setup) + try: + _run_report_generation(db, report.id) + db.refresh(report) + except Exception: + pass + + return { + "data": ReportOut.model_validate(report).model_dump(), + "message": "Report generated", + } + + +@router.get("/reports", response_model=dict) +def list_reports(current_user=Depends(get_current_user), db: Session = Depends(get_db)): + reports = ( + db.query(OptimizationReport) + .filter(OptimizationReport.user_id == current_user.id) + .order_by(OptimizationReport.created_at.desc()) + .all() + ) + return { + "data": [ReportOut.model_validate(r).model_dump() for r in reports], + "message": "OK", + } + + +@router.get("/reports/{report_id}", response_model=dict) +def get_report( + report_id: UUID, + current_user=Depends(get_current_user), + db: Session = Depends(get_db), +): + report = db.query(OptimizationReport).filter( + OptimizationReport.id == report_id, + OptimizationReport.user_id == current_user.id, + ).first() + if not report: + raise HTTPException(status_code=404, detail="Report not found") + + return { + "data": ReportDetailOut.model_validate(report).model_dump(), + "message": "OK", + } + + +@router.get("/reports/{report_id}/export", response_model=dict) +def export_report( + report_id: UUID, + current_user=Depends(get_current_user), + db: Session = Depends(get_db), +): + report = db.query(OptimizationReport).filter( + OptimizationReport.id == report_id, + OptimizationReport.user_id == current_user.id, + ).first() + if not report: + raise HTTPException(status_code=404, detail="Report not found") + + detail = ReportDetailOut.model_validate(report).model_dump() + + # Build export-friendly structure + export = { + "report_id": str(report.id), + "university": detail["university"]["name"], + "generated_at": detail["created_at"], + "summary": detail["summary_text"], + "recommendations": [ + { + "rank": r["suggested_rank"], + "type": r["recommendation_type"], + "title": r["achievement"]["title"], + "rationale": r["rationale"], + "confidence": r["confidence_label"], + } + for r in detail["recommendations"] + if r["suggested_rank"] is not None + ], + "rewrite_variants": [ + { + "achievement_title": next( + (r["achievement"]["title"] for r in detail["recommendations"] if r["achievement_id"] == v["achievement_id"]), + "Unknown", + ), + "style": v["style_mode"], + "text": v["text"], + "char_count": v["character_count"], + } + for v in detail["rewrite_variants"] + if v["is_recommended"] + ], + "sources": [ + { + "section": s["section"], + "title": s["policy_entry"]["title"], + "source_type": s["policy_entry"]["source_type"], + "reliability_tier": s["policy_entry"]["reliability_tier"], + "url": s["policy_entry"]["source_url"], + } + for s in detail["source_references"] + ], + } + + return {"data": export, "message": "OK"} diff --git a/apps/api/src/routes/universities.py b/apps/api/src/routes/universities.py new file mode 100644 index 0000000000000000000000000000000000000000..065a776893849d9fe6d4c8152de1d2770ce4dc83 --- /dev/null +++ b/apps/api/src/routes/universities.py @@ -0,0 +1,217 @@ +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.orm import Session +from typing import Optional +from uuid import UUID + +from ..database import get_db +from ..schemas.university import ( + UniversityOut, + UniversityListOut, + PolicyEntryOut, + CommonAppRecommendationRequest, + UniversityAdvisorRequest, +) +from ..models.achievement import Achievement +from ..models.university import University, UniversityPolicyEntry +from ..routes.auth import get_current_user +from ..services.university_filters import enrich_university, filter_universities +from ..services.university_advisor import ( + SearchNotConfiguredError, + generate_university_action_plan, + search_university_sources, +) +from ..services.university_recommender import recommend_common_app_universities + +router = APIRouter(prefix="/api/universities", tags=["universities"]) + + +@router.get("", response_model=dict) +def list_universities( + search: Optional[str] = None, + country: Optional[str] = None, + region: Optional[str] = None, + application_system: Optional[str] = None, + teaching_language: Optional[str] = None, + major: Optional[str] = None, + school_years: Optional[int] = None, + full_ride_only: bool = False, + common_app_only: bool = False, + aid_type: Optional[str] = None, + sort_by: str = "name", + sort_dir: str = "asc", + db: Session = Depends(get_db), +): + query = db.query(University).filter(University.is_active == True) + universities = [ + enrich_university(university) + for university in query.order_by(University.name).all() + ] + universities = filter_universities( + universities, + search=search, + country=country, + region=region, + application_system=application_system, + teaching_language=teaching_language, + major=major, + school_years=school_years, + full_ride_only=full_ride_only, + common_app_only=common_app_only, + aid_type=aid_type, + sort_by=sort_by, + sort_dir=sort_dir, + ) + return { + "data": [UniversityListOut.model_validate(u).model_dump() for u in universities], + "message": "OK", + } + + +@router.post("/recommendations/common-app", response_model=dict, status_code=status.HTTP_201_CREATED) +def recommend_common_app( + payload: CommonAppRecommendationRequest, + current_user=Depends(get_current_user), + db: Session = Depends(get_db), +): + if not payload.top_honor_ids and not payload.top_activity_ids: + raise HTTPException(status_code=400, detail="Select up to 5 honors and up to 10 activities first") + + profile = current_user.profile + if not profile: + raise HTTPException(status_code=400, detail="Complete your profile before generating recommendations") + + existing_preferences = profile.application_preferences_json or {} + preferences = { + **existing_preferences, + **payload.preferences, + "top_honor_ids": [str(item) for item in payload.top_honor_ids[:5]], + "top_activity_ids": [str(item) for item in payload.top_activity_ids[:10]], + "intended_major": payload.preferences.get("intended_major") or profile.intended_major, + "curriculum": profile.curriculum, + "graduation_year": profile.graduation_year, + } + if payload.save_preferences: + profile.application_preferences_json = preferences + db.commit() + db.refresh(profile) + + achievements = db.query(Achievement).filter(Achievement.user_id == current_user.id).all() + by_id = {str(achievement.id): achievement for achievement in achievements} + selected_honors = [ + by_id[str(item)] + for item in payload.top_honor_ids[:5] + if str(item) in by_id and by_id[str(item)].type.value == "honor" + ] + selected_activities = [ + by_id[str(item)] + for item in payload.top_activity_ids[:10] + if str(item) in by_id and by_id[str(item)].type.value == "activity" + ] + if not selected_honors and not selected_activities: + raise HTTPException(status_code=400, detail="Selected achievements were not found") + + universities = [ + enrich_university(university) + for university in db.query(University).filter(University.is_active == True).all() + ] + common_app_universities = filter_universities( + universities, + common_app_only=True, + school_years=int(preferences["school_years"]) if str(preferences.get("school_years") or "").isdigit() else None, + sort_by="aid_strength", + sort_dir="desc", + ) + + recommendations = recommend_common_app_universities( + selected_honors=selected_honors, + selected_activities=selected_activities, + preferences=preferences, + universities=common_app_universities, + ) + return { + "data": { + "recommendations": recommendations, + "selected_honors": len(selected_honors), + "selected_activities": len(selected_activities), + "available_common_app_universities": len(common_app_universities), + "category_note": "Safe means relative safety within the funded Common App shortlist, not guaranteed admission or aid.", + }, + "message": "Recommendations generated", + } + + +@router.post("/advisor/plan", response_model=dict) +def university_advisor_plan( + payload: UniversityAdvisorRequest, + current_user=Depends(get_current_user), + db: Session = Depends(get_db), +): + profile = current_user.profile + intended_major = payload.intended_major or (profile.intended_major if profile else None) + search_warning = None + try: + search_results = search_university_sources(payload.university_name, intended_major) + except SearchNotConfiguredError: + search_results = [] + search_warning = ( + "Google Custom Search is not configured. Set GOOGLE_SEARCH_API_KEY and " + "GOOGLE_SEARCH_ENGINE_ID to enable source-backed live search." + ) + except Exception: + search_results = [] + search_warning = ( + "Google Custom Search is currently unavailable or misconfigured. The plan below is limited " + "to saved profile data and cannot confirm current university facts." + ) + + achievements = ( + db.query(Achievement) + .filter(Achievement.user_id == current_user.id) + .order_by(Achievement.created_at.desc()) + .limit(25) + .all() + ) + plan = generate_university_action_plan( + university_name=payload.university_name, + user=current_user, + achievements=achievements, + search_results=search_results, + ) + if search_warning: + plan.setdefault("source_notes", []) + plan["source_notes"] = [search_warning, *plan["source_notes"]] + return { + "data": { + "university_name": payload.university_name, + "sources": search_results, + "plan": plan, + }, + "message": "Advisor plan generated", + } + + +@router.get("/{university_id}", response_model=dict) +def get_university(university_id: UUID, db: Session = Depends(get_db)): + university = db.query(University).filter(University.id == university_id).first() + if not university: + raise HTTPException(status_code=404, detail="University not found") + return { + "data": UniversityOut.model_validate(university).model_dump(), + "message": "OK", + } + + +@router.get("/{university_id}/sources", response_model=dict) +def get_university_sources(university_id: UUID, db: Session = Depends(get_db)): + university = db.query(University).filter(University.id == university_id).first() + if not university: + raise HTTPException(status_code=404, detail="University not found") + + entries = db.query(UniversityPolicyEntry).filter( + UniversityPolicyEntry.university_id == university_id + ).all() + + return { + "data": [PolicyEntryOut.model_validate(e).model_dump() for e in entries], + "message": "OK", + } diff --git a/apps/api/src/schema_maintenance.py b/apps/api/src/schema_maintenance.py new file mode 100644 index 0000000000000000000000000000000000000000..62f5bd710ac8bce935b38f24d6dc25277ce292ca --- /dev/null +++ b/apps/api/src/schema_maintenance.py @@ -0,0 +1,90 @@ +from sqlalchemy import inspect, text + +from .database import engine + + +def ensure_application_schema() -> None: + inspector = inspect(engine) + student_profile_columns = {column["name"] for column in inspector.get_columns("student_profiles")} + university_columns = {column["name"] for column in inspector.get_columns("universities")} + report_columns = {column["name"] for column in inspector.get_columns("optimization_reports")} + target_university_columns = {column["name"] for column in inspector.get_columns("target_universities")} + + if "application_preferences_json" not in student_profile_columns: + column_type = "JSONB" if engine.dialect.name == "postgresql" else "JSON" + with engine.begin() as connection: + connection.execute( + text(f"ALTER TABLE student_profiles ADD COLUMN application_preferences_json {column_type}") + ) + + student_profile_column_defs = { + "sat_math": "INTEGER", + "sat_ebrw": "INTEGER", + "ielts_listening": "VARCHAR(10)", + "ielts_reading": "VARCHAR(10)", + "ielts_writing": "VARCHAR(10)", + "ielts_speaking": "VARCHAR(10)", + "toefl_reading": "INTEGER", + "toefl_listening": "INTEGER", + "toefl_speaking": "INTEGER", + "toefl_writing": "INTEGER", + "duolingo_score": "INTEGER", + "a_level_subjects": "VARCHAR(500)", + "a_level_predicted": "VARCHAR(255)", + "ap_subjects": "VARCHAR(500)", + "ib_predicted_score": "INTEGER", + "unt_score": "INTEGER", + "nis_grade12_certificate_gpa": "VARCHAR(50)", + } + missing_student_profile_columns = [ + (name, column_type) + for name, column_type in student_profile_column_defs.items() + if name not in student_profile_columns + ] + if missing_student_profile_columns: + with engine.begin() as connection: + for name, column_type in missing_student_profile_columns: + connection.execute(text(f"ALTER TABLE student_profiles ADD COLUMN {name} {column_type}")) + + json_type = "JSONB" if engine.dialect.name == "postgresql" else "JSON" + university_column_defs = { + "application_source_url": "VARCHAR(1000)", + "region": "VARCHAR(100)", + "city": "VARCHAR(255)", + "is_common_app": "BOOLEAN DEFAULT FALSE NOT NULL", + "teaching_languages": json_type, + "major_strengths": json_type, + "education_years_required": "INTEGER", + "school_years_note": "TEXT", + "aid_type": "VARCHAR(100)", + "aid_strength": "INTEGER", + "selectivity_score": "INTEGER", + "full_ride_possible": "BOOLEAN DEFAULT FALSE NOT NULL", + "full_tuition_possible": "BOOLEAN DEFAULT FALSE NOT NULL", + "aid_notes": "TEXT", + "funding_source_url": "VARCHAR(1000)", + "funding_source_title": "VARCHAR(500)", + "eligibility_notes": "TEXT", + } + + missing_university_columns = [ + (name, column_type) + for name, column_type in university_column_defs.items() + if name not in university_columns + ] + if missing_university_columns: + with engine.begin() as connection: + for name, column_type in missing_university_columns: + connection.execute(text(f"ALTER TABLE universities ADD COLUMN {name} {column_type}")) + + if "advisor_snapshot_json" not in report_columns: + with engine.begin() as connection: + connection.execute( + text(f"ALTER TABLE optimization_reports ADD COLUMN advisor_snapshot_json {json_type}") + ) + + if "fit_category" not in target_university_columns: + with engine.begin() as connection: + connection.execute( + text("ALTER TABLE target_universities ADD COLUMN fit_category VARCHAR(20) DEFAULT 'target' NOT NULL") + ) diff --git a/apps/api/src/schemas/__init__.py b/apps/api/src/schemas/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..48fc27e7d483c27399429415a3b2ad4e37548e69 --- /dev/null +++ b/apps/api/src/schemas/__init__.py @@ -0,0 +1,17 @@ +from .user import UserCreate, UserLogin, UserOut, UserUpdate, ProfileCreate, ProfileUpdate, ProfileOut, TokenOut +from .achievement import AchievementCreate, AchievementUpdate, AchievementOut, EvidenceFileOut +from .university import UniversityCreate, UniversityUpdate, UniversityOut, PolicyEntryCreate, PolicyEntryUpdate, PolicyEntryOut +from .report import ( + TargetUniversityCreate, TargetUniversityOut, + ReportOut, ReportDetailOut, RecommendationOut, + RewriteVariantOut, SourceReferenceOut, +) + +__all__ = [ + "UserCreate", "UserLogin", "UserOut", "UserUpdate", "ProfileCreate", "ProfileUpdate", "ProfileOut", "TokenOut", + "AchievementCreate", "AchievementUpdate", "AchievementOut", "EvidenceFileOut", + "UniversityCreate", "UniversityUpdate", "UniversityOut", "PolicyEntryCreate", "PolicyEntryUpdate", "PolicyEntryOut", + "TargetUniversityCreate", "TargetUniversityOut", + "ReportOut", "ReportDetailOut", "RecommendationOut", + "RewriteVariantOut", "SourceReferenceOut", +] diff --git a/apps/api/src/schemas/achievement.py b/apps/api/src/schemas/achievement.py new file mode 100644 index 0000000000000000000000000000000000000000..b9fcd80d0c9c46170d08c1092db3203f91153e1d --- /dev/null +++ b/apps/api/src/schemas/achievement.py @@ -0,0 +1,120 @@ +from pydantic import BaseModel, Field +from typing import Optional, List +from datetime import datetime, date +from uuid import UUID +from ..models.achievement import AchievementType, ImpactScope, LeadershipLevel + + +class AchievementCreate(BaseModel): + type: AchievementType + title: str = Field(max_length=500) + organization_name: Optional[str] = Field(None, max_length=255) + role_title: Optional[str] = Field(None, max_length=255) + description_raw: Optional[str] = None + category: Optional[str] = Field(None, max_length=100) + start_date: Optional[date] = None + end_date: Optional[date] = None + hours_per_week: Optional[float] = Field(None, ge=0, le=168) + weeks_per_year: Optional[int] = Field(None, ge=0, le=52) + impact_scope: Optional[ImpactScope] = None + leadership_level: Optional[LeadershipLevel] = None + truth_risk_flag: Optional[bool] = None + + +class AchievementUpdate(BaseModel): + title: Optional[str] = Field(None, max_length=500) + organization_name: Optional[str] = Field(None, max_length=255) + role_title: Optional[str] = Field(None, max_length=255) + description_raw: Optional[str] = None + category: Optional[str] = Field(None, max_length=100) + start_date: Optional[date] = None + end_date: Optional[date] = None + hours_per_week: Optional[float] = Field(None, ge=0, le=168) + weeks_per_year: Optional[int] = Field(None, ge=0, le=52) + impact_scope: Optional[ImpactScope] = None + leadership_level: Optional[LeadershipLevel] = None + truth_risk_flag: Optional[bool] = None + + +class EvidenceFileOut(BaseModel): + id: UUID + file_url: str + file_name: str + mime_type: Optional[str] = None + uploaded_at: datetime + + model_config = {"from_attributes": True} + + +class AchievementOut(BaseModel): + id: UUID + user_id: UUID + type: AchievementType + title: str + organization_name: Optional[str] = None + role_title: Optional[str] = None + description_raw: Optional[str] = None + category: Optional[str] = None + start_date: Optional[date] = None + end_date: Optional[date] = None + hours_per_week: Optional[float] = None + weeks_per_year: Optional[int] = None + impact_scope: Optional[ImpactScope] = None + leadership_level: Optional[LeadershipLevel] = None + major_relevance_score: Optional[float] = None + continuity_score: Optional[float] = None + selectivity_score: Optional[float] = None + distinctiveness_score: Optional[float] = None + truth_risk_flag: Optional[bool] = None + created_at: datetime + updated_at: datetime + evidence_files: List[EvidenceFileOut] = [] + + model_config = {"from_attributes": True} + + +class AchievementImportSelectionItem(BaseModel): + achievement_id: UUID + type: AchievementType + rank: int + title: str + common_app_text: str + word_count: int + character_count: int + common_app_position: Optional[str] = None + common_app_organization: Optional[str] = None + common_app_activity_description: Optional[str] = None + common_app_honor_description: Optional[str] = None + position_character_count: Optional[int] = None + organization_character_count: Optional[int] = None + activity_description_character_count: Optional[int] = None + honor_character_count: Optional[int] = None + selection_reason: Optional[str] = None + verification_notes: List[str] = [] + missing_or_unclear_facts: List[str] = [] + + +class AchievementImportStep(BaseModel): + key: str + label: str + status: str + detail: str + + +class AchievementImportOut(BaseModel): + file_name: str + word_limit: int + imported_count: int + strongest_angle: str + needs_student_clarification: bool = False + clarifying_questions: List[str] = [] + additional_information_recommended: bool = False + additional_information_reason: Optional[str] = None + additional_information_draft: Optional[str] = None + formatting_notes: List[str] = [] + extraction_notes: List[str] = [] + source_excerpts: List[str] = [] + processing_steps: List[AchievementImportStep] = [] + imported_achievements: List[AchievementOut] + top_activities: List[AchievementImportSelectionItem] + top_honors: List[AchievementImportSelectionItem] diff --git a/apps/api/src/schemas/report.py b/apps/api/src/schemas/report.py new file mode 100644 index 0000000000000000000000000000000000000000..442d77833d913a0348f648dfe41137a50ee432f8 --- /dev/null +++ b/apps/api/src/schemas/report.py @@ -0,0 +1,111 @@ +from pydantic import BaseModel +from typing import Optional, List +from datetime import datetime +from uuid import UUID +from ..models.report import ReportStatus, RecommendationType, ConfidenceLabel, SourceSection +from .achievement import AchievementOut +from .university import UniversityListOut, PolicyEntryOut + + +class TargetUniversityCreate(BaseModel): + university_id: UUID + priority_order: Optional[int] = None + fit_category: str = "target" + + +class TargetUniversityOut(BaseModel): + id: UUID + user_id: UUID + university_id: UUID + priority_order: Optional[int] = None + fit_category: str = "target" + created_at: datetime + university: UniversityListOut + + model_config = {"from_attributes": True} + + +class RecommendationOut(BaseModel): + id: UUID + report_id: UUID + achievement_id: UUID + recommendation_type: RecommendationType + suggested_rank: Optional[int] = None + rationale: Optional[str] = None + confidence_label: ConfidenceLabel + created_at: datetime + achievement: AchievementOut + + model_config = {"from_attributes": True} + + +class RewriteVariantOut(BaseModel): + id: UUID + achievement_id: UUID + report_id: UUID + style_mode: str + text: str + character_count: int + is_recommended: bool + explanation: Optional[str] = None + created_at: datetime + + model_config = {"from_attributes": True} + + +class SourceReferenceOut(BaseModel): + id: UUID + report_id: UUID + university_policy_entry_id: UUID + section: SourceSection + note: Optional[str] = None + created_at: datetime + policy_entry: PolicyEntryOut + + model_config = {"from_attributes": True} + + +class AdvisorProgramOut(BaseModel): + name: str + why_it_matters: str + funding_note: str + priority: str + + +class AdvisorActionOut(BaseModel): + title: str + detail: str + + +class AdvisorSnapshotOut(BaseModel): + title: str + subtitle: str + target_major: str + report_note: str + focus_areas: List[str] = [] + research_programs: List[AdvisorProgramOut] = [] + funding_plan: List[str] = [] + action_plan: List[AdvisorActionOut] = [] + + +class ReportOut(BaseModel): + id: UUID + user_id: UUID + university_id: UUID + status: ReportStatus + summary_text: Optional[str] = None + advisor_snapshot_json: Optional[AdvisorSnapshotOut] = None + version_number: int + created_at: datetime + completed_at: Optional[datetime] = None + university: UniversityListOut + + model_config = {"from_attributes": True} + + +class ReportDetailOut(ReportOut): + recommendations: List[RecommendationOut] = [] + rewrite_variants: List[RewriteVariantOut] = [] + source_references: List[SourceReferenceOut] = [] + + model_config = {"from_attributes": True} diff --git a/apps/api/src/schemas/university.py b/apps/api/src/schemas/university.py new file mode 100644 index 0000000000000000000000000000000000000000..d0ee76a1bf26b00e3b24640d1be62400e90bb33d --- /dev/null +++ b/apps/api/src/schemas/university.py @@ -0,0 +1,181 @@ +from pydantic import BaseModel, Field +from typing import Optional, List +from datetime import datetime +from uuid import UUID +from ..models.university import WeightPreset, SourceType, ReliabilityTier + + +class PolicyEntryCreate(BaseModel): + title: str = Field(max_length=500) + content: str + source_url: Optional[str] = None + source_title: Optional[str] = None + source_type: SourceType + reliability_tier: ReliabilityTier = ReliabilityTier.B + excerpt: Optional[str] = None + + +class PolicyEntryUpdate(BaseModel): + title: Optional[str] = Field(None, max_length=500) + content: Optional[str] = None + source_url: Optional[str] = None + source_title: Optional[str] = None + source_type: Optional[SourceType] = None + reliability_tier: Optional[ReliabilityTier] = None + excerpt: Optional[str] = None + + +class PolicyEntryOut(BaseModel): + id: UUID + university_id: UUID + title: str + content: str + source_url: Optional[str] = None + source_title: Optional[str] = None + source_type: SourceType + reliability_tier: ReliabilityTier + excerpt: Optional[str] = None + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} + + +class UniversityCreate(BaseModel): + slug: str = Field(max_length=100) + name: str = Field(max_length=255) + country: str = Field(max_length=100) + application_system: Optional[str] = None + application_source_url: Optional[str] = None + short_description: Optional[str] = None + weight_preset: WeightPreset = WeightPreset.balanced_holistic + region: Optional[str] = None + city: Optional[str] = None + is_common_app: bool = False + teaching_languages: List[str] = [] + major_strengths: List[str] = [] + education_years_required: Optional[int] = None + school_years_note: Optional[str] = None + aid_type: Optional[str] = None + aid_strength: Optional[int] = None + selectivity_score: Optional[int] = None + full_ride_possible: bool = False + full_tuition_possible: bool = False + aid_notes: Optional[str] = None + funding_source_url: Optional[str] = None + funding_source_title: Optional[str] = None + eligibility_notes: Optional[str] = None + is_active: bool = True + + +class UniversityUpdate(BaseModel): + name: Optional[str] = Field(None, max_length=255) + country: Optional[str] = Field(None, max_length=100) + application_system: Optional[str] = None + application_source_url: Optional[str] = None + short_description: Optional[str] = None + weight_preset: Optional[WeightPreset] = None + region: Optional[str] = None + city: Optional[str] = None + is_common_app: Optional[bool] = None + teaching_languages: Optional[List[str]] = None + major_strengths: Optional[List[str]] = None + education_years_required: Optional[int] = None + school_years_note: Optional[str] = None + aid_type: Optional[str] = None + aid_strength: Optional[int] = None + selectivity_score: Optional[int] = None + full_ride_possible: Optional[bool] = None + full_tuition_possible: Optional[bool] = None + aid_notes: Optional[str] = None + funding_source_url: Optional[str] = None + funding_source_title: Optional[str] = None + eligibility_notes: Optional[str] = None + is_active: Optional[bool] = None + + +class UniversityOut(BaseModel): + id: UUID + slug: str + name: str + country: str + application_system: Optional[str] = None + application_source_url: Optional[str] = None + short_description: Optional[str] = None + weight_preset: WeightPreset + region: Optional[str] = None + city: Optional[str] = None + is_common_app: bool = False + teaching_languages: List[str] = [] + major_strengths: List[str] = [] + education_years_required: Optional[int] = None + school_years_note: Optional[str] = None + aid_type: Optional[str] = None + aid_strength: Optional[int] = None + selectivity_score: Optional[int] = None + full_ride_possible: bool = False + full_tuition_possible: bool = False + aid_notes: Optional[str] = None + funding_source_url: Optional[str] = None + funding_source_title: Optional[str] = None + eligibility_notes: Optional[str] = None + is_active: bool + created_at: datetime + updated_at: datetime + policy_entries: List[PolicyEntryOut] = [] + + model_config = {"from_attributes": True} + + +class UniversityListOut(BaseModel): + id: UUID + slug: str + name: str + country: str + application_system: Optional[str] = None + short_description: Optional[str] = None + weight_preset: WeightPreset + is_active: bool + region: Optional[str] = None + city: Optional[str] = None + is_common_app: bool = False + application_source_url: Optional[str] = None + teaching_languages: List[str] = [] + major_strengths: List[str] = [] + education_years_required: Optional[int] = None + school_years_note: Optional[str] = None + aid_type: Optional[str] = None + aid_strength: Optional[int] = None + selectivity_score: Optional[int] = None + full_ride_possible: bool = False + full_tuition_possible: bool = False + aid_notes: Optional[str] = None + funding_source_url: Optional[str] = None + funding_source_title: Optional[str] = None + eligibility_notes: Optional[str] = None + + model_config = {"from_attributes": True} + + +class CommonAppRecommendationRequest(BaseModel): + top_honor_ids: List[UUID] = Field(default_factory=list, max_length=5) + top_activity_ids: List[UUID] = Field(default_factory=list, max_length=10) + preferences: dict = Field(default_factory=dict) + save_preferences: bool = True + + +class UniversityAdvisorRequest(BaseModel): + university_name: str = Field(min_length=2, max_length=255) + intended_major: Optional[str] = None + + +class CommonAppRecommendationOut(BaseModel): + university_id: UUID + slug: str + name: str + country: str + category: str + rationale: str + fit_notes: Optional[str] = None + aid_notes: Optional[str] = None + funding_source_url: Optional[str] = None diff --git a/apps/api/src/schemas/user.py b/apps/api/src/schemas/user.py new file mode 100644 index 0000000000000000000000000000000000000000..08db4f611b08e0153112bca60aabc5a4aeb399e8 --- /dev/null +++ b/apps/api/src/schemas/user.py @@ -0,0 +1,116 @@ +from pydantic import BaseModel, EmailStr, Field, field_validator +from typing import Optional, Any +from datetime import datetime +from uuid import UUID +from ..models.user import UserRole + + +class UserCreate(BaseModel): + email: EmailStr + password: str = Field(min_length=8) + full_name: Optional[str] = None + country: Optional[str] = None + + @field_validator("password") + @classmethod + def password_strength(cls, v: str) -> str: + if len(v) < 8: + raise ValueError("Password must be at least 8 characters") + return v + + +class UserLogin(BaseModel): + email: EmailStr + password: str + + +class UserUpdate(BaseModel): + full_name: Optional[str] = None + country: Optional[str] = None + + +class UserOut(BaseModel): + id: UUID + email: str + role: UserRole + full_name: Optional[str] = None + country: Optional[str] = None + created_at: datetime + + model_config = {"from_attributes": True} + + +class ProfileCreate(BaseModel): + graduation_year: Optional[int] = None + curriculum: Optional[str] = None + intended_major: Optional[str] = None + sat_score: Optional[int] = None + sat_math: Optional[int] = None + sat_ebrw: Optional[int] = None + act_score: Optional[int] = None + ielts_score: Optional[str] = None + ielts_listening: Optional[str] = None + ielts_reading: Optional[str] = None + ielts_writing: Optional[str] = None + ielts_speaking: Optional[str] = None + toefl_score: Optional[int] = None + toefl_reading: Optional[int] = None + toefl_listening: Optional[int] = None + toefl_speaking: Optional[int] = None + toefl_writing: Optional[int] = None + duolingo_score: Optional[int] = None + a_level_subjects: Optional[str] = None + a_level_predicted: Optional[str] = None + ap_subjects: Optional[str] = None + ib_predicted_score: Optional[int] = None + unt_score: Optional[int] = None + nis_grade12_certificate_gpa: Optional[str] = None + budget_range: Optional[str] = None + aid_needed: Optional[bool] = None + application_preferences_json: Optional[dict[str, Any]] = None + + +class ProfileUpdate(ProfileCreate): + pass + + +class ProfileOut(BaseModel): + id: UUID + user_id: UUID + graduation_year: Optional[int] = None + curriculum: Optional[str] = None + intended_major: Optional[str] = None + sat_score: Optional[int] = None + sat_math: Optional[int] = None + sat_ebrw: Optional[int] = None + act_score: Optional[int] = None + ielts_score: Optional[str] = None + ielts_listening: Optional[str] = None + ielts_reading: Optional[str] = None + ielts_writing: Optional[str] = None + ielts_speaking: Optional[str] = None + toefl_score: Optional[int] = None + toefl_reading: Optional[int] = None + toefl_listening: Optional[int] = None + toefl_speaking: Optional[int] = None + toefl_writing: Optional[int] = None + duolingo_score: Optional[int] = None + a_level_subjects: Optional[str] = None + a_level_predicted: Optional[str] = None + ap_subjects: Optional[str] = None + ib_predicted_score: Optional[int] = None + unt_score: Optional[int] = None + nis_grade12_certificate_gpa: Optional[str] = None + budget_range: Optional[str] = None + aid_needed: Optional[bool] = None + application_preferences_json: Optional[dict[str, Any]] = None + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} + + +class TokenOut(BaseModel): + access_token: str + token_type: str = "bearer" + user: UserOut diff --git a/apps/api/src/services/__init__.py b/apps/api/src/services/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/apps/api/src/services/achievement_import_service.py b/apps/api/src/services/achievement_import_service.py new file mode 100644 index 0000000000000000000000000000000000000000..95a8c7cc62239ceb61776a589213ec490aa59048 --- /dev/null +++ b/apps/api/src/services/achievement_import_service.py @@ -0,0 +1,824 @@ +import json +import os +import re +from typing import Any, Optional + +import httpx + +from ..config import settings +from ..models.achievement import AchievementType, ImpactScope, LeadershipLevel +from .counselor_knowledge import CHANCELLOR_COUNSELOR_FRAMEWORK + +MAX_IMPORT_BYTES = 10_000_000 +MAX_IMPORT_CHARS = 80_000 +DEFAULT_WORD_LIMIT = 22 +MAX_IMPORTED_ITEMS = 60 +MAX_TOP_ACTIVITIES = 10 +MAX_TOP_HONORS = 5 +COMMON_APP_ACTIVITY_POSITION_LIMIT = 50 +COMMON_APP_ACTIVITY_ORGANIZATION_LIMIT = 100 +COMMON_APP_ACTIVITY_DESCRIPTION_LIMIT = 150 +COMMON_APP_HONOR_DESCRIPTION_LIMIT = 100 + +IMPORT_SCHEMA = { + "type": "object", + "properties": { + "strongest_angle": {"type": "string"}, + "needs_student_clarification": {"type": "boolean"}, + "clarifying_questions": {"type": "array", "items": {"type": "string"}}, + "additional_information_recommended": {"type": "boolean"}, + "additional_information_reason": {"type": "string"}, + "additional_information_draft": {"type": "string"}, + "formatting_notes": {"type": "array", "items": {"type": "string"}}, + "extraction_notes": {"type": "array", "items": {"type": "string"}}, + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "source_index": {"type": "integer"}, + "type": {"type": "string", "enum": ["activity", "honor"]}, + "title": {"type": "string"}, + "organization_name": {"type": ["string", "null"]}, + "role_title": {"type": ["string", "null"]}, + "description_raw": {"type": ["string", "null"]}, + "category": {"type": ["string", "null"]}, + "hours_per_week": {"type": ["number", "null"]}, + "weeks_per_year": {"type": ["integer", "null"]}, + "impact_scope": {"type": ["string", "null"]}, + "leadership_level": {"type": ["string", "null"]}, + "truth_risk_flag": {"type": "boolean"}, + "major_relevance_score": {"type": "number"}, + "selectivity_score": {"type": "number"}, + "continuity_score": {"type": "number"}, + "distinctiveness_score": {"type": "number"}, + "selection_reason": {"type": "string"}, + "common_app_text": {"type": "string"}, + "common_app_position": {"type": ["string", "null"]}, + "common_app_organization": {"type": ["string", "null"]}, + "common_app_activity_description": {"type": ["string", "null"]}, + "common_app_honor_description": {"type": ["string", "null"]}, + "verification_queries": {"type": "array", "items": {"type": "string"}}, + "verification_notes": {"type": "array", "items": {"type": "string"}}, + "missing_or_unclear_facts": {"type": "array", "items": {"type": "string"}}, + "recommended_rank": {"type": ["integer", "null"]}, + }, + "required": [ + "source_index", + "type", + "title", + "truth_risk_flag", + "major_relevance_score", + "selectivity_score", + "continuity_score", + "distinctiveness_score", + "selection_reason", + "common_app_text", + "common_app_position", + "common_app_organization", + "common_app_activity_description", + "common_app_honor_description", + "verification_queries", + "verification_notes", + "missing_or_unclear_facts", + "recommended_rank", + ], + }, + }, + }, + "required": [ + "strongest_angle", + "needs_student_clarification", + "clarifying_questions", + "additional_information_recommended", + "additional_information_reason", + "additional_information_draft", + "formatting_notes", + "extraction_notes", + "items", + ], +} + +HONOR_KEYWORDS = ( + "award", + "winner", + "won", + "prize", + "medal", + "honor", + "honour", + "olympiad", + "scholarship", + "finalist", + "laureate", + "distinction", + "champion", +) + + +def _profile_context(user: Optional[Any]) -> dict[str, Any]: + profile = getattr(user, "profile", None) + if profile is None: + return { + "country": getattr(user, "country", None), + } + + return { + "country": getattr(user, "country", None), + "graduation_year": getattr(profile, "graduation_year", None), + "curriculum": getattr(profile, "curriculum", None), + "intended_major": getattr(profile, "intended_major", None), + "sat_score": getattr(profile, "sat_score", None), + "act_score": getattr(profile, "act_score", None), + "ielts_score": getattr(profile, "ielts_score", None), + "toefl_score": getattr(profile, "toefl_score", None), + "application_preferences_json": getattr(profile, "application_preferences_json", None), + } + + +def _compact_whitespace(value: str) -> str: + return re.sub(r"\s+", " ", value or "").strip() + + +def _normalize_student_facing_text(value: str) -> str: + text = _compact_whitespace(value) + text = re.sub(r"\bRepublican\b", "National", text, flags=re.IGNORECASE) + text = re.sub(r"\bRespublikalyk\b", "National", text, flags=re.IGNORECASE) + text = re.sub(r"\bRespublikanskiy\b", "National", text, flags=re.IGNORECASE) + for index, char in enumerate(text): + if char.isalpha(): + text = text[:index] + char.upper() + text[index + 1 :] + break + return text + + +def _preserve_source_structure(value: str) -> str: + lines = [] + for line in (value or "").replace("\r\n", "\n").replace("\r", "\n").split("\n"): + cleaned = re.sub(r"[ \t\f\v]+", " ", line).strip() + if cleaned: + lines.append(cleaned) + elif lines and lines[-1] != "": + lines.append("") + return re.sub(r"\n{3,}", "\n\n", "\n".join(lines)).strip() + + +def _source_excerpts(raw_text: str, *, max_items: int = 8, max_chars: int = 240) -> list[str]: + scored: list[tuple[int, int, str]] = [] + keywords = ( + "award", + "winner", + "honor", + "activities", + "president", + "captain", + "founder", + "research", + "olympiad", + "competition", + "volunteer", + "raised", + "published", + "selected", + "national", + "international", + "hr/wk", + "wk/yr", + ) + for chunk in re.split(r"(?:\n\s*){1,}|(?:\s*[•\u2022*]\s+)", raw_text): + text = _compact_whitespace(chunk) + if len(text) < 18: + continue + lower = text.lower() + score = sum(2 for keyword in keywords if keyword in lower) + if any(char.isdigit() for char in text): + score += 1 + scored.append((score, len(scored), _truncate_characters(text, max_chars))) + scored.sort(key=lambda item: (-item[0], item[1])) + return [text for _, _, text in scored[:max_items]] + + +def _count_words(value: str) -> int: + return len(re.findall(r"\b[\w'-]+\b", value)) + + +def _truncate_characters(value: str, limit: int) -> str: + if len(value) <= limit: + return value + truncated = value[:limit] + last_space = truncated.rfind(" ") + if last_space > max(0, limit - 24): + truncated = truncated[:last_space] + return truncated.rstrip(",.;: ") + + +def _clean_string_list(value: Any, *, max_items: int = 6, max_chars: int = 260) -> list[str]: + if not isinstance(value, list): + return [] + + strings: list[str] = [] + for item in value: + text = _compact_whitespace(str(item or "")) + if text: + strings.append(_truncate_characters(text, max_chars)) + if len(strings) >= max_items: + break + return strings + + +def _enforce_common_app_limit(value: str, word_limit: int, achievement_type: str) -> str: + words = _compact_whitespace(value).split() + if word_limit > 0 and len(words) > word_limit: + value = " ".join(words[:word_limit]) + if achievement_type == AchievementType.activity.value: + value = _truncate_characters(value, COMMON_APP_ACTIVITY_DESCRIPTION_LIMIT) + if achievement_type == AchievementType.honor.value: + value = _truncate_characters(value, COMMON_APP_HONOR_DESCRIPTION_LIMIT) + return _compact_whitespace(value) + + +def _activity_position(item: dict[str, Any]) -> str: + value = _compact_whitespace(str(item.get("common_app_position") or item.get("role_title") or item.get("title") or "")) + return _truncate_characters(value, COMMON_APP_ACTIVITY_POSITION_LIMIT) + + +def _activity_organization(item: dict[str, Any]) -> str: + value = _compact_whitespace(str(item.get("common_app_organization") or item.get("organization_name") or "")) + return _truncate_characters(value, COMMON_APP_ACTIVITY_ORGANIZATION_LIMIT) + + +def _activity_description(item: dict[str, Any], word_limit: int) -> str: + value = _compact_whitespace( + str(item.get("common_app_activity_description") or item.get("common_app_text") or item.get("description_raw") or "") + ) + if not value: + value = _fallback_common_app_text(item, word_limit) + return _enforce_common_app_limit(value, word_limit, AchievementType.activity.value) + + +def _honor_description(item: dict[str, Any], word_limit: int) -> str: + value = _compact_whitespace( + str(item.get("common_app_honor_description") or item.get("common_app_text") or item.get("title") or "") + ) + return _enforce_common_app_limit(value, word_limit, AchievementType.honor.value) + + +def _coerce_enum(enum_cls: Any, value: Any) -> Any: + if value in (None, "", "null"): + return None + try: + return enum_cls(value) + except ValueError: + return None + + +def _clamp_score(value: Any) -> float: + try: + return round(max(0.0, min(10.0, float(value))), 1) + except (TypeError, ValueError): + return 5.0 + + +def _extract_pdf_text(raw_bytes: bytes) -> str: + import io + + try: + import pdfplumber + except ImportError: + raise ValueError("PDF support requires pdfplumber. Run: pip install pdfplumber") + + pages_text: list[str] = [] + with pdfplumber.open(io.BytesIO(raw_bytes)) as pdf: + for page in pdf.pages: + page_text = page.extract_text() or "" + if page_text.strip(): + pages_text.append(page_text) + return "\n".join(pages_text) + + +def _extract_docx_text(raw_bytes: bytes) -> str: + import io + + try: + from docx import Document + except ImportError: + raise ValueError("DOCX support requires python-docx. Run: pip install python-docx") + + doc = Document(io.BytesIO(raw_bytes)) + paragraphs = [p.text for p in doc.paragraphs if p.text.strip()] + return "\n".join(paragraphs) + + +def decode_import_file(file_name: str, raw_bytes: bytes) -> str: + if len(raw_bytes) > MAX_IMPORT_BYTES: + raise ValueError("File is too large for import. Keep it under 10 MB.") + + extension = os.path.splitext(file_name or "")[1].lower() + supported = {".txt", ".md", ".csv", ".json", ".pdf", ".docx"} + if extension and extension not in supported: + raise ValueError( + "Import supports .txt, .md, .csv, .json, .pdf, and .docx files." + ) + + if extension == ".pdf": + text = _extract_pdf_text(raw_bytes) + elif extension == ".docx": + text = _extract_docx_text(raw_bytes) + else: + for encoding in ("utf-8", "utf-8-sig", "utf-16", "cp1251", "latin-1"): + try: + text = raw_bytes.decode(encoding) + break + except UnicodeDecodeError: + continue + else: + raise ValueError("Could not read the uploaded file as text.") + + text = _preserve_source_structure(text) + if not text: + raise ValueError("The uploaded file is empty.") + return text[:MAX_IMPORT_CHARS] + + +def _fallback_title(line: str) -> str: + line = _compact_whitespace(line) + chunks = re.split(r"[.;:-]", line, maxsplit=1) + title = chunks[0].strip() if chunks else line + return title[:120] or "Untitled achievement" + + +def _fallback_type(line: str) -> AchievementType: + normalized = line.lower() + if any(keyword in normalized for keyword in HONOR_KEYWORDS): + return AchievementType.honor + return AchievementType.activity + + +def _fallback_common_app_text(item: dict[str, Any], word_limit: int) -> str: + parts = [ + item.get("role_title"), + item.get("organization_name"), + item.get("description_raw"), + ] + value = _compact_whitespace(". ".join(part for part in parts if part)) + if not value: + value = item["title"] + return _enforce_common_app_limit(value, word_limit, item["type"]) + + +def _fallback_parse(raw_text: str, user: Optional[Any], word_limit: int) -> dict[str, Any]: + from .chancellor_analysis import _heuristic_scores + + lines = [ + _compact_whitespace(line) + for line in re.split(r"(?:\r?\n)+|(?:\s*[-*•]\s+)", raw_text) + if _compact_whitespace(line) + ] + + items: list[dict[str, Any]] = [] + for index, line in enumerate(lines[:MAX_IMPORTED_ITEMS], start=1): + item_type = _fallback_type(line) + base_item = { + "source_index": index, + "type": item_type.value, + "title": _fallback_title(line), + "organization_name": None, + "role_title": None, + "description_raw": line, + "category": None, + "hours_per_week": None, + "weeks_per_year": None, + "impact_scope": None, + "leadership_level": None, + "truth_risk_flag": False, + "selection_reason": "Selected by heuristic fallback because AI extraction was unavailable.", + } + scores = _heuristic_scores(base_item, user) + item = { + **base_item, + **scores, + "common_app_text": "", + "common_app_position": None, + "common_app_organization": None, + "common_app_activity_description": None, + "common_app_honor_description": None, + "verification_queries": [], + "verification_notes": [], + "missing_or_unclear_facts": ["AI extraction was unavailable; verify the wording against the original evidence."], + "recommended_rank": None, + } + item["common_app_text"] = _fallback_common_app_text(item, word_limit) + items.append(item) + + strongest_angle = ( + "Present the profile as a focused, evidence-backed student story with the strongest sustained work first." + ) + return { + "strongest_angle": strongest_angle, + "needs_student_clarification": True, + "clarifying_questions": [ + "Please confirm titles, dates, roles, and measurable outcomes before using the generated Common App wording." + ], + "additional_information_recommended": False, + "additional_information_reason": "", + "additional_information_draft": "", + "formatting_notes": ["Gemini extraction was unavailable, so ApplyMap used a conservative local fallback."], + "extraction_notes": [ + f"Local fallback split the source into {len(items)} candidate achievement lines." + ], + "items": items, + } + + +def _import_prompt( + raw_text: str, + user: Optional[Any], + word_limit: int, + clarification_answers: Optional[dict[str, str]] = None, +) -> str: + payload = { + "student_profile": _profile_context(user), + "word_limit": word_limit, + "common_app_limits": { + "activities_max_items": MAX_TOP_ACTIVITIES, + "activity_position_leadership_description_chars": COMMON_APP_ACTIVITY_POSITION_LIMIT, + "activity_organization_name_chars": COMMON_APP_ACTIVITY_ORGANIZATION_LIMIT, + "activity_description_chars": COMMON_APP_ACTIVITY_DESCRIPTION_LIMIT, + "honors_max_items": MAX_TOP_HONORS, + "honor_title_description_chars": COMMON_APP_HONOR_DESCRIPTION_LIMIT, + }, + "source_excerpts": _source_excerpts(raw_text), + "student_clarification_answers": clarification_answers or {}, + "raw_source_text": raw_text, + } + return ( + "You are ApplyMap Chancellor, helping an international student convert a messy mixed-achievement note file " + "into a clean, factual application-ready shortlist.\n\n" + f"{CHANCELLOR_COUNSELOR_FRAMEWORK}\n\n" + "Tasks:\n" + "1. Extract every distinct student achievement from the raw text before ranking. Merge only true duplicates.\n" + "2. Classify each item as either 'activity' or 'honor'.\n" + "3. Fill structured fields conservatively. If a field is missing, use null instead of inventing facts.\n" + "4. Score each item from 0 to 10 on major_relevance_score, selectivity_score, continuity_score, and distinctiveness_score.\n" + "5. Recommend the strongest top 10 activities and top 5 academic honors for a Common App-style application. Use recommended_rank " + "for selected items and null for the rest.\n" + "6. For activities, fill separate Common App fields: common_app_position <= 50 characters, " + "common_app_organization <= 100 characters, and common_app_activity_description <= 150 characters. " + "Use the activity description for accomplishments and measurable impact, not role repetition.\n" + "7. For honors, fill common_app_honor_description as one title/description block <= 100 characters.\n" + "8. strongest_angle must explain the single best overall application angle in one sentence.\n" + "9. If there are inconsistencies in years, roles, award level, school grade, hours, or metrics, set " + "needs_student_clarification=true and write short clarifying_questions before the student should trust final wording.\n" + "10. Recommend Additional Information only when it is genuinely needed to clarify important context that cannot fit " + "in the activity/honor fields, unusual school/curriculum context, or multiple related awards. If recommended, write a " + "ready-to-paste concise additional_information_draft; otherwise leave it blank.\n\n" + "11. If student_clarification_answers includes an answer to a missing detail, use that answer to improve the fields, " + "scores, ranking, and Common App wording. Do not keep asking the same question unless the answer is still unclear.\n\n" + "Important constraints:\n" + "- Do not invent achievements, outcomes, metrics, organizations, dates, leadership roles, or awards.\n" + "- Output all student-facing fields in polished English even when the source is Russian, Kazakh, or mixed-language.\n" + "- Fix lowercase or informal source phrasing into proper English capitalization and grammar.\n" + "- Preserve years, date ranges, school grade, event names, number of students served, placements, and supported metrics. " + "Do not remove these facts just to make the sentence shorter.\n" + "- Never replace a concrete source detail with a guessed or more impressive detail. If the source says gift cards, " + "lessons, mentoring, or another specific activity, translate that detail directly; do not invent tournaments, " + "research, publications, awards, or program names.\n" + "- Translate Kazakhstan award level words like Republican/Respublikalyk/Respublikanskiy as National unless the " + "official English title clearly uses Republican.\n" + "- If the source says the student mentored five 8th graders as an 11th grader in 2024-2025 and organized events, " + "keep those facts in concise English instead of reducing the entry to a generic mentoring sentence.\n" + "- If the source sounds uncertain or inflated, set truth_risk_flag to true.\n" + "- Prefer concrete, specific language over hype.\n" + "- Preserve Kazakhstan/NIS/IB/A-Level context when present.\n" + "- Treat MESK written in Russian or Kazakh as NIS Grade 12 Certificate in English output.\n" + "- For Korean university targets, do not assume Common App. Korea entries may need Study in Korea, KAIST Apply, " + "UwayApply, JinhakApply, or a university-specific format. Mark Korea-specific wording as application-ready, and " + "ask for the target portal if the limit is unclear.\n" + "- Apply the College Essay Guy-style approach: active verbs, measurable impact, no filler, no repeated role wording, " + "selectivity where supported, and abbreviations only when they improve clarity.\n" + "- If the student omits participant counts, selection rates, dates, or exact award level, add those to " + "missing_or_unclear_facts and propose verification_queries. Do not fabricate counts.\n" + "- The shortlist should reward spike, depth, selectivity, continuity, and distinctive impact.\n" + "- A weak selected item is worse than leaving a slot empty. Only rank items that are truly shortlist-worthy.\n\n" + f"Input JSON:\n{json.dumps(payload, ensure_ascii=False, default=str)}" + ) + + +def _extract_gemini_text(response_payload: dict[str, Any]) -> str: + candidates = response_payload.get("candidates") or [] + content = candidates[0].get("content") if candidates else {} + parts = content.get("parts") or [] + return str(parts[0].get("text", "")) if parts else "" + + +def _gemini_parse( + raw_text: str, + user: Optional[Any], + word_limit: int, + clarification_answers: Optional[dict[str, str]] = None, +) -> Optional[dict[str, Any]]: + api_key = settings.GEMINI_API_KEY.strip() + if not api_key: + return None + + model = (settings.GEMINI_MODEL or "gemini-2.5-flash").strip() + url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent" + payload = { + "contents": [{"parts": [{"text": _import_prompt(raw_text, user, word_limit, clarification_answers)}]}], + "generationConfig": { + "temperature": 0.1, + "maxOutputTokens": 30000, + "responseMimeType": "application/json", + "responseJsonSchema": IMPORT_SCHEMA, + }, + } + + try: + with httpx.Client(timeout=90.0) as client: + response = client.post( + url, + headers={ + "x-goog-api-key": api_key, + "Content-Type": "application/json", + }, + json=payload, + ) + response.raise_for_status() + response_text = _extract_gemini_text(response.json()) + parsed = json.loads(response_text) + if not isinstance(parsed, dict) or not isinstance(parsed.get("items"), list): + return None + return parsed + except (httpx.HTTPError, json.JSONDecodeError, KeyError, TypeError, ValueError): + return None + + +def _local_score(item: dict[str, Any]) -> float: + return ( + float(item.get("major_relevance_score") or 0) + + float(item.get("selectivity_score") or 0) + + float(item.get("continuity_score") or 0) + + float(item.get("distinctiveness_score") or 0) + ) + + +class SearchNotConfiguredError(RuntimeError): + pass + + +def _google_search(query: str, *, num: int = 3) -> list[dict[str, str]]: + api_key = settings.GOOGLE_SEARCH_API_KEY.strip() + engine_id = settings.GOOGLE_SEARCH_ENGINE_ID.strip() + if not api_key or not engine_id: + raise SearchNotConfiguredError("Google Custom Search is not configured") + + with httpx.Client(timeout=5.0) as client: + response = client.get( + "https://www.googleapis.com/customsearch/v1", + params={ + "key": api_key, + "cx": engine_id, + "q": query, + "num": num, + "safe": "active", + "hl": "en", + }, + ) + response.raise_for_status() + + results = response.json().get("items") or [] + return [ + { + "title": _compact_whitespace(str(item.get("title") or "")), + "url": str(item.get("link") or ""), + "snippet": _compact_whitespace(str(item.get("snippet") or "")), + } + for item in results + if item.get("link") + ] + + +def _default_verification_query(item: dict[str, Any], user: Optional[Any]) -> str: + country = _compact_whitespace(str(getattr(user, "country", "") or "")) + parts = [ + f'"{item.get("title")}"' if item.get("title") else "", + f'"{item.get("organization_name")}"' if item.get("organization_name") else "", + country if country else "", + "participants results award official", + ] + return _compact_whitespace(" ".join(part for part in parts if part)) + + +def _format_search_note(result: dict[str, str]) -> str: + title = _truncate_characters(result.get("title") or "Search result", 80) + snippet = _truncate_characters(result.get("snippet") or "No snippet available", 150) + url = result.get("url") or "" + return _truncate_characters(f"Source candidate: {title} - {snippet} ({url})", 300) + + +def _attach_google_verification(items: list[dict[str, Any]], user: Optional[Any]) -> list[str]: + if not settings.GOOGLE_SEARCH_API_KEY.strip() or not settings.GOOGLE_SEARCH_ENGINE_ID.strip(): + return [ + "Google Search is not configured. Set GOOGLE_SEARCH_API_KEY and GOOGLE_SEARCH_ENGINE_ID to verify achievements online." + ] + + notes: list[str] = [] + for item in items: + query = (item.get("verification_queries") or [_default_verification_query(item, user)])[0] + if not query: + item.setdefault("missing_or_unclear_facts", []).append("No searchable title or organization was available.") + continue + try: + search_results = _google_search(query, num=3) + except (SearchNotConfiguredError, httpx.HTTPError): + return [ + "Google verification is currently unavailable. Ask the student for official links, certificates, or organizer pages for unsupported claims." + ] + + if not search_results: + item.setdefault("missing_or_unclear_facts", []).append( + "No Google result found for this item; ask the student for an official source or certificate." + ) + continue + + item.setdefault("verification_notes", []).extend(_format_search_note(result) for result in search_results[:3]) + notes.append(f"Checked Google for: {query}") + + return notes + + +def _normalize_items(result: dict[str, Any], word_limit: int) -> dict[str, Any]: + normalized_items: list[dict[str, Any]] = [] + + for index, raw_item in enumerate(result.get("items") or [], start=1): + title = _normalize_student_facing_text(str(raw_item.get("title") or "")) + if not title: + continue + + item_type = _coerce_enum(AchievementType, raw_item.get("type")) or AchievementType.activity + normalized = { + "source_index": int(raw_item.get("source_index") or index), + "type": item_type.value, + "title": title[:500], + "organization_name": _normalize_student_facing_text(str(raw_item.get("organization_name") or "")) or None, + "role_title": _normalize_student_facing_text(str(raw_item.get("role_title") or "")) or None, + "description_raw": _normalize_student_facing_text(str(raw_item.get("description_raw") or "")) or None, + "category": _normalize_student_facing_text(str(raw_item.get("category") or "")) or None, + "hours_per_week": raw_item.get("hours_per_week"), + "weeks_per_year": raw_item.get("weeks_per_year"), + "impact_scope": (_coerce_enum(ImpactScope, raw_item.get("impact_scope")) or None), + "leadership_level": (_coerce_enum(LeadershipLevel, raw_item.get("leadership_level")) or None), + "truth_risk_flag": bool(raw_item.get("truth_risk_flag")), + "major_relevance_score": _clamp_score(raw_item.get("major_relevance_score")), + "selectivity_score": _clamp_score(raw_item.get("selectivity_score")), + "continuity_score": _clamp_score(raw_item.get("continuity_score")), + "distinctiveness_score": _clamp_score(raw_item.get("distinctiveness_score")), + "selection_reason": _normalize_student_facing_text(str(raw_item.get("selection_reason") or "")), + "common_app_text": _enforce_common_app_limit( + _normalize_student_facing_text(str(raw_item.get("common_app_text") or "")), + word_limit, + item_type.value, + ), + "common_app_position": _normalize_student_facing_text(str(raw_item.get("common_app_position") or "")) or None, + "common_app_organization": _normalize_student_facing_text(str(raw_item.get("common_app_organization") or "")) or None, + "common_app_activity_description": _normalize_student_facing_text( + str(raw_item.get("common_app_activity_description") or "") + ) + or None, + "common_app_honor_description": _normalize_student_facing_text(str(raw_item.get("common_app_honor_description") or "")) + or None, + "verification_queries": _clean_string_list(raw_item.get("verification_queries"), max_items=3, max_chars=160), + "verification_notes": _clean_string_list(raw_item.get("verification_notes"), max_items=5, max_chars=300), + "missing_or_unclear_facts": _clean_string_list( + raw_item.get("missing_or_unclear_facts"), max_items=6, max_chars=180 + ), + "recommended_rank": raw_item.get("recommended_rank"), + } + if not normalized["common_app_text"]: + normalized["common_app_text"] = _fallback_common_app_text(normalized, word_limit) + if item_type == AchievementType.activity: + normalized["common_app_position"] = _activity_position(normalized) + normalized["common_app_organization"] = _activity_organization(normalized) or None + normalized["common_app_activity_description"] = _activity_description(normalized, word_limit) + normalized["common_app_text"] = normalized["common_app_activity_description"] + normalized["common_app_honor_description"] = None + else: + normalized["common_app_honor_description"] = _honor_description(normalized, word_limit) + normalized["common_app_text"] = normalized["common_app_honor_description"] + normalized["common_app_position"] = None + normalized["common_app_organization"] = None + normalized["common_app_activity_description"] = None + normalized_items.append(normalized) + + return { + "strongest_angle": _compact_whitespace(str(result.get("strongest_angle") or "")), + "needs_student_clarification": bool(result.get("needs_student_clarification")), + "clarifying_questions": _clean_string_list(result.get("clarifying_questions"), max_items=8, max_chars=220), + "additional_information_recommended": bool(result.get("additional_information_recommended")), + "additional_information_reason": _compact_whitespace(str(result.get("additional_information_reason") or "")), + "additional_information_draft": _compact_whitespace(str(result.get("additional_information_draft") or "")), + "formatting_notes": _clean_string_list(result.get("formatting_notes"), max_items=8, max_chars=220), + "extraction_notes": _clean_string_list(result.get("extraction_notes"), max_items=8, max_chars=220), + "items": normalized_items, + } + + +def parse_achievement_import( + raw_text: str, + user: Optional[Any], + word_limit: int, + clarification_answers: Optional[dict[str, str]] = None, +) -> dict[str, Any]: + parsed = _gemini_parse(raw_text, user, word_limit, clarification_answers) + used_gemini = parsed is not None + if parsed is None: + parsed = _fallback_parse(raw_text, user, word_limit) + normalized = _normalize_items(parsed, word_limit) + items = normalized["items"] + + activities = [item for item in items if item["type"] == AchievementType.activity.value] + honors = [item for item in items if item["type"] == AchievementType.honor.value] + + ranked_activities = sorted( + activities, + key=lambda item: ( + item.get("recommended_rank") is None, + item.get("recommended_rank") or 99, + -_local_score(item), + ), + ) + ranked_honors = sorted( + honors, + key=lambda item: ( + item.get("recommended_rank") is None, + item.get("recommended_rank") or 99, + -_local_score(item), + ), + ) + + if not any(item.get("recommended_rank") for item in ranked_activities): + for rank, item in enumerate(sorted(activities, key=_local_score, reverse=True)[:MAX_TOP_ACTIVITIES], start=1): + item["recommended_rank"] = rank + ranked_activities = sorted(activities, key=lambda item: item.get("recommended_rank") or 99) + + if not any(item.get("recommended_rank") for item in ranked_honors): + for rank, item in enumerate(sorted(honors, key=_local_score, reverse=True)[:MAX_TOP_HONORS], start=1): + item["recommended_rank"] = rank + ranked_honors = sorted(honors, key=lambda item: item.get("recommended_rank") or 99) + + normalized["strongest_angle"] = normalized["strongest_angle"] or ( + "Lead with the most selective, sustained, and distinctive work, then support it with the strongest honors." + ) + normalized["top_activities"] = [ + item + for item in ranked_activities + if item.get("recommended_rank") and item["recommended_rank"] <= MAX_TOP_ACTIVITIES + ][:MAX_TOP_ACTIVITIES] + normalized["top_honors"] = [ + item for item in ranked_honors if item.get("recommended_rank") and item["recommended_rank"] <= MAX_TOP_HONORS + ][:MAX_TOP_HONORS] + verification_notes = _attach_google_verification( + [*normalized["top_activities"], *normalized["top_honors"]], + user, + ) + normalized["formatting_notes"].extend(verification_notes) + normalized["source_excerpts"] = _source_excerpts(raw_text) + normalized["processing_steps"] = [ + { + "key": "read_file", + "label": "Read uploaded file", + "status": "complete", + "detail": f"Extracted {len(raw_text):,} characters while preserving line breaks and bullet structure.", + }, + { + "key": "extract_candidates", + "label": "Extract achievement candidates", + "status": "complete", + "detail": f"Built {len(items)} structured candidates from the source text.", + }, + { + "key": "rank_shortlist", + "label": "Rank activities and honors", + "status": "complete", + "detail": f"Selected {len(normalized['top_activities'])} activities and {len(normalized['top_honors'])} honors for Common App.", + }, + { + "key": "format_common_app", + "label": "Format Common App fields", + "status": "complete", + "detail": "Enforced 50/100/150-character activity fields and 100-character honor lines.", + }, + { + "key": "verify_claims", + "label": "Check uncertainty and verification needs", + "status": "complete", + "detail": "Generated clarification questions and verification notes for unsupported claims.", + }, + { + "key": "ai_engine", + "label": "AI extraction engine", + "status": "complete", + "detail": "Gemini returned structured JSON." if used_gemini else "Gemini was unavailable or returned invalid JSON; used local fallback.", + }, + ] + return normalized diff --git a/apps/api/src/services/auth_service.py b/apps/api/src/services/auth_service.py new file mode 100644 index 0000000000000000000000000000000000000000..083328842e40443367007a8a5516520e5f6c5e3b --- /dev/null +++ b/apps/api/src/services/auth_service.py @@ -0,0 +1,76 @@ +from datetime import datetime, timedelta +from typing import Optional +from jose import JWTError, jwt +from passlib.context import CryptContext +from sqlalchemy.orm import Session +from uuid import UUID + +from ..config import settings +from ..models.user import User, StudentProfile + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + return pwd_context.verify(plain_password, hashed_password) + + +def get_password_hash(password: str) -> str: + return pwd_context.hash(password) + + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + to_encode.update({"exp": expire}) + return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) + + +def decode_token(token: str) -> Optional[dict]: + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) + return payload + except JWTError: + return None + + +def get_user_by_email(db: Session, email: str) -> Optional[User]: + return db.query(User).filter(User.email == email).first() + + +def get_user_by_id(db: Session, user_id: UUID) -> Optional[User]: + return db.query(User).filter(User.id == user_id).first() + + +def create_user(db: Session, email: str, password: str, full_name: Optional[str] = None, country: Optional[str] = None) -> User: + hashed = get_password_hash(password) + user = User( + email=email, + password_hash=hashed, + full_name=full_name, + country=country, + ) + db.add(user) + db.flush() + + # Create empty profile + profile = StudentProfile(user_id=user.id) + db.add(profile) + + db.commit() + db.refresh(user) + return user + + +def authenticate_user(db: Session, email: str, password: str) -> Optional[User]: + user = get_user_by_email(db, email) + if not user: + return None + if not user.password_hash: + return None + if not verify_password(password, user.password_hash): + return None + return user diff --git a/apps/api/src/services/chancellor_analysis.py b/apps/api/src/services/chancellor_analysis.py new file mode 100644 index 0000000000000000000000000000000000000000..cf7ccc35ece166b88de7c743a3b460a743e989a7 --- /dev/null +++ b/apps/api/src/services/chancellor_analysis.py @@ -0,0 +1,301 @@ +import json +from typing import Any, Optional + +import httpx + +from ..config import settings +from .counselor_knowledge import CHANCELLOR_COUNSELOR_FRAMEWORK + + +SCORE_KEYS = ( + "major_relevance_score", + "selectivity_score", + "continuity_score", + "distinctiveness_score", +) + +SCORE_SCHEMA = { + "type": "object", + "properties": { + "major_relevance_score": { + "type": "number", + "minimum": 0, + "maximum": 10, + "description": "Alignment between the achievement and the student's intended major or academic direction.", + }, + "selectivity_score": { + "type": "number", + "minimum": 0, + "maximum": 10, + "description": "Competitiveness, award level, and selection difficulty behind the achievement.", + }, + "continuity_score": { + "type": "number", + "minimum": 0, + "maximum": 10, + "description": "Sustained commitment based on duration, hours, and ongoing responsibility.", + }, + "distinctiveness_score": { + "type": "number", + "minimum": 0, + "maximum": 10, + "description": "How uncommon, self-directed, leadership-heavy, impact-heavy, or spike-relevant the achievement is.", + }, + }, + "required": list(SCORE_KEYS), + "additionalProperties": False, +} + +ADMISSIONS_FRAMEWORK = """ +Use this admissions strategy framework: +- Depth beats breadth. A few world-class or deeply developed achievements should score higher than many generic activities. +- Favor a strong, authentic spike: a sustained area where the student shows uncommon initiative, impact, rigor, or visibility. +- Impact and leadership matter most when they are specific: built, published, founded, led, scaled, won, selected, presented, or served a defined audience. +- Authenticity matters. Do not reward over-polished, vague, buzzword-heavy, or all-perfect claims without concrete evidence. +- Major strategy matters. If the intended major is crowded for the student's context, reward achievements that make the profile more distinctive or cross-disciplinary. +- Home-country context matters for international students. Global impact is stronger when it is also tied back to a real local or national context. +- Be conservative when evidence is missing. Never invent facts. +""".strip() + + +def _value(source: Any, field: str) -> Any: + if isinstance(source, dict): + return source.get(field) + return getattr(source, field, None) + + +def _enum_value(value: Any) -> str: + if value is None: + return "" + return getattr(value, "value", str(value)) + + +def _text(source: Any) -> str: + parts = [ + _value(source, "title"), + _value(source, "organization_name"), + _value(source, "role_title"), + _value(source, "description_raw"), + _value(source, "category"), + ] + return " ".join(str(part) for part in parts if part).lower() + + +def _profile_major(user: Optional[Any]) -> str: + profile = getattr(user, "profile", None) + intended_major = getattr(profile, "intended_major", None) + return str(intended_major).lower() if intended_major else "" + + +def _contains_any(text: str, keywords: list[str]) -> bool: + return any(keyword in text for keyword in keywords) + + +def _clamp(value: float) -> float: + return round(max(0.0, min(10.0, value)), 1) + + +def _profile_context(user: Optional[Any]) -> dict[str, Any]: + profile = getattr(user, "profile", None) + if profile is None: + return {} + + return { + "country": getattr(user, "country", None), + "graduation_year": getattr(profile, "graduation_year", None), + "curriculum": getattr(profile, "curriculum", None), + "intended_major": getattr(profile, "intended_major", None), + "sat_score": getattr(profile, "sat_score", None), + "act_score": getattr(profile, "act_score", None), + "ielts_score": getattr(profile, "ielts_score", None), + "toefl_score": getattr(profile, "toefl_score", None), + } + + +def _achievement_context(source: Any) -> dict[str, Any]: + return { + "type": _enum_value(_value(source, "type")), + "title": _value(source, "title"), + "organization_name": _value(source, "organization_name"), + "role_title": _value(source, "role_title"), + "description_raw": _value(source, "description_raw"), + "category": _value(source, "category"), + "hours_per_week": _value(source, "hours_per_week"), + "weeks_per_year": _value(source, "weeks_per_year"), + "impact_scope": _enum_value(_value(source, "impact_scope")), + "leadership_level": _enum_value(_value(source, "leadership_level")), + } + + +def _gemini_prompt(source: Any, user: Optional[Any]) -> str: + payload = { + "student_profile": _profile_context(user), + "achievement": _achievement_context(source), + } + return ( + "You are ApplyMap Chancellor, an admissions evaluation assistant for international applicants. " + "Score one student achievement. Use the framework below, but only use facts present in the input.\n\n" + "Kazakhstan context: treat UNT/ENT, NIS selection context, the NIS Grade 12 Certificate, IB, and A-levels " + "as relevant academic contexts when they appear. NIS applicants may have selective STEM-focused, " + "trilingual, Cambridge-aligned academic backgrounds. MESK in Russian/Kazakh user language maps to " + "NIS Grade 12 Certificate in English output.\n\n" + f"{ADMISSIONS_FRAMEWORK}\n\n" + f"{CHANCELLOR_COUNSELOR_FRAMEWORK}\n\n" + "Score each field from 0 to 10, using one decimal place when useful:\n" + "- major_relevance_score: fit with intended major, academic direction, and profile strategy.\n" + "- selectivity_score: competitiveness, award level, and selection difficulty.\n" + "- continuity_score: sustained commitment based on time, duration, and responsibility.\n" + "- distinctiveness_score: uncommon spike, initiative, leadership, originality, visibility, or impact.\n\n" + "Be conservative when evidence is missing. Return JSON only.\n\n" + f"Input JSON:\n{json.dumps(payload, ensure_ascii=False, default=str)}" + ) + + +def _extract_gemini_text(response_payload: dict[str, Any]) -> str: + candidates = response_payload.get("candidates") or [] + content = candidates[0].get("content") if candidates else {} + parts = content.get("parts") or [] + return str(parts[0].get("text", "")) if parts else "" + + +def _scores_from_mapping(value: Any) -> Optional[dict[str, float]]: + if not isinstance(value, dict): + return None + + scores: dict[str, float] = {} + for key in SCORE_KEYS: + raw = value.get(key) + if raw is None or isinstance(raw, bool): + return None + try: + scores[key] = _clamp(float(raw)) + except (TypeError, ValueError): + return None + return scores + + +def _gemini_scores(source: Any, user: Optional[Any]) -> Optional[dict[str, float]]: + api_key = settings.GEMINI_API_KEY.strip() + if not api_key: + return None + + model = (settings.GEMINI_MODEL or "gemini-2.5-flash").strip() + url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent" + request_payload = { + "contents": [{"parts": [{"text": _gemini_prompt(source, user)}]}], + "generationConfig": { + "temperature": 0.1, + "responseMimeType": "application/json", + "responseJsonSchema": SCORE_SCHEMA, + }, + } + + try: + with httpx.Client(timeout=12.0) as client: + response = client.post( + url, + headers={ + "x-goog-api-key": api_key, + "Content-Type": "application/json", + }, + json=request_payload, + ) + response.raise_for_status() + response_text = _extract_gemini_text(response.json()) + return _scores_from_mapping(json.loads(response_text)) + except (httpx.HTTPError, json.JSONDecodeError, KeyError, TypeError, ValueError): + return None + + +def _heuristic_scores(source: Any, user: Optional[Any] = None) -> dict[str, float]: + text = _text(source) + scope = _enum_value(_value(source, "impact_scope")) + leadership = _enum_value(_value(source, "leadership_level")) + hours = _value(source, "hours_per_week") or 0 + weeks = _value(source, "weeks_per_year") or 0 + intended_major = _profile_major(user) + + major_score = 5.0 + if intended_major: + major_terms = [ + term + for term in intended_major.replace("/", " ").replace(",", " ").split() + if len(term) > 2 + ] + major_score = 7.0 if any(term in text for term in major_terms) else 4.5 + elif _contains_any( + text, + [ + "research", + "science", + "math", + "physics", + "chemistry", + "biology", + "robot", + "programming", + "engineering", + "economics", + ], + ): + major_score = 6.0 + if _contains_any(text, ["coursework", "project", "lab", "olympiad", "competition", "internship"]): + major_score += 1.0 + + scope_base = { + "international": 8.0, + "national": 7.0, + "regional": 5.5, + "local": 4.5, + "school": 3.5, + "family": 3.0, + "personal": 2.5, + } + selectivity_score = scope_base.get(scope, 4.5) + if _contains_any( + text, + ["selected", "selective", "winner", "finalist", "medal", "olympiad", "scholarship", "first place", "top "], + ): + selectivity_score += 1.5 + if _contains_any(text, ["imo", "ipho", "ibo", "nasa", "global", "international"]): + selectivity_score += 1.0 + + annual_hours = float(hours) * float(weeks) + if annual_hours >= 250: + continuity_score = 8.0 + elif annual_hours >= 120: + continuity_score = 6.5 + elif annual_hours >= 40: + continuity_score = 5.0 + elif weeks or hours: + continuity_score = 3.5 + else: + continuity_score = 4.0 + if _contains_any(text, ["year", "years", "since", "ongoing", "weekly", "semester"]): + continuity_score += 1.0 + + distinctiveness_score = 4.5 + if leadership in {"lead", "captain", "founder"}: + distinctiveness_score += 1.5 + if scope in {"national", "international"}: + distinctiveness_score += 1.0 + if _contains_any( + text, + ["founded", "created", "built", "published", "patent", "research", "startup", "world champion", "first place"], + ): + distinctiveness_score += 1.5 + if _contains_any(text, ["press", "media", "tedx", "conference", "downloads", "users"]): + distinctiveness_score += 1.0 + if _contains_any(text, ["helped", "member", "participated"]) and len(text) < 120: + distinctiveness_score -= 0.5 + + return { + "major_relevance_score": _clamp(major_score), + "selectivity_score": _clamp(selectivity_score), + "continuity_score": _clamp(continuity_score), + "distinctiveness_score": _clamp(distinctiveness_score), + } + + +def estimate_chancellor_scores(source: Any, user: Optional[Any] = None) -> dict[str, float]: + return _gemini_scores(source, user) or _heuristic_scores(source, user) diff --git a/apps/api/src/services/counselor_knowledge.py b/apps/api/src/services/counselor_knowledge.py new file mode 100644 index 0000000000000000000000000000000000000000..f1268e3066ac2b744053a82e5b8a0a62330ad5ca --- /dev/null +++ b/apps/api/src/services/counselor_knowledge.py @@ -0,0 +1,70 @@ +CHANCELLOR_COUNSELOR_FRAMEWORK = """ +Use this internal admissions counseling framework silently. Do not mention these materials to the student. + +Achievement extraction and Common App writing: +- Preserve every distinct achievement from the uploaded file before ranking. Do not collapse unrelated awards, projects, + leadership roles, jobs, family duties, research, and service into one item. +- Rank for quality, not quantity: sustained depth, selective recognition, independent initiative, measurable impact, + leadership, intellectual vitality, and connection to the student's intended direction matter most. +- Write copy-paste-ready English. Use strong active verbs, concrete metrics, and compressed phrasing. Remove filler, + adjectives that do not add evidence, and repeated role wording. +- Always translate Russian, Kazakh, or mixed-language notes into polished English output. Fix capitalization when the + source starts in lowercase or uses informal phrasing. +- Preserve years, school grade, number of students served, event names, placements, participant counts, and other + concrete facts when they appear in the source. Compress wording, but do not remove the meaning. +- Never replace a concrete source detail with a more impressive or more likely-sounding detail. If the source says + gift-card events, lessons, mentoring, or another specific activity, keep that meaning instead of inventing + tournaments, research, publications, or competitions. +- For Kazakhstan award levels, translate "Republican" / "Respublikalyk" / "Respublikanskiy" as "National" unless an + official English title clearly uses a different wording. +- Activities need three fields: position/leadership, organization, and description. Use the description for impact, + scale, selectivity, outputs, audiences, and results. +- Honors need one compact title/description line. Include level, placement, and selectivity only when supported. +- If facts conflict or are missing, ask direct questions before treating the wording as final. + +Low GPA, weak SAT/ACT, or academic dips: +- Recommend Additional Information only when it explains a material transcript/test-score weakness, an access barrier, + a serious disruption, or school context that changes interpretation. +- The best explanation is concise: context, accountability, concrete recovery, and evidence that the issue is resolved + or no longer defines the student's academic ability. +- Acceptable contexts can include diagnosed learning differences, health issues, family instability or caretaking, + unsafe home environment, immigration/language transition, financial hardship, or unusually heavy work obligations. +- Do not blame teachers, claim the student simply did not care, use generic pandemic excuses without specifics, + compare to classmates, or over-explain. If there is no meaningful context, advise not to force an explanation. +- For low testing, prefer strategy over excuse: use stronger coursework, external academic evidence, competitions, + research, predicted/official exam scores, or test-optional positioning when appropriate. + +Ultra-selective admissions strategy: +- Stats alone rarely differentiate a strong international applicant. Look for one or two unusually strong spikes rather + than many generic activities. +- Strong applications show academic evidence beyond grades: selective awards, original research, advanced coursework, + publications, technical artifacts, public work, or rigorous external validation. +- Overrepresented majors need sharper differentiation. A technical student can stand out through policy, ethics, + education, local impact, research, or another authentic cross-disciplinary angle. +- Essays and activity descriptions should reveal judgment, motivation, and personality through concrete choices, + not buzzwords or an all-perfect persona. +- For Kazakhstan/NIS students, preserve local context: NIS, NIS Grade 12 Certificate, UNT/ENT, research schools, + olympiad stages, language background, 11 vs 12 years of schooling, and access to counseling. +- MESK in Russian/Kazakh means NIS Grade 12 Certificate in English output. + +Korea application context: +- Do not treat Korean universities as Common App schools by default. South Korea applications commonly use Study in + Korea resources, university-specific portals, or third-party Korean application portals depending on the university. +- For KAIST, UNIST, POSTECH, SNU, Yonsei, Korea University, and similar Korean targets, produce concise + application-ready English that follows the target platform's own limit when known. Do not force the 50/100/150 + Common App fields unless the application system is explicitly Common App. +- For KAIST-style short fields, prioritize title, year, placement/selectivity, exact role, and quantified output. +- Internal Korea drafting checklist from the user's local counselor material: KAIST-style portals may separate honors + and extracurriculars into about 5 slots each and can use very short 200-byte descriptions; SNU/Yonsei-style portals + can use about 300-byte activity descriptions; Korea University may vary around 300-500 bytes; GKS forms may list + awards separately while the Personal Statement carries the explanation. Treat these as drafting heuristics, not + confirmed current rules. +- When Korea-specific limits are uncertain, state that the limit must be checked against the current official portal + instead of inventing a number. + +Source discipline: +- Separate confirmed facts from student claims. Never invent participant counts, selection rates, official titles, + award levels, deadlines, scholarships, or program requirements. +- When using search results, prefer official university, organizer, government, or program pages. If a fact is not + confirmed, mark it as unconfirmed and ask the student for a certificate, official link, or clearer detail. +""".strip() diff --git a/apps/api/src/services/optimization_engine.py b/apps/api/src/services/optimization_engine.py new file mode 100644 index 0000000000000000000000000000000000000000..1a013d844b86abb2960611f87cfbc84985531550 --- /dev/null +++ b/apps/api/src/services/optimization_engine.py @@ -0,0 +1,420 @@ +""" +Optimization engine for ranking and recommending achievements. +Uses weighted scoring based on university-specific weight presets. +""" +from typing import List, Dict, Tuple, Optional +from dataclasses import dataclass +from sqlalchemy.orm import Session +from uuid import UUID + +from ..models.achievement import Achievement, AchievementType, ImpactScope, LeadershipLevel +from ..models.university import University, WeightPreset +from ..models.user import StudentProfile +from ..models.report import ( + OptimizationReport, ReportRecommendation, SourceReference, + RecommendationType, ConfidenceLabel, SourceSection, ReportStatus +) +from .report_advisor import build_report_advisor_snapshot + + +# Weight configurations per preset +WEIGHT_PRESETS: Dict[str, Dict[str, float]] = { + WeightPreset.research_heavy: { + "impact_scope": 0.15, + "selectivity": 0.25, + "leadership": 0.10, + "continuity": 0.15, + "major_relevance": 0.25, + "distinctiveness": 0.10, + "clarity": 0.05, + "duplication_penalty": 2.0, + }, + WeightPreset.leadership_heavy: { + "impact_scope": 0.20, + "selectivity": 0.10, + "leadership": 0.30, + "continuity": 0.15, + "major_relevance": 0.10, + "distinctiveness": 0.10, + "clarity": 0.05, + "duplication_penalty": 2.0, + }, + WeightPreset.balanced_holistic: { + "impact_scope": 0.20, + "selectivity": 0.15, + "leadership": 0.15, + "continuity": 0.15, + "major_relevance": 0.15, + "distinctiveness": 0.15, + "clarity": 0.05, + "duplication_penalty": 2.0, + }, + WeightPreset.community_service_heavy: { + "impact_scope": 0.30, + "selectivity": 0.10, + "leadership": 0.15, + "continuity": 0.20, + "major_relevance": 0.10, + "distinctiveness": 0.10, + "clarity": 0.05, + "duplication_penalty": 2.0, + }, +} + +# Numeric mappings for enum values +IMPACT_SCOPE_MAP: Dict[str, float] = { + ImpactScope.school: 3.0, + ImpactScope.local: 4.0, + ImpactScope.regional: 6.0, + ImpactScope.national: 8.5, + ImpactScope.international: 10.0, + ImpactScope.family: 2.0, + ImpactScope.personal: 1.0, +} + +LEADERSHIP_MAP: Dict[str, float] = { + LeadershipLevel.none: 1.0, + LeadershipLevel.member: 4.0, + LeadershipLevel.lead: 7.0, + LeadershipLevel.captain: 8.5, + LeadershipLevel.founder: 10.0, +} + + +@dataclass +class ScoredAchievement: + achievement: Achievement + raw_score: float + breakdown: Dict[str, float] + is_duplicate: bool + duplicate_of: Optional[UUID] + + +def _get_impact_score(achievement: Achievement) -> float: + if achievement.impact_scope: + return IMPACT_SCOPE_MAP.get(achievement.impact_scope, 5.0) + return 5.0 # default middle value + + +def _get_leadership_score(achievement: Achievement) -> float: + if achievement.leadership_level: + return LEADERSHIP_MAP.get(achievement.leadership_level, 4.0) + return 4.0 + + +def _get_clarity_score(achievement: Achievement) -> float: + """Estimate clarity from description completeness.""" + score = 0.0 + if achievement.description_raw and len(achievement.description_raw) > 30: + score += 4.0 + if achievement.role_title: + score += 2.0 + if achievement.organization_name: + score += 2.0 + if achievement.hours_per_week: + score += 1.0 + if achievement.start_date: + score += 1.0 + return min(score, 10.0) + + +def _detect_duplicates(achievements: List[Achievement]) -> Dict[UUID, Optional[UUID]]: + """Detect duplicate achievements based on organization and overlapping titles.""" + duplicate_map: Dict[UUID, Optional[UUID]] = {} + seen: List[Achievement] = [] + + for ach in achievements: + is_dup = False + for prev in seen: + # Same org and similar title suggests duplication + if ( + ach.organization_name + and prev.organization_name + and ach.organization_name.lower().strip() == prev.organization_name.lower().strip() + ): + ach_words = set((ach.title or "").lower().split()) + prev_words = set((prev.title or "").lower().split()) + if ach_words and prev_words: + overlap = len(ach_words & prev_words) / max(len(ach_words), len(prev_words)) + if overlap > 0.5: + duplicate_map[ach.id] = prev.id + is_dup = True + break + if not is_dup: + duplicate_map[ach.id] = None + seen.append(ach) + + return duplicate_map + + +def score_achievement( + achievement: Achievement, + weights: Dict[str, float], + is_duplicate: bool = False, +) -> Tuple[float, Dict[str, float]]: + """Score a single achievement using the given weight configuration.""" + + impact = _get_impact_score(achievement) + selectivity = achievement.selectivity_score if achievement.selectivity_score is not None else 5.0 + leadership = _get_leadership_score(achievement) + continuity = achievement.continuity_score if achievement.continuity_score is not None else 5.0 + major_relevance = achievement.major_relevance_score if achievement.major_relevance_score is not None else 5.0 + distinctiveness = achievement.distinctiveness_score if achievement.distinctiveness_score is not None else 5.0 + clarity = _get_clarity_score(achievement) + + breakdown = { + "impact_scope": impact * weights["impact_scope"], + "selectivity": selectivity * weights["selectivity"], + "leadership": leadership * weights["leadership"], + "continuity": continuity * weights["continuity"], + "major_relevance": major_relevance * weights["major_relevance"], + "distinctiveness": distinctiveness * weights["distinctiveness"], + "clarity": clarity * weights["clarity"], + } + + raw = sum(breakdown.values()) + + if is_duplicate: + raw -= weights["duplication_penalty"] + + return raw, breakdown + + +def _calculate_confidence(achievement: Achievement, score: float, all_scores: List[float]) -> ConfidenceLabel: + """Determine confidence label based on data completeness and score spread.""" + filled_fields = sum([ + 1 if achievement.impact_scope else 0, + 1 if achievement.selectivity_score is not None else 0, + 1 if achievement.leadership_level else 0, + 1 if achievement.continuity_score is not None else 0, + 1 if achievement.major_relevance_score is not None else 0, + 1 if achievement.distinctiveness_score is not None else 0, + 1 if achievement.description_raw else 0, + ]) + + if filled_fields >= 6: + completeness = "high" + elif filled_fields >= 3: + completeness = "medium" + else: + completeness = "low" + + if len(all_scores) > 1: + score_range = max(all_scores) - min(all_scores) + if score_range < 1.0: + spread = "low" + elif score_range < 3.0: + spread = "medium" + else: + spread = "high" + else: + spread = "medium" + + if completeness == "high" and spread in ("medium", "high"): + return ConfidenceLabel.high + elif completeness == "low" or spread == "low": + return ConfidenceLabel.low + else: + return ConfidenceLabel.medium + + +def _generate_rationale( + achievement: Achievement, + recommendation_type: RecommendationType, + rank: Optional[int], + weights: Dict[str, float], + breakdown: Dict[str, float], + university: University, +) -> str: + """Generate a human-readable rationale for the recommendation.""" + lines = [] + + if recommendation_type == RecommendationType.keep: + lines.append(f"Recommended for {university.name} (#{rank}) based on {university.weight_preset.replace('_', ' ')} criteria.") + + # Find top contributing factors + sorted_factors = sorted(breakdown.items(), key=lambda x: x[1], reverse=True) + top_factors = [f[0].replace("_", " ") for f in sorted_factors[:2]] + lines.append(f"Strongest factors: {', '.join(top_factors)}.") + + if achievement.impact_scope in (ImpactScope.national, ImpactScope.international): + lines.append(f"Noteworthy {achievement.impact_scope.value}-level impact.") + + if achievement.leadership_level in (LeadershipLevel.founder, LeadershipLevel.captain): + lines.append(f"Leadership role ({achievement.leadership_level.value}) adds significant weight.") + + elif recommendation_type == RecommendationType.remove: + lines.append(f"Not recommended for inclusion in your {university.name} application.") + lines.append("Score falls below the threshold given your other achievements and this university's priorities.") + if achievement.impact_scope in (ImpactScope.personal, ImpactScope.family): + lines.append("Limited external impact scope reduces fit for this university profile.") + + elif recommendation_type == RecommendationType.rewrite: + lines.append(f"Strong potential — keep for {university.name} but the description needs sharpening.") + if not achievement.description_raw or len(achievement.description_raw) < 50: + lines.append("Current description is too brief to convey full impact.") + lines.append("Use the Rewrite Studio to tighten language and lead with impact.") + + elif recommendation_type == RecommendationType.merge: + lines.append("Overlaps significantly with another entry. Consider merging to avoid duplication.") + lines.append("Admissions readers notice repetition — a single strong entry outperforms two weak ones.") + + elif recommendation_type == RecommendationType.reorder: + lines.append(f"Include at position #{rank}. Ordering here is important — earlier positions receive more attention.") + + return " ".join(lines) + + +def run_optimization( + db: Session, + report: OptimizationReport, + achievements: List[Achievement], + university: University, + profile: StudentProfile | None = None, + user_country: str | None = None, +) -> None: + """ + Run the full optimization pipeline and populate the report with recommendations. + """ + weights = WEIGHT_PRESETS.get(university.weight_preset, WEIGHT_PRESETS[WeightPreset.balanced_holistic]) + + activities = [a for a in achievements if a.type == AchievementType.activity] + honors = [a for a in achievements if a.type == AchievementType.honor] + + dup_map_activities = _detect_duplicates(activities) + dup_map_honors = _detect_duplicates(honors) + + def score_list(items: List[Achievement], dup_map: dict) -> List[ScoredAchievement]: + scored = [] + for ach in items: + is_dup = dup_map.get(ach.id) is not None + dup_of = dup_map.get(ach.id) + raw, breakdown = score_achievement(ach, weights, is_duplicate=is_dup) + scored.append(ScoredAchievement( + achievement=ach, + raw_score=raw, + breakdown=breakdown, + is_duplicate=is_dup, + duplicate_of=dup_of, + )) + scored.sort(key=lambda x: x.raw_score, reverse=True) + return scored + + scored_activities = score_list(activities, dup_map_activities) + scored_honors = score_list(honors, dup_map_honors) + + all_activity_scores = [s.raw_score for s in scored_activities] + all_honor_scores = [s.raw_score for s in scored_honors] + + recommendations = [] + + # Process activities: top 10 keep, rest remove/merge + for rank, scored in enumerate(scored_activities, start=1): + ach = scored.achievement + + if scored.is_duplicate: + rec_type = RecommendationType.merge + suggested_rank = None + elif rank <= 10: + if not ach.description_raw or len(ach.description_raw or "") < 30: + rec_type = RecommendationType.rewrite + else: + rec_type = RecommendationType.keep + suggested_rank = rank + else: + rec_type = RecommendationType.remove + suggested_rank = None + + confidence = _calculate_confidence(ach, scored.raw_score, all_activity_scores) + rationale = _generate_rationale(ach, rec_type, suggested_rank, weights, scored.breakdown, university) + + rec = ReportRecommendation( + report_id=report.id, + achievement_id=ach.id, + recommendation_type=rec_type, + suggested_rank=suggested_rank, + rationale=rationale, + confidence_label=confidence, + ) + recommendations.append(rec) + + # Process honors: top 5 keep, rest remove + for rank, scored in enumerate(scored_honors, start=1): + ach = scored.achievement + + if scored.is_duplicate: + rec_type = RecommendationType.merge + suggested_rank = None + elif rank <= 5: + if not ach.description_raw or len(ach.description_raw or "") < 30: + rec_type = RecommendationType.rewrite + else: + rec_type = RecommendationType.keep + suggested_rank = rank + else: + rec_type = RecommendationType.remove + suggested_rank = None + + confidence = _calculate_confidence(ach, scored.raw_score, all_honor_scores) + rationale = _generate_rationale(ach, rec_type, suggested_rank, weights, scored.breakdown, university) + + rec = ReportRecommendation( + report_id=report.id, + achievement_id=ach.id, + recommendation_type=rec_type, + suggested_rank=suggested_rank, + rationale=rationale, + confidence_label=confidence, + ) + recommendations.append(rec) + + db.add_all(recommendations) + + # Add source references from university policy entries + for entry in university.policy_entries: + section = ( + SourceSection.official_guidance + if entry.source_type.value == "official" + else SourceSection.public_examples + ) + source_ref = SourceReference( + report_id=report.id, + university_policy_entry_id=entry.id, + section=section, + note=f"Referenced for {university.name} application context.", + ) + db.add(source_ref) + + # Build summary text + target_major = ( + profile.intended_major if profile and profile.intended_major else None + ) or (university.major_strengths[0] if university.major_strengths else None) or "your target major" + weight_label = getattr(university.weight_preset, "value", university.weight_preset).replace("_", " ") + weight_emphasis = ", ".join( + key.replace("_", " ") + for key, value in weights.items() + if isinstance(value, float) and value >= 0.20 and key != "duplication_penalty" + ) + funding_note = ( + "A full-funding route is visible in the current dataset." + if university.full_ride_possible + else "Funding still needs careful verification before this school stays in the core list." + ) + + report.summary_text = ( + f"Advisor ready for {university.name} v{report.version_number}. " + f"Focus major: {target_major}. " + f"University profile: {weight_label}. " + f"Weight emphasis: {weight_emphasis}. " + f"{funding_note}" + ) + report.advisor_snapshot_json = build_report_advisor_snapshot( + university=university, + profile=profile, + user_country=user_country, + report_note=report.summary_text, + ) + report.status = ReportStatus.completed + report.completed_at = __import__("datetime").datetime.utcnow() + + db.commit() diff --git a/apps/api/src/services/report_advisor.py b/apps/api/src/services/report_advisor.py new file mode 100644 index 0000000000000000000000000000000000000000..138e10c9bb66b0ce9123d05abcf89286fb8081f0 --- /dev/null +++ b/apps/api/src/services/report_advisor.py @@ -0,0 +1,273 @@ +from __future__ import annotations + +from typing import Any + +from ..models.university import University +from ..models.user import StudentProfile + + +PROGRAM_LIBRARY: dict[str, list[dict[str, str]]] = { + "mit": [ + { + "name": "MIT PRIMES", + "why_it_matters": "High-signal math and computer science research track that reads as real academic depth, not just another club.", + "funding_note": "Treat as top priority if you can access the remote track, but verify the current international eligibility and cost rules.", + "priority": "verify", + }, + { + "name": "Research Science Institute (RSI)", + "why_it_matters": "Elite research credential with immediate value for STEM-heavy applications and a much stronger narrative than a generic summer school.", + "funding_note": "Prioritize because the program has historically been fully funded for admitted students, but still verify the current cycle.", + "priority": "full-funding", + }, + { + "name": "MITES", + "why_it_matters": "Strong MIT-branded pre-college signal for STEM readiness and academic stretch.", + "funding_note": "Useful only if the current cycle accepts your profile and funding route. Verify international access before planning around it.", + "priority": "verify", + }, + { + "name": "Beaver Works Summer Institute", + "why_it_matters": "Good applied CS, AI, and robotics proof if you need a stronger technical build before applications.", + "funding_note": "Do not assume full funding. Apply only if scholarship or sponsored access is available.", + "priority": "scholarship", + }, + ], + "default_cs": [ + { + "name": "Research Science Institute (RSI)", + "why_it_matters": "Recognizable research signal for highly selective STEM admissions.", + "funding_note": "Prioritize first because it has historically offered a strong funding route for admitted students, but verify the current cycle.", + "priority": "full-funding", + }, + { + "name": "Pioneer Academics", + "why_it_matters": "Produces an actual research output and helps move your story from project-builder to research-capable applicant.", + "funding_note": "Apply only if scholarship support is available. Do not treat it as an automatic full-funding option.", + "priority": "scholarship", + }, + { + "name": "PROMYS", + "why_it_matters": "Strong fit if your target major needs proof of mathematical rigor behind CS or AI ambitions.", + "funding_note": "Useful when aid is available. Verify current scholarship access for international students.", + "priority": "verify", + }, + { + "name": "YYGS IST", + "why_it_matters": "Not as strong as true research, but still better than a generic enrichment camp if you need a branded academic program.", + "funding_note": "Only worth it with substantial aid or sponsorship.", + "priority": "scholarship", + }, + ], + "default_engineering": [ + { + "name": "Research Science Institute (RSI)", + "why_it_matters": "Adds hard research credibility for engineering-heavy applications.", + "funding_note": "Prioritize because the funding path has historically been strong for admitted students, but verify the current cycle.", + "priority": "full-funding", + }, + { + "name": "Beaver Works Summer Institute", + "why_it_matters": "Strong applied engineering and robotics signal when you need a build-heavy program.", + "funding_note": "Use only with scholarship support or sponsor backing.", + "priority": "scholarship", + }, + { + "name": "PROMYS", + "why_it_matters": "Helpful if your engineering target expects serious math underneath the build work.", + "funding_note": "Verify current financial aid options for international students.", + "priority": "verify", + }, + ], +} + +MAJOR_KEYWORDS = { + "cs": ["computer science", "cs", "artificial intelligence", "ai", "machine learning", "software"], + "engineering": ["engineering", "electrical", "mechanical", "robotics"], + "business": ["business", "economics", "finance", "management"], + "life_sciences": ["biology", "biotech", "medicine", "neuroscience", "chemistry"], +} + + +def _normalize(value: str | None) -> str: + return (value or "").strip().lower() + + +def _dedupe(values: list[str]) -> list[str]: + seen: set[str] = set() + output: list[str] = [] + for value in values: + if not value: + continue + if value in seen: + continue + seen.add(value) + output.append(value) + return output + + +def _includes_any(value: str, terms: list[str]) -> bool: + return any(term in value for term in terms) + + +def _detect_track(university: University, major: str) -> str: + combined = " ".join( + [ + _normalize(major), + _normalize(" ".join(university.major_strengths or [])), + ] + ) + + if _includes_any(combined, MAJOR_KEYWORDS["cs"]): + return "cs" + if _includes_any(combined, MAJOR_KEYWORDS["engineering"]): + return "engineering" + if _includes_any(combined, MAJOR_KEYWORDS["business"]): + return "business" + if _includes_any(combined, MAJOR_KEYWORDS["life_sciences"]): + return "life_sciences" + return "general" + + +def _programs_for(university: University, major: str) -> list[dict[str, str]]: + slug = _normalize(university.slug) + if slug in PROGRAM_LIBRARY: + return PROGRAM_LIBRARY[slug] + + track = _detect_track(university, major) + if track == "cs": + return PROGRAM_LIBRARY["default_cs"] + if track == "engineering": + return PROGRAM_LIBRARY["default_engineering"] + + return [ + { + "name": "Professor-led summer research program", + "why_it_matters": "You still need a real academic signal tied to the target major, not only extracurricular activity.", + "funding_note": "Choose only options with a clear scholarship or sponsor route.", + "priority": "verify", + } + ] + + +def _focus_areas(university: University, major: str, profile: StudentProfile | None) -> list[str]: + school_years = None + preferences = profile.application_preferences_json if profile else None + if isinstance(preferences, dict): + raw_school_years = preferences.get("school_years") + if raw_school_years is not None: + school_years = str(raw_school_years) + + focus = [ + f"Target major: {major}. Keep the advisor anchored to this major, not to the whole student profile.", + f"Application route: {university.application_system}." if university.application_system else "", + f"Curriculum context: {profile.curriculum}." if profile and profile.curriculum else "", + ( + f"Academic baseline: the school expects {university.education_years_required}+ years of schooling." + if university.education_years_required + else "" + ), + f"Your saved school-years setting is {school_years}." if school_years else "", + f"School-years note: {university.school_years_note}" if university.school_years_note else "", + ( + f"The university already reads strongest for: {', '.join((university.major_strengths or [])[:4])}." + if university.major_strengths + else "" + ), + ( + "This school profile rewards research depth and proof of technical rigor more than generic leadership packaging." + if getattr(university.weight_preset, "value", university.weight_preset) == "research_heavy" + else "" + ), + ( + "This school profile rewards leadership and initiative, but it still needs major-specific substance." + if getattr(university.weight_preset, "value", university.weight_preset) == "leadership_heavy" + else "" + ), + ( + "This school profile is balanced, so academic rigor, fit, and a clear narrative all matter." + if getattr(university.weight_preset, "value", university.weight_preset) == "balanced_holistic" + else "" + ), + ( + "This school profile values community impact, but the story still has to stay connected to the intended major." + if getattr(university.weight_preset, "value", university.weight_preset) == "community_service_heavy" + else "" + ), + ] + + return _dedupe(focus)[:5] + + +def _funding_plan(university: University, user_country: str | None) -> list[str]: + country_label = user_country or "your country" + funding = [ + ( + f"This university stays on the shortlist because a full-funding route appears possible for an international applicant from {country_label}." + if university.full_ride_possible + else "Do not position this university as full-ride-safe yet. Keep it only if you can cover the gap or find a separate sponsor route." + ), + f"Funding route in the dataset: {university.aid_type.replace('_', ' ')}." if university.aid_type else "", + f"Aid note: {university.aid_notes}" if university.aid_notes else "", + f"Eligibility check: {university.eligibility_notes}" if university.eligibility_notes else "", + ( + "Before final submission, verify the current aid policy on the university funding page." + if university.funding_source_url + else "Before final submission, verify the current aid policy on the official admissions and financial aid pages." + ), + ] + return _dedupe(funding) + + +def _action_plan(university: University, major: str, programs: list[dict[str, str]]) -> list[dict[str, str]]: + top_programs = ", ".join(program["name"] for program in programs[:3]) + return [ + { + "title": "Lock the exact application lane", + "detail": f"Keep this advisor strictly on {major} at {university.name}. Do not dilute it into a generic student summary.", + }, + { + "title": "Add one real research signal", + "detail": ( + f"Prioritize named programs like {top_programs} instead of generic competitions or random certificates." + if top_programs + else "Prioritize a named research program with a real output, not another generic extracurricular." + ), + }, + { + "title": "Build one flagship major-aligned artifact", + "detail": "Ship one serious project, paper, or technical build that can carry the application narrative for this major.", + }, + { + "title": "Protect the funding story", + "detail": ( + "Keep this school only if the international full-funding route still checks out on the current official page." + if university.full_ride_possible + else "Treat funding as a blocker, not a footnote. If full funding is not realistic, move the school out of the core list." + ), + }, + ] + + +def build_report_advisor_snapshot( + *, + university: University, + profile: StudentProfile | None, + user_country: str | None, + report_note: str, +) -> dict[str, Any]: + major = (profile.intended_major if profile and profile.intended_major else None) or ( + university.major_strengths[0] if university.major_strengths else None + ) or "your target major" + programs = _programs_for(university, major) + + return { + "title": f"{university.name} advisor", + "subtitle": f"Focused on {major} and the funding reality for an international applicant.", + "target_major": major, + "report_note": report_note, + "focus_areas": _focus_areas(university, major, profile), + "research_programs": programs, + "funding_plan": _funding_plan(university, user_country), + "action_plan": _action_plan(university, major, programs), + } diff --git a/apps/api/src/services/rewrite_service.py b/apps/api/src/services/rewrite_service.py new file mode 100644 index 0000000000000000000000000000000000000000..cd661262dfe7ef46d49dec35ae5f12f31c5ad112 --- /dev/null +++ b/apps/api/src/services/rewrite_service.py @@ -0,0 +1,379 @@ +""" +Rewrite service for generating style variants of achievement descriptions. +Uses Gemini when available, then falls back to conservative local formatting. +""" +import json +import re +from typing import Any, Dict, List, Optional, Tuple + +import httpx +from sqlalchemy.orm import Session + +from ..config import settings +from ..models.achievement import Achievement, AchievementType +from ..models.report import OptimizationReport, RewriteVariant +from .counselor_knowledge import CHANCELLOR_COUNSELOR_FRAMEWORK + + +COMMON_APP_ACTIVITY_DESC_LIMIT = 150 +COMMON_APP_HONOR_DESC_LIMIT = 100 +KAIST_DESC_LIMIT = 200 +KOREA_DEFAULT_DESC_LIMIT = 300 + +STYLE_ORDER: list[tuple[str, bool, str]] = [ + ( + "factual", + False, + "Clean English wording that preserves the verified facts without hype.", + ), + ( + "impact_first", + True, + "Leads with the strongest outcome, scope, or selectivity before explaining the role.", + ), + ( + "understated", + False, + "Concise, restrained version that keeps the student's voice factual.", + ), +] + +REWRITE_SCHEMA = { + "type": "object", + "properties": { + "variants": { + "type": "array", + "items": { + "type": "object", + "properties": { + "style_mode": { + "type": "string", + "enum": ["factual", "impact_first", "understated"], + }, + "text": {"type": "string"}, + "explanation": {"type": "string"}, + }, + "required": ["style_mode", "text", "explanation"], + }, + }, + }, + "required": ["variants"], +} + + +def _compact_whitespace(value: str) -> str: + return re.sub(r"\s+", " ", value or "").strip() + + +def _truncate_to_limit(text: str, limit: int) -> str: + """Hard truncate text to a character limit, breaking at a word boundary when possible.""" + text = _compact_whitespace(text) + if len(text) <= limit: + return text + truncated = text[:limit] + last_space = truncated.rfind(" ") + if last_space > limit - 24: + truncated = truncated[:last_space] + return truncated.rstrip(",.;: ") + + +def _has_cyrillic(value: str) -> bool: + return bool(re.search(r"[\u0400-\u04FF]", value or "")) + + +def _ascii_punctuation(value: str) -> str: + replacements = { + "\u2018": "'", + "\u2019": "'", + "\u201c": '"', + "\u201d": '"', + "\u2013": "-", + "\u2014": "-", + "\u2026": "...", + "\u00a0": " ", + } + for source, target in replacements.items(): + value = value.replace(source, target) + return value + + +def _normalize_award_level(value: str) -> str: + value = re.sub(r"\bRepublican\b", "National", value, flags=re.IGNORECASE) + value = re.sub(r"\bRespublikalyk\b", "National", value, flags=re.IGNORECASE) + value = re.sub(r"\bRespublikanskiy\b", "National", value, flags=re.IGNORECASE) + return value + + +def _capitalize_first(value: str) -> str: + if not value: + return value + for index, char in enumerate(value): + if char.isalpha(): + return value[:index] + char.upper() + value[index + 1 :] + return value + + +def _extract_year_phrases(value: str) -> list[str]: + years: list[str] = [] + for match in re.finditer(r"\b20\d{2}(?:\s*[-\u2013\u2014]\s*(?:20)?\d{2})?\b", value or ""): + year = _ascii_punctuation(match.group(0)) + year = re.sub(r"\s*-\s*", "-", year) + if year not in years: + years.append(year) + return years[:2] + + +def _preserve_required_year(text: str, required_years: list[str], limit: int) -> str: + if not text or not required_years: + return text + if any(year in text for year in required_years): + return text + + suffix = f", {required_years[0]}" + if text.endswith("."): + suffix = f" {required_years[0]}." + base = text[:-1] + else: + base = text + if len(base) + len(suffix) <= limit: + return f"{base}{suffix}" + + base = _truncate_to_limit(base, max(1, limit - len(suffix))) + return f"{base}{suffix}" + + +def _clean_generated_text(text: str, *, limit: int, required_years: list[str]) -> str: + text = _ascii_punctuation(_compact_whitespace(text)).strip(" \"'") + text = _normalize_award_level(text) + text = _capitalize_first(text) + if _has_cyrillic(text): + return "" + text = _preserve_required_year(text, required_years, limit) + return _truncate_to_limit(text, limit) + + +def _achievement_type(achievement: Achievement) -> str: + value = getattr(achievement.type, "value", achievement.type) + return str(value or AchievementType.activity.value) + + +def _target_format(report: OptimizationReport, achievement: Achievement) -> dict[str, Any]: + university = getattr(report, "university", None) + name = str(getattr(university, "name", "") or "") + country = str(getattr(university, "country", "") or "") + application_system = str(getattr(university, "application_system", "") or "") + haystack = f"{name} {country} {application_system}".lower() + + if "korea" in haystack or any(token in haystack for token in ["kaist", "unist", "postech", "yonsei"]): + if "kaist" in haystack: + return { + "label": "KAIST Apply", + "limit": KAIST_DESC_LIMIT, + "limit_unit": "English bytes/chars", + "is_common_app": False, + "instruction": "Use KAIST-style concise English. Prioritize year, placement/selectivity, role, and quantified output.", + } + return { + "label": "Korean university application", + "limit": KOREA_DEFAULT_DESC_LIMIT, + "limit_unit": "English bytes/chars", + "is_common_app": False, + "instruction": "Do not assume Common App. Use concise Study in Korea or university-portal-ready English.", + } + + if _achievement_type(achievement) == AchievementType.honor.value: + return { + "label": "Common App honors", + "limit": COMMON_APP_HONOR_DESC_LIMIT, + "limit_unit": "characters", + "is_common_app": True, + "instruction": "Write one Common App honor title/description block.", + } + + return { + "label": "Common App activities", + "limit": COMMON_APP_ACTIVITY_DESC_LIMIT, + "limit_unit": "characters", + "is_common_app": True, + "instruction": "Write a Common App activity description. Do not repeat the position field.", + } + + +def _extract_key_facts(achievement: Achievement) -> Dict[str, Any]: + """Extract factual elements from the achievement for use in rewrites.""" + facts: Dict[str, Any] = { + "type": _achievement_type(achievement), + "title": achievement.title, + "organization_name": achievement.organization_name, + "role_title": achievement.role_title, + "description_raw": achievement.description_raw, + "category": achievement.category, + "start_date": achievement.start_date.isoformat() if achievement.start_date else None, + "end_date": achievement.end_date.isoformat() if achievement.end_date else None, + "hours_per_week": achievement.hours_per_week, + "weeks_per_year": achievement.weeks_per_year, + "impact_scope": getattr(achievement.impact_scope, "value", achievement.impact_scope), + "leadership_level": getattr(achievement.leadership_level, "value", achievement.leadership_level), + } + return {key: value for key, value in facts.items() if value not in (None, "")} + + +def _source_text(facts: dict[str, Any]) -> str: + return " ".join(str(value) for value in facts.values() if value not in (None, "")) + + +def _fallback_text(achievement: Achievement, facts: dict[str, Any], limit: int, required_years: list[str]) -> str: + parts = [ + str(facts.get("role_title") or ""), + f"at {facts['organization_name']}" if facts.get("organization_name") else "", + str(facts.get("description_raw") or facts.get("title") or ""), + ] + text = _clean_generated_text(" ".join(part for part in parts if part), limit=limit, required_years=required_years) + if text: + return text + return _truncate_to_limit( + "English rewrite unavailable; verify exact facts before submission.", + limit, + ) + + +def _rewrite_prompt( + *, + achievement: Achievement, + facts: dict[str, Any], + target_format: dict[str, Any], + required_years: list[str], +) -> str: + payload = { + "achievement_facts": facts, + "target_format": target_format, + "required_years_from_source": required_years, + } + return ( + "You are ApplyMap Chancellor. Rewrite one achievement into copy-paste-ready application text.\n\n" + f"{CHANCELLOR_COUNSELOR_FRAMEWORK}\n\n" + "Rules:\n" + "- Return exactly three variants: factual, impact_first, and understated.\n" + "- Output only polished English text. Translate Russian, Kazakh, or mixed-language input into English.\n" + "- Fix capitalization, grammar, and informal phrasing.\n" + "- Preserve years, date ranges, grade level, number of people served, event names, placements, and supported metrics.\n" + "- Never replace a concrete source detail with a guessed or more impressive detail. If the source says gift cards, " + "lessons, mentoring, or another specific activity, translate that detail directly; do not invent tournaments, " + "research, publications, awards, or program names.\n" + "- Translate Kazakhstan award level words like Republican/Respublikalyk/Respublikanskiy as National unless an official " + "English title clearly uses Republican.\n" + "- Do not invent facts, participant counts, selection rates, titles, or outcomes.\n" + "- Use ASCII punctuation. Do not output Cyrillic text.\n" + f"- Target format: {target_format['label']}; limit: {target_format['limit']} {target_format['limit_unit']}.\n" + f"- {target_format['instruction']}\n" + "- For Korean universities, do not call the output Common App wording unless the application system is Common App.\n" + "- Keep the meaning even when compressing. If the source says the student mentored five 8th graders as an 11th grader " + "in 2024-2025 and organized events, keep those facts in concise English.\n" + "- Each text must be no longer than the target limit.\n" + "- Explanations should be one short sentence and should not mention internal materials.\n\n" + f"Input JSON:\n{json.dumps(payload, ensure_ascii=False, default=str)}" + ) + + +def _extract_gemini_text(response_payload: dict[str, Any]) -> str: + candidates = response_payload.get("candidates") or [] + content = candidates[0].get("content") if candidates else {} + parts = content.get("parts") or [] + return str(parts[0].get("text", "")) if parts else "" + + +def _gemini_rewrite_variants( + achievement: Achievement, + facts: dict[str, Any], + target_format: dict[str, Any], + required_years: list[str], +) -> Optional[dict[str, tuple[str, str]]]: + api_key = settings.GEMINI_API_KEY.strip() + if not api_key: + return None + + model = (settings.GEMINI_MODEL or "gemini-2.5-flash").strip() + url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent" + payload = { + "contents": [ + { + "parts": [ + { + "text": _rewrite_prompt( + achievement=achievement, + facts=facts, + target_format=target_format, + required_years=required_years, + ) + } + ] + } + ], + "generationConfig": { + "temperature": 0.1, + "maxOutputTokens": 4096, + "responseMimeType": "application/json", + "responseJsonSchema": REWRITE_SCHEMA, + }, + } + + try: + with httpx.Client(timeout=45.0) as client: + response = client.post( + url, + headers={"x-goog-api-key": api_key, "Content-Type": "application/json"}, + json=payload, + ) + response.raise_for_status() + parsed = json.loads(_extract_gemini_text(response.json())) + except (httpx.HTTPError, json.JSONDecodeError, KeyError, TypeError, ValueError): + return None + + variants: dict[str, tuple[str, str]] = {} + for raw_variant in parsed.get("variants") or []: + style_mode = str(raw_variant.get("style_mode") or "") + if style_mode not in {style for style, _, _ in STYLE_ORDER}: + continue + text = _clean_generated_text( + str(raw_variant.get("text") or ""), + limit=int(target_format["limit"]), + required_years=required_years, + ) + if not text: + continue + explanation = _compact_whitespace(str(raw_variant.get("explanation") or "Generated from verified student facts.")) + variants[style_mode] = (text, explanation) + + return variants or None + + +def generate_rewrite_variants( + db: Session, + achievement: Achievement, + report: OptimizationReport, +) -> List[RewriteVariant]: + """ + Generate 3 style variants for an achievement. + Returns RewriteVariant objects (not yet committed to DB). + """ + facts = _extract_key_facts(achievement) + target_format = _target_format(report, achievement) + required_years = _extract_year_phrases(_source_text(facts)) + generated = _gemini_rewrite_variants(achievement, facts, target_format, required_years) or {} + + fallback = _fallback_text(achievement, facts, int(target_format["limit"]), required_years) + variants = [] + for style_mode, is_recommended, default_explanation in STYLE_ORDER: + text, explanation = generated.get(style_mode, (fallback, default_explanation)) + variant = RewriteVariant( + achievement_id=achievement.id, + report_id=report.id, + style_mode=style_mode, + text=text, + character_count=len(text), + is_recommended=is_recommended, + explanation=explanation, + ) + variants.append(variant) + + return variants diff --git a/apps/api/src/services/university_advisor.py b/apps/api/src/services/university_advisor.py new file mode 100644 index 0000000000000000000000000000000000000000..11fb4dc0715c1161c7f025b6a34dd2ad67c7d5de --- /dev/null +++ b/apps/api/src/services/university_advisor.py @@ -0,0 +1,280 @@ +import json +from typing import Any +from urllib.parse import urlparse + +import httpx + +from ..config import settings +from .chancellor_analysis import ADMISSIONS_FRAMEWORK +from .counselor_knowledge import CHANCELLOR_COUNSELOR_FRAMEWORK + + +ADVISOR_SCHEMA = { + "type": "object", + "properties": { + "summary": {"type": "string"}, + "exams_to_prioritize": { + "type": "array", + "items": { + "type": "object", + "properties": { + "exam": {"type": "string"}, + "why": {"type": "string"}, + "priority": {"type": "string", "enum": ["high", "medium", "low"]}, + }, + "required": ["exam", "why", "priority"], + }, + }, + "profile_actions": { + "type": "array", + "items": {"type": "string"}, + }, + "low_value_activities": { + "type": "array", + "items": {"type": "string"}, + }, + "research_or_summer_programs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "why_it_helps": {"type": "string"}, + "source_url": {"type": "string"}, + }, + "required": ["name", "why_it_helps"], + }, + }, + "source_notes": { + "type": "array", + "items": {"type": "string"}, + }, + }, + "required": [ + "summary", + "exams_to_prioritize", + "profile_actions", + "low_value_activities", + "research_or_summer_programs", + "source_notes", + ], +} + + +class SearchNotConfiguredError(RuntimeError): + pass + + +def _google_search(query: str, *, num: int = 5) -> list[dict[str, str]]: + api_key = settings.GOOGLE_SEARCH_API_KEY.strip() + engine_id = settings.GOOGLE_SEARCH_ENGINE_ID.strip() + if not api_key or not engine_id: + raise SearchNotConfiguredError("Google Custom Search is not configured") + + with httpx.Client(timeout=12.0) as client: + response = client.get( + "https://www.googleapis.com/customsearch/v1", + params={ + "key": api_key, + "cx": engine_id, + "q": query, + "num": num, + "safe": "active", + "hl": "en", + }, + ) + response.raise_for_status() + + items = response.json().get("items") or [] + return [ + { + "title": str(item.get("title") or ""), + "url": str(item.get("link") or ""), + "snippet": str(item.get("snippet") or ""), + } + for item in items + if item.get("link") + ] + + +def _source_tier(url: str, university_name: str) -> str: + host = urlparse(url).netloc.lower() + name_tokens = [token for token in university_name.lower().replace("-", " ").split() if len(token) > 3] + has_name_token = any(token in host for token in name_tokens) + if has_name_token and (".edu" in host or ".ac." in host or host.endswith(".edu")): + return "official" + if has_name_token: + return "likely_official" + if ".edu" in host or ".ac." in host: + return "education_domain" + return "third_party" + + +def search_university_sources(university_name: str, intended_major: str | None = None) -> list[dict[str, str]]: + major = intended_major or "undergraduate" + queries = [ + f"{university_name} official undergraduate admissions international students requirements", + f"{university_name} official scholarships financial aid international students", + f"{university_name} official English taught programs {major}", + f"{university_name} official research summer programs high school students {major}", + ] + + seen: set[str] = set() + results: list[dict[str, str]] = [] + for query in queries: + for item in _google_search(query, num=5): + url = item["url"] + if url in seen: + continue + seen.add(url) + results.append( + { + **item, + "query": query, + "source_tier": _source_tier(url, university_name), + } + ) + if len(results) >= 12: + return results + return results + + +def _profile_payload(user: Any) -> dict[str, Any]: + profile = getattr(user, "profile", None) + return { + "country": getattr(user, "country", None), + "graduation_year": getattr(profile, "graduation_year", None), + "curriculum": getattr(profile, "curriculum", None), + "intended_major": getattr(profile, "intended_major", None), + "sat_score": getattr(profile, "sat_score", None), + "sat_math": getattr(profile, "sat_math", None), + "sat_ebrw": getattr(profile, "sat_ebrw", None), + "act_score": getattr(profile, "act_score", None), + "ielts_score": getattr(profile, "ielts_score", None), + "toefl_score": getattr(profile, "toefl_score", None), + "duolingo_score": getattr(profile, "duolingo_score", None), + "a_level_subjects": getattr(profile, "a_level_subjects", None), + "ib_predicted_score": getattr(profile, "ib_predicted_score", None), + "unt_score": getattr(profile, "unt_score", None), + "nis_grade12_certificate_gpa": getattr(profile, "nis_grade12_certificate_gpa", None), + } + + +def _achievement_payload(achievements: list[Any]) -> list[dict[str, Any]]: + return [ + { + "type": getattr(item.type, "value", item.type), + "title": item.title, + "organization_name": item.organization_name, + "role_title": item.role_title, + "description_raw": item.description_raw, + "category": item.category, + "impact_scope": getattr(item.impact_scope, "value", item.impact_scope), + "leadership_level": getattr(item.leadership_level, "value", item.leadership_level), + "major_relevance_score": item.major_relevance_score, + "selectivity_score": item.selectivity_score, + "continuity_score": item.continuity_score, + "distinctiveness_score": item.distinctiveness_score, + } + for item in achievements + ] + + +def _prompt( + *, + university_name: str, + user: Any, + achievements: list[Any], + search_results: list[dict[str, str]], +) -> str: + payload = { + "target_university": university_name, + "student_profile": _profile_payload(user), + "student_achievements": _achievement_payload(achievements), + "google_search_results": search_results, + } + return ( + "You are ApplyMap Chancellor. Give a concise, factual action plan for one target university. " + "Use only the student profile, achievements, and the supplied Google Custom Search results. " + "Do not invent admission requirements, scores, deadlines, program names, or scholarships. " + "If a fact is not supported by a source result, write that it cannot be confirmed from the current sources.\n\n" + "Kazakhstan context: interpret UNT/ENT, NIS Grade 12 Certificate, NIS school context, IB, A-levels, " + "and 11 vs 12 years of schooling as important fit factors. MESK in Russian/Kazakh user language maps " + "to NIS Grade 12 Certificate in English.\n\n" + f"{ADMISSIONS_FRAMEWORK}\n\n" + f"{CHANCELLOR_COUNSELOR_FRAMEWORK}\n\n" + "Be direct. Avoid motivational filler. Identify exams that could materially improve the application, " + "activities that are low-value for this target, and research or summer programs only when they appear " + "in the supplied source results. If google_search_results is empty, say current university facts cannot " + "be confirmed and give only general next steps that do not depend on current requirements. Return JSON only.\n\n" + f"Input JSON:\n{json.dumps(payload, ensure_ascii=False, default=str)}" + ) + + +def _extract_text(response_payload: dict[str, Any]) -> str: + candidates = response_payload.get("candidates") or [] + content = candidates[0].get("content") if candidates else {} + parts = content.get("parts") or [] + return str(parts[0].get("text", "")) if parts else "" + + +def generate_university_action_plan( + *, + university_name: str, + user: Any, + achievements: list[Any], + search_results: list[dict[str, str]], +) -> dict[str, Any]: + api_key = settings.GEMINI_API_KEY.strip() + if not api_key: + return { + "summary": "Gemini is not configured, so ApplyMap cannot generate a source-backed action plan yet.", + "exams_to_prioritize": [], + "profile_actions": [], + "low_value_activities": [], + "research_or_summer_programs": [], + "source_notes": ["Set GEMINI_API_KEY to enable the Chancellor action plan."], + } + + model = (settings.GEMINI_MODEL or "gemini-2.5-flash").strip() + url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent" + request_payload = { + "contents": [ + { + "parts": [ + { + "text": _prompt( + university_name=university_name, + user=user, + achievements=achievements, + search_results=search_results, + ) + } + ] + } + ], + "generationConfig": { + "temperature": 0.1, + "responseMimeType": "application/json", + "responseJsonSchema": ADVISOR_SCHEMA, + }, + } + + try: + with httpx.Client(timeout=25.0) as client: + response = client.post( + url, + headers={"x-goog-api-key": api_key, "Content-Type": "application/json"}, + json=request_payload, + ) + response.raise_for_status() + return json.loads(_extract_text(response.json())) + except (httpx.HTTPError, json.JSONDecodeError, KeyError, TypeError, ValueError): + return { + "summary": "The Chancellor could not generate a reliable JSON plan from the current sources.", + "exams_to_prioritize": [], + "profile_actions": [], + "low_value_activities": [], + "research_or_summer_programs": [], + "source_notes": ["Retry with a more specific university name or after checking API configuration."], + } diff --git a/apps/api/src/services/university_filters.py b/apps/api/src/services/university_filters.py new file mode 100644 index 0000000000000000000000000000000000000000..c0734449372a9b1c0ccc8edf54a87b35f7cc12f2 --- /dev/null +++ b/apps/api/src/services/university_filters.py @@ -0,0 +1,97 @@ +from typing import Any, Iterable + +from ..models.university import University + + +def enrich_university(university: University) -> dict[str, Any]: + return { + "id": university.id, + "slug": university.slug, + "name": university.name, + "country": university.country, + "application_system": university.application_system, + "application_source_url": university.application_source_url, + "short_description": university.short_description, + "weight_preset": university.weight_preset, + "is_active": university.is_active, + "region": university.region, + "city": university.city, + "is_common_app": university.is_common_app, + "teaching_languages": university.teaching_languages or [], + "major_strengths": university.major_strengths or [], + "education_years_required": university.education_years_required, + "school_years_note": university.school_years_note, + "aid_type": university.aid_type, + "aid_strength": university.aid_strength, + "selectivity_score": university.selectivity_score, + "full_ride_possible": university.full_ride_possible, + "full_tuition_possible": university.full_tuition_possible, + "aid_notes": university.aid_notes, + "funding_source_url": university.funding_source_url, + "funding_source_title": university.funding_source_title, + "eligibility_notes": university.eligibility_notes, + "created_at": university.created_at, + "updated_at": university.updated_at, + } + + +def filter_universities( + universities: Iterable[dict[str, Any]], + *, + search: str | None = None, + country: str | None = None, + region: str | None = None, + application_system: str | None = None, + teaching_language: str | None = None, + major: str | None = None, + school_years: int | None = None, + full_ride_only: bool = False, + common_app_only: bool = False, + aid_type: str | None = None, + sort_by: str = "name", + sort_dir: str = "asc", +) -> list[dict[str, Any]]: + result = list(universities) + + if search: + needle = search.lower() + result = [ + item for item in result + if needle in item["name"].lower() + or needle in (item.get("country") or "").lower() + or needle in " ".join(item.get("major_strengths") or []).lower() + ] + if country: + result = [item for item in result if (item.get("country") or "").lower() == country.lower()] + if region: + result = [item for item in result if (item.get("region") or "").lower() == region.lower()] + if application_system: + needle = application_system.lower() + result = [item for item in result if needle in (item.get("application_system") or "").lower()] + if teaching_language: + result = [ + item for item in result + if teaching_language.lower() in [language.lower() for language in item.get("teaching_languages") or []] + ] + if major: + major_needle = major.lower() + result = [ + item for item in result + if major_needle in " ".join(item.get("major_strengths") or []).lower() + ] + if school_years: + result = [ + item for item in result + if not item.get("education_years_required") + or int(item["education_years_required"]) <= school_years + ] + if full_ride_only: + result = [item for item in result if item.get("full_ride_possible")] + if common_app_only: + result = [item for item in result if item.get("is_common_app")] + if aid_type: + result = [item for item in result if item.get("aid_type") == aid_type] + + sort_key = sort_by if sort_by in {"name", "country", "aid_type", "aid_strength", "selectivity_score", "education_years_required"} else "name" + reverse = sort_dir == "desc" + return sorted(result, key=lambda item: (item.get(sort_key) is None, item.get(sort_key)), reverse=reverse) diff --git a/apps/api/src/services/university_recommender.py b/apps/api/src/services/university_recommender.py new file mode 100644 index 0000000000000000000000000000000000000000..181220ce3b62b4ec4406a3b188a2ffb9088c41c5 --- /dev/null +++ b/apps/api/src/services/university_recommender.py @@ -0,0 +1,248 @@ +import json +from typing import Any + +import httpx + +from ..config import settings +from ..models.achievement import Achievement + + +RECOMMENDATION_SCHEMA = { + "type": "object", + "properties": { + "summary": {"type": "string"}, + "recommendations": { + "type": "array", + "maxItems": 20, + "items": { + "type": "object", + "properties": { + "slug": {"type": "string"}, + "category": {"type": "string", "enum": ["dream", "target", "safe"]}, + "rationale": {"type": "string"}, + "fit_notes": {"type": "string"}, + }, + "required": ["slug", "category", "rationale"], + }, + }, + }, + "required": ["recommendations"], +} + + +def _achievement_payload(achievement: Achievement) -> dict[str, Any]: + scores = [ + achievement.major_relevance_score, + achievement.selectivity_score, + achievement.continuity_score, + achievement.distinctiveness_score, + ] + numeric_scores = [score for score in scores if isinstance(score, (int, float))] + return { + "id": str(achievement.id), + "type": achievement.type.value, + "title": achievement.title, + "organization_name": achievement.organization_name, + "role_title": achievement.role_title, + "description_raw": achievement.description_raw, + "category": achievement.category, + "impact_scope": getattr(achievement.impact_scope, "value", achievement.impact_scope), + "leadership_level": getattr(achievement.leadership_level, "value", achievement.leadership_level), + "hours_per_week": achievement.hours_per_week, + "weeks_per_year": achievement.weeks_per_year, + "chancellor_score_average": round(sum(numeric_scores) / len(numeric_scores), 1) if numeric_scores else None, + } + + +def _prompt( + *, + selected_honors: list[Achievement], + selected_activities: list[Achievement], + preferences: dict[str, Any], + universities: list[dict[str, Any]], +) -> str: + payload = { + "student_preferences": preferences, + "selected_top_honors": [_achievement_payload(item) for item in selected_honors], + "selected_top_activities": [_achievement_payload(item) for item in selected_activities], + "allowed_common_app_universities": [ + { + "slug": item["slug"], + "name": item["name"], + "country": item["country"], + "major_strengths": item.get("major_strengths"), + "aid_type": item.get("aid_type"), + "aid_strength": item.get("aid_strength"), + "selectivity_score": item.get("selectivity_score"), + "education_years_required": item.get("education_years_required"), + "school_years_note": item.get("school_years_note"), + "aid_notes": item.get("aid_notes"), + } + for item in universities + ], + } + return ( + "You are SourceLock Chancellor. Recommend up to 20 Common App universities using only the selected " + "top 5 honors, selected top 10 activities, and saved student preferences in the input JSON. " + "Do not use unselected achievements. Do not recommend universities outside allowed_common_app_universities.\n\n" + "Categorize results as dream, target, or safe. For a high-need international applicant, safe means " + "relative safety within this funded/Common App shortlist, not guaranteed admission or aid. Prefer about " + "4 dream, 10 target, and 6 safe when enough universities are available.\n\n" + "Consider intended major, preferred countries/regions, school years, teaching language, full-ride need, " + "aid route quality, and selectivity. Return JSON only.\n\n" + f"Input JSON:\n{json.dumps(payload, ensure_ascii=False, default=str)}" + ) + + +def _extract_text(response_payload: dict[str, Any]) -> str: + candidates = response_payload.get("candidates") or [] + content = candidates[0].get("content") if candidates else {} + parts = content.get("parts") or [] + return str(parts[0].get("text", "")) if parts else "" + + +def _gemini_recommendations( + *, + selected_honors: list[Achievement], + selected_activities: list[Achievement], + preferences: dict[str, Any], + universities: list[dict[str, Any]], +) -> list[dict[str, Any]] | None: + api_key = settings.GEMINI_API_KEY.strip() + if not api_key: + return None + + model = (settings.GEMINI_MODEL or "gemini-2.5-flash").strip() + url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent" + request_payload = { + "contents": [{"parts": [{"text": _prompt( + selected_honors=selected_honors, + selected_activities=selected_activities, + preferences=preferences, + universities=universities, + )}]}], + "generationConfig": { + "temperature": 0.15, + "responseMimeType": "application/json", + "responseJsonSchema": RECOMMENDATION_SCHEMA, + }, + } + + try: + with httpx.Client(timeout=20.0) as client: + response = client.post( + url, + headers={"x-goog-api-key": api_key, "Content-Type": "application/json"}, + json=request_payload, + ) + response.raise_for_status() + payload = json.loads(_extract_text(response.json())) + except (httpx.HTTPError, json.JSONDecodeError, KeyError, TypeError, ValueError): + return None + + allowed_by_slug = {item["slug"]: item for item in universities} + results = [] + for rec in payload.get("recommendations", []): + slug = rec.get("slug") + if slug not in allowed_by_slug: + continue + university = allowed_by_slug[slug] + category = rec.get("category") if rec.get("category") in {"dream", "target", "safe"} else "target" + results.append(_merge_recommendation(university, category, rec.get("rationale") or "", rec.get("fit_notes"))) + if len(results) == 20: + break + return results or None + + +def _merge_recommendation(university: dict[str, Any], category: str, rationale: str, fit_notes: str | None) -> dict[str, Any]: + return { + "university_id": university["id"], + "slug": university["slug"], + "name": university["name"], + "country": university["country"], + "category": category, + "rationale": rationale, + "fit_notes": fit_notes, + "aid_notes": university.get("aid_notes"), + "funding_source_url": university.get("funding_source_url"), + } + + +def _score_university(university: dict[str, Any], preferences: dict[str, Any], achievement_text: str) -> float: + score = float(university.get("aid_strength") or 0) + major = str(preferences.get("intended_major") or preferences.get("major") or "").lower() + preferred_countries = [str(item).lower() for item in preferences.get("preferred_countries", []) if item] + preferred_regions = [str(item).lower() for item in preferences.get("preferred_regions", []) if item] + teaching_language = str(preferences.get("teaching_language") or "").lower() + school_years = preferences.get("school_years") + + strengths = " ".join(university.get("major_strengths") or []).lower() + if major and any(term in strengths for term in major.replace("/", " ").split() if len(term) > 2): + score += 18 + if preferred_countries and university["country"].lower() in preferred_countries: + score += 10 + if preferred_regions and str(university.get("region") or "").lower() in preferred_regions: + score += 8 + if teaching_language and teaching_language in [language.lower() for language in university.get("teaching_languages") or []]: + score += 5 + if preferences.get("needs_full_ride") and university.get("full_ride_possible"): + score += 12 + if school_years and university.get("education_years_required") and int(school_years) < int(university["education_years_required"]): + score -= 40 + if "research" in achievement_text and university.get("weight_preset") == "research_heavy": + score += 6 + return score + + +def _fallback_recommendations( + *, + selected_honors: list[Achievement], + selected_activities: list[Achievement], + preferences: dict[str, Any], + universities: list[dict[str, Any]], +) -> list[dict[str, Any]]: + achievement_text = " ".join( + f"{item.title} {item.description_raw or ''} {item.category or ''}" + for item in [*selected_honors, *selected_activities] + ).lower() + ranked = sorted( + universities, + key=lambda item: ( + _score_university(item, preferences, achievement_text), + -(item.get("selectivity_score") or 0), + ), + reverse=True, + )[:20] + + results = [] + for index, university in enumerate(ranked): + if index < 4: + category = "dream" + elif index < 14: + category = "target" + else: + category = "safe" + rationale = "Heuristic fallback based on major fit, funding route, school-year compatibility, and selected achievements." + results.append(_merge_recommendation(university, category, rationale, university.get("eligibility_notes"))) + return results + + +def recommend_common_app_universities( + *, + selected_honors: list[Achievement], + selected_activities: list[Achievement], + preferences: dict[str, Any], + universities: list[dict[str, Any]], +) -> list[dict[str, Any]]: + gemini_results = _gemini_recommendations( + selected_honors=selected_honors, + selected_activities=selected_activities, + preferences=preferences, + universities=universities, + ) + return gemini_results or _fallback_recommendations( + selected_honors=selected_honors, + selected_activities=selected_activities, + preferences=preferences, + universities=universities, + ) diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..ea7ea11daaaa07851eeacaa50802b6ec9f5e28d9 --- /dev/null +++ b/apps/web/Dockerfile @@ -0,0 +1,23 @@ +FROM node:20-alpine AS base +RUN npm i -g pnpm + +FROM base AS deps +WORKDIR /app +COPY package.json ./ +RUN pnpm install + +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN pnpm build + +FROM base AS runner +WORKDIR /app +ENV NODE_ENV production +COPY --from=builder /app/public ./public +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static + +EXPOSE 3000 +CMD ["node", "server.js"] diff --git a/apps/web/components.json b/apps/web/components.json new file mode 100644 index 0000000000000000000000000000000000000000..1a82e748555e370c27cb0b6323d8e1df53fa3d34 --- /dev/null +++ b/apps/web/components.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "src/app/globals.css", + "baseColor": "slate", + "cssVariables": true + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils" + } +} diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..4f11a03dc6cc37f2b5105c08f2e7b24c603ab2f4 --- /dev/null +++ b/apps/web/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs new file mode 100644 index 0000000000000000000000000000000000000000..2f47f29762ccde4baf0445b53469746f1f244260 --- /dev/null +++ b/apps/web/next.config.mjs @@ -0,0 +1,23 @@ +const nextConfig = { + async rewrites() { + return [ + { + source: "/api/:path*", + destination: `${process.env.INTERNAL_API_URL || "http://127.0.0.1:8000"}/api/:path*`, + }, + ]; + }, + images: { + remotePatterns: [ + { + protocol: "https", + hostname: "**", + }, + ], + }, + experimental: { + serverComponentsExternalPackages: [], + }, +}; + +export default nextConfig; diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000000000000000000000000000000000000..dd4946a7c60db6b3bd839aacf0ba33b48d3f97ac --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,57 @@ +{ + "name": "@applymap/web", + "version": "0.0.1", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "next": "14.2.3", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "next-auth": "^4.24.7", + "@tanstack/react-query": "^5.40.0", + "@tanstack/react-query-devtools": "^5.40.0", + "react-hook-form": "^7.51.5", + "zod": "^3.23.8", + "@hookform/resolvers": "^3.6.0", + "axios": "^1.7.2", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.1", + "tailwind-merge": "^2.3.0", + "tailwindcss-animate": "^1.0.7", + "lucide-react": "^0.395.0", + "@radix-ui/react-accordion": "^1.2.0", + "@radix-ui/react-alert-dialog": "^1.1.1", + "@radix-ui/react-avatar": "^1.1.0", + "@radix-ui/react-checkbox": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.1", + "@radix-ui/react-dropdown-menu": "^2.1.1", + "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-progress": "^1.1.0", + "@radix-ui/react-radio-group": "^1.2.0", + "@radix-ui/react-select": "^2.1.1", + "@radix-ui/react-separator": "^1.1.0", + "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-tabs": "^1.1.0", + "@radix-ui/react-toast": "^1.2.1", + "@radix-ui/react-tooltip": "^1.1.1", + "date-fns": "^3.6.0", + "sonner": "^1.5.0" + }, + "devDependencies": { + "typescript": "^5.4.5", + "@types/node": "^20.14.2", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "tailwindcss": "^3.4.4", + "postcss": "^8.4.38", + "autoprefixer": "^10.4.19", + "eslint": "^8.57.0", + "eslint-config-next": "14.2.3" + } +} diff --git a/apps/web/postcss.config.js b/apps/web/postcss.config.js new file mode 100644 index 0000000000000000000000000000000000000000..a1b36d24e45d09a3126d96cc009fb744b40a3181 --- /dev/null +++ b/apps/web/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/apps/web/public/applymap-logo.png b/apps/web/public/applymap-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..8200a409c6bca1af63a3ea91339cafeb8898d481 --- /dev/null +++ b/apps/web/public/applymap-logo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:35a399aed229b46e648b80b778ed2b52fd9338d0d4ccf3bcbf88da3924922bef +size 147486 diff --git a/apps/web/src/app/(app)/advisor/page.tsx b/apps/web/src/app/(app)/advisor/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..bbc92f4026a486add2c82cfed96cf875cdcf8b36 --- /dev/null +++ b/apps/web/src/app/(app)/advisor/page.tsx @@ -0,0 +1,191 @@ +"use client"; + +import { useState } from "react"; +import { useMutation } from "@tanstack/react-query"; +import { universitiesApi } from "@/lib/api"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { Label } from "@/components/ui/label"; +import { Loader2, Search, Sparkles } from "lucide-react"; +import type { UniversityAdvisorPlan, UniversityAdvisorSource } from "@/types"; + +type AdvisorResponse = { + university_name: string; + sources: UniversityAdvisorSource[]; + plan: UniversityAdvisorPlan; +}; + +const SOURCE_LABELS: Record = { + official: "Official", + likely_official: "Likely official", + education_domain: "Education domain", + third_party: "Third party", +}; + +export default function AdvisorPage() { + const [universityName, setUniversityName] = useState(""); + const [intendedMajor, setIntendedMajor] = useState(""); + const [error, setError] = useState(null); + const [result, setResult] = useState(null); + + const advisorMutation = useMutation({ + mutationFn: () => + universitiesApi.advisorPlan({ + university_name: universityName, + intended_major: intendedMajor || undefined, + }), + onSuccess: (response) => { + setError(null); + setResult(response.data?.data ?? null); + }, + onError: (err: unknown) => { + const detail = + typeof err === "object" && err && "response" in err + ? (err as { response?: { data?: { detail?: string } } }).response?.data?.detail + : null; + setResult(null); + setError(detail ?? "Advisor search failed. Check the backend Google Search configuration."); + }, + }); + + return ( +
+
+
+

University advisor

+

Ask about one target school

+

+ Get a concise source-backed plan: exams to prioritize, profile moves that matter, low-value activities, and relevant research or summer programs when current sources support them. +

+
+ +
+
+
+ + setUniversityName(event.target.value)} + placeholder="e.g. NYU Abu Dhabi, University of Toronto, KAIST" + className="h-11 rounded-xl" + /> +
+
+ + setIntendedMajor(event.target.value)} + placeholder="e.g. Computer Science" + className="h-11 rounded-xl" + /> +
+ +
+ {error && ( +

+ {error} +

+ )} +
+ + {result && ( +
+
+
+ +

Chancellor plan

+
+

+ {result.plan.summary} +

+ +
+
+

Exams to prioritize

+
+ {result.plan.exams_to_prioritize.length ? result.plan.exams_to_prioritize.map((item) => ( +
+
+

{item.exam}

+ {item.priority} +
+

{item.why}

+
+ )) :

No exam advice was confirmed from current sources.

} +
+
+ +
+

Profile actions

+
    + {result.plan.profile_actions.map((item) => ( +
  • {item}
  • + ))} +
+
+ +
+

Low-value activities

+
    + {result.plan.low_value_activities.map((item) => ( +
  • {item}
  • + ))} +
+
+ +
+

Research or summer programs

+
+ {result.plan.research_or_summer_programs.length ? result.plan.research_or_summer_programs.map((item) => ( +
+

{item.name}

+

{item.why_it_helps}

+ {item.source_url && Source} +
+ )) :

No programs were confirmed from current sources.

} +
+
+
+ + {!!result.plan.source_notes.length && ( +
+

Source notes

+
    + {result.plan.source_notes.map((note) =>
  • {note}
  • )} +
+
+ )} +
+ +
+

Sources checked

+

+ Google Custom Search results are classified by domain. Official university pages should carry the most weight. +

+ +
+
+ )} +
+
+ ); +} diff --git a/apps/web/src/app/(app)/dashboard/page.tsx b/apps/web/src/app/(app)/dashboard/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..af8f0239111e934657a8f7c3b3fc920a1ab34984 --- /dev/null +++ b/apps/web/src/app/(app)/dashboard/page.tsx @@ -0,0 +1,496 @@ +"use client"; + +import Link from "next/link"; +import { Fragment } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { achievementsApi, reportsApi, targetsApi, profileApi } from "@/lib/api"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useAuth } from "@/hooks/useAuth"; +import { + ArrowRight, Plus, GraduationCap, FileText, BookOpen, +} from "lucide-react"; +import { formatDate } from "@/lib/utils"; +import type { Achievement, Report, ReportStatus, TargetUniversity } from "@/types"; + +// ─── Circular Progress Ring ───────────────────────────────────────────────── + +function CircleProgress({ value, size = 56, strokeWidth = 3.5 }: { + value: number; + size?: number; + strokeWidth?: number; +}) { + const radius = (size - strokeWidth) / 2; + const circumference = 2 * Math.PI * radius; + const offset = circumference - (value / 100) * circumference; + + return ( + + {/* Track */} + + {/* Fill */} + + + ); +} + +// ─── Mini Report Status Timeline ──────────────────────────────────────────── + +function ReportStatusBadge({ status }: { status: ReportStatus }) { + if (status === "completed") return Completed; + if (status === "failed") return Failed; + + const stages: ReportStatus[] = ["pending", "processing", "completed"]; + const currentIdx = stages.indexOf(status); + + return ( +
+ {stages.map((stage, i) => ( + +
+ {i < stages.length - 1 && ( +
+ )} + + ))} + {status} +
+ ); +} + +// ─── Empty Reports Illustration ────────────────────────────────────────────── + +function EmptyReports() { + return ( +
+ +

No advisors yet

+

+ Generate one from the Universities page. +

+ + + +
+ ); +} + +// ─── Quick Action Item ──────────────────────────────────────────────────────── + +function QuickActionItem({ + href, + icon: Icon, + iconBg, + iconColor, + title, + subtitle, +}: { + href: string; + icon: React.ElementType; + iconBg: string; + iconColor: string; + title: string; + subtitle: string; +}) { + return ( + +
+
+ +
+
+

{title}

+

{subtitle}

+
+ +
+ + ); +} + +// ─── Dashboard Page ────────────────────────────────────────────────────────── + +export default function DashboardPage() { + const { user } = useAuth(); + + const { data: profileData, isLoading: profileLoading } = useQuery({ + queryKey: ["profile"], + queryFn: () => profileApi.get(), + }); + const { data: achievementsData, isLoading: achievementsLoading } = useQuery({ + queryKey: ["achievements"], + queryFn: () => achievementsApi.list(), + }); + const { data: reportsData, isLoading: reportsLoading } = useQuery({ + queryKey: ["reports"], + queryFn: () => reportsApi.list(), + }); + const { data: targetsData, isLoading: targetsLoading } = useQuery({ + queryKey: ["targets"], + queryFn: () => targetsApi.list(), + }); + + const isLoading = profileLoading || achievementsLoading || reportsLoading || targetsLoading; + + const profile = profileData?.data?.data?.profile; + const achievements: Achievement[] = achievementsData?.data?.data ?? []; + const reports: Report[] = reportsData?.data?.data ?? []; + const targets: TargetUniversity[] = targetsData?.data?.data ?? []; + + const activities = achievements.filter((a) => a.type === "activity"); + const honors = achievements.filter((a) => a.type === "honor"); + const recentReports = reports.slice(0, 3); + + // Profile completeness + const profileFields = [ + !!user?.full_name, + !!user?.country, + !!profile?.graduation_year, + !!profile?.curriculum, + !!profile?.intended_major, + !!(profile?.sat_score || profile?.act_score || profile?.ielts_score || profile?.toefl_score), + ]; + const filledCount = profileFields.filter(Boolean).length; + const profilePct = Math.round((filledCount / profileFields.length) * 100); + + // Welcome banner context + const firstName = user?.full_name?.split(" ")[0]; + let bannerMessage = "Here's where your application stands."; + let bannerCTA: { href: string; label: string } | null = null; + + if (achievements.length === 0) { + bannerMessage = "Start by adding your activities and honors to the vault."; + bannerCTA = { href: "/vault", label: "Add achievements" }; + } else if (targets.length === 0) { + bannerMessage = "Good start. Select your target universities to unlock advisors."; + bannerCTA = { href: "/universities", label: "Choose universities" }; + } else if (!reports.some((r) => r.status === "completed")) { + bannerMessage = "You're ready. Generate your first university advisor."; + bannerCTA = { href: "/universities", label: "Open advisor" }; + } else { + const latest = reports.find((r) => r.status === "completed"); + if (latest) { + bannerMessage = `Your latest ${latest.university.name} advisor is ready to review.`; + bannerCTA = { href: `/reports/${latest.id}`, label: "View advisor" }; + } + } + + // Quick action stage + const quickActions = []; + if (achievements.length === 0) { + quickActions.push({ + href: "/vault", + icon: Plus, + iconBg: "bg-navy-50", + iconColor: "text-navy-800", + title: "Add your first achievement", + subtitle: "Start building your vault", + }); + } + if (achievements.length > 0 && targets.length === 0) { + quickActions.push({ + href: "/universities", + icon: GraduationCap, + iconBg: "bg-amber-50", + iconColor: "text-amber-700", + title: "Select target universities", + subtitle: "Unlock university advisors", + }); + } + if (achievements.length > 0 && targets.length > 0) { + quickActions.push({ + href: "/universities", + icon: FileText, + iconBg: "bg-emerald-50", + iconColor: "text-emerald-700", + title: "Open a new advisor", + subtitle: `${targets.length} target ${targets.length === 1 ? "university" : "universities"} ready`, + }); + } + if (achievements.length > 0) { + quickActions.push({ + href: "/vault", + icon: BookOpen, + iconBg: "bg-slate-100", + iconColor: "text-slate-600", + title: "Manage vault", + subtitle: `${activities.length} activities · ${honors.length} honors`, + }); + } + + if (isLoading) { + return ( +
+ {/* Banner skeleton */} + + {/* Stat cards skeleton */} +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ + + +
+ ))} +
+ {/* Bottom grid skeleton */} +
+
+ +
+ {Array.from({ length: 2 }).map((_, i) => ( + + ))} +
+
+
+ +
+ {Array.from({ length: 2 }).map((_, i) => ( + + ))} +
+
+
+
+ ); + } + + return ( +
+ + {/* Welcome banner */} +
+
+
+
+

+ {new Date().toLocaleDateString("en-US", { + weekday: "long", + month: "long", + day: "numeric", + })} +

+

+ {firstName ? `Welcome back, ${firstName}.` : "Welcome back."} +

+

{bannerMessage}

+
+ {bannerCTA && ( + + + + )} +
+
+ + {/* Stat cards */} +
+ + {/* Profile completeness — circular ring */} + + +

Profile

+
+
+ +
+ {profilePct}% +
+
+
+

+ {profilePct < 100 + ? `${profileFields.length - filledCount} fields left` + : "Complete"} +

+ {profilePct < 100 && ( + + Finish setup + + )} +
+
+
+
+ + {/* Achievements */} + + +

Achievements

+
+ {achievements.length} +
+

+ {activities.length} activities · {honors.length} honors +

+ {/* Activities-to-10 bar */} +
+
+ Activities + {Math.min(activities.length, 10)}/10 +
+
+
+
+
+ + + + {/* Target universities */} + + +

Target Universities

+
+ {targets.length} +
+

+ {targets.length > 0 + ? targets.map((t) => t.university.name.split(" ")[0]).join(", ") + : "None selected"} +

+ + {targets.length > 0 ? "Manage" : "Add universities"} + + +
+
+ + {/* Reports */} + + +

Advisors Generated

+
+ {reports.length} +
+

+ {reports.filter((r) => r.status === "completed").length} completed +

+ + View all + +
+
+
+ +
+ + {/* Quick actions */} + + + Quick actions + + + {quickActions.length > 0 ? ( + quickActions.map((action) => ( + + )) + ) : ( +

+ Everything looks good — keep your vault up to date. +

+ )} +
+
+ + {/* Recent reports */} + + + Recent advisors + + View all + + + + {recentReports.length === 0 ? ( + + ) : ( +
    + {recentReports.map((report) => ( +
  • + +
    +
    +

    + {report.university.name} +

    +

    {formatDate(report.created_at)}

    +
    +
    + +
    +
    + +
  • + ))} +
+ )} +
+
+
+
+ ); +} diff --git a/apps/web/src/app/(app)/evidence/page.tsx b/apps/web/src/app/(app)/evidence/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0c4aab91d35dea3cf28aa56f4ff9698bd1734ae0 --- /dev/null +++ b/apps/web/src/app/(app)/evidence/page.tsx @@ -0,0 +1,194 @@ +"use client"; + +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { universitiesApi } from "@/lib/api"; +import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; +import { Shield, AlertTriangle, Search } from "lucide-react"; +import { cn } from "@/lib/utils"; +import type { University, PolicyEntry } from "@/types"; + +const TIER_COLORS: Record = { + A: "bg-emerald-100 text-emerald-800 border-emerald-200", + B: "bg-blue-100 text-blue-800 border-blue-200", + C: "bg-amber-100 text-amber-800 border-amber-200", + D: "bg-slate-100 text-slate-600 border-slate-200", +}; + +interface UniversityWithSources extends University { + policy_entries?: PolicyEntry[]; +} + +export default function EvidencePage() { + const [search, setSearch] = useState(""); + const [selectedUni, setSelectedUni] = useState("all"); + const [selectedType, setSelectedType] = useState<"all" | "official" | "public_example">("all"); + + const { data: uniData } = useQuery({ + queryKey: ["universities"], + queryFn: () => universitiesApi.list(), + }); + + const universities: University[] = uniData?.data?.data ?? []; + + // Fetch sources for each university + const { data: sourcesData } = useQuery({ + queryKey: ["all-sources"], + queryFn: async () => { + const results = await Promise.all( + universities.map(async (uni) => { + const res = await universitiesApi.getSources(uni.id); + return { uni, entries: res.data?.data ?? [] as PolicyEntry[] }; + }) + ); + return results; + }, + enabled: universities.length > 0, + }); + + const allEntries: Array<{ entry: PolicyEntry; university: University }> = + (sourcesData ?? []).flatMap(({ uni, entries }) => + entries.map((entry: PolicyEntry) => ({ entry, university: uni })) + ); + + const filtered = allEntries.filter(({ entry, university }) => { + const matchUni = selectedUni === "all" || university.id === selectedUni; + const matchType = selectedType === "all" || entry.source_type === selectedType; + const matchSearch = + !search || + entry.title.toLowerCase().includes(search.toLowerCase()) || + entry.content.toLowerCase().includes(search.toLowerCase()) || + university.name.toLowerCase().includes(search.toLowerCase()); + return matchUni && matchType && matchSearch; + }); + + return ( +
+
+

Evidence Library

+

+ Browse all guidance sources used to generate recommendations. +

+
+ + {/* Filters */} +
+
+ + setSearch(e.target.value)} + /> +
+ + + + +
+ +

{filtered.length} sources

+ +
+ {filtered.map(({ entry, university }) => { + const isOfficial = entry.source_type === "official"; + + return ( +
+
+ {isOfficial ? ( + + ) : ( + + )} +
+
+ {entry.title} + + Tier {entry.reliability_tier} + + + {isOfficial ? "Official" : "Public Example"} + +
+ +

+ {university.name} + {entry.source_title && ` · ${entry.source_title}`} +

+ + {entry.excerpt && ( +
+ “{entry.excerpt}” +
+ )} + +

+ {entry.content} +

+ + {entry.source_url && ( + + View original source → + + )} +
+
+
+ ); + })} + + {filtered.length === 0 && ( +
+

No sources match your filters.

+
+ )} +
+
+ ); +} diff --git a/apps/web/src/app/(app)/layout.tsx b/apps/web/src/app/(app)/layout.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a607f375db52bea745f559c10a15e10fe35460ce --- /dev/null +++ b/apps/web/src/app/(app)/layout.tsx @@ -0,0 +1,12 @@ +import { AppSidebar } from "@/components/layout/AppSidebar"; + +export default function AppLayout({ children }: { children: React.ReactNode }) { + return ( +
+ +
+ {children} +
+
+ ); +} diff --git a/apps/web/src/app/(app)/onboarding/page.tsx b/apps/web/src/app/(app)/onboarding/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..13c71bc6b7d0c2187e2453a67ab70af6fb1173ca --- /dev/null +++ b/apps/web/src/app/(app)/onboarding/page.tsx @@ -0,0 +1,456 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useForm } from "react-hook-form"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { profileApi, targetsApi, universitiesApi } from "@/lib/api"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Progress } from "@/components/ui/progress"; +import { useRouter } from "next/navigation"; +import { cn } from "@/lib/utils"; +import type { University } from "@/types"; + +const TOTAL_STEPS = 6; +const STEP_TITLES = ["Academic context", "Major interests", "Test scores", "Preferences", "Target universities", "All set!"]; +const STEP_NOTES = [ + "Anchor the profile to your curriculum and graduation timing.", + "This drives relevance in ranking and recommendations.", + "Add only the exams you already have.", + "These settings power the Common App recommender.", + "Choose a shortlist to carry into university advisors.", + "Move into the dashboard and start the workflow.", +]; + +const CURRICULA = ["NIS Grade 12 Certificate", "NIS Programme (Kazakhstan/Cambridge)", "Kazakhstan National Curriculum", "UNT/ENT preparation track", "IB (International Baccalaureate)", "A-Levels", "AP (Advanced Placement)", "French Baccalaureate", "German Abitur", "CBSE", "IGCSE", "National Curriculum", "Other"]; +const COUNTRIES = ["Afghanistan", "Albania", "Algeria", "Argentina", "Armenia", "Australia", "Austria", "Azerbaijan", "Bangladesh", "Belgium", "Bolivia", "Brazil", "Cambodia", "Cameroon", "Canada", "Chile", "China", "Colombia", "Congo", "Costa Rica", "Croatia", "Cuba", "Czech Republic", "Denmark", "Ecuador", "Egypt", "Ethiopia", "Finland", "France", "Germany", "Ghana", "Greece", "Guatemala", "Honduras", "Hungary", "India", "Indonesia", "Iran", "Iraq", "Israel", "Italy", "Jamaica", "Japan", "Jordan", "Kazakhstan", "Kenya", "South Korea", "Kuwait", "Lebanon", "Malaysia", "Mexico", "Morocco", "Nepal", "Netherlands", "New Zealand", "Nigeria", "Norway", "Pakistan", "Peru", "Philippines", "Poland", "Portugal", "Romania", "Russia", "Saudi Arabia", "Senegal", "Singapore", "South Africa", "Spain", "Sri Lanka", "Sweden", "Switzerland", "Syria", "Taiwan", "Tanzania", "Thailand", "Tunisia", "Turkey", "Uganda", "Ukraine", "United Arab Emirates", "United Kingdom", "United States", "Uruguay", "Venezuela", "Vietnam", "Yemen", "Zambia", "Zimbabwe"]; + +function csvToArray(value: string) { + return value.split(",").map((item) => item.trim()).filter(Boolean); +} + +export default function OnboardingPage() { + const router = useRouter(); + const queryClient = useQueryClient(); + const [step, setStep] = useState(1); + const [selectedTargets, setSelectedTargets] = useState([]); + const [search, setSearch] = useState(""); + const [loadedProfile, setLoadedProfile] = useState(false); + + const { data: universitiesData, isLoading: isUniversitiesLoading } = useQuery({ + queryKey: ["universities"], + queryFn: () => universitiesApi.list(), + }); + const { data: profileData } = useQuery({ + queryKey: ["profile"], + queryFn: () => profileApi.get(), + }); + const universities: University[] = universitiesData?.data?.data ?? []; + + const filteredUniversities = useMemo(() => { + const term = search.trim().toLowerCase(); + if (!term) return universities; + return universities.filter((uni) => + [uni.name, uni.country, uni.city, uni.short_description].filter(Boolean).some((value) => + String(value).toLowerCase().includes(term) + ) + ); + }, [search, universities]); + + const updateProfileMutation = useMutation({ + mutationFn: (data: Record) => profileApi.update(data), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["profile"] }), + }); + + const addTargetMutation = useMutation({ + mutationFn: (universityId: string) => targetsApi.add({ university_id: universityId }), + }); + + const form = useForm({ + defaultValues: { + country: "", + graduation_year: "", + curriculum: "", + intended_major: "", + sat_score: "", + sat_math: "", + sat_ebrw: "", + act_score: "", + ielts_score: "", + ielts_listening: "", + ielts_reading: "", + ielts_writing: "", + ielts_speaking: "", + toefl_score: "", + toefl_reading: "", + toefl_listening: "", + toefl_speaking: "", + toefl_writing: "", + duolingo_score: "", + a_level_subjects: "", + a_level_predicted: "", + ap_subjects: "", + ib_predicted_score: "", + unt_score: "", + nis_grade12_certificate_gpa: "", + preferred_countries: "United States, United Arab Emirates, Canada", + preferred_regions: "USA, Abu Dhabi / UAE, Canada, Hong Kong, Korea, Japan, Europe", + teaching_language: "English", + school_years: "11", + needs_full_ride: true, + }, + }); + + useEffect(() => { + if (loadedProfile || !profileData?.data?.data) return; + const { user, profile } = profileData.data.data; + const saved = profile?.application_preferences_json ?? {}; + form.reset({ + country: user?.country ?? "", + graduation_year: profile?.graduation_year ? String(profile.graduation_year) : "", + curriculum: profile?.curriculum ?? "", + intended_major: profile?.intended_major ?? "", + sat_score: profile?.sat_score ? String(profile.sat_score) : "", + sat_math: profile?.sat_math ? String(profile.sat_math) : "", + sat_ebrw: profile?.sat_ebrw ? String(profile.sat_ebrw) : "", + act_score: profile?.act_score ? String(profile.act_score) : "", + ielts_score: profile?.ielts_score ?? "", + ielts_listening: profile?.ielts_listening ?? "", + ielts_reading: profile?.ielts_reading ?? "", + ielts_writing: profile?.ielts_writing ?? "", + ielts_speaking: profile?.ielts_speaking ?? "", + toefl_score: profile?.toefl_score ? String(profile.toefl_score) : "", + toefl_reading: profile?.toefl_reading ? String(profile.toefl_reading) : "", + toefl_listening: profile?.toefl_listening ? String(profile.toefl_listening) : "", + toefl_speaking: profile?.toefl_speaking ? String(profile.toefl_speaking) : "", + toefl_writing: profile?.toefl_writing ? String(profile.toefl_writing) : "", + duolingo_score: profile?.duolingo_score ? String(profile.duolingo_score) : "", + a_level_subjects: profile?.a_level_subjects ?? "", + a_level_predicted: profile?.a_level_predicted ?? "", + ap_subjects: profile?.ap_subjects ?? "", + ib_predicted_score: profile?.ib_predicted_score ? String(profile.ib_predicted_score) : "", + unt_score: profile?.unt_score ? String(profile.unt_score) : "", + nis_grade12_certificate_gpa: profile?.nis_grade12_certificate_gpa ?? "", + preferred_countries: Array.isArray(saved.preferred_countries) ? saved.preferred_countries.join(", ") : "United States, United Arab Emirates, Canada", + preferred_regions: Array.isArray(saved.preferred_regions) ? saved.preferred_regions.join(", ") : "USA, Abu Dhabi / UAE, Canada, Hong Kong, Korea, Japan, Europe", + teaching_language: typeof saved.teaching_language === "string" ? saved.teaching_language : "English", + school_years: saved.school_years ? String(saved.school_years) : "11", + needs_full_ride: typeof saved.needs_full_ride === "boolean" ? saved.needs_full_ride : true, + }); + setLoadedProfile(true); + }, [form, loadedProfile, profileData]); + + const handleNext = async () => { + const values = form.getValues(); + + if (step === 1) { + if (values.country) { + await profileApi.updateUser({ country: values.country }); + queryClient.invalidateQueries({ queryKey: ["auth", "me"] }); + } + await updateProfileMutation.mutateAsync({ + graduation_year: values.graduation_year ? parseInt(values.graduation_year, 10) : undefined, + curriculum: values.curriculum || undefined, + }); + } else if (step === 2) { + await updateProfileMutation.mutateAsync({ intended_major: values.intended_major || undefined }); + } else if (step === 3) { + await updateProfileMutation.mutateAsync({ + sat_score: values.sat_score ? parseInt(values.sat_score, 10) : undefined, + sat_math: values.sat_math ? parseInt(values.sat_math, 10) : undefined, + sat_ebrw: values.sat_ebrw ? parseInt(values.sat_ebrw, 10) : undefined, + act_score: values.act_score ? parseInt(values.act_score, 10) : undefined, + ielts_score: values.ielts_score || undefined, + ielts_listening: values.ielts_listening || undefined, + ielts_reading: values.ielts_reading || undefined, + ielts_writing: values.ielts_writing || undefined, + ielts_speaking: values.ielts_speaking || undefined, + toefl_score: values.toefl_score ? parseInt(values.toefl_score, 10) : undefined, + toefl_reading: values.toefl_reading ? parseInt(values.toefl_reading, 10) : undefined, + toefl_listening: values.toefl_listening ? parseInt(values.toefl_listening, 10) : undefined, + toefl_speaking: values.toefl_speaking ? parseInt(values.toefl_speaking, 10) : undefined, + toefl_writing: values.toefl_writing ? parseInt(values.toefl_writing, 10) : undefined, + duolingo_score: values.duolingo_score ? parseInt(values.duolingo_score, 10) : undefined, + a_level_subjects: values.a_level_subjects || undefined, + a_level_predicted: values.a_level_predicted || undefined, + ap_subjects: values.ap_subjects || undefined, + ib_predicted_score: values.ib_predicted_score ? parseInt(values.ib_predicted_score, 10) : undefined, + unt_score: values.unt_score ? parseInt(values.unt_score, 10) : undefined, + nis_grade12_certificate_gpa: values.nis_grade12_certificate_gpa || undefined, + }); + } else if (step === 4) { + await updateProfileMutation.mutateAsync({ + application_preferences_json: { + preferred_countries: csvToArray(values.preferred_countries), + preferred_regions: csvToArray(values.preferred_regions), + teaching_language: values.teaching_language || undefined, + school_years: values.school_years ? parseInt(values.school_years, 10) : undefined, + intended_major: values.intended_major || undefined, + needs_full_ride: values.needs_full_ride, + }, + }); + } else if (step === 5) { + for (const universityId of selectedTargets) { + try { + await addTargetMutation.mutateAsync(universityId); + } catch {} + } + } + + if (step < TOTAL_STEPS) { + setStep((current) => current + 1); + } else { + router.push("/dashboard"); + } + }; + + const selectedPreview = universities.filter((uni) => selectedTargets.includes(uni.id)); + const isSaving = updateProfileMutation.isPending || addTargetMutation.isPending; + + return ( +
+
+
+
+ +
+ + +
+
+
+
+

Step {step} of {TOTAL_STEPS}

+

{STEP_TITLES[step - 1]}

+
+
+

Prefs

Saved

+

Targets

{selectedTargets.length}

+

Ready

{Math.round((step / TOTAL_STEPS) * 100)}%

+
+
+ + {step === 1 && ( +
+

We use this context to calibrate fit, shortlist suggestions, and the first report narrative.

+
+ + +
+
+ + +
+
+ + +
+
+ )} + + {step === 2 && ( +
+

This field has outsized impact on recommendation quality, so it is worth setting clearly now.

+
+ {["Computer Science", "Economics", "Mechanical Engineering"].map((suggestion) => ( + + ))} +
+
+ + +
+
+ )} + + {step === 3 && ( +
+

These inputs are optional. Skip anything you do not already have.

+
+ +
+ + + +
+
+
+
+
+ +
+ + + + + +
+
+
+ +
+ + + + + +
+
+
+
+
+
+
+
+
+ )} + + {step === 4 && ( +
+
+
+
+
+
+
+
+
+
+

Funding posture

+

Tell the recommender how strict to be.

+ +
+
+ )} + + {step === 5 && ( +
+
+
+

Available universities

{filteredUniversities.length} shown

+
setSearch(event.target.value)} placeholder="Search by university or country" className="h-11 rounded-xl bg-white" />
+
+
+ {isUniversitiesLoading ? Array.from({ length: 5 }).map((_, index) => ( +
+ )) : filteredUniversities.length ? filteredUniversities.map((university) => { + const isSelected = selectedTargets.includes(university.id); + return ( + + ); + }) :
No universities match this search.
} +
+
+ +
+

Selection summary

+
+

{selectedTargets.length}

+

universities selected

+
+
+ {selectedPreview.length ? selectedPreview.map((university) => ( +
+

{university.name}

+

{university.country}

+
+ )) :
Pick a few targets to start the workflow with realistic examples.
} +
+
+
+ )} + + {step === 6 && ( +
+
🎯
+
+

Profile complete

+

You are ready to move into the dashboard, add achievements, and generate the first university advisor.

+
+
+

Major

{form.getValues("intended_major") || "To be refined"}

+

Targets

{selectedTargets.length} selected

+

Next step

Fill the vault and open advisors

+
+
+ )} + +
+ {step > 1 ? :
} +
+ {step < TOTAL_STEPS && } + +
+
+
+
+
+
+ ); +} diff --git a/apps/web/src/app/(app)/profile/page.tsx b/apps/web/src/app/(app)/profile/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e8ca1c12688da7933dd2da156a7cf0661764836f --- /dev/null +++ b/apps/web/src/app/(app)/profile/page.tsx @@ -0,0 +1,242 @@ +"use client"; + +import { useEffect } from "react"; +import { useForm } from "react-hook-form"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { profileApi } from "@/lib/api"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Skeleton } from "@/components/ui/skeleton"; +import { toast } from "sonner"; + +type ProfileForm = { + full_name: string; + country: string; + graduation_year: string; + curriculum: string; + intended_major: string; + sat_score: string; + sat_math: string; + sat_ebrw: string; + act_score: string; + ielts_score: string; + ielts_listening: string; + ielts_reading: string; + ielts_writing: string; + ielts_speaking: string; + toefl_score: string; + toefl_reading: string; + toefl_listening: string; + toefl_speaking: string; + toefl_writing: string; + duolingo_score: string; + a_level_subjects: string; + a_level_predicted: string; + ap_subjects: string; + ib_predicted_score: string; + unt_score: string; + nis_grade12_certificate_gpa: string; + budget_range: string; +}; + +function numberOrUndefined(value: string) { + return value.trim() ? Number(value) : undefined; +} + +export default function ProfilePage() { + const queryClient = useQueryClient(); + const form = useForm({ + defaultValues: { + full_name: "", + country: "", + graduation_year: "", + curriculum: "", + intended_major: "", + sat_score: "", + sat_math: "", + sat_ebrw: "", + act_score: "", + ielts_score: "", + ielts_listening: "", + ielts_reading: "", + ielts_writing: "", + ielts_speaking: "", + toefl_score: "", + toefl_reading: "", + toefl_listening: "", + toefl_speaking: "", + toefl_writing: "", + duolingo_score: "", + a_level_subjects: "", + a_level_predicted: "", + ap_subjects: "", + ib_predicted_score: "", + unt_score: "", + nis_grade12_certificate_gpa: "", + budget_range: "", + }, + }); + + const { data, isLoading } = useQuery({ + queryKey: ["profile"], + queryFn: () => profileApi.get(), + }); + + useEffect(() => { + const payload = data?.data?.data; + if (!payload) return; + const { user, profile } = payload; + form.reset({ + full_name: user?.full_name ?? "", + country: user?.country ?? "", + graduation_year: profile?.graduation_year ? String(profile.graduation_year) : "", + curriculum: profile?.curriculum ?? "", + intended_major: profile?.intended_major ?? "", + sat_score: profile?.sat_score ? String(profile.sat_score) : "", + sat_math: profile?.sat_math ? String(profile.sat_math) : "", + sat_ebrw: profile?.sat_ebrw ? String(profile.sat_ebrw) : "", + act_score: profile?.act_score ? String(profile.act_score) : "", + ielts_score: profile?.ielts_score ?? "", + ielts_listening: profile?.ielts_listening ?? "", + ielts_reading: profile?.ielts_reading ?? "", + ielts_writing: profile?.ielts_writing ?? "", + ielts_speaking: profile?.ielts_speaking ?? "", + toefl_score: profile?.toefl_score ? String(profile.toefl_score) : "", + toefl_reading: profile?.toefl_reading ? String(profile.toefl_reading) : "", + toefl_listening: profile?.toefl_listening ? String(profile.toefl_listening) : "", + toefl_speaking: profile?.toefl_speaking ? String(profile.toefl_speaking) : "", + toefl_writing: profile?.toefl_writing ? String(profile.toefl_writing) : "", + duolingo_score: profile?.duolingo_score ? String(profile.duolingo_score) : "", + a_level_subjects: profile?.a_level_subjects ?? "", + a_level_predicted: profile?.a_level_predicted ?? "", + ap_subjects: profile?.ap_subjects ?? "", + ib_predicted_score: profile?.ib_predicted_score ? String(profile.ib_predicted_score) : "", + unt_score: profile?.unt_score ? String(profile.unt_score) : "", + nis_grade12_certificate_gpa: profile?.nis_grade12_certificate_gpa ?? "", + budget_range: profile?.budget_range ?? "", + }); + }, [data, form]); + + const saveMutation = useMutation({ + mutationFn: async (values: ProfileForm) => { + await profileApi.updateUser({ + full_name: values.full_name || undefined, + country: values.country || undefined, + }); + return profileApi.update({ + graduation_year: numberOrUndefined(values.graduation_year), + curriculum: values.curriculum || undefined, + intended_major: values.intended_major || undefined, + sat_score: numberOrUndefined(values.sat_score), + sat_math: numberOrUndefined(values.sat_math), + sat_ebrw: numberOrUndefined(values.sat_ebrw), + act_score: numberOrUndefined(values.act_score), + ielts_score: values.ielts_score || undefined, + ielts_listening: values.ielts_listening || undefined, + ielts_reading: values.ielts_reading || undefined, + ielts_writing: values.ielts_writing || undefined, + ielts_speaking: values.ielts_speaking || undefined, + toefl_score: numberOrUndefined(values.toefl_score), + toefl_reading: numberOrUndefined(values.toefl_reading), + toefl_listening: numberOrUndefined(values.toefl_listening), + toefl_speaking: numberOrUndefined(values.toefl_speaking), + toefl_writing: numberOrUndefined(values.toefl_writing), + duolingo_score: numberOrUndefined(values.duolingo_score), + a_level_subjects: values.a_level_subjects || undefined, + a_level_predicted: values.a_level_predicted || undefined, + ap_subjects: values.ap_subjects || undefined, + ib_predicted_score: numberOrUndefined(values.ib_predicted_score), + unt_score: numberOrUndefined(values.unt_score), + nis_grade12_certificate_gpa: values.nis_grade12_certificate_gpa || undefined, + budget_range: values.budget_range || undefined, + }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["profile"] }); + queryClient.invalidateQueries({ queryKey: ["auth", "me"] }); + toast.success("Profile saved"); + }, + onError: () => toast.error("Profile could not be saved"), + }); + + if (isLoading) { + return ( +
+ + +
+ ); + } + + return ( +
+
+
+

Student profile

+

Your saved context

+

+ Keep this current. The Chancellor uses it for university fit, exam advice, and funding realism. +

+
+ +
saveMutation.mutate(values))} className="space-y-6"> +
+

Identity and academics

+
+
+
+
+
+
+
+
+ +
+

Exams

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+

Curriculum scores and funding

+
+
+
+
+
+
+
+
+
+ +
+ +
+
+
+
+ ); +} diff --git a/apps/web/src/app/(app)/reports/[id]/page.tsx b/apps/web/src/app/(app)/reports/[id]/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9299717e42301f2ea0b7bb8fe597567843e1a6ed --- /dev/null +++ b/apps/web/src/app/(app)/reports/[id]/page.tsx @@ -0,0 +1,647 @@ +"use client"; + +import Link from "next/link"; +import type { ReactNode } from "react"; +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { + AlertTriangle, + ArrowLeft, + CheckCircle2, + ChevronDown, + ChevronUp, + Download, + FileSearch, + GraduationCap, + Loader2, + ReceiptText, + Shield, + Sparkles, +} from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { profileApi, reportsApi } from "@/lib/api"; +import { cn, formatDate } from "@/lib/utils"; +import { toast } from "sonner"; +import type { + Recommendation, + ReportDetail, + ReportStatus, + RewriteVariant, + SourceReference, +} from "@/types"; + +const STATUS_STEPS = { + pending: [ + "Loading university record", + "Reading major and funding constraints", + "Preparing advisor structure", + ], + processing: [ + "Matching university with your intended major", + "Building research-program shortlist", + "Preparing funding and action plan", + ], + completed: [], + failed: [], +} satisfies Record; + +const REC_TYPE_COLORS = { + keep: "success", + rewrite: "warning", + remove: "destructive", + merge: "info", + reorder: "secondary", +} as const; + +const CONFIDENCE_COLORS = { + high: "bg-emerald-100 text-emerald-800", + medium: "bg-blue-100 text-blue-800", + low: "bg-slate-100 text-slate-600", +}; + +function StatusBadge({ status }: { status: ReportStatus }) { + const variants = { + completed: "success", + pending: "info", + processing: "info", + failed: "destructive", + } as const; + + return {status}; +} + +function getRewriteFormat(report: ReportDetail, achievementType: string) { + const haystack = [report.university.name, report.university.country, report.university.application_system] + .filter(Boolean) + .join(" ") + .toLowerCase(); + + if (haystack.includes("korea") || ["kaist", "unist", "postech", "yonsei"].some((token) => haystack.includes(token))) { + if (haystack.includes("kaist")) { + return { label: "KAIST Apply", limit: 200, unit: "English bytes/chars" }; + } + return { label: "Korean university application", limit: 300, unit: "English bytes/chars" }; + } + + if (achievementType === "honor") { + return { label: "Common App honor", limit: 100, unit: "chars" }; + } + + return { label: "Common App activity", limit: 150, unit: "chars" }; +} + +function RewriteStudio({ + report, + recommendation, + variants, +}: { + report: ReportDetail; + recommendation: Recommendation; + variants: RewriteVariant[]; +}) { + const [selectedStyle, setSelectedStyle] = useState( + variants.find((v) => v.is_recommended)?.style_mode ?? variants[0]?.style_mode + ); + const [expanded, setExpanded] = useState(false); + + const myVariants = variants.filter((v) => v.achievement_id === recommendation.achievement_id); + if (myVariants.length === 0) return null; + + const selected = myVariants.find((v) => v.style_mode === selectedStyle) ?? myVariants[0]; + const rewriteFormat = getRewriteFormat(report, recommendation.achievement.type); + + return ( +
+ + + {expanded && ( +
+
+ {myVariants.map((variant) => ( + + ))} +
+ +
+

Original

+
+ {recommendation.achievement.description_raw || ( + No description provided + )} +
+ {(recommendation.achievement.description_raw?.length ?? 0)} chars +
+
+
+ + {selected && ( +
+

+ {selected.style_mode.replace("_", " ")} style +

+

Target: {rewriteFormat.label}

+
+ {selected.text} +
+ rewriteFormat.limit ? "text-red-600" : "text-emerald-700")}> + {selected.character_count}/{rewriteFormat.limit} {rewriteFormat.unit} + + +
+
+ {selected.explanation && ( +

{selected.explanation}

+ )} +
+ )} +
+ )} +
+ ); +} + +function SourceCard({ source }: { source: SourceReference }) { + const isOfficial = source.policy_entry.source_type === "official"; + + return ( +
+
+ {isOfficial ? ( + + ) : ( + + )} +
+
+ + {isOfficial ? "Official" : "Public Example"} — Tier {source.policy_entry.reliability_tier} + + {!isOfficial && ( + (not official university guidance) + )} +
+

+ {source.policy_entry.title} +

+ {source.policy_entry.source_title && ( +

+ {source.policy_entry.source_title} +

+ )} +
+
+ {source.policy_entry.excerpt && ( +
+ “{source.policy_entry.excerpt}” +
+ )} + {source.policy_entry.source_url && ( + + View source → + + )} +
+ ); +} + +function InfoCard({ + icon: Icon, + title, + children, +}: { + icon: typeof GraduationCap; + title: string; + children: ReactNode; +}) { + return ( +
+
+ +

{title}

+
+
{children}
+
+ ); +} + +export default function ReportDetailPage({ params }: { params: { id: string } }) { + const [isExporting, setIsExporting] = useState(false); + + const { data, isLoading, error } = useQuery({ + queryKey: ["reports", params.id], + queryFn: () => reportsApi.get(params.id), + }); + + const { data: profileData } = useQuery({ + queryKey: ["profile"], + queryFn: () => profileApi.get(), + }); + + const report: ReportDetail | undefined = data?.data?.data; + const profile = profileData?.data?.data?.profile; + + const handleExport = async () => { + setIsExporting(true); + try { + const res = await reportsApi.export(params.id); + const blob = new Blob([JSON.stringify(res.data.data, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = `applymap-report-${params.id}.json`; + anchor.click(); + URL.revokeObjectURL(url); + toast.success("Report exported"); + } catch { + toast.error("Export failed"); + } finally { + setIsExporting(false); + } + }; + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error || !report) { + return ( +
+

Advisor not found or failed to load.

+ + + +
+ ); + } + + const advisor = report.advisor_snapshot_json; + const intendedMajor = advisor?.target_major || profile?.intended_major || report.university.major_strengths?.[0] || "your target major"; + const inProgress = report.status === "pending" || report.status === "processing"; + const isLegacyCompletedReport = report.status === "completed" && !advisor; + const keepRecs = report.recommendations + .filter((rec) => rec.recommendation_type === "keep" || rec.recommendation_type === "rewrite") + .sort((a, b) => (a.suggested_rank ?? 999) - (b.suggested_rank ?? 999)); + const removeRecs = report.recommendations.filter( + (rec) => rec.recommendation_type === "remove" || rec.recommendation_type === "merge" + ); + const activities = keepRecs.filter((rec) => rec.achievement.type === "activity"); + const honors = keepRecs.filter((rec) => rec.achievement.type === "honor"); + const officialSources = report.source_references.filter((source) => source.policy_entry.source_type === "official"); + const publicSources = report.source_references.filter((source) => source.policy_entry.source_type === "public_example"); + + return ( +
+
+
+
+ + + Back to advisors + + +
+
+

+ University advisor +

+

+ {report.university.name} +

+

+ {advisor?.subtitle ?? "This report was generated before versioned advisor snapshots were stored."} +

+
+ {report.university.country} + {intendedMajor} + {report.university.full_ride_possible && Full-funding route visible} + {report.university.is_common_app && Common App} +
+
+ +
+
+
+

Status

+

+ Built {formatDate(report.created_at)} • v{report.version_number} +

+
+ +
+ +

+ {advisor?.report_note ?? report.summary_text ?? "This view is intentionally university-first: school, major, funding route, and next moves."} +

+
+
+
+ + {inProgress && ( +
+
+ +
+

Building your university advisor

+

You can see what the app is doing right now.

+
+
+
+ {STATUS_STEPS[report.status].map((step, index) => ( +
+

Step {index + 1}

+

{step}

+
+ ))} +
+
+ )} + + {isLegacyCompletedReport && ( + <> +
+ This is a legacy report created before advisor snapshots were stored. Regenerate the advisor from the Universities page to lock a versioned university-specific snapshot. +
+ + {honors.length > 0 && ( +
+

Recommended honors

+
+ {honors.map((rec) => ( +
+
+
+ {rec.suggested_rank} +
+
+
+

{rec.achievement.title}

+ {rec.recommendation_type} + + {rec.confidence_label} confidence + +
+ {rec.achievement.organization_name && ( +

{rec.achievement.organization_name}

+ )} + {rec.rationale &&

{rec.rationale}

} + +
+
+
+ ))} +
+
+ )} + + {activities.length > 0 && ( +
+

Recommended activities

+
+ {activities.map((rec) => ( +
+
+
+ {rec.suggested_rank} +
+
+
+

{rec.achievement.title}

+ {rec.recommendation_type} + + {rec.confidence_label} confidence + +
+ {rec.achievement.organization_name && ( +

{rec.achievement.organization_name}

+ )} + {rec.rationale &&

{rec.rationale}

} + +
+
+
+ ))} +
+
+ )} + + {removeRecs.length > 0 && ( +
+

Not recommended for this university

+
+ {removeRecs.map((rec) => ( +
+
+

{rec.achievement.title}

+ {rec.rationale &&

{rec.rationale}

} +
+ {rec.recommendation_type} +
+ ))} +
+
+ )} + + {!!(officialSources.length || publicSources.length) && ( +
+

Guidance sources

+

Every legacy recommendation is grounded in these sources.

+ + {officialSources.length > 0 && ( +
+

+ Official sources (Tier A) +

+
+ {officialSources.map((source) => ( + + ))} +
+
+ )} + + {publicSources.length > 0 && ( +
+

+ Public examples (Tier B/C) +

+
+ {publicSources.map((source) => ( + + ))} +
+

+ Public example sources represent patterns observed in community discussions and admitted student profiles. + They are not official university guidance. Always verify important decisions against official sources. +

+
+ )} +
+ )} + + )} + + {!inProgress && report.status === "completed" && advisor && ( + <> +
+ +
+ {advisor.focus_areas.map((item) => ( +
+ {item} +
+ ))} +
+
+ + +
+ {advisor.funding_plan.map((item) => ( +
+ {item} +
+ ))} +
+
+
+ +
+
+ +

Research programs to target

+
+

+ These are named programs worth prioritizing so the advisor sounds like a real admissions strategy, not a generic dashboard summary. +

+ +
+ {advisor.research_programs.map((program) => ( +
+
+
+

{program.name}

+

{program.why_it_matters}

+
+ + {program.priority === "full-funding" ? "Funding first" : program.priority === "scholarship" ? "Scholarship route" : "Verify"} + +
+
+ {program.funding_note} +
+
+ ))} +
+
+ +
+
+ +

What to do next

+
+
+ {advisor.action_plan.map((step, index) => ( +
+

Action {index + 1}

+

{step.title}

+

{step.detail}

+
+ ))} +
+
+ + )} + + {report.status === "failed" && ( +
+ The advisor failed to build. Open the university list and generate it again. +
+ )} +
+
+ ); +} diff --git a/apps/web/src/app/(app)/reports/page.tsx b/apps/web/src/app/(app)/reports/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c1119afdb70ef2d744b97f5e246470b46788a720 --- /dev/null +++ b/apps/web/src/app/(app)/reports/page.tsx @@ -0,0 +1,91 @@ +"use client"; + +import Link from "next/link"; +import { useQuery } from "@tanstack/react-query"; +import { ArrowRight, FileText, Loader2 } from "lucide-react"; +import { reportsApi } from "@/lib/api"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { formatDate } from "@/lib/utils"; +import type { Report } from "@/types"; + +function StatusBadge({ status }: { status: Report["status"] }) { + const variants = { + completed: "success", + pending: "info", + processing: "info", + failed: "destructive", + } as const; + + return {status}; +} + +export default function ReportsPage() { + const { data, isLoading } = useQuery({ + queryKey: ["reports"], + queryFn: () => reportsApi.list(), + }); + + const reports: Report[] = data?.data?.data ?? []; + + return ( +
+
+
+

University Advisors

+

+ University-first guidance for each target: major fit, funding route, and next steps. +

+
+ + + +
+ + {isLoading ? ( +
+ + Loading advisors... +
+ ) : reports.length === 0 ? ( +
+ +

No advisors yet

+

+ Pick a university and generate your first advisor. +

+ + + +
+ ) : ( +
+ {reports.map((report) => ( + +
+
+
+
+

{report.university.name}

+ + v{report.version_number} +
+

+ {report.university.country} • university advisor • Generated {formatDate(report.created_at)} +

+
+ +
+
+ + ))} +
+ )} +
+ ); +} diff --git a/apps/web/src/app/(app)/universities/page.tsx b/apps/web/src/app/(app)/universities/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c1f167131a7a0a9332be07e43dd9250dcd8dd3a8 --- /dev/null +++ b/apps/web/src/app/(app)/universities/page.tsx @@ -0,0 +1,716 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { achievementsApi, profileApi, reportsApi, targetsApi, universitiesApi } from "@/lib/api"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { Label } from "@/components/ui/label"; +import { cn } from "@/lib/utils"; +import { useRouter } from "next/navigation"; +import { CheckCircle, FileText, Loader2, Plus, Search, SlidersHorizontal, Sparkles } from "lucide-react"; +import { toast } from "sonner"; +import type { Achievement, CommonAppRecommendation, StudentProfile, TargetUniversity, University } from "@/types"; + +const PRESET_LABELS: Record = { + research_heavy: "Research-heavy", + leadership_heavy: "Leadership-heavy", + balanced_holistic: "Balanced holistic", + community_service_heavy: "Community service", +}; + +const PRESET_COLORS: Record = { + research_heavy: "bg-blue-100 text-blue-800", + leadership_heavy: "bg-amber-100 text-amber-800", + balanced_holistic: "bg-slate-100 text-slate-800", + community_service_heavy: "bg-emerald-100 text-emerald-800", +}; + +const AID_LABELS: Record = { + need_blind_full_need: "Need-blind full need", + need_aware_full_need: "Need-aware full need", + need_based_possible: "Need-based possible", + merit_full_ride_possible: "Merit full ride possible", + merit_full_tuition_possible: "Merit full tuition possible", + need_and_merit_full_ride_possible: "Need + merit full ride possible", + government_full_funding_possible: "Government full funding route", + partial_scholarship_possible: "Partial scholarship", +}; + +const COUNTRIES = ["", "United States", "United Arab Emirates", "Hong Kong", "South Korea", "Japan", "Canada", "France", "Italy", "Hungary"]; +const REGIONS = ["", "USA", "Abu Dhabi / UAE", "Hong Kong", "Korea", "Japan", "Canada", "Europe"]; +const SORTS = [ + { value: "name", label: "Name" }, + { value: "aid_type", label: "Need policy" }, + { value: "aid_strength", label: "Aid strength" }, + { value: "selectivity_score", label: "Selectivity" }, + { value: "education_years_required", label: "School years required" }, +]; + +const ADVISOR_PROGRESS_STEPS = [ + "Loading university profile", + "Matching major and funding route", + "Preparing research-program guidance", + "Opening advisor", +] as const; + +const FIT_CATEGORY_LABELS = { + dream: "Dream", + target: "Target", + safe: "Safe", +} as const; + +function csvToArray(value: string) { + return value.split(",").map((item) => item.trim()).filter(Boolean); +} + +function averageAchievementScore(achievement: Achievement) { + const scores = [ + achievement.major_relevance_score, + achievement.selectivity_score, + achievement.continuity_score, + achievement.distinctiveness_score, + ].filter((score): score is number => typeof score === "number"); + + if (!scores.length) return null; + return Math.round((scores.reduce((sum, score) => sum + score, 0) / scores.length) * 10) / 10; +} + +function SelectionColumn({ + title, + count, + limit, + items, + selectedIds, + onToggle, + emptyText, +}: { + title: string; + count: number; + limit: number; + items: Achievement[]; + selectedIds: string[]; + onToggle: (id: string) => void; + emptyText: string; +}) { + return ( +
+
+ + + {count}/{limit} + +
+ +
+ {items.length ? ( + items.map((item) => { + const isSelected = selectedIds.includes(item.id); + return ( + + ); + }) + ) : ( +
+ {emptyText} +
+ )} +
+
+ ); +} + +export default function UniversitiesPage() { + const router = useRouter(); + const queryClient = useQueryClient(); + const [generatingId, setGeneratingId] = useState(null); + const [advisorTargetName, setAdvisorTargetName] = useState(null); + const [advisorProgressIndex, setAdvisorProgressIndex] = useState(0); + const advisorGenerationLockedRef = useRef(false); + const advisorGenerationRequestRef = useRef(0); + const [recommendations, setRecommendations] = useState([]); + const [selectedHonorIds, setSelectedHonorIds] = useState([]); + const [selectedActivityIds, setSelectedActivityIds] = useState([]); + const [loadedSavedPrefs, setLoadedSavedPrefs] = useState(false); + const [filters, setFilters] = useState({ + search: "", + country: "", + region: "", + application_system: "", + teaching_language: "", + major: "", + school_years: "", + full_ride_only: false, + common_app_only: false, + aid_type: "", + sort_by: "name", + sort_dir: "asc", + }); + const [prefs, setPrefs] = useState({ + preferred_countries: "United States, United Arab Emirates, Canada", + preferred_regions: "USA, Abu Dhabi / UAE, Canada, Hong Kong, Korea, Japan, Europe", + teaching_language: "English", + school_years: "11", + intended_major: "", + needs_full_ride: true, + }); + + const { data: universitiesData, isLoading } = useQuery({ + queryKey: ["universities", filters], + queryFn: () => + universitiesApi.list({ + ...filters, + school_years: filters.school_years || undefined, + }), + }); + + const { data: targetsData } = useQuery({ + queryKey: ["targets"], + queryFn: () => targetsApi.list(), + }); + + const { data: achievementsData } = useQuery({ + queryKey: ["achievements"], + queryFn: () => achievementsApi.list(), + }); + + const { data: profileData } = useQuery({ + queryKey: ["profile"], + queryFn: () => profileApi.get(), + }); + + const profile: StudentProfile | undefined = profileData?.data?.data?.profile; + const achievements: Achievement[] = achievementsData?.data?.data ?? []; + const honors = achievements.filter((item) => item.type === "honor"); + const activities = achievements.filter((item) => item.type === "activity"); + const universities: University[] = universitiesData?.data?.data ?? []; + const targets: TargetUniversity[] = targetsData?.data?.data ?? []; + const targetUniversityIds = new Set(targets.map((target) => target.university_id)); + const isAnyAdvisorGenerating = generatingId !== null; + + useEffect(() => { + if (!profile || loadedSavedPrefs) return; + const saved = (profile.application_preferences_json ?? {}) as Record; + setPrefs((current) => ({ + ...current, + preferred_countries: Array.isArray(saved.preferred_countries) + ? saved.preferred_countries.join(", ") + : current.preferred_countries, + preferred_regions: Array.isArray(saved.preferred_regions) + ? saved.preferred_regions.join(", ") + : current.preferred_regions, + teaching_language: typeof saved.teaching_language === "string" ? saved.teaching_language : current.teaching_language, + school_years: saved.school_years ? String(saved.school_years) : current.school_years, + intended_major: typeof saved.intended_major === "string" ? saved.intended_major : profile.intended_major ?? "", + needs_full_ride: typeof saved.needs_full_ride === "boolean" ? saved.needs_full_ride : current.needs_full_ride, + })); + setSelectedHonorIds(Array.isArray(saved.top_honor_ids) ? saved.top_honor_ids.map(String).slice(0, 5) : []); + setSelectedActivityIds(Array.isArray(saved.top_activity_ids) ? saved.top_activity_ids.map(String).slice(0, 10) : []); + setLoadedSavedPrefs(true); + }, [loadedSavedPrefs, profile]); + + const groupedRecommendations = useMemo(() => ({ + dream: recommendations.filter((item) => item.category === "dream"), + target: recommendations.filter((item) => item.category === "target"), + safe: recommendations.filter((item) => item.category === "safe"), + }), [recommendations]); + const targetsByCategory = useMemo(() => ({ + dream: targets.filter((item) => item.fit_category === "dream"), + target: targets.filter((item) => item.fit_category === "target"), + safe: targets.filter((item) => item.fit_category === "safe"), + }), [targets]); + + const addTargetMutation = useMutation({ + mutationFn: ({ universityId, fitCategory }: { universityId: string; fitCategory: "dream" | "target" | "safe" }) => + targetsApi.add({ university_id: universityId, fit_category: fitCategory }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["targets"] }); + toast.success("University added to targets"); + }, + onError: () => toast.error("Already in targets or error adding"), + }); + + const recommendMutation = useMutation({ + mutationFn: () => + universitiesApi.recommendCommonApp({ + top_honor_ids: selectedHonorIds, + top_activity_ids: selectedActivityIds, + preferences: { + preferred_countries: csvToArray(prefs.preferred_countries), + preferred_regions: csvToArray(prefs.preferred_regions), + teaching_language: prefs.teaching_language || undefined, + school_years: prefs.school_years ? Number(prefs.school_years) : undefined, + intended_major: prefs.intended_major || profile?.intended_major || undefined, + needs_full_ride: prefs.needs_full_ride, + common_app_only: true, + }, + save_preferences: true, + }), + onSuccess: (response) => { + setRecommendations(response.data?.data?.recommendations ?? []); + queryClient.invalidateQueries({ queryKey: ["profile"] }); + toast.success("Common App recommendations generated"); + }, + onError: () => toast.error("Select achievements and complete your profile first"), + }); + + const handleGenerateReport = async (university: University) => { + if (advisorGenerationLockedRef.current) return; + + advisorGenerationLockedRef.current = true; + const requestId = advisorGenerationRequestRef.current + 1; + advisorGenerationRequestRef.current = requestId; + setGeneratingId(university.id); + setAdvisorTargetName(university.name); + setAdvisorProgressIndex(0); + const interval = window.setInterval(() => { + setAdvisorProgressIndex((current) => + current >= ADVISOR_PROGRESS_STEPS.length - 1 ? current : current + 1 + ); + }, 950); + + try { + const res = await reportsApi.generate(university.id); + const reportId = res.data?.data?.id; + queryClient.invalidateQueries({ queryKey: ["reports"] }); + toast.success("University advisor ready"); + router.push(reportId ? `/reports/${reportId}` : "/reports"); + } catch { + toast.error("Failed to build the university advisor."); + } finally { + window.clearInterval(interval); + if (advisorGenerationRequestRef.current === requestId) { + setGeneratingId(null); + setAdvisorTargetName(null); + setAdvisorProgressIndex(0); + } + advisorGenerationLockedRef.current = false; + } + }; + + const toggleSelection = (id: string, type: "honor" | "activity") => { + if (type === "honor") { + setSelectedHonorIds((current) => { + if (current.includes(id)) return current.filter((item) => item !== id); + if (current.length >= 5) { + toast.error("Common App has 5 honor slots"); + return current; + } + return [...current, id]; + }); + return; + } + + setSelectedActivityIds((current) => { + if (current.includes(id)) return current.filter((item) => item !== id); + if (current.length >= 10) { + toast.error("Common App has 10 activity slots"); + return current; + } + return [...current, id]; + }); + }; + + return ( +
+
+
+
+
+
+

+ Shortlist workspace +

+

+ Universities +

+

+ Filter funded options, preserve your preferences, generate a Common App shortlist, + then open a university advisor focused on the school, your major, and the funding path. +

+
+ +
+
+

Targets

+

{targets.length}

+
+
+

Visible

+

{universities.length}

+
+
+

Honors

+

{selectedHonorIds.length}

+
+
+

Activities

+

{selectedActivityIds.length}

+
+
+
+
+ +
+
+ +

Sort and filter

+
+ +
+
+ + setFilters((current) => ({ ...current, search: event.target.value }))} + /> +
+ + + setFilters((current) => ({ ...current, major: event.target.value }))} /> + setFilters((current) => ({ ...current, teaching_language: event.target.value }))} /> + setFilters((current) => ({ ...current, school_years: event.target.value }))} /> + + + + + + +
+
+ + {!!targets.length && ( +
+

Your university list

+

+ Keep your own Dream, Target, and Safe list separate from the AI suggestions. +

+
+ {(["dream", "target", "safe"] as const).map((category) => ( +
+
+

{FIT_CATEGORY_LABELS[category]}

+ {targetsByCategory[category].length} +
+
+ {targetsByCategory[category].length ? targetsByCategory[category].map((target) => ( +
+

{target.university.name}

+

{target.university.country}

+
+ )) : ( +

+ No schools yet. +

+ )} +
+
+ ))} +
+
+ )} + +
+
+
+
+ +

Common App top 20 recommender

+
+

+ Use saved preferences plus selected honors and activities to generate a shortlist + that is filtered for fit and funding reality. +

+
+ + +
+ +
+
+

+ Saved preferences +

+
+
+ + setPrefs((current) => ({ ...current, preferred_countries: event.target.value }))} /> +
+
+ + setPrefs((current) => ({ ...current, preferred_regions: event.target.value }))} /> +
+
+ + setPrefs((current) => ({ ...current, teaching_language: event.target.value }))} /> +
+
+
+ + setPrefs((current) => ({ ...current, intended_major: event.target.value }))} /> +
+
+ + setPrefs((current) => ({ ...current, school_years: event.target.value }))} /> +
+
+ +
+
+ + toggleSelection(id, "honor")} + emptyText="Add honors in Achievement Vault first." + /> + + toggleSelection(id, "activity")} + emptyText="Add activities in Achievement Vault first." + /> +
+ + {!!recommendations.length && ( +
+ {(["dream", "target", "safe"] as const).map((category) => ( +
+
+

{category}

+ {groupedRecommendations[category].length} +
+

+ Relative category only, not an admission or aid guarantee. +

+
+ {groupedRecommendations[category].map((recommendation) => ( +
+

{recommendation.name}

+

{recommendation.country}

+

{recommendation.rationale}

+ {recommendation.aid_notes && ( +

+ {recommendation.aid_notes} +

+ )} + +
+ ))} +
+
+ ))} +
+ )} +
+ +
+ {isLoading ? ( +
+ + Loading universities... +
+ ) : universities.length ? ( +
+ {universities.map((university) => { + const isTarget = targetUniversityIds.has(university.id); + const isGeneratingThisCard = generatingId === university.id; + + return ( +
+
+
+

+ {university.name} +

+

+ {[university.country, university.city].filter(Boolean).join(" • ")} +

+
+ {isTarget && } +
+ +
+ + {PRESET_LABELS[university.weight_preset]} + + {university.is_common_app && Common App} + {university.full_ride_possible && Full ride possible} + {university.full_tuition_possible && !university.full_ride_possible && Full tuition possible} +
+ + {university.short_description && ( +

+ {university.short_description} +

+ )} + +
+ {university.aid_type &&

Aid: {AID_LABELS[university.aid_type] ?? university.aid_type}

} + {university.education_years_required &&

School years: {university.education_years_required}+ expected

} + {!!university.teaching_languages?.length &&

Language: {university.teaching_languages.join(", ")}

} + {!!university.major_strengths?.length &&

Strong fits: {university.major_strengths.join(", ")}

} +
+ +
+ {!isTarget ? ( + (["dream", "target", "safe"] as const).map((category) => ( + + )) + ) : ( + + )} +
+
+ ); + })} +
+ ) : ( +
+

No universities found. Try different filters.

+
+ )} +
+ + {generatingId && advisorTargetName && ( +
+
+ +
+

+ University advisor +

+

{advisorTargetName}

+

+ The app now shows exactly what it is doing while the advisor is being prepared. +

+
+
+ +
+ {ADVISOR_PROGRESS_STEPS.map((step, index) => { + const isDone = index < advisorProgressIndex; + const isCurrent = index === advisorProgressIndex; + + return ( +
+ {step} +
+ ); + })} +
+
+ )} +
+
+ ); +} diff --git a/apps/web/src/app/(app)/vault/page.tsx b/apps/web/src/app/(app)/vault/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e3c78a3131b6d72ea9461fee6efc5c8fb2e055ea --- /dev/null +++ b/apps/web/src/app/(app)/vault/page.tsx @@ -0,0 +1,983 @@ +"use client"; + +import { useState, useEffect, useMemo, useRef } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { achievementsApi } from "@/lib/api"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; +import { + AllAchievementsPanel, + type ClarificationAnswers, + type ImportProgressState, +} from "@/components/vault/AllAchievementsPanel"; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { + Plus, Pencil, Trash2, AlertTriangle, CheckCircle, Info, GripVertical, Sparkles, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { toast } from "sonner"; +import type { Achievement, AchievementImportResult } from "@/types"; + +const ACTIVITY_ORDER_STORAGE_KEY = "sourcelock_activity_order"; +const IMPORT_ANALYSIS_STORAGE_KEY = "applymap_all_import_analysis"; +const TEXT_PREVIEW_EXTENSIONS = new Set(["txt", "md", "csv", "json"]); + +// ─── Form schema ──────────────────────────────────────────────────────────── + +const achievementSchema = z.object({ + type: z.enum(["activity", "honor"]), + title: z.string().min(1, "Title is required").max(500), + organization_name: z.string().optional(), + role_title: z.string().optional(), + description_raw: z.string().optional(), + category: z.string().optional(), + start_date: z.string().optional(), + end_date: z.string().optional(), + hours_per_week: z.string().optional(), + weeks_per_year: z.string().optional(), + impact_scope: z + .enum(["school", "local", "regional", "national", "international", "family", "personal", ""]) + .optional(), + leadership_level: z.enum(["none", "member", "lead", "founder", "captain", ""]).optional(), +}); +type FormData = z.infer; + +// ─── Status helpers ────────────────────────────────────────────────────────── + +const SCORE_FIELDS: { key: keyof Achievement; label: string }[] = [ + { key: "major_relevance_score", label: "Major relevance" }, + { key: "selectivity_score", label: "Selectivity" }, + { key: "continuity_score", label: "Continuity" }, + { key: "distinctiveness_score", label: "Distinctiveness" }, +]; + +function hasChancellorScores(achievement: Achievement) { + return SCORE_FIELDS.every((field) => typeof achievement[field.key] === "number"); +} + +function formatFileSize(bytes: number) { + if (bytes >= 1024 * 1024) { + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + } + return `${Math.max(1, Math.round(bytes / 1024))} KB`; +} + +async function getLocalSourcePreview(file: File) { + const extension = file.name.split(".").pop()?.toLowerCase() ?? ""; + if (!TEXT_PREVIEW_EXTENSIONS.has(extension)) return []; + + const text = await file.text(); + return text + .split(/\r?\n|[\u2022*]\s+/) + .map((line) => line.replace(/\s+/g, " ").trim()) + .filter((line) => line.length > 18) + .slice(0, 5) + .map((line) => (line.length > 220 ? `${line.slice(0, 217)}...` : line)); +} + +function getStatus(achievement: Achievement) { + const desc = achievement.description_raw ?? ""; + if (achievement.truth_risk_flag) { + return { + label: "Review Needed", + variant: "destructive" as const, + icon: AlertTriangle, + reason: + "The Chancellor found an unsupported, conflicting, or unclear claim. Add evidence or clarify dates, award level, scope, hours, or results.", + }; + } + if (desc.length < 30) { + return { + label: "Needs Detail", + variant: "warning" as const, + icon: Info, + reason: "The description is too short to judge impact. Add what you did, scale, outcome, and time commitment.", + }; + } + if (!hasChancellorScores(achievement)) { + return { + label: "Analysis Pending", + variant: "info" as const, + icon: Sparkles, + reason: "The Chancellor has not scored this achievement yet.", + }; + } + return { + label: "Strong", + variant: "success" as const, + icon: CheckCircle, + reason: "The entry has enough detail for the current Chancellor scoring pass.", + }; +} + +// ─── Score bar ─────────────────────────────────────────────────────────────── + +function ScoreBar({ label, value }: { label: string; value?: number | null }) { + const pct = value != null ? Math.min((value / 10) * 100, 100) : 0; + const fillClass = + value == null + ? "" + : value >= 7 + ? "bg-emerald-500" + : value >= 4 + ? "bg-amber-400" + : "bg-red-400"; + + return ( +
+
+ {label} + + {value != null ? value.toFixed(1).replace(/\.0$/, "") : "Pending"} + +
+
+ {value != null && ( +
+ )} +
+
+ ); +} + +// ─── Empty vault state ─────────────────────────────────────────────────────── + +function EmptyVaultState({ + type, + onAdd, +}: { + type: "activity" | "honor"; + onAdd: () => void; +}) { + return ( +
+ {/* Vault door illustration */} + + +

+ {type === "activity" ? "Your activity vault is empty" : "No honors added yet"} +

+

+ {type === "activity" + ? "Add clubs, research, jobs, competitions — anything you've done outside class." + : "Add academic prizes, scholarships, and awards you've received."} +

+ +
+ ); +} + +// ─── Achievement modal ─────────────────────────────────────────────────────── + +function AchievementModal({ + open, + onClose, + defaultType, + editing, + onSaved, +}: { + open: boolean; + onClose: () => void; + defaultType: "activity" | "honor"; + editing?: Achievement; + onSaved: () => void; +}) { + const queryClient = useQueryClient(); + + const { register, handleSubmit, reset, formState: { errors } } = useForm({ + resolver: zodResolver(achievementSchema), + defaultValues: editing + ? { + ...editing, + hours_per_week: editing.hours_per_week?.toString() ?? "", + weeks_per_year: editing.weeks_per_year?.toString() ?? "", + start_date: editing.start_date ?? "", + end_date: editing.end_date ?? "", + impact_scope: editing.impact_scope ?? "", + leadership_level: editing.leadership_level ?? "", + } + : { type: defaultType, impact_scope: "", leadership_level: "" }, + }); + + useEffect(() => { + reset( + editing + ? { + ...editing, + hours_per_week: editing.hours_per_week?.toString() ?? "", + weeks_per_year: editing.weeks_per_year?.toString() ?? "", + start_date: editing.start_date ?? "", + end_date: editing.end_date ?? "", + impact_scope: editing.impact_scope ?? "", + leadership_level: editing.leadership_level ?? "", + } + : { type: defaultType, impact_scope: "", leadership_level: "" } + ); + }, [defaultType, editing, reset]); + + const createMutation = useMutation({ + mutationFn: (data: Record) => achievementsApi.create(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["achievements"] }); + onSaved(); + toast.success("Achievement added"); + onClose(); + }, + }); + + const updateMutation = useMutation({ + mutationFn: ({ id, data }: { id: string; data: Record }) => + achievementsApi.update(id, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["achievements"] }); + onSaved(); + toast.success("Achievement updated"); + onClose(); + }, + }); + + const onSubmit = (raw: FormData) => { + const data: Record = { + ...raw, + hours_per_week: raw.hours_per_week ? parseFloat(raw.hours_per_week) : undefined, + weeks_per_year: raw.weeks_per_year ? parseInt(raw.weeks_per_year, 10) : undefined, + start_date: raw.start_date || undefined, + end_date: raw.end_date || undefined, + impact_scope: raw.impact_scope || undefined, + leadership_level: raw.leadership_level || undefined, + }; + if (editing) { + updateMutation.mutate({ id: editing.id, data }); + } else { + createMutation.mutate(data); + } + }; + + const isPending = createMutation.isPending || updateMutation.isPending; + + return ( + !nextOpen && onClose()}> + + + {editing ? "Edit achievement" : "Add achievement"} + + +
+ + +
+ + + {errors.title &&

{errors.title.message}

} +
+ +
+
+ + +
+
+ + +
+
+ +
+ +