kumar-aditya commited on
Commit
c8f4a46
Β·
verified Β·
1 Parent(s): 90c38a2

Upload 60 files

Browse files
This view is limited to 50 files because it contains too many changes. Β  See raw diff
Files changed (50) hide show
  1. alembic.ini +149 -0
  2. alembic/README +1 -0
  3. alembic/__pycache__/env.cpython-312.pyc +0 -0
  4. alembic/env.py +95 -0
  5. alembic/script.py.mako +28 -0
  6. alembic/versions/8606f9c05795_initial_tables.py +59 -0
  7. alembic/versions/__pycache__/8606f9c05795_initial_tables.cpython-312.pyc +0 -0
  8. app/__init__.py +0 -0
  9. app/__pycache__/__init__.cpython-312.pyc +0 -0
  10. app/__pycache__/main.cpython-312.pyc +0 -0
  11. app/api/__init__.py +0 -0
  12. app/api/__pycache__/__init__.cpython-312.pyc +0 -0
  13. app/api/__pycache__/deps.cpython-312.pyc +0 -0
  14. app/api/deps.py +13 -0
  15. app/api/routes/__init__.py +0 -0
  16. app/api/routes/__pycache__/__init__.cpython-312.pyc +0 -0
  17. app/api/routes/__pycache__/analysis.cpython-312.pyc +0 -0
  18. app/api/routes/__pycache__/health.cpython-312.pyc +0 -0
  19. app/api/routes/__pycache__/resume.cpython-312.pyc +0 -0
  20. app/api/routes/analysis.py +252 -0
  21. app/api/routes/health.py +14 -0
  22. app/api/routes/resume.py +135 -0
  23. app/core/__init__.py +0 -0
  24. app/core/__pycache__/__init__.cpython-312.pyc +0 -0
  25. app/core/__pycache__/config.cpython-312.pyc +0 -0
  26. app/core/__pycache__/db.cpython-312.pyc +0 -0
  27. app/core/__pycache__/logging.cpython-312.pyc +0 -0
  28. app/core/config.py +32 -0
  29. app/core/db.py +27 -0
  30. app/core/logging.py +22 -0
  31. app/main.py +56 -0
  32. app/models/__init__.py +0 -0
  33. app/models/__pycache__/__init__.cpython-312.pyc +0 -0
  34. app/models/__pycache__/analysis.cpython-312.pyc +0 -0
  35. app/models/__pycache__/resume.cpython-312.pyc +0 -0
  36. app/models/analysis.py +35 -0
  37. app/models/resume.py +29 -0
  38. app/schemas/__init__.py +0 -0
  39. app/schemas/__pycache__/__init__.cpython-312.pyc +0 -0
  40. app/schemas/__pycache__/analysis.cpython-312.pyc +0 -0
  41. app/schemas/__pycache__/resume.cpython-312.pyc +0 -0
  42. app/schemas/analysis.py +80 -0
  43. app/schemas/resume.py +22 -0
  44. app/services/__init__.py +0 -0
  45. app/services/__pycache__/__init__.cpython-312.pyc +0 -0
  46. app/services/__pycache__/ats_scoring.cpython-312.pyc +0 -0
  47. app/services/__pycache__/embedding_service.cpython-312.pyc +0 -0
  48. app/services/__pycache__/keyword_extractor.cpython-312.pyc +0 -0
  49. app/services/__pycache__/llm_service.cpython-312.pyc +0 -0
  50. app/services/__pycache__/pdf_extractor.cpython-312.pyc +0 -0
alembic.ini ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # A generic, single database configuration.
2
+
3
+ [alembic]
4
+ # path to migration scripts.
5
+ # this is typically a path given in POSIX (e.g. forward slashes)
6
+ # format, relative to the token %(here)s which refers to the location of this
7
+ # ini file
8
+ script_location = %(here)s/alembic
9
+
10
+ # template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
11
+ # Uncomment the line below if you want the files to be prepended with date and time
12
+ # see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
13
+ # for all available tokens
14
+ # file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
15
+ # Or organize into date-based subdirectories (requires recursive_version_locations = true)
16
+ # file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
17
+
18
+ # sys.path path, will be prepended to sys.path if present.
19
+ # defaults to the current working directory. for multiple paths, the path separator
20
+ # is defined by "path_separator" below.
21
+ prepend_sys_path = .
22
+
23
+ # timezone to use when rendering the date within the migration file
24
+ # as well as the filename.
25
+ # If specified, requires the tzdata library which can be installed by adding
26
+ # `alembic[tz]` to the pip requirements.
27
+ # string value is passed to ZoneInfo()
28
+ # leave blank for localtime
29
+ # timezone =
30
+
31
+ # max length of characters to apply to the "slug" field
32
+ # truncate_slug_length = 40
33
+
34
+ # set to 'true' to run the environment during
35
+ # the 'revision' command, regardless of autogenerate
36
+ # revision_environment = false
37
+
38
+ # set to 'true' to allow .pyc and .pyo files without
39
+ # a source .py file to be detected as revisions in the
40
+ # versions/ directory
41
+ # sourceless = false
42
+
43
+ # version location specification; This defaults
44
+ # to <script_location>/versions. When using multiple version
45
+ # directories, initial revisions must be specified with --version-path.
46
+ # The path separator used here should be the separator specified by "path_separator"
47
+ # below.
48
+ # version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
49
+
50
+ # path_separator; This indicates what character is used to split lists of file
51
+ # paths, including version_locations and prepend_sys_path within configparser
52
+ # files such as alembic.ini.
53
+ # The default rendered in new alembic.ini files is "os", which uses os.pathsep
54
+ # to provide os-dependent path splitting.
55
+ #
56
+ # Note that in order to support legacy alembic.ini files, this default does NOT
57
+ # take place if path_separator is not present in alembic.ini. If this
58
+ # option is omitted entirely, fallback logic is as follows:
59
+ #
60
+ # 1. Parsing of the version_locations option falls back to using the legacy
61
+ # "version_path_separator" key, which if absent then falls back to the legacy
62
+ # behavior of splitting on spaces and/or commas.
63
+ # 2. Parsing of the prepend_sys_path option falls back to the legacy
64
+ # behavior of splitting on spaces, commas, or colons.
65
+ #
66
+ # Valid values for path_separator are:
67
+ #
68
+ # path_separator = :
69
+ # path_separator = ;
70
+ # path_separator = space
71
+ # path_separator = newline
72
+ #
73
+ # Use os.pathsep. Default configuration used for new projects.
74
+ path_separator = os
75
+
76
+
77
+ # set to 'true' to search source files recursively
78
+ # in each "version_locations" directory
79
+ # new in Alembic version 1.10
80
+ # recursive_version_locations = false
81
+
82
+ # the output encoding used when revision files
83
+ # are written from script.py.mako
84
+ # output_encoding = utf-8
85
+
86
+ # database URL. This is consumed by the user-maintained env.py script only.
87
+ # other means of configuring database URLs may be customized within the env.py
88
+ # file.
89
+ sqlalchemy.url = driver://user:pass@localhost/dbname
90
+
91
+
92
+ [post_write_hooks]
93
+ # post_write_hooks defines scripts or Python functions that are run
94
+ # on newly generated revision scripts. See the documentation for further
95
+ # detail and examples
96
+
97
+ # format using "black" - use the console_scripts runner, against the "black" entrypoint
98
+ # hooks = black
99
+ # black.type = console_scripts
100
+ # black.entrypoint = black
101
+ # black.options = -l 79 REVISION_SCRIPT_FILENAME
102
+
103
+ # lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
104
+ # hooks = ruff
105
+ # ruff.type = module
106
+ # ruff.module = ruff
107
+ # ruff.options = check --fix REVISION_SCRIPT_FILENAME
108
+
109
+ # Alternatively, use the exec runner to execute a binary found on your PATH
110
+ # hooks = ruff
111
+ # ruff.type = exec
112
+ # ruff.executable = ruff
113
+ # ruff.options = check --fix REVISION_SCRIPT_FILENAME
114
+
115
+ # Logging configuration. This is also consumed by the user-maintained
116
+ # env.py script only.
117
+ [loggers]
118
+ keys = root,sqlalchemy,alembic
119
+
120
+ [handlers]
121
+ keys = console
122
+
123
+ [formatters]
124
+ keys = generic
125
+
126
+ [logger_root]
127
+ level = WARNING
128
+ handlers = console
129
+ qualname =
130
+
131
+ [logger_sqlalchemy]
132
+ level = WARNING
133
+ handlers =
134
+ qualname = sqlalchemy.engine
135
+
136
+ [logger_alembic]
137
+ level = INFO
138
+ handlers =
139
+ qualname = alembic
140
+
141
+ [handler_console]
142
+ class = StreamHandler
143
+ args = (sys.stderr,)
144
+ level = NOTSET
145
+ formatter = generic
146
+
147
+ [formatter_generic]
148
+ format = %(levelname)-5.5s [%(name)s] %(message)s
149
+ datefmt = %H:%M:%S
alembic/README ADDED
@@ -0,0 +1 @@
 
 
1
+ Generic single-database configuration with an async dbapi.
alembic/__pycache__/env.cpython-312.pyc ADDED
Binary file (3.62 kB). View file
 
alembic/env.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ from logging.config import fileConfig
3
+
4
+ from sqlalchemy import pool
5
+ from sqlalchemy.engine import Connection
6
+ from sqlalchemy.ext.asyncio import async_engine_from_config
7
+
8
+ from alembic import context
9
+
10
+ from app.core.config import settings
11
+ from app.core.db import Base
12
+ # Import all models so Alembic can discover them
13
+ import app.models.resume
14
+ import app.models.analysis
15
+
16
+ # this is the Alembic Config object, which provides
17
+ # access to the values within the .ini file in use.
18
+ config = context.config
19
+
20
+ config.set_main_option("sqlalchemy.url", settings.database_url)
21
+
22
+ # Interpret the config file for Python logging.
23
+ # This line sets up loggers basically.
24
+ if config.config_file_name is not None:
25
+ fileConfig(config.config_file_name)
26
+
27
+ # add your model's MetaData object here
28
+ # for 'autogenerate' support
29
+ target_metadata = Base.metadata
30
+
31
+ # other values from the config, defined by the needs of env.py,
32
+ # can be acquired:
33
+ # my_important_option = config.get_main_option("my_important_option")
34
+ # ... etc.
35
+
36
+
37
+ def run_migrations_offline() -> None:
38
+ """Run migrations in 'offline' mode.
39
+
40
+ This configures the context with just a URL
41
+ and not an Engine, though an Engine is acceptable
42
+ here as well. By skipping the Engine creation
43
+ we don't even need a DBAPI to be available.
44
+
45
+ Calls to context.execute() here emit the given string to the
46
+ script output.
47
+
48
+ """
49
+ url = config.get_main_option("sqlalchemy.url")
50
+ context.configure(
51
+ url=url,
52
+ target_metadata=target_metadata,
53
+ literal_binds=True,
54
+ dialect_opts={"paramstyle": "named"},
55
+ )
56
+
57
+ with context.begin_transaction():
58
+ context.run_migrations()
59
+
60
+
61
+ def do_run_migrations(connection: Connection) -> None:
62
+ context.configure(connection=connection, target_metadata=target_metadata)
63
+
64
+ with context.begin_transaction():
65
+ context.run_migrations()
66
+
67
+
68
+ async def run_async_migrations() -> None:
69
+ """In this scenario we need to create an Engine
70
+ and associate a connection with the context.
71
+
72
+ """
73
+
74
+ connectable = async_engine_from_config(
75
+ config.get_section(config.config_ini_section, {}),
76
+ prefix="sqlalchemy.",
77
+ poolclass=pool.NullPool,
78
+ )
79
+
80
+ async with connectable.connect() as connection:
81
+ await connection.run_sync(do_run_migrations)
82
+
83
+ await connectable.dispose()
84
+
85
+
86
+ def run_migrations_online() -> None:
87
+ """Run migrations in 'online' mode."""
88
+
89
+ asyncio.run(run_async_migrations())
90
+
91
+
92
+ if context.is_offline_mode():
93
+ run_migrations_offline()
94
+ else:
95
+ run_migrations_online()
alembic/script.py.mako ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """${message}
2
+
3
+ Revision ID: ${up_revision}
4
+ Revises: ${down_revision | comma,n}
5
+ Create Date: ${create_date}
6
+
7
+ """
8
+ from typing import Sequence, Union
9
+
10
+ from alembic import op
11
+ import sqlalchemy as sa
12
+ ${imports if imports else ""}
13
+
14
+ # revision identifiers, used by Alembic.
15
+ revision: str = ${repr(up_revision)}
16
+ down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
17
+ branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
18
+ depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
19
+
20
+
21
+ def upgrade() -> None:
22
+ """Upgrade schema."""
23
+ ${upgrades if upgrades else "pass"}
24
+
25
+
26
+ def downgrade() -> None:
27
+ """Downgrade schema."""
28
+ ${downgrades if downgrades else "pass"}
alembic/versions/8606f9c05795_initial_tables.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Initial tables
2
+
3
+ Revision ID: 8606f9c05795
4
+ Revises:
5
+ Create Date: 2026-07-12 19:11:23.136578
6
+
7
+ """
8
+ from typing import Sequence, Union
9
+
10
+ from alembic import op
11
+ import sqlalchemy as sa
12
+
13
+
14
+ # revision identifiers, used by Alembic.
15
+ revision: str = '8606f9c05795'
16
+ down_revision: Union[str, Sequence[str], None] = None
17
+ branch_labels: Union[str, Sequence[str], None] = None
18
+ depends_on: Union[str, Sequence[str], None] = None
19
+
20
+
21
+ def upgrade() -> None:
22
+ """Upgrade schema."""
23
+ # ### commands auto generated by Alembic - please adjust! ###
24
+ op.create_table('resumes',
25
+ sa.Column('id', sa.String(), nullable=False),
26
+ sa.Column('session_id', sa.String(), nullable=False),
27
+ sa.Column('file_name', sa.String(), nullable=False),
28
+ sa.Column('file_path', sa.String(), nullable=False),
29
+ sa.Column('extracted_text', sa.Text(), nullable=False),
30
+ sa.Column('pages', sa.Integer(), nullable=False),
31
+ sa.Column('uploaded_at', sa.DateTime(timezone=True), nullable=False),
32
+ sa.PrimaryKeyConstraint('id')
33
+ )
34
+ op.create_index(op.f('ix_resumes_session_id'), 'resumes', ['session_id'], unique=False)
35
+ op.create_table('analyses',
36
+ sa.Column('id', sa.String(), nullable=False),
37
+ sa.Column('resume_id', sa.String(), nullable=False),
38
+ sa.Column('session_id', sa.String(), nullable=False),
39
+ sa.Column('job_description', sa.Text(), nullable=False),
40
+ sa.Column('target_role', sa.String(), nullable=False),
41
+ sa.Column('result', sa.JSON(), nullable=False),
42
+ sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
43
+ sa.ForeignKeyConstraint(['resume_id'], ['resumes.id'], ondelete='CASCADE'),
44
+ sa.PrimaryKeyConstraint('id')
45
+ )
46
+ op.create_index(op.f('ix_analyses_resume_id'), 'analyses', ['resume_id'], unique=False)
47
+ op.create_index(op.f('ix_analyses_session_id'), 'analyses', ['session_id'], unique=False)
48
+ # ### end Alembic commands ###
49
+
50
+
51
+ def downgrade() -> None:
52
+ """Downgrade schema."""
53
+ # ### commands auto generated by Alembic - please adjust! ###
54
+ op.drop_index(op.f('ix_analyses_session_id'), table_name='analyses')
55
+ op.drop_index(op.f('ix_analyses_resume_id'), table_name='analyses')
56
+ op.drop_table('analyses')
57
+ op.drop_index(op.f('ix_resumes_session_id'), table_name='resumes')
58
+ op.drop_table('resumes')
59
+ # ### end Alembic commands ###
alembic/versions/__pycache__/8606f9c05795_initial_tables.cpython-312.pyc ADDED
Binary file (4.06 kB). View file
 
app/__init__.py ADDED
File without changes
app/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (171 Bytes). View file
 
app/__pycache__/main.cpython-312.pyc ADDED
Binary file (1.85 kB). View file
 
app/api/__init__.py ADDED
File without changes
app/api/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (175 Bytes). View file
 
app/api/__pycache__/deps.cpython-312.pyc ADDED
Binary file (766 Bytes). View file
 
app/api/deps.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import uuid
2
+ from typing import Annotated
3
+
4
+ from fastapi import Header
5
+
6
+
7
+ async def get_session_id(x_session_id: Annotated[str | None, Header()] = None) -> str:
8
+ """
9
+ Return the session ID from the X-Session-Id header.
10
+ If the header is absent, generate a fresh UUID so every resource
11
+ is still scoped to *some* session.
12
+ """
13
+ return x_session_id or str(uuid.uuid4())
app/api/routes/__init__.py ADDED
File without changes
app/api/routes/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (182 Bytes). View file
 
app/api/routes/__pycache__/analysis.cpython-312.pyc ADDED
Binary file (9.94 kB). View file
 
app/api/routes/__pycache__/health.cpython-312.pyc ADDED
Binary file (853 Bytes). View file
 
app/api/routes/__pycache__/resume.cpython-312.pyc ADDED
Binary file (5.39 kB). View file
 
app/api/routes/analysis.py ADDED
@@ -0,0 +1,252 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Annotated
4
+
5
+ from fastapi import APIRouter, Depends, HTTPException, status
6
+
7
+ from app.api.deps import get_session_id
8
+ from app.core.logging import get_logger
9
+ from app.models.analysis import AnalysisRecord
10
+ from app.models.resume import ResumeRecord
11
+ from sqlalchemy.ext.asyncio import AsyncSession
12
+ from app.core.db import get_db
13
+ from app.schemas.analysis import (
14
+ AnalysisDetail,
15
+ AnalyzeRequest,
16
+ AnalyzeResponse,
17
+ BulletRewrite,
18
+ ComponentBreakdown,
19
+ Recommendation,
20
+ ScoreWeights,
21
+ SectionNote,
22
+ )
23
+ from app.services.ats_scoring import ATSResult, run_ats_scoring
24
+
25
+ logger = get_logger(__name__)
26
+ router = APIRouter()
27
+
28
+
29
+ # ── POST /api/analyze ─────────────────────────────────────────────────────
30
+
31
+ @router.post(
32
+ "/analyze",
33
+ response_model=AnalyzeResponse,
34
+ tags=["analysis"],
35
+ summary="Analyse resume against a job description",
36
+ )
37
+ async def analyze(
38
+ body: AnalyzeRequest,
39
+ session_id: Annotated[str, Depends(get_session_id)],
40
+ db: Annotated[AsyncSession, Depends(get_db)],
41
+ ) -> AnalyzeResponse:
42
+ """
43
+ Run the full ATS-style analysis pipeline:
44
+ 1. Fetch extracted resume text.
45
+ 2. Parse sections.
46
+ 3. Extract and compare keywords.
47
+ 4. Compute semantic similarity.
48
+ 5. Compute weighted ATS score.
49
+ 6. Return structured result.
50
+
51
+ Bullet rewrites are empty in Phase 2 β€” LLM populates them in Phase 4.
52
+ """
53
+ # ── Fetch resume ──────────────────────────────────────────────────────
54
+ record = await db.get(ResumeRecord, body.resume_id)
55
+ if record is None or record.session_id != session_id:
56
+ raise HTTPException(
57
+ status_code=status.HTTP_404_NOT_FOUND,
58
+ detail="Resume not found in this session.",
59
+ )
60
+
61
+ logger.info(
62
+ "analyze: resume_id=%s session=%s jd_chars=%d role=%r",
63
+ body.resume_id, session_id, len(body.job_description), body.target_role,
64
+ )
65
+
66
+ # ── Run scoring pipeline ──────────────────────────────────────────────
67
+ try:
68
+ result: ATSResult = run_ats_scoring(
69
+ resume_text=record.extracted_text,
70
+ jd_text=body.job_description,
71
+ target_role=body.target_role,
72
+ )
73
+ except Exception as exc:
74
+ logger.exception("Scoring pipeline failed: %s", exc)
75
+ raise HTTPException(
76
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
77
+ detail="Analysis failed β€” please retry.",
78
+ ) from exc
79
+
80
+ # ── Build response ────────────────────────────────────────────────────
81
+ response = _build_response(result, record.file_name)
82
+
83
+ # ── LLM Bullet Rewrites (Phase 4) ─────────────────────────────────────
84
+ import asyncio
85
+ from app.services.llm_service import rewrite_weak_bullets
86
+
87
+ try:
88
+ # Run sync Gemini call in threadpool to avoid blocking ASGI loop
89
+ rewrites_raw = await asyncio.to_thread(
90
+ rewrite_weak_bullets,
91
+ bullets=result.parsed.experience_bullets,
92
+ job_description=body.job_description,
93
+ target_role=body.target_role,
94
+ max_rewrites=3
95
+ )
96
+ # Map to Pydantic objects
97
+ response.bullet_rewrites = [BulletRewrite(**rw) for rw in rewrites_raw]
98
+ except Exception as exc:
99
+ logger.warning("LLM rewrite failed: %s", exc)
100
+ # Non-fatal, just leave rewrites empty
101
+ pass
102
+
103
+ # ── Persist ───────────────────────────────────────────────────────────
104
+ ar = AnalysisRecord(
105
+ resume_id=body.resume_id,
106
+ session_id=session_id,
107
+ job_description=body.job_description,
108
+ target_role=body.target_role,
109
+ result=response.model_dump(),
110
+ )
111
+ db.add(ar)
112
+ await db.commit()
113
+ await db.refresh(ar)
114
+ response.analysis_id = ar.id
115
+
116
+ logger.info(
117
+ "analyze: analysis_id=%s overall=%d latency_ms=%d",
118
+ ar.id, result.overall_score, result.latency_ms,
119
+ )
120
+
121
+ return response
122
+
123
+
124
+ # ── GET /api/analysis/{analysis_id} ──────────────────────────────────────
125
+
126
+ @router.get(
127
+ "/analysis/{analysis_id}",
128
+ response_model=AnalysisDetail,
129
+ tags=["analysis"],
130
+ summary="Get past analysis result",
131
+ )
132
+ async def get_analysis(
133
+ analysis_id: str,
134
+ session_id: Annotated[str, Depends(get_session_id)],
135
+ db: Annotated[AsyncSession, Depends(get_db)],
136
+ ) -> AnalysisDetail:
137
+ """Fetch an analysis result generated in this session."""
138
+ ar = await db.get(AnalysisRecord, analysis_id)
139
+ if ar is None or ar.session_id != session_id:
140
+ raise HTTPException(
141
+ status_code=status.HTTP_404_NOT_FOUND,
142
+ detail="Analysis not found in this session.",
143
+ )
144
+ return AnalysisDetail(
145
+ analysis_id=ar.id,
146
+ resume_id=ar.resume_id,
147
+ created_at=ar.created_at,
148
+ result=AnalyzeResponse(**ar.result),
149
+ )
150
+
151
+
152
+ # ── Private helpers ───────────────────────────────────────────────────────
153
+
154
+ def _role_fit(score: int) -> tuple[str, str]:
155
+ if score >= 80:
156
+ return (
157
+ "Strong fit",
158
+ "Resume aligns well with the JD across skills and experience. "
159
+ "Fix the missing keywords and quantify a few bullets to push into shortlist range.",
160
+ )
161
+ if score >= 65:
162
+ return (
163
+ "Moderate fit",
164
+ "Core skills overlap but presentation and specificity are weak. "
165
+ "Prioritise the top-3 missing keywords and add measurable outcomes.",
166
+ )
167
+ return (
168
+ "Needs work",
169
+ "Significant gaps in skills or presentation. "
170
+ "Address missing keywords first, then rewrite bullets around measurable outcomes.",
171
+ )
172
+
173
+
174
+ def _recommendations(result: ATSResult, file_name: str) -> list[Recommendation]:
175
+ recs: list[Recommendation] = []
176
+ comp = result.components
177
+ kw = result.keywords
178
+
179
+ if kw.missing:
180
+ top3 = ", ".join(kw.missing[:3])
181
+ recs.append(Recommendation(
182
+ priority="high", category="keywords",
183
+ message=f"Add missing keywords: {top3}. These appear in the JD but not in your resume.",
184
+ ))
185
+
186
+ if comp.experience_alignment < 60:
187
+ recs.append(Recommendation(
188
+ priority="high", category="experience",
189
+ message="Quantify at least 5 bullets with numbers, percentages, or impact metrics.",
190
+ ))
191
+
192
+ if comp.semantic_similarity < 55:
193
+ recs.append(Recommendation(
194
+ priority="high", category="alignment",
195
+ message="Rephrase your summary and experience to mirror the language and priorities in the JD.",
196
+ ))
197
+
198
+ if not result.parsed.summary:
199
+ recs.append(Recommendation(
200
+ priority="medium", category="structure",
201
+ message="Add a 2-line summary at the top tailored to this specific role.",
202
+ ))
203
+
204
+ if not result.parsed.certifications_raw:
205
+ recs.append(Recommendation(
206
+ priority="low", category="certifications",
207
+ message="Even one relevant certification improves ATS signal and fills a structural gap.",
208
+ ))
209
+
210
+ if kw.weak:
211
+ recs.append(Recommendation(
212
+ priority="medium", category="keywords",
213
+ message=f"Strengthen underused keywords: {', '.join(kw.weak[:3])}. Mention them in context.",
214
+ ))
215
+
216
+ return recs[:6] # cap at 6 recommendations
217
+
218
+
219
+ def _build_response(result: ATSResult, file_name: str) -> AnalyzeResponse:
220
+ verdict, summary = _role_fit(result.overall_score)
221
+ comp = result.components
222
+
223
+ return AnalyzeResponse(
224
+ analysis_id="", # filled in after persist
225
+ overall_score=result.overall_score,
226
+ weights=ScoreWeights(),
227
+ components=ComponentBreakdown(
228
+ keyword_coverage=comp.keyword_coverage,
229
+ semantic_similarity=comp.semantic_similarity,
230
+ skills_overlap=comp.skills_overlap,
231
+ experience_alignment=comp.experience_alignment,
232
+ resume_quality=comp.resume_quality,
233
+ ),
234
+ matched_keywords=result.keywords.matched,
235
+ missing_keywords=result.keywords.missing,
236
+ weak_keywords=result.keywords.weak,
237
+ section_notes=[
238
+ SectionNote(
239
+ section=sn.section,
240
+ status=sn.status,
241
+ note=sn.note,
242
+ score=sn.score,
243
+ )
244
+ for sn in result.section_notes
245
+ ],
246
+ recommendations=_recommendations(result, file_name),
247
+ bullet_rewrites=[], # Phase 4 (LLM)
248
+ role_fit_verdict=verdict,
249
+ role_fit_summary=summary,
250
+ parsed_sections=result.parsed.to_dict(),
251
+ latency_ms=result.latency_ms,
252
+ )
app/api/routes/health.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter
2
+ from pydantic import BaseModel
3
+
4
+ router = APIRouter()
5
+
6
+
7
+ class HealthResponse(BaseModel):
8
+ status: str
9
+
10
+
11
+ @router.get("/health", response_model=HealthResponse, tags=["meta"])
12
+ async def health() -> HealthResponse:
13
+ """Basic liveness probe."""
14
+ return HealthResponse(status="ok")
app/api/routes/resume.py ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import time
2
+ from pathlib import Path
3
+ from typing import Annotated
4
+
5
+ from fastapi import APIRouter, Depends, HTTPException, UploadFile, status
6
+
7
+ from app.api.deps import get_session_id
8
+ from app.core.config import settings
9
+ from app.core.logging import get_logger
10
+ from app.models.resume import ResumeRecord
11
+ from app.schemas.resume import ResumeDetail, ResumeUploadResponse
12
+ from app.services.pdf_extractor import ExtractionError, extract_text
13
+
14
+ logger = get_logger(__name__)
15
+ router = APIRouter()
16
+
17
+ _ALLOWED_CONTENT_TYPES = {"application/pdf"}
18
+
19
+
20
+ from sqlalchemy.ext.asyncio import AsyncSession
21
+ from app.core.db import get_db
22
+
23
+ @router.post(
24
+ "/resumes/upload",
25
+ response_model=ResumeUploadResponse,
26
+ status_code=status.HTTP_201_CREATED,
27
+ tags=["resumes"],
28
+ summary="Upload a resume PDF",
29
+ )
30
+ async def upload_resume(
31
+ file: UploadFile,
32
+ session_id: Annotated[str, Depends(get_session_id)],
33
+ db: Annotated[AsyncSession, Depends(get_db)],
34
+ ) -> ResumeUploadResponse:
35
+ """
36
+ Accept a PDF resume, extract its text, persist the record, and return
37
+ an ID for subsequent analysis requests.
38
+
39
+ Error codes
40
+ -----------
41
+ 400 : wrong file type or file too large
42
+ 422 : PDF is unreadable or appears to be scanned / image-only
43
+ """
44
+ t0 = time.perf_counter()
45
+
46
+ # ── 1. Validate content type ──────────────────────────────────────────
47
+ if file.content_type not in _ALLOWED_CONTENT_TYPES:
48
+ raise HTTPException(
49
+ status_code=status.HTTP_400_BAD_REQUEST,
50
+ detail="Please upload a PDF resume.",
51
+ )
52
+
53
+ # ── 2. Read and validate size ─────────────────────────────────────────
54
+ data = await file.read()
55
+ if len(data) > settings.max_upload_bytes:
56
+ raise HTTPException(
57
+ status_code=status.HTTP_400_BAD_REQUEST,
58
+ detail=f"Max size is {settings.max_upload_mb} MB.",
59
+ )
60
+
61
+ # ── 3. Persist to disk under session-scoped directory ─────────────────
62
+ session_dir: Path = settings.upload_dir / session_id
63
+ session_dir.mkdir(parents=True, exist_ok=True)
64
+
65
+ safe_name = Path(file.filename or "resume.pdf").name # strip path traversal
66
+ dest = session_dir / safe_name
67
+ dest.write_bytes(data)
68
+ logger.info("Saved %s (%d bytes) for session %s", safe_name, len(data), session_id)
69
+
70
+ # ── 4. Extract text ───────────────────────────────────────────────────
71
+ try:
72
+ extracted_text, pages = extract_text(dest)
73
+ except ExtractionError as exc:
74
+ # Remove the file so the client can retry with a better PDF
75
+ dest.unlink(missing_ok=True)
76
+ raise HTTPException(
77
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
78
+ detail=str(exc),
79
+ ) from exc
80
+
81
+ # ── 5. Store record ───────────────────────────────────────────────────
82
+ record = ResumeRecord(
83
+ session_id=session_id,
84
+ file_name=safe_name,
85
+ file_path=str(dest),
86
+ extracted_text=extracted_text,
87
+ pages=pages,
88
+ )
89
+ db.add(record)
90
+ await db.commit()
91
+ await db.refresh(record)
92
+
93
+ elapsed_ms = round((time.perf_counter() - t0) * 1000)
94
+ logger.info(
95
+ "upload_resume: resume_id=%s pages=%d chars=%d elapsed_ms=%d",
96
+ record.id,
97
+ pages,
98
+ len(extracted_text),
99
+ elapsed_ms,
100
+ )
101
+
102
+ return ResumeUploadResponse(
103
+ resume_id=record.id,
104
+ file_name=safe_name,
105
+ pages_estimate=pages,
106
+ extraction_status="success",
107
+ )
108
+
109
+
110
+ @router.get(
111
+ "/resumes/{resume_id}",
112
+ response_model=ResumeDetail,
113
+ tags=["resumes"],
114
+ summary="Get resume metadata",
115
+ )
116
+ async def get_resume(
117
+ resume_id: str,
118
+ session_id: Annotated[str, Depends(get_session_id)],
119
+ db: Annotated[AsyncSession, Depends(get_db)],
120
+ ) -> ResumeDetail:
121
+ """Return metadata for a previously uploaded resume (session-scoped)."""
122
+ record = await db.get(ResumeRecord, resume_id)
123
+ if record is None or record.session_id != session_id:
124
+ raise HTTPException(
125
+ status_code=status.HTTP_404_NOT_FOUND,
126
+ detail="Resume not found in this session.",
127
+ )
128
+ return ResumeDetail(
129
+ resume_id=record.id,
130
+ file_name=record.file_name,
131
+ pages=record.pages,
132
+ extraction_status="success",
133
+ uploaded_at=record.uploaded_at,
134
+ char_count=len(record.extracted_text),
135
+ )
app/core/__init__.py ADDED
File without changes
app/core/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (176 Bytes). View file
 
app/core/__pycache__/config.cpython-312.pyc ADDED
Binary file (1.28 kB). View file
 
app/core/__pycache__/db.cpython-312.pyc ADDED
Binary file (1.28 kB). View file
 
app/core/__pycache__/logging.cpython-312.pyc ADDED
Binary file (1.43 kB). View file
 
app/core/config.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+ from pydantic_settings import BaseSettings, SettingsConfigDict
3
+
4
+
5
+ class Settings(BaseSettings):
6
+ model_config = SettingsConfigDict(
7
+ env_file=".env",
8
+ env_file_encoding="utf-8",
9
+ extra="ignore",
10
+ )
11
+
12
+ # Upload limits
13
+ max_upload_mb: int = 5
14
+
15
+ # Where uploaded files land on disk
16
+ upload_dir: Path = Path("./uploads")
17
+
18
+ # LLM (Phase 4)
19
+ gemini_api_key: str = ""
20
+
21
+ # Database (Phase 2+)
22
+ database_url: str = "sqlite+aiosqlite:///./resume.db"
23
+
24
+ @property
25
+ def max_upload_bytes(self) -> int:
26
+ return self.max_upload_mb * 1024 * 1024
27
+
28
+
29
+ settings = Settings()
30
+
31
+ # Make sure the upload directory exists at startup
32
+ settings.upload_dir.mkdir(parents=True, exist_ok=True)
app/core/db.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from collections.abc import AsyncGenerator
2
+
3
+ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
4
+ from sqlalchemy.orm import declarative_base
5
+
6
+ from app.core.config import settings
7
+
8
+ # Engine setup
9
+ engine = create_async_engine(
10
+ settings.database_url,
11
+ echo=False,
12
+ future=True,
13
+ connect_args={"check_same_thread": False} if settings.database_url.startswith("sqlite") else {}
14
+ )
15
+
16
+ # Session factory
17
+ async_session_maker = async_sessionmaker(
18
+ engine, class_=AsyncSession, expire_on_commit=False, autoflush=False
19
+ )
20
+
21
+ Base = declarative_base()
22
+
23
+
24
+ async def get_db() -> AsyncGenerator[AsyncSession, None]:
25
+ """Dependency for getting async database sessions."""
26
+ async with async_session_maker() as session:
27
+ yield session
app/core/logging.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ import sys
3
+
4
+
5
+ def setup_logging(level: int = logging.INFO) -> None:
6
+ """Configure structured application logging."""
7
+ fmt = "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s"
8
+ handler = logging.StreamHandler(sys.stdout)
9
+ handler.setFormatter(logging.Formatter(fmt, datefmt="%Y-%m-%dT%H:%M:%S"))
10
+
11
+ root = logging.getLogger()
12
+ root.setLevel(level)
13
+ # Avoid duplicate handlers if called more than once (e.g. in tests)
14
+ if not root.handlers:
15
+ root.addHandler(handler)
16
+
17
+ # Quieten noisy libraries
18
+ logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
19
+
20
+
21
+ def get_logger(name: str) -> logging.Logger:
22
+ return logging.getLogger(name)
app/main.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from contextlib import asynccontextmanager
2
+ from typing import AsyncIterator
3
+
4
+ from fastapi import FastAPI
5
+ from fastapi.middleware.cors import CORSMiddleware
6
+
7
+ from app.api.routes import health, resume, analysis
8
+ from app.core.config import settings
9
+ from app.core.logging import setup_logging
10
+
11
+ setup_logging()
12
+
13
+
14
+ @asynccontextmanager
15
+ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
16
+ """Startup / shutdown hook."""
17
+ # Ensure upload directory exists (config.py also does this at import time,
18
+ # but this is the canonical place for startup side-effects).
19
+ settings.upload_dir.mkdir(parents=True, exist_ok=True)
20
+ yield
21
+
22
+
23
+ app = FastAPI(
24
+ title="AI Resume Reviewer API",
25
+ description=(
26
+ "Hybrid ATS-style resume analysis: rules + embeddings + LLM. "
27
+ "Phase 0/1 β€” ingestion and extraction only."
28
+ ),
29
+ version="0.1.0",
30
+ lifespan=lifespan,
31
+ )
32
+
33
+ # ── CORS ──────────────────────────────────────────────────────────────────────
34
+ # Allow the Vite dev server (8081) and any localhost variant during development.
35
+ # In production, lock this to your actual frontend domain.
36
+ app.add_middleware(
37
+ CORSMiddleware,
38
+ allow_origins=[
39
+ "http://localhost:8080",
40
+ "http://localhost:8081",
41
+ "http://localhost:3000",
42
+ "http://localhost:5173",
43
+ "http://127.0.0.1:8080",
44
+ "http://127.0.0.1:8081",
45
+ "http://127.0.0.1:5173",
46
+ ],
47
+ allow_credentials=True,
48
+ allow_methods=["*"],
49
+ allow_headers=["*"],
50
+ expose_headers=["X-Session-Id"],
51
+ )
52
+
53
+ # ── Routers ───────────────────────────────────────────────────────────────────
54
+ app.include_router(health.router, prefix="/api")
55
+ app.include_router(resume.router, prefix="/api")
56
+ app.include_router(analysis.router, prefix="/api")
app/models/__init__.py ADDED
File without changes
app/models/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (178 Bytes). View file
 
app/models/__pycache__/analysis.cpython-312.pyc ADDED
Binary file (1.9 kB). View file
 
app/models/__pycache__/resume.cpython-312.pyc ADDED
Binary file (1.56 kB). View file
 
app/models/analysis.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ SQLAlchemy analysis model (Phase 5).
3
+ """
4
+ import uuid
5
+ from datetime import datetime, timezone
6
+
7
+ from sqlalchemy import Column, DateTime, ForeignKey, String, Text
8
+ from sqlalchemy.dialects.sqlite import JSON as SQLiteJSON
9
+ from sqlalchemy.types import JSON
10
+
11
+ from app.core.db import Base, engine
12
+
13
+
14
+ def _new_id() -> str:
15
+ return f"an_{uuid.uuid4().hex[:12]}"
16
+
17
+
18
+ # Handle JSON fallback for SQLite vs Postgres
19
+ JsonType = SQLiteJSON if engine.url.drivername == "sqlite" else JSON
20
+
21
+
22
+ class AnalysisRecord(Base):
23
+ __tablename__ = "analyses"
24
+
25
+ id = Column(String, primary_key=True, default=_new_id)
26
+ resume_id = Column(String, ForeignKey("resumes.id", ondelete="CASCADE"), nullable=False, index=True)
27
+ session_id = Column(String, index=True, nullable=False)
28
+ job_description = Column(Text, nullable=False)
29
+ target_role = Column(String, nullable=False)
30
+ result = Column(JsonType, nullable=False)
31
+ created_at = Column(
32
+ DateTime(timezone=True),
33
+ default=lambda: datetime.now(timezone.utc),
34
+ nullable=False
35
+ )
app/models/resume.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ SQLAlchemy resume model (Phase 5).
3
+ """
4
+ import uuid
5
+ from datetime import datetime, timezone
6
+
7
+ from sqlalchemy import Column, DateTime, Integer, String, Text
8
+
9
+ from app.core.db import Base
10
+
11
+
12
+ def _new_id() -> str:
13
+ return f"res_{uuid.uuid4().hex[:12]}"
14
+
15
+
16
+ class ResumeRecord(Base):
17
+ __tablename__ = "resumes"
18
+
19
+ id = Column(String, primary_key=True, default=_new_id)
20
+ session_id = Column(String, index=True, nullable=False)
21
+ file_name = Column(String, nullable=False)
22
+ file_path = Column(String, nullable=False)
23
+ extracted_text = Column(Text, nullable=False)
24
+ pages = Column(Integer, nullable=False)
25
+ uploaded_at = Column(
26
+ DateTime(timezone=True),
27
+ default=lambda: datetime.now(timezone.utc),
28
+ nullable=False
29
+ )
app/schemas/__init__.py ADDED
File without changes
app/schemas/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (179 Bytes). View file
 
app/schemas/__pycache__/analysis.cpython-312.pyc ADDED
Binary file (3.35 kB). View file
 
app/schemas/__pycache__/resume.cpython-312.pyc ADDED
Binary file (1.01 kB). View file
 
app/schemas/analysis.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+ from typing import Literal
5
+
6
+ from pydantic import BaseModel, Field
7
+
8
+
9
+ # ── Request ───────────────────────────────────────────────────────────────
10
+
11
+ class AnalyzeRequest(BaseModel):
12
+ resume_id: str
13
+ job_description: str = Field(min_length=100, max_length=10_000)
14
+ target_role: str = ""
15
+
16
+
17
+ # ── Response sub-types ────────────────────────────────────────────────────
18
+
19
+ class ScoreWeights(BaseModel):
20
+ keyword_coverage: float = 0.30
21
+ semantic_similarity: float = 0.25
22
+ skills_overlap: float = 0.20
23
+ experience_alignment: float = 0.15
24
+ resume_quality: float = 0.10
25
+
26
+
27
+ class ComponentBreakdown(BaseModel):
28
+ keyword_coverage: int
29
+ semantic_similarity: int
30
+ skills_overlap: int
31
+ experience_alignment: int
32
+ resume_quality: int
33
+
34
+
35
+ class SectionNote(BaseModel):
36
+ section: str
37
+ status: Literal["strong", "ok", "weak", "missing"]
38
+ note: str
39
+ score: int
40
+
41
+
42
+ class Recommendation(BaseModel):
43
+ priority: Literal["high", "medium", "low"]
44
+ category: str
45
+ message: str
46
+
47
+
48
+ class BulletRewrite(BaseModel):
49
+ original: str
50
+ improved: str
51
+ reason: str
52
+
53
+
54
+ # ── Full response ─────────────────────────────────────────────────────────
55
+
56
+ class AnalyzeResponse(BaseModel):
57
+ analysis_id: str
58
+ overall_score: int
59
+ weights: ScoreWeights
60
+ components: ComponentBreakdown
61
+ matched_keywords: list[str]
62
+ missing_keywords: list[str]
63
+ weak_keywords: list[str]
64
+ section_notes: list[SectionNote]
65
+ recommendations: list[Recommendation]
66
+ bullet_rewrites: list[BulletRewrite] # populated in Phase 4 (LLM)
67
+ role_fit_verdict: str
68
+ role_fit_summary: str
69
+ parsed_sections: dict # skills list, bullets, etc.
70
+ disclaimer: str = (
71
+ "ATS-style compatibility score (heuristic) β€” not an official ATS result."
72
+ )
73
+ latency_ms: int
74
+
75
+
76
+ class AnalysisDetail(BaseModel):
77
+ analysis_id: str
78
+ resume_id: str
79
+ created_at: datetime
80
+ result: AnalyzeResponse
app/schemas/resume.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime
2
+ from typing import Literal
3
+
4
+ from pydantic import BaseModel
5
+
6
+
7
+ class ResumeUploadResponse(BaseModel):
8
+ resume_id: str
9
+ file_name: str
10
+ pages_estimate: int
11
+ extraction_status: Literal["success", "pending", "failed"]
12
+
13
+
14
+ class ResumeDetail(BaseModel):
15
+ resume_id: str
16
+ file_name: str
17
+ pages: int
18
+ extraction_status: Literal["success"]
19
+ uploaded_at: datetime
20
+ # Do NOT expose extracted_text in the response to keep payloads small.
21
+ # Phase 2 analysis endpoint will fetch the record server-side.
22
+ char_count: int
app/services/__init__.py ADDED
File without changes
app/services/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (180 Bytes). View file
 
app/services/__pycache__/ats_scoring.cpython-312.pyc ADDED
Binary file (10.1 kB). View file
 
app/services/__pycache__/embedding_service.cpython-312.pyc ADDED
Binary file (4.45 kB). View file
 
app/services/__pycache__/keyword_extractor.cpython-312.pyc ADDED
Binary file (5.47 kB). View file
 
app/services/__pycache__/llm_service.cpython-312.pyc ADDED
Binary file (4.48 kB). View file
 
app/services/__pycache__/pdf_extractor.cpython-312.pyc ADDED
Binary file (3.02 kB). View file