diff --git a/README.md b/README.md index 6b16a70..f012ac6 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,9 @@ # Resume Agent(Offerπ 简历生成 Agent) -对话式简历生成服务:引导用户分段填写经历,AI 将用户确认过的事实整理为优化稿,支持导入既有简历继续编辑。本仓库为 MVP 交付范围: +对话式简历生成服务:引导用户从新建流程分段填写经历,AI 将用户确认过的事实整理为优化稿。本仓库为 MVP 交付范围: - **简历生成(Builder)**:分板块对话采集(教育/实习/项目/校园/竞赛等),事实→候选稿→确认写入 - **轻度优化**:基于条目已有事实的一键 STAR 优化稿(纯 LLM 改写 + 声明校验,不追加追问) -- **简历导入**:docx/pdf/图片解析为结构化草稿,确认后并入在线简历 - 个人总结生成/再生成、技能推荐、目标岗位设置、条目级编辑/撤销 **当前不包含**:深度优化(多轮追问式)与 RAG 知识库。两者将随深度优化架构重构后单独集成;轻度优化自始不依赖知识库(优化稿仅基于用户已确认事实 + 声明校验)。 diff --git a/backend/README.md b/backend/README.md index e0fb6ca..fbf1d7c 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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 diff --git a/backend/app/agent.py b/backend/app/agent.py index 2846790..c61922b 100644 --- a/backend/app/agent.py +++ b/backend/app/agent.py @@ -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: diff --git a/backend/app/database.py b/backend/app/database.py index 3fe1a95..164eb9d 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -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() diff --git a/backend/app/fsm.py b/backend/app/fsm.py index 4669060..6364776 100644 --- a/backend/app/fsm.py +++ b/backend/app/fsm.py @@ -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( diff --git a/backend/app/main.py b/backend/app/main.py index 7ba197a..1cefdbd 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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, diff --git a/backend/app/postgres_database.py b/backend/app/postgres_database.py index 9480d4e..736e544 100644 --- a/backend/app/postgres_database.py +++ b/backend/app/postgres_database.py @@ -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) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index fc51f3e..0254ff9 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -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"}) diff --git a/backend/tests/test_offerpai_auth.py b/backend/tests/test_offerpai_auth.py index 88ca822..9d11c71 100644 --- a/backend/tests/test_offerpai_auth.py +++ b/backend/tests/test_offerpai_auth.py @@ -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: diff --git a/backend/tests/test_offerpai_resume_integration.py b/backend/tests/test_offerpai_resume_integration.py index 79179e3..f06a022 100644 --- a/backend/tests/test_offerpai_resume_integration.py +++ b/backend/tests/test_offerpai_resume_integration.py @@ -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, diff --git a/backend/tests/test_resume_import_api.py b/backend/tests/test_resume_import_api.py index 06feff5..2dd4f53 100644 --- a/backend/tests/test_resume_import_api.py +++ b/backend/tests/test_resume_import_api.py @@ -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 diff --git a/backend/tests/test_workflow_upgrade.py b/backend/tests/test_workflow_upgrade.py index cd77618..b4cdf67 100644 --- a/backend/tests/test_workflow_upgrade.py +++ b/backend/tests/test_workflow_upgrade.py @@ -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( diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 327376f..c7368d0 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -5,7 +5,6 @@ import AppHeader from './components/AppHeader.vue' import ComposerBar from './components/ComposerBar.vue' import EditResumePreview from './components/EditResumePreview.vue' import FeatureNavigation from './components/FeatureNavigation.vue' -import ResumeImportPanel from './components/ResumeImportPanel.vue' import { useResumeAgent } from './composables/useResumeAgent' import { useResumeDocument } from './composables/useResumeDocument' @@ -52,7 +51,6 @@ const remoteRefreshBlocked = computed( !sessionId.value || isBusy.value || Boolean(resumeDocument.busyEntryId.value) || - resumeDocument.importBusy.value || resumeDocument.skillsBusy.value || resumeDocument.summaryBusy.value, ) @@ -94,8 +92,6 @@ function handleVisibilityChange() { const stageLabels: Record = { starting: '准备会话', PRIVACY_CONSENT: '隐私确认', - RESUME_SOURCE_SELECT: '选择创建方式', - RESUME_IMPORT_UPLOAD: '导入简历', PHONE_SELECTION: '手机号授权', MANUAL_PHONE_INPUT: '填写手机号', PERSONAL_INFO: '基本信息', @@ -118,10 +114,6 @@ watch(sessionId, (value, previous) => { if (!value || value !== previous) void resumeDocument.restoreOptimizationRuns() }) -watch(() => resumeDocument.resumeImport.value?.status, (status) => { - if (status === 'applied') void refreshTimeline() -}) - watch(remoteRefreshBlocked, (blocked) => { if (blocked) cancelRemoteRefresh() }, { flush: 'sync' }) @@ -248,12 +240,6 @@ onBeforeUnmount(() => { - - (sessionPath(sessionId, "/resume-imports"), { - method: "POST", - body: form, - signal, - }) - }, - - getResumeImport(sessionId: string, importId: string, signal?: AbortSignal) { - return request( - sessionPath(sessionId, `/resume-imports/${encodeURIComponent(importId)}`), - { signal }, - ) - }, - - applyResumeImport( - sessionId: string, - importId: string, - expectedRevision: number, - signal?: AbortSignal, - ) { - return request( - sessionPath(sessionId, `/resume-imports/${encodeURIComponent(importId)}/apply`), - { method: "POST", body: JSON.stringify({ expected_revision: expectedRevision }), signal }, - ) - }, - - cancelResumeImport(sessionId: string, importId: string, signal?: AbortSignal) { - return request( - sessionPath(sessionId, `/resume-imports/${encodeURIComponent(importId)}`), - { method: "DELETE", signal }, - ) - }, deleteSession(sessionId: string, signal?: AbortSignal) { return request>(sessionPath(sessionId), { method: 'DELETE', diff --git a/frontend/src/components/ResumeImportPanel.vue b/frontend/src/components/ResumeImportPanel.vue deleted file mode 100644 index 1fd0e87..0000000 --- a/frontend/src/components/ResumeImportPanel.vue +++ /dev/null @@ -1,145 +0,0 @@ - - - - - \ No newline at end of file diff --git a/frontend/src/composables/useResumeAgent.ts b/frontend/src/composables/useResumeAgent.ts index 57c98f9..1d178fa 100644 --- a/frontend/src/composables/useResumeAgent.ts +++ b/frontend/src/composables/useResumeAgent.ts @@ -143,6 +143,20 @@ function normalizeBlock(raw: RawTimelineBlock, index: number): TimelineBlock { } } +function isRemovedResumeSourceBlock(block: TimelineBlock): boolean { + if (block.type === 'text') { + return ['请选择开始方式。', '请选择需要导入的 PDF 或 DOCX 简历。'].includes(block.text || '') + } + if (block.type !== 'component' || block.component !== 'choice_chips') return false + const options = Array.isArray(block.data.options) ? block.data.options : [] + const values = new Set( + options + .map((option) => asString(asRecord(option).value)) + .filter((value): value is string => Boolean(value)), + ) + return values.has('import') && values.has('manual') +} + function normalizeComposer(envelope: ResumeAgentEnvelope, turns: unknown): ComposerConfig { const timelineRecord = asRecord(envelope.timeline) const gate = asRecord(envelope.gate) @@ -205,7 +219,7 @@ export function normalizeResumeAgentResponse(response: ResumeAgentEnvelope): Nor : typeof latestTurn.sequence === 'number' ? latestTurn.sequence : 0, - timeline: rawBlocks.map(normalizeBlock), + timeline: rawBlocks.map(normalizeBlock).filter((block) => !isRemovedResumeSourceBlock(block)), composer: normalizeComposer(envelope, rawTimeline), missingFields: Array.isArray(envelope.missing_fields) ? envelope.missing_fields.filter((item): item is string => typeof item === 'string') diff --git a/frontend/src/composables/useResumeDocument.ts b/frontend/src/composables/useResumeDocument.ts index 000bce4..136371e 100644 --- a/frontend/src/composables/useResumeDocument.ts +++ b/frontend/src/composables/useResumeDocument.ts @@ -3,7 +3,6 @@ import { ResumeAgentApiError, resumeAgentApi } from '../api/resumeAgent' import type { OptimizationRunView, ResumeAgentEnvelope, - ResumeImportView, ResumePatchOperationInput, ResumeView, SkillRecommendationCandidate, @@ -20,8 +19,6 @@ export function useResumeDocument(sessionId: Ref) { const resume = ref(null) const busyEntryId = ref('') const errorMessage = ref('') - const resumeImport = ref(null) - const importBusy = ref(false) const optimizationRuns = ref>({}) const skillCandidates = ref([]) const skillsBusy = ref(false) @@ -49,9 +46,6 @@ export function useResumeDocument(sessionId: Ref) { if (error.status === 403 && error.payload?.error?.code === 'deep_requires_vip') { return '深度优化为 VIP 功能,升级后可继续进行多轮追问与改写。' } - if (error.payload?.error?.code === 'resume_import_not_allowed') { - return '简历预览已有内容,如需导入请先从头部重新开始。' - } return error.message } if (error instanceof Error && error.message) return error.message @@ -189,62 +183,10 @@ export function useResumeDocument(sessionId: Ref) { summaryBusy.value = false } } - async function uploadImport(file: File) { - if (!sessionId.value || importBusy.value) return - if (resume.value) { - errorMessage.value = '简历预览已有内容,如需导入请先从头部重新开始。' - return - } - importBusy.value = true - errorMessage.value = '' - try { - resumeImport.value = await resumeAgentApi.uploadResumeImport(sessionId.value, file) - } catch (error) { - errorMessage.value = formatError(error) - } finally { - importBusy.value = false - } - } - - async function applyImport() { - if (!sessionId.value || !resumeImport.value || importBusy.value) return - importBusy.value = true - errorMessage.value = '' - try { - const response = await resumeAgentApi.applyResumeImport( - sessionId.value, - resumeImport.value.id, - resume.value?.revision ?? 0, - ) - syncFrom(response) - resumeImport.value = { ...resumeImport.value, status: 'applied' } - } catch (error) { - errorMessage.value = formatError(error) - } finally { - importBusy.value = false - } - } - - async function cancelImport() { - if (!sessionId.value || !resumeImport.value || importBusy.value) return - importBusy.value = true - errorMessage.value = '' - try { - await resumeAgentApi.cancelResumeImport(sessionId.value, resumeImport.value.id) - resumeImport.value = null - } catch (error) { - errorMessage.value = formatError(error) - } finally { - importBusy.value = false - } - } - return { resume, busyEntryId, errorMessage, - resumeImport, - importBusy, optimizationRuns, skillCandidates, skillsBusy, @@ -253,9 +195,6 @@ export function useResumeDocument(sessionId: Ref) { syncFrom, setTargetPosition, restoreOptimizationRuns, - uploadImport, - applyImport, - cancelImport, updateBasics: (fields: Record) => patch({ type: 'update_basics', fields }), updateSkillGroups: (skills: string[]) => patch({ type: 'update_skill_groups', skills }, 'skills'), updateProfileSummary: (content: string) => @@ -304,4 +243,3 @@ export function useResumeDocument(sessionId: Ref) { }, } } - diff --git a/frontend/src/types/resumeAgent.ts b/frontend/src/types/resumeAgent.ts index 4178655..18bee8f 100644 --- a/frontend/src/types/resumeAgent.ts +++ b/frontend/src/types/resumeAgent.ts @@ -277,34 +277,6 @@ export interface ResumeDocument { } | null } -export interface ResumeImportEvidence { - page?: number | null - paragraph?: number | null - text: string -} - -export interface ResumeImportFieldReview { - field_path: string - value: unknown - confidence: number - status: "needs_review" | "verified" - evidence: ResumeImportEvidence[] -} - -export interface ResumeImportView { - id: string - session_id: string - file_name: string - mime_type: string - size_bytes: number - sha256: string - status: "awaiting_review" | "applied" | "failed" | "cancelled" - document: ResumeDocument | null - field_reviews: ResumeImportFieldReview[] - error_code?: string | null - created_at: string - updated_at: string -} export interface SkillRecommendationCandidate { skill: string category: string @@ -339,4 +311,4 @@ export interface BuilderStreamEvent { message?: string status_code?: number } -} \ No newline at end of file +}