"""Application services: the glue between the LLM provider and the database. Routes stay thin by delegating all domain logic here. Everything is synchronous and provider-agnostic, so the same code path runs identically with the offline stub or a real model. """ from __future__ import annotations import datetime as dt import json from typing import List, Optional from sqlalchemy.orm import Session from app.llm import LLMProvider, ScoredAnswer from app.models import Answer, InterviewSession, User from app.security import hash_password, verify_password # -- demo / sample data ------------------------------------------------------ # Shared by `scripts/seed.py` (CLI) and the one-click "/demo" route so a visitor # sees the full flow instantly, offline, with no API key. DEMO_EMAIL = "demo@interviewcoach.dev" DEMO_PASSWORD = "demopass123" SAMPLE_JD = """Senior Backend Engineer (Python / Distributed Systems) We're hiring a senior backend engineer to design and operate the APIs and data pipelines behind our product. You'll own services end to end: system design, implementation in Python, the database schema, performance, and on-call. Responsibilities: - Design scalable APIs and distributed systems with clear trade-offs. - Model data and tune the database for production load. - Drive testing, reliability, and observability for services you own. - Collaborate with product and communicate decisions to stakeholders. Requirements: - Strong Python and API design experience. - Solid grounding in databases and system design. - A track record of measurable impact and ownership. """ # A strong, quantified answer used to pre-score the first question so the demo # dashboard opens with a real rubric breakdown to look at. Also offered in the # UI as a one-click "use a sample answer" so visitors can try scoring instantly. SAMPLE_ANSWER = ( "At my last company I owned the checkout API end to end. I redesigned it " "around idempotent endpoints and a durable queue, which cut failed payments " "by 38% and reduced p99 latency from 1.2s to 340ms. I led the rollout across " "three teams with zero downtime and added dashboards so we caught regressions " "before customers did." ) def ensure_demo_user(db: Session, provider: LLMProvider) -> User: """Return the demo user, creating it (with a seeded scored session) if needed. Idempotent: safe to call on every visit to the one-click demo. The demo user always has at least one session whose first answer is already scored. """ user = get_user_by_email(db, DEMO_EMAIL) if user is None: user = create_user(db, DEMO_EMAIL, DEMO_PASSWORD) if not list_sessions(db, user): session = create_session(db, user, SAMPLE_JD, provider, question_count=5) if session.answers: score_and_save_answer(db, session.answers[0], SAMPLE_ANSWER, provider) return user # -- users ------------------------------------------------------------------- def create_user(db: Session, email: str, password: str) -> User: user = User(email=email.strip().lower(), password_hash=hash_password(password)) db.add(user) db.commit() db.refresh(user) return user def get_user_by_email(db: Session, email: str) -> Optional[User]: return db.query(User).filter(User.email == email.strip().lower()).first() def authenticate(db: Session, email: str, password: str) -> Optional[User]: user = get_user_by_email(db, email) if user and verify_password(password, user.password_hash): return user return None # -- interview sessions ------------------------------------------------------ def _derive_role_title(job_description: str) -> str: """Best-effort role title from the first meaningful line of the JD.""" for line in job_description.splitlines(): line = line.strip(" #-*\t") if len(line) >= 3: return line[:120] return "Interview practice" def create_session( db: Session, user: User, job_description: str, provider: LLMProvider, question_count: int = 5, role_title: Optional[str] = None, ) -> InterviewSession: """Create a session and pre-generate its questions via the provider.""" job_description = job_description.strip() session = InterviewSession( user_id=user.id, job_description=job_description, role_title=(role_title or _derive_role_title(job_description)), ) db.add(session) db.flush() # assign session.id before adding answers for position, q in enumerate(provider.generate_questions(job_description, question_count)): db.add( Answer( session_id=session.id, position=position, skill=q.skill, question=q.text, ) ) db.commit() db.refresh(session) return session def get_session(db: Session, user: User, session_id: int) -> Optional[InterviewSession]: return ( db.query(InterviewSession) .filter(InterviewSession.id == session_id, InterviewSession.user_id == user.id) .first() ) def list_sessions(db: Session, user: User) -> List[InterviewSession]: return ( db.query(InterviewSession) .filter(InterviewSession.user_id == user.id) .order_by(InterviewSession.created_at.desc()) .all() ) def dashboard_stats(sessions: List[InterviewSession]) -> dict: """Portfolio-level aggregates for the history dashboard header.""" total_sessions = len(sessions) total_questions = sum(len(s.answers) for s in sessions) total_answered = sum(s.answered_count for s in sessions) scored_percents = [ a.percent for s in sessions for a in s.answers if a.percent is not None ] avg_percent = ( int(round(sum(scored_percents) / len(scored_percents))) if scored_percents else None ) best_percent = max(scored_percents) if scored_percents else None return { "total_sessions": total_sessions, "total_questions": total_questions, "total_answered": total_answered, "avg_percent": avg_percent, "best_percent": best_percent, } def get_answer(db: Session, user: User, answer_id: int) -> Optional[Answer]: return ( db.query(Answer) .join(InterviewSession) .filter(Answer.id == answer_id, InterviewSession.user_id == user.id) .first() ) def score_and_save_answer( db: Session, answer: Answer, answer_text: str, provider: LLMProvider, ) -> ScoredAnswer: """Grade an answer with the provider and persist the full result.""" result = provider.score_answer( job_description=answer.session.job_description, question=answer.question, answer=answer_text, ) answer.answer_text = answer_text.strip() answer.overall = result.overall answer.percent = result.percent answer.band = result.band answer.summary = result.summary answer.axis_json = json.dumps( [{"axis_id": a.axis_id, "score": a.score, "reason": a.reason} for a in result.axis_scores] ) answer.strengths_json = json.dumps(result.strengths) answer.improvements_json = json.dumps(result.improvements) answer.scored_at = dt.datetime.now(dt.timezone.utc) db.commit() db.refresh(answer) return result def decode_axes(answer: Answer) -> List[dict]: if not answer.axis_json: return [] try: return json.loads(answer.axis_json) except json.JSONDecodeError: return [] def decode_list(raw: Optional[str]) -> List[str]: if not raw: return [] try: data = json.loads(raw) return [str(x) for x in data] if isinstance(data, list) else [] except json.JSONDecodeError: return []