feat: start resume sessions in new flow

This commit is contained in:
Codex
2026-08-06 10:49:25 +08:00
parent 671a9b9419
commit 1c762fd092
18 changed files with 189 additions and 556 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Resume Agent backend
FastAPI service for the conversational resume builder: sessions, turns, resumes, imports,
FastAPI service for the conversational resume builder: sessions, turns, resumes,
and light-optimization state, persisted in SQLite (pilot) or PostgreSQL (production).
## Run locally
+27 -1
View File
@@ -17,6 +17,7 @@ from .fsm import (
gate_allowed,
initial_turn,
missing_fields,
new_resume_transition,
process_component_event,
required_fields,
)
@@ -867,7 +868,7 @@ class ResumeAgent(ResumeEditingMixin, OptimizationFlowMixin):
return self.timeline(session_id)
def timeline(self, session_id: str) -> TimelineResponse:
session = self._require_session(session_id)
session = self._advance_legacy_resume_source_stage(session_id)
turns = self.database.list_turns(session_id)
gate = self._gate(session)
resume = self._resume_view(session)
@@ -885,6 +886,31 @@ class ResumeAgent(ResumeEditingMixin, OptimizationFlowMixin):
trace_id=self._trace_id(),
)
def _advance_legacy_resume_source_stage(self, session_id: str) -> dict[str, Any]:
"""Move sessions parked on removed source/import screens into new-resume setup."""
session = self._require_session(session_id)
removed_stages = {Stage.RESUME_SOURCE_SELECT, Stage.RESUME_IMPORT_UPLOAD}
if Stage(session["stage"]) not in removed_stages or session.get("resume_id"):
return session
with self.database.transaction(immediate=True) as connection:
current = self.database.fetch_session(connection, session_id, for_update=True)
if current is None:
raise FSMError("session_not_found", "Session not found", status_code=404)
if Stage(current["stage"]) not in removed_stages or current.get("resume_id"):
return current
transition = new_resume_transition(current["profile"])
self.database.supersede_active_components(connection, session_id)
updated = self.database.update_session(
connection,
session_id,
stage=transition.stage,
profile=transition.profile,
)
self.database.insert_turn(connection, session_id=session_id, **transition.turn)
return updated
def component_event(
self, session_id: str, request: ComponentEventRequest
) -> ActionResponse:
+6 -1
View File
@@ -160,8 +160,13 @@ class Database:
self.insert_turn(connection, session_id=session_id, **initial_turn)
def fetch_session(
self, connection: sqlite3.Connection, session_id: str
self,
connection: sqlite3.Connection,
session_id: str,
*,
for_update: bool = False,
) -> dict[str, Any] | None:
del for_update
row = connection.execute(
"SELECT * FROM sessions WHERE id = ?", (session_id,)
).fetchone()
+28 -43
View File
@@ -213,6 +213,27 @@ def initial_turn() -> dict[str, Any]:
)
def new_resume_transition(profile: dict[str, Any]) -> Transition:
"""Enter the new-resume flow without presenting an import/manual choice."""
updated = deepcopy(profile)
updated["resume_source"] = "manual"
return Transition(
Stage.PHONE_SELECTION,
updated,
assistant_turn(
"请选择手机号来源。",
[
component(
"ResumePhoneSelector",
has_account_phone=bool(updated.get("account_phone")),
masked_phone=mask_phone(updated.get("account_phone")),
default_value=("account" if updated.get("account_phone") else None),
)
],
),
)
def required_fields(profile: dict[str, Any]) -> list[str]:
"""The initial Builder resume only requires verified setup information."""
return []
@@ -267,56 +288,20 @@ def process_component_event(
)
_expect(action, "accept_privacy")
updated["privacy_accepted"] = True
return Transition(
Stage.RESUME_SOURCE_SELECT,
updated,
assistant_turn(
"\u8bf7\u9009\u62e9\u5f00\u59cb\u65b9\u5f0f\u3002",
[
component(
"ChoiceChips",
eyebrow="\u5f00\u59cb\u521b\u5efa",
title="\u9009\u62e9\u521b\u5efa\u65b9\u5f0f",
description="\u5bfc\u5165\u4f1a\u5148\u63d0\u53d6\u6587\u6863\u5185\u5bb9\uff0c\u518d\u6620\u5c04\u4e3a\u53ef\u7f16\u8f91\u7684\u7b80\u5386\u7ed3\u6784\u3002",
options=[
{"value": "import", "label": "\u5bfc\u5165\u5df2\u6709\u7b80\u5386", "description": "\u652f\u6301 PDF \u6216 DOCX"},
{"value": "manual", "label": "\u521b\u5efa\u65b0\u7b80\u5386", "description": "\u4ece\u57fa\u7840\u4fe1\u606f\u548c\u7ecf\u5386\u5f00\u59cb\u586b\u5199"},
],
)
],
),
)
return new_resume_transition(updated)
if stage == Stage.RESUME_SOURCE_SELECT:
_expect(action, "select_choice")
source = str(payload.get("value") or "").strip()
if source == "import":
updated["resume_source"] = "import"
return Transition(
Stage.RESUME_IMPORT_UPLOAD,
updated,
assistant_turn("\u8bf7\u9009\u62e9\u9700\u8981\u5bfc\u5165\u7684 PDF \u6216 DOCX \u7b80\u5386\u3002", []),
raise FSMError(
"resume_import_disabled",
"Resume import is no longer available; create a new resume instead",
status_code=410,
)
if source == "manual":
updated["resume_source"] = "manual"
return Transition(
Stage.PHONE_SELECTION,
updated,
assistant_turn(
"\u8bf7\u9009\u62e9\u624b\u673a\u53f7\u6765\u6e90\u3002",
[
component(
"ResumePhoneSelector",
has_account_phone=bool(updated.get("account_phone")),
masked_phone=mask_phone(updated.get("account_phone")),
default_value=(
"account" if updated.get("account_phone") else None
),
)
],
),
)
raise FSMError("invalid_resume_source", "Select import or manual", status_code=422)
return new_resume_transition(updated)
raise FSMError("invalid_resume_source", "Create a new resume", status_code=422)
if stage == Stage.PHONE_SELECTION:
if action == "use_other_phone":
return Transition(
+1 -26
View File
@@ -21,7 +21,7 @@ from .database import Database
from .postgres_database import PostgresDatabase
from .fsm import FSMError
from .builder_sse import stream_builder_message
from .llm_services import OpenAICompatibleStructuredClient, build_services
from .llm_services import build_services
from .models import (
ActionResponse,
ComponentEventRequest,
@@ -34,9 +34,6 @@ from .models import (
TimelineResponse,
)
from .resume_routes import register_resume_routes
from .resume_import_routes import register_resume_import_routes
from .resume_import_service import ResumeImportService, RuleBasedResumeImportParser
from .import_parser import OpenAIResumeImportParser
from .rate_limit import SlidingWindowRateLimiter
from .services import (
EntryExpander,
@@ -77,7 +74,6 @@ def create_app(
cors_origins: list[str] | None = None,
settings: Settings | None = None,
openai_client: Any | None = None,
resume_import_service: ResumeImportService | None = None,
profile_summary_generator: ProfileSummaryGenerator | None = None,
offerpai_identity_provider: OfferPaiIdentityProvider | None = None,
offerpai_resume_provider: OfferPaiResumeProvider | None = None,
@@ -156,19 +152,6 @@ def create_app(
application.state.resume_agent = agent
application.state.offerpai_identity_provider = offerpai_identity_provider
application.state.offerpai_resume_provider = offerpai_resume_provider
if resume_import_service is None:
import_fallback = RuleBasedResumeImportParser()
import_parser = import_fallback
if resolved_settings.use_openai:
import_parser = OpenAIResumeImportParser(
completion=OpenAICompatibleStructuredClient(resolved_settings, openai_client),
fallback=import_fallback,
)
resume_import_service = ResumeImportService(
storage_root=Path(__file__).resolve().parent.parent / "data" / "resume_imports",
parser=import_parser,
)
application.state.resume_import_service = resume_import_service
application.state.light_opt_limiter = SlidingWindowRateLimiter(
limit=resolved_settings.light_opt_rate_limit,
window_seconds=resolved_settings.light_opt_rate_window_seconds,
@@ -366,14 +349,6 @@ def create_app(
API_PREFIX,
authorize_session_request=authorize_session_request,
)
register_resume_import_routes(
application,
agent,
application.state.resume_import_service,
API_PREFIX,
authorize_session_request=authorize_session_request,
)
@application.delete(
f"{API_PREFIX}/sessions/{{session_id}}",
status_code=status.HTTP_204_NO_CONTENT,
+11 -2
View File
@@ -46,9 +46,18 @@ class PostgresDatabase:
))
self.insert_turn(connection, session_id=session_id, **initial_turn)
def fetch_session(self, connection: Connection, session_id: str) -> dict[str, Any] | None:
def fetch_session(
self,
connection: Connection,
session_id: str,
*,
for_update: bool = False,
) -> dict[str, Any] | None:
sessions = self.tables["sessions"]
row = connection.execute(select(sessions).where(sessions.c.id == session_id)).mappings().first()
statement = select(sessions).where(sessions.c.id == session_id)
if for_update:
statement = statement.with_for_update()
row = connection.execute(statement).mappings().first()
if row is None:
return None
result = dict(row)
+82 -14
View File
@@ -1,8 +1,10 @@
from __future__ import annotations
from concurrent.futures import ThreadPoolExecutor
import json
from typing import Any
import pytest
from fastapi.testclient import TestClient
@@ -46,11 +48,7 @@ def start_manual_profile(
session_id = body["session_id"]
assert body["stage"] == "PRIVACY_CONSENT"
source = event(client, session_id, body, "accept", {"accepted": True})
assert source.status_code == 200
assert source.json()["stage"] == "RESUME_SOURCE_SELECT"
phone_selector = event(client, session_id, source.json(), "select", {"value": "manual"})
phone_selector = event(client, session_id, body, "accept", {"accepted": True})
assert phone_selector.status_code == 200
assert phone_selector.json()["stage"] == "PHONE_SELECTION"
@@ -109,22 +107,92 @@ def campus_ready(client: TestClient) -> tuple[str, dict[str, Any]]:
return start_manual_profile(client, job_type="campus")
def test_privacy_precedes_resume_source_selection(client: TestClient) -> None:
def test_privacy_continues_directly_into_new_resume_flow(client: TestClient) -> None:
created = client.post(f"{BASE}/sessions", json={})
session_id = created.json()["session_id"]
source = event(client, session_id, created.json(), "accept", {"accepted": True})
assert source.status_code == 200
body = source.json()
assert body["stage"] == "RESUME_SOURCE_SELECT"
options = active_component(body)["data"]["options"]
assert {option["value"] for option in options} == {"import", "manual"}
phone_selector = event(client, session_id, created.json(), "accept", {"accepted": True})
assert phone_selector.status_code == 200
body = phone_selector.json()
assert body["stage"] == "PHONE_SELECTION"
assert active_component(body)["data"]["component"] == "resume_phone_selector"
assert "导入已有简历" not in json.dumps(body, ensure_ascii=False)
with client.app.state.database.transaction() as connection:
session = client.app.state.database.fetch_session(connection, session_id)
assert session is not None
assert session["profile"]["resume_source"] == "manual"
@pytest.mark.parametrize("legacy_stage", ["RESUME_SOURCE_SELECT", "RESUME_IMPORT_UPLOAD"])
def test_legacy_source_stages_advance_to_new_resume_flow(
client: TestClient, legacy_stage: str
) -> None:
created = client.post(f"{BASE}/sessions", json={}).json()
session_id = created["session_id"]
accepted = event(client, session_id, created, "accept", {"accepted": True})
assert accepted.status_code == 200
with client.app.state.database.transaction(immediate=True) as connection:
session = client.app.state.database.fetch_session(connection, session_id)
assert session is not None
profile = dict(session["profile"])
profile["resume_source"] = "import"
client.app.state.database.update_session(
connection,
session_id,
stage=legacy_stage,
profile=profile,
)
migrated = client.get(f"{BASE}/sessions/{session_id}/timeline")
assert migrated.status_code == 200
body = migrated.json()
assert body["stage"] == "PHONE_SELECTION"
assert active_component(body)["data"]["component"] == "resume_phone_selector"
turn_count = len(body["turns"])
repeated = client.get(f"{BASE}/sessions/{session_id}/timeline").json()
assert len(repeated["turns"]) == turn_count
with client.app.state.database.transaction() as connection:
session = client.app.state.database.fetch_session(connection, session_id)
assert session is not None
assert session["profile"]["resume_source"] == "manual"
def test_concurrent_legacy_stage_refresh_advances_only_once(client: TestClient) -> None:
created = client.post(f"{BASE}/sessions", json={}).json()
session_id = created["session_id"]
accepted = event(client, session_id, created, "accept", {"accepted": True})
assert accepted.status_code == 200
baseline_turn_count = len(
client.get(f"{BASE}/sessions/{session_id}/timeline").json()["turns"]
)
with client.app.state.database.transaction(immediate=True) as connection:
session = client.app.state.database.fetch_session(connection, session_id)
assert session is not None
profile = dict(session["profile"])
profile["resume_source"] = "import"
client.app.state.database.update_session(
connection,
session_id,
stage="RESUME_SOURCE_SELECT",
profile=profile,
)
agent = client.app.state.resume_agent
with ThreadPoolExecutor(max_workers=4) as executor:
responses = list(executor.map(lambda _: agent.timeline(session_id), range(4)))
assert all(response.stage == "PHONE_SELECTION" for response in responses)
timeline = client.get(f"{BASE}/sessions/{session_id}/timeline").json()
assert len(timeline["turns"]) == baseline_turn_count + 1
def test_manual_phone_is_strict_and_retryable(client: TestClient) -> None:
created = client.post(f"{BASE}/sessions", json={}).json()
session_id = created["session_id"]
source = event(client, session_id, created, "accept", {"accepted": True}).json()
phone_selector = event(client, session_id, source, "select", {"value": "manual"}).json()
phone_selector = event(client, session_id, created, "accept", {"accepted": True}).json()
phone_input = event(client, session_id, phone_selector, "select", {"source": "other"}).json()
invalid = event(client, session_id, phone_input, "submit", {"phone": "+8613800138000"})
+2 -10
View File
@@ -146,7 +146,7 @@ def test_session_creation_authenticates_and_defaults_account_phone(tmp_path: Pat
assert TOKEN not in json.dumps(profile, ensure_ascii=False)
auth_headers = {"Authorization": f"Bearer {TOKEN}"}
source = event(
phone_selector = event(
client,
session_id,
created,
@@ -154,14 +154,6 @@ def test_session_creation_authenticates_and_defaults_account_phone(tmp_path: Pat
{"accepted": True},
headers=auth_headers,
).json()
phone_selector = event(
client,
session_id,
source,
"select",
{"value": "manual"},
headers=auth_headers,
).json()
data = active_component(phone_selector)["data"]
assert data["has_account_phone"] is True
assert data["masked_phone"] == "134****2384"
@@ -182,7 +174,7 @@ def test_session_creation_authenticates_and_defaults_account_phone(tmp_path: Pat
assert updated["profile"]["phone"] == "13421012384"
assert updated["profile"]["phone_source"] == "account"
assert provider.tokens == [TOKEN, TOKEN, TOKEN, TOKEN]
assert provider.tokens == [TOKEN, TOKEN, TOKEN]
def test_invalid_external_token_does_not_create_session(tmp_path: Path) -> None:
@@ -264,14 +264,6 @@ def _complete_initial_collection(
response = event(
client, session_id, body, "accept", {"accepted": True}, headers=headers
)
response = event(
client,
session_id,
response.json(),
"select",
{"value": "manual"},
headers=headers,
)
response = event(
client,
session_id,
+14 -160
View File
@@ -1,171 +1,25 @@
from __future__ import annotations
from io import BytesIO
from docx import Document
from fastapi.testclient import TestClient
from app.main import create_app
from app.resume_import_models import ParsedResumeDraft
from app.resume_import_service import ResumeImportService
from app.services import RuleBasedEntryExpander, RuleBasedExperienceExtractor, RuleBasedResumeRewriter
from app.settings import Settings
from test_api import BASE
BASE = "/ai-api/resume-agent"
def test_resume_import_routes_are_removed(client: TestClient) -> None:
created = client.post(f"{BASE}/sessions", json={}).json()
session_id = created["session_id"]
class FakeResumeImportParser:
def parse(self, *, text: str, source_name: str) -> ParsedResumeDraft:
return ParsedResumeDraft(
document={
"schema_version": 3,
"basics": {"name": "Imported Name", "phone": "13800138000", "email": "import@example.com"},
"target": {"job_type": "campus", "position": "Backend Engineer"},
"sections": [{"kind": "education", "heading": "Education", "items": [{"school": "Example University", "major": "Computer Science"}]}],
"skill_groups": [{"category": "Programming Languages", "skills": ["Python"]}],
},
field_reviews=[],
)
def docx_bytes(text: str) -> bytes:
document = Document()
document.add_paragraph(text)
buffer = BytesIO()
document.save(buffer)
return buffer.getvalue()
def client_for_import(tmp_path) -> TestClient:
application = create_app(
database_path=tmp_path / "test.db",
cors_origins=["http://localhost:5173"],
extractor=RuleBasedExperienceExtractor(),
rewriter=RuleBasedResumeRewriter(),
expander=RuleBasedEntryExpander(),
settings=Settings(llm_provider="rule", offerpai_auth_required=False),
resume_import_service=ResumeImportService(storage_root=tmp_path / "imports", parser=FakeResumeImportParser()),
)
return TestClient(application)
def _active_component(body: dict) -> dict:
turns = body.get("turns") or [body["turn"]]
for turn in reversed(turns):
for block in reversed(turn["blocks"]):
if block["type"] == "component" and block["lifecycle"] == "active":
return block
raise AssertionError("response has no active component")
def _event(client: TestClient, session_id: str, body: dict, name: str, payload: dict | None = None):
return client.post(
f"{BASE}/sessions/{session_id}/component-events",
json={"component_id": _active_component(body)["id"], "event": name, "payload": payload or {}},
)
def import_session(client: TestClient) -> str:
created = client.post(f"{BASE}/sessions", json={})
session_id = created.json()["session_id"]
source = _event(client, session_id, created.json(), "accept", {"accepted": True})
selected = _event(client, session_id, source.json(), "select", {"value": "import"})
assert selected.status_code == 200
assert selected.json()["stage"] == "RESUME_IMPORT_UPLOAD"
return session_id
def upload(client: TestClient, session_id: str, name: str = "resume.docx"):
return client.post(
upload = client.post(
f"{BASE}/sessions/{session_id}/resume-imports",
files={"file": (name, docx_bytes("Imported Name\nExample University"), "application/vnd.openxmlformats-officedocument.wordprocessingml.document")},
files={"file": ("resume.docx", b"unused")},
)
assert upload.status_code == 404
read = client.get(f"{BASE}/sessions/{session_id}/resume-imports/import_legacy")
assert read.status_code == 404
def test_import_requires_privacy_consent_and_import_selection(tmp_path) -> None:
with client_for_import(tmp_path) as client:
session_id = client.post(f"{BASE}/sessions", json={}).json()["session_id"]
before_consent = upload(client, session_id)
assert before_consent.status_code == 409
assert before_consent.json()["error"]["code"] == "privacy_consent_required"
timeline = client.get(f"{BASE}/sessions/{session_id}/timeline").json()
source = _event(client, session_id, timeline, "accept", {"accepted": True})
without_choice = upload(client, session_id)
assert without_choice.status_code == 409
assert without_choice.json()["error"]["code"] == "resume_import_not_selected"
manual = _event(client, session_id, source.json(), "select", {"value": "manual"})
assert manual.status_code == 200
after_manual_choice = upload(client, session_id)
assert after_manual_choice.status_code == 409
assert after_manual_choice.json()["error"]["code"] == "resume_import_not_selected"
def test_docx_import_is_reviewable_and_apply_updates_live_resume(tmp_path) -> None:
with client_for_import(tmp_path) as client:
session_id = import_session(client)
imported = upload(client, session_id)
assert imported.status_code == 201, imported.text
view = imported.json()
assert view["status"] == "awaiting_review"
applied = client.post(
f"{BASE}/sessions/{session_id}/resume-imports/{view['id']}/apply",
json={"expected_revision": 0},
)
assert applied.status_code == 200, applied.text
body = applied.json()
assert body["stage"] == "RESUME_ENRICHING"
content = body["resume"]["content"]
assert content["basics"]["name"] == "Imported Name"
assert content["basics"]["masked_phone"] == "138****8000"
assert "phone" not in content["basics"]
assert content["basics"]["email"] == "import@example.com"
assert content["sections"][0]["items"][0]["school"] == "Example University"
def test_import_is_blocked_after_an_imported_resume_is_applied(tmp_path) -> None:
with client_for_import(tmp_path) as client:
session_id = import_session(client)
first_upload = upload(client, session_id)
imported = first_upload.json()
applied = client.post(
f"{BASE}/sessions/{session_id}/resume-imports/{imported['id']}/apply",
json={"expected_revision": 0},
)
assert applied.status_code == 200
blocked = upload(client, session_id, "second.docx")
assert blocked.status_code == 409
assert blocked.json()["error"]["code"] == "resume_import_not_allowed"
def test_legacy_doc_and_scanned_pdf_return_stable_errors(tmp_path) -> None:
with client_for_import(tmp_path) as client:
session_id = import_session(client)
legacy = client.post(f"{BASE}/sessions/{session_id}/resume-imports", files={"file": ("resume.doc", b"not-a-docx", "application/msword")})
assert legacy.status_code == 422
assert legacy.json()["error"]["code"] == "legacy_doc_unsupported"
scanned = client.post(f"{BASE}/sessions/{session_id}/resume-imports", files={"file": ("scan.pdf", b"%PDF-1.7\n", "application/pdf")})
assert scanned.status_code == 422
assert scanned.json()["error"]["code"] == "ocr_required"
def test_imported_resume_continue_enriching_keeps_imported_content(tmp_path) -> None:
with client_for_import(tmp_path) as client:
session_id = import_session(client)
imported = upload(client, session_id)
applied = client.post(
f"{BASE}/sessions/{session_id}/resume-imports/{imported.json()['id']}/apply",
json={"expected_revision": 0},
)
assert applied.status_code == 200, applied.text
before = applied.json()["resume"]["content"]
continued = _event(client, session_id, applied.json(), "continue_enriching")
assert continued.status_code == 200, continued.text
body = continued.json()
assert body["stage"] == "RESUME_ENRICHING"
assert _active_component(body)["data"]["component"] == "custom_card_picker"
assert body["resume"]["content"] == before
apply = client.post(
f"{BASE}/sessions/{session_id}/resume-imports/import_legacy/apply",
json={"expected_revision": 0},
)
assert apply.status_code == 404
-1
View File
@@ -14,7 +14,6 @@ def _reach_job_type(client: TestClient) -> tuple[str, dict[str, Any]]:
body = client.post(f"{BASE}/sessions", json={}).json()
session_id = body["session_id"]
body = event(client, session_id, body, "accept", {"accepted": True}).json()
body = event(client, session_id, body, "select", {"value": "manual"}).json()
body = event(client, session_id, body, "select", {"source": "other"}).json()
body = event(client, session_id, body, "submit", {"phone": "13800138000"}).json()
body = event(