generated from kgod/ai-review-template
feat: start resume sessions in new flow
This commit is contained in:
@@ -1,10 +1,9 @@
|
|||||||
# Resume Agent(Offerπ 简历生成 Agent)
|
# Resume Agent(Offerπ 简历生成 Agent)
|
||||||
|
|
||||||
对话式简历生成服务:引导用户分段填写经历,AI 将用户确认过的事实整理为优化稿,支持导入既有简历继续编辑。本仓库为 MVP 交付范围:
|
对话式简历生成服务:引导用户从新建流程分段填写经历,AI 将用户确认过的事实整理为优化稿。本仓库为 MVP 交付范围:
|
||||||
|
|
||||||
- **简历生成(Builder)**:分板块对话采集(教育/实习/项目/校园/竞赛等),事实→候选稿→确认写入
|
- **简历生成(Builder)**:分板块对话采集(教育/实习/项目/校园/竞赛等),事实→候选稿→确认写入
|
||||||
- **轻度优化**:基于条目已有事实的一键 STAR 优化稿(纯 LLM 改写 + 声明校验,不追加追问)
|
- **轻度优化**:基于条目已有事实的一键 STAR 优化稿(纯 LLM 改写 + 声明校验,不追加追问)
|
||||||
- **简历导入**:docx/pdf/图片解析为结构化草稿,确认后并入在线简历
|
|
||||||
- 个人总结生成/再生成、技能推荐、目标岗位设置、条目级编辑/撤销
|
- 个人总结生成/再生成、技能推荐、目标岗位设置、条目级编辑/撤销
|
||||||
|
|
||||||
**当前不包含**:深度优化(多轮追问式)与 RAG 知识库。两者将随深度优化架构重构后单独集成;轻度优化自始不依赖知识库(优化稿仅基于用户已确认事实 + 声明校验)。
|
**当前不包含**:深度优化(多轮追问式)与 RAG 知识库。两者将随深度优化架构重构后单独集成;轻度优化自始不依赖知识库(优化稿仅基于用户已确认事实 + 声明校验)。
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# Resume Agent backend
|
# 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).
|
and light-optimization state, persisted in SQLite (pilot) or PostgreSQL (production).
|
||||||
|
|
||||||
## Run locally
|
## Run locally
|
||||||
|
|||||||
+27
-1
@@ -17,6 +17,7 @@ from .fsm import (
|
|||||||
gate_allowed,
|
gate_allowed,
|
||||||
initial_turn,
|
initial_turn,
|
||||||
missing_fields,
|
missing_fields,
|
||||||
|
new_resume_transition,
|
||||||
process_component_event,
|
process_component_event,
|
||||||
required_fields,
|
required_fields,
|
||||||
)
|
)
|
||||||
@@ -867,7 +868,7 @@ class ResumeAgent(ResumeEditingMixin, OptimizationFlowMixin):
|
|||||||
return self.timeline(session_id)
|
return self.timeline(session_id)
|
||||||
|
|
||||||
def timeline(self, session_id: str) -> TimelineResponse:
|
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)
|
turns = self.database.list_turns(session_id)
|
||||||
gate = self._gate(session)
|
gate = self._gate(session)
|
||||||
resume = self._resume_view(session)
|
resume = self._resume_view(session)
|
||||||
@@ -885,6 +886,31 @@ class ResumeAgent(ResumeEditingMixin, OptimizationFlowMixin):
|
|||||||
trace_id=self._trace_id(),
|
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(
|
def component_event(
|
||||||
self, session_id: str, request: ComponentEventRequest
|
self, session_id: str, request: ComponentEventRequest
|
||||||
) -> ActionResponse:
|
) -> ActionResponse:
|
||||||
|
|||||||
@@ -160,8 +160,13 @@ class Database:
|
|||||||
self.insert_turn(connection, session_id=session_id, **initial_turn)
|
self.insert_turn(connection, session_id=session_id, **initial_turn)
|
||||||
|
|
||||||
def fetch_session(
|
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:
|
) -> dict[str, Any] | None:
|
||||||
|
del for_update
|
||||||
row = connection.execute(
|
row = connection.execute(
|
||||||
"SELECT * FROM sessions WHERE id = ?", (session_id,)
|
"SELECT * FROM sessions WHERE id = ?", (session_id,)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
|||||||
+28
-43
@@ -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]:
|
def required_fields(profile: dict[str, Any]) -> list[str]:
|
||||||
"""The initial Builder resume only requires verified setup information."""
|
"""The initial Builder resume only requires verified setup information."""
|
||||||
return []
|
return []
|
||||||
@@ -267,56 +288,20 @@ def process_component_event(
|
|||||||
)
|
)
|
||||||
_expect(action, "accept_privacy")
|
_expect(action, "accept_privacy")
|
||||||
updated["privacy_accepted"] = True
|
updated["privacy_accepted"] = True
|
||||||
return Transition(
|
return new_resume_transition(updated)
|
||||||
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"},
|
|
||||||
],
|
|
||||||
)
|
|
||||||
],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
if stage == Stage.RESUME_SOURCE_SELECT:
|
if stage == Stage.RESUME_SOURCE_SELECT:
|
||||||
_expect(action, "select_choice")
|
_expect(action, "select_choice")
|
||||||
source = str(payload.get("value") or "").strip()
|
source = str(payload.get("value") or "").strip()
|
||||||
if source == "import":
|
if source == "import":
|
||||||
updated["resume_source"] = "import"
|
raise FSMError(
|
||||||
return Transition(
|
"resume_import_disabled",
|
||||||
Stage.RESUME_IMPORT_UPLOAD,
|
"Resume import is no longer available; create a new resume instead",
|
||||||
updated,
|
status_code=410,
|
||||||
assistant_turn("\u8bf7\u9009\u62e9\u9700\u8981\u5bfc\u5165\u7684 PDF \u6216 DOCX \u7b80\u5386\u3002", []),
|
|
||||||
)
|
)
|
||||||
if source == "manual":
|
if source == "manual":
|
||||||
updated["resume_source"] = "manual"
|
return new_resume_transition(updated)
|
||||||
return Transition(
|
raise FSMError("invalid_resume_source", "Create a new resume", status_code=422)
|
||||||
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)
|
|
||||||
if stage == Stage.PHONE_SELECTION:
|
if stage == Stage.PHONE_SELECTION:
|
||||||
if action == "use_other_phone":
|
if action == "use_other_phone":
|
||||||
return Transition(
|
return Transition(
|
||||||
|
|||||||
+1
-26
@@ -21,7 +21,7 @@ from .database import Database
|
|||||||
from .postgres_database import PostgresDatabase
|
from .postgres_database import PostgresDatabase
|
||||||
from .fsm import FSMError
|
from .fsm import FSMError
|
||||||
from .builder_sse import stream_builder_message
|
from .builder_sse import stream_builder_message
|
||||||
from .llm_services import OpenAICompatibleStructuredClient, build_services
|
from .llm_services import build_services
|
||||||
from .models import (
|
from .models import (
|
||||||
ActionResponse,
|
ActionResponse,
|
||||||
ComponentEventRequest,
|
ComponentEventRequest,
|
||||||
@@ -34,9 +34,6 @@ from .models import (
|
|||||||
TimelineResponse,
|
TimelineResponse,
|
||||||
)
|
)
|
||||||
from .resume_routes import register_resume_routes
|
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 .rate_limit import SlidingWindowRateLimiter
|
||||||
from .services import (
|
from .services import (
|
||||||
EntryExpander,
|
EntryExpander,
|
||||||
@@ -77,7 +74,6 @@ def create_app(
|
|||||||
cors_origins: list[str] | None = None,
|
cors_origins: list[str] | None = None,
|
||||||
settings: Settings | None = None,
|
settings: Settings | None = None,
|
||||||
openai_client: Any | None = None,
|
openai_client: Any | None = None,
|
||||||
resume_import_service: ResumeImportService | None = None,
|
|
||||||
profile_summary_generator: ProfileSummaryGenerator | None = None,
|
profile_summary_generator: ProfileSummaryGenerator | None = None,
|
||||||
offerpai_identity_provider: OfferPaiIdentityProvider | None = None,
|
offerpai_identity_provider: OfferPaiIdentityProvider | None = None,
|
||||||
offerpai_resume_provider: OfferPaiResumeProvider | None = None,
|
offerpai_resume_provider: OfferPaiResumeProvider | None = None,
|
||||||
@@ -156,19 +152,6 @@ def create_app(
|
|||||||
application.state.resume_agent = agent
|
application.state.resume_agent = agent
|
||||||
application.state.offerpai_identity_provider = offerpai_identity_provider
|
application.state.offerpai_identity_provider = offerpai_identity_provider
|
||||||
application.state.offerpai_resume_provider = offerpai_resume_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(
|
application.state.light_opt_limiter = SlidingWindowRateLimiter(
|
||||||
limit=resolved_settings.light_opt_rate_limit,
|
limit=resolved_settings.light_opt_rate_limit,
|
||||||
window_seconds=resolved_settings.light_opt_rate_window_seconds,
|
window_seconds=resolved_settings.light_opt_rate_window_seconds,
|
||||||
@@ -366,14 +349,6 @@ def create_app(
|
|||||||
API_PREFIX,
|
API_PREFIX,
|
||||||
authorize_session_request=authorize_session_request,
|
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(
|
@application.delete(
|
||||||
f"{API_PREFIX}/sessions/{{session_id}}",
|
f"{API_PREFIX}/sessions/{{session_id}}",
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
|||||||
@@ -46,9 +46,18 @@ class PostgresDatabase:
|
|||||||
))
|
))
|
||||||
self.insert_turn(connection, session_id=session_id, **initial_turn)
|
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"]
|
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:
|
if row is None:
|
||||||
return None
|
return None
|
||||||
result = dict(row)
|
result = dict(row)
|
||||||
|
|||||||
+82
-14
@@ -1,8 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
import json
|
import json
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
@@ -46,11 +48,7 @@ def start_manual_profile(
|
|||||||
session_id = body["session_id"]
|
session_id = body["session_id"]
|
||||||
assert body["stage"] == "PRIVACY_CONSENT"
|
assert body["stage"] == "PRIVACY_CONSENT"
|
||||||
|
|
||||||
source = event(client, session_id, body, "accept", {"accepted": True})
|
phone_selector = 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"})
|
|
||||||
assert phone_selector.status_code == 200
|
assert phone_selector.status_code == 200
|
||||||
assert phone_selector.json()["stage"] == "PHONE_SELECTION"
|
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")
|
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={})
|
created = client.post(f"{BASE}/sessions", json={})
|
||||||
session_id = created.json()["session_id"]
|
session_id = created.json()["session_id"]
|
||||||
source = event(client, session_id, created.json(), "accept", {"accepted": True})
|
phone_selector = event(client, session_id, created.json(), "accept", {"accepted": True})
|
||||||
assert source.status_code == 200
|
assert phone_selector.status_code == 200
|
||||||
body = source.json()
|
body = phone_selector.json()
|
||||||
assert body["stage"] == "RESUME_SOURCE_SELECT"
|
assert body["stage"] == "PHONE_SELECTION"
|
||||||
options = active_component(body)["data"]["options"]
|
assert active_component(body)["data"]["component"] == "resume_phone_selector"
|
||||||
assert {option["value"] for option in options} == {"import", "manual"}
|
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:
|
def test_manual_phone_is_strict_and_retryable(client: TestClient) -> None:
|
||||||
created = client.post(f"{BASE}/sessions", json={}).json()
|
created = client.post(f"{BASE}/sessions", json={}).json()
|
||||||
session_id = created["session_id"]
|
session_id = created["session_id"]
|
||||||
source = event(client, session_id, created, "accept", {"accepted": True}).json()
|
phone_selector = event(client, session_id, created, "accept", {"accepted": True}).json()
|
||||||
phone_selector = event(client, session_id, source, "select", {"value": "manual"}).json()
|
|
||||||
phone_input = event(client, session_id, phone_selector, "select", {"source": "other"}).json()
|
phone_input = event(client, session_id, phone_selector, "select", {"source": "other"}).json()
|
||||||
|
|
||||||
invalid = event(client, session_id, phone_input, "submit", {"phone": "+8613800138000"})
|
invalid = event(client, session_id, phone_input, "submit", {"phone": "+8613800138000"})
|
||||||
|
|||||||
@@ -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)
|
assert TOKEN not in json.dumps(profile, ensure_ascii=False)
|
||||||
|
|
||||||
auth_headers = {"Authorization": f"Bearer {TOKEN}"}
|
auth_headers = {"Authorization": f"Bearer {TOKEN}"}
|
||||||
source = event(
|
phone_selector = event(
|
||||||
client,
|
client,
|
||||||
session_id,
|
session_id,
|
||||||
created,
|
created,
|
||||||
@@ -154,14 +154,6 @@ def test_session_creation_authenticates_and_defaults_account_phone(tmp_path: Pat
|
|||||||
{"accepted": True},
|
{"accepted": True},
|
||||||
headers=auth_headers,
|
headers=auth_headers,
|
||||||
).json()
|
).json()
|
||||||
phone_selector = event(
|
|
||||||
client,
|
|
||||||
session_id,
|
|
||||||
source,
|
|
||||||
"select",
|
|
||||||
{"value": "manual"},
|
|
||||||
headers=auth_headers,
|
|
||||||
).json()
|
|
||||||
data = active_component(phone_selector)["data"]
|
data = active_component(phone_selector)["data"]
|
||||||
assert data["has_account_phone"] is True
|
assert data["has_account_phone"] is True
|
||||||
assert data["masked_phone"] == "134****2384"
|
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"] == "13421012384"
|
||||||
assert updated["profile"]["phone_source"] == "account"
|
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:
|
def test_invalid_external_token_does_not_create_session(tmp_path: Path) -> None:
|
||||||
|
|||||||
@@ -264,14 +264,6 @@ def _complete_initial_collection(
|
|||||||
response = event(
|
response = event(
|
||||||
client, session_id, body, "accept", {"accepted": True}, headers=headers
|
client, session_id, body, "accept", {"accepted": True}, headers=headers
|
||||||
)
|
)
|
||||||
response = event(
|
|
||||||
client,
|
|
||||||
session_id,
|
|
||||||
response.json(),
|
|
||||||
"select",
|
|
||||||
{"value": "manual"},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
response = event(
|
response = event(
|
||||||
client,
|
client,
|
||||||
session_id,
|
session_id,
|
||||||
|
|||||||
@@ -1,171 +1,25 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from io import BytesIO
|
|
||||||
|
|
||||||
from docx import Document
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from app.main import create_app
|
from test_api import BASE
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
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"]
|
||||||
|
|
||||||
|
upload = client.post(
|
||||||
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(
|
|
||||||
f"{BASE}/sessions/{session_id}/resume-imports",
|
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:
|
apply = client.post(
|
||||||
with client_for_import(tmp_path) as client:
|
f"{BASE}/sessions/{session_id}/resume-imports/import_legacy/apply",
|
||||||
session_id = client.post(f"{BASE}/sessions", json={}).json()["session_id"]
|
json={"expected_revision": 0},
|
||||||
before_consent = upload(client, session_id)
|
)
|
||||||
assert before_consent.status_code == 409
|
assert apply.status_code == 404
|
||||||
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
|
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ def _reach_job_type(client: TestClient) -> tuple[str, dict[str, Any]]:
|
|||||||
body = client.post(f"{BASE}/sessions", json={}).json()
|
body = client.post(f"{BASE}/sessions", json={}).json()
|
||||||
session_id = body["session_id"]
|
session_id = body["session_id"]
|
||||||
body = event(client, session_id, body, "accept", {"accepted": True}).json()
|
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, "select", {"source": "other"}).json()
|
||||||
body = event(client, session_id, body, "submit", {"phone": "13800138000"}).json()
|
body = event(client, session_id, body, "submit", {"phone": "13800138000"}).json()
|
||||||
body = event(
|
body = event(
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import AppHeader from './components/AppHeader.vue'
|
|||||||
import ComposerBar from './components/ComposerBar.vue'
|
import ComposerBar from './components/ComposerBar.vue'
|
||||||
import EditResumePreview from './components/EditResumePreview.vue'
|
import EditResumePreview from './components/EditResumePreview.vue'
|
||||||
import FeatureNavigation from './components/FeatureNavigation.vue'
|
import FeatureNavigation from './components/FeatureNavigation.vue'
|
||||||
import ResumeImportPanel from './components/ResumeImportPanel.vue'
|
|
||||||
import { useResumeAgent } from './composables/useResumeAgent'
|
import { useResumeAgent } from './composables/useResumeAgent'
|
||||||
import { useResumeDocument } from './composables/useResumeDocument'
|
import { useResumeDocument } from './composables/useResumeDocument'
|
||||||
|
|
||||||
@@ -52,7 +51,6 @@ const remoteRefreshBlocked = computed(
|
|||||||
!sessionId.value ||
|
!sessionId.value ||
|
||||||
isBusy.value ||
|
isBusy.value ||
|
||||||
Boolean(resumeDocument.busyEntryId.value) ||
|
Boolean(resumeDocument.busyEntryId.value) ||
|
||||||
resumeDocument.importBusy.value ||
|
|
||||||
resumeDocument.skillsBusy.value ||
|
resumeDocument.skillsBusy.value ||
|
||||||
resumeDocument.summaryBusy.value,
|
resumeDocument.summaryBusy.value,
|
||||||
)
|
)
|
||||||
@@ -94,8 +92,6 @@ function handleVisibilityChange() {
|
|||||||
const stageLabels: Record<string, string> = {
|
const stageLabels: Record<string, string> = {
|
||||||
starting: '准备会话',
|
starting: '准备会话',
|
||||||
PRIVACY_CONSENT: '隐私确认',
|
PRIVACY_CONSENT: '隐私确认',
|
||||||
RESUME_SOURCE_SELECT: '选择创建方式',
|
|
||||||
RESUME_IMPORT_UPLOAD: '导入简历',
|
|
||||||
PHONE_SELECTION: '手机号授权',
|
PHONE_SELECTION: '手机号授权',
|
||||||
MANUAL_PHONE_INPUT: '填写手机号',
|
MANUAL_PHONE_INPUT: '填写手机号',
|
||||||
PERSONAL_INFO: '基本信息',
|
PERSONAL_INFO: '基本信息',
|
||||||
@@ -118,10 +114,6 @@ watch(sessionId, (value, previous) => {
|
|||||||
if (!value || value !== previous) void resumeDocument.restoreOptimizationRuns()
|
if (!value || value !== previous) void resumeDocument.restoreOptimizationRuns()
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(() => resumeDocument.resumeImport.value?.status, (status) => {
|
|
||||||
if (status === 'applied') void refreshTimeline()
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(remoteRefreshBlocked, (blocked) => {
|
watch(remoteRefreshBlocked, (blocked) => {
|
||||||
if (blocked) cancelRemoteRefresh()
|
if (blocked) cancelRemoteRefresh()
|
||||||
}, { flush: 'sync' })
|
}, { flush: 'sync' })
|
||||||
@@ -248,12 +240,6 @@ onBeforeUnmount(() => {
|
|||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<ResumeImportPanel
|
|
||||||
v-if="stage === 'RESUME_IMPORT_UPLOAD'"
|
|
||||||
:document="resumeDocument"
|
|
||||||
:disabled="!sessionId || initializing"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<AgentTimeline
|
<AgentTimeline
|
||||||
:timeline="timeline"
|
:timeline="timeline"
|
||||||
:initializing="initializing"
|
:initializing="initializing"
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import type {
|
|||||||
ComponentEventInput,
|
ComponentEventInput,
|
||||||
MessageInput,
|
MessageInput,
|
||||||
ResumeAgentEnvelope,
|
ResumeAgentEnvelope,
|
||||||
ResumeImportView,
|
|
||||||
OptimizationRunView,
|
OptimizationRunView,
|
||||||
ResumePatchOperationInput,
|
ResumePatchOperationInput,
|
||||||
SkillRecommendationCandidate,
|
SkillRecommendationCandidate,
|
||||||
@@ -186,41 +185,6 @@ export const resumeAgentApi = {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
uploadResumeImport(sessionId: string, file: File, signal?: AbortSignal) {
|
|
||||||
const form = new FormData()
|
|
||||||
form.append("file", file)
|
|
||||||
return request<ResumeImportView>(sessionPath(sessionId, "/resume-imports"), {
|
|
||||||
method: "POST",
|
|
||||||
body: form,
|
|
||||||
signal,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
getResumeImport(sessionId: string, importId: string, signal?: AbortSignal) {
|
|
||||||
return request<ResumeImportView>(
|
|
||||||
sessionPath(sessionId, `/resume-imports/${encodeURIComponent(importId)}`),
|
|
||||||
{ signal },
|
|
||||||
)
|
|
||||||
},
|
|
||||||
|
|
||||||
applyResumeImport(
|
|
||||||
sessionId: string,
|
|
||||||
importId: string,
|
|
||||||
expectedRevision: number,
|
|
||||||
signal?: AbortSignal,
|
|
||||||
) {
|
|
||||||
return request<ResumeAgentEnvelope>(
|
|
||||||
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<ResumeImportView>(
|
|
||||||
sessionPath(sessionId, `/resume-imports/${encodeURIComponent(importId)}`),
|
|
||||||
{ method: "DELETE", signal },
|
|
||||||
)
|
|
||||||
},
|
|
||||||
deleteSession(sessionId: string, signal?: AbortSignal) {
|
deleteSession(sessionId: string, signal?: AbortSignal) {
|
||||||
return request<Record<string, unknown>>(sessionPath(sessionId), {
|
return request<Record<string, unknown>>(sessionPath(sessionId), {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
|
|||||||
@@ -1,145 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
|
||||||
import type { useResumeDocument } from '../composables/useResumeDocument'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
document: ReturnType<typeof useResumeDocument>
|
|
||||||
disabled?: boolean
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const input = ref<HTMLInputElement | null>(null)
|
|
||||||
const selectedName = ref('')
|
|
||||||
const accepted = '.pdf,.docx,application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
|
||||||
const reviewCount = computed(() => props.document.resumeImport.value?.field_reviews.length ?? 0)
|
|
||||||
const importStatus = computed(() => props.document.resumeImport.value?.status)
|
|
||||||
const hasContent = computed(() => {
|
|
||||||
const content = props.document.resume.value?.content
|
|
||||||
if (!content) return false
|
|
||||||
if (String(content.basics?.name || '').trim()) return true
|
|
||||||
if ((content.skill_groups || []).length) return true
|
|
||||||
return (content.sections || []).some((section) => (section.items || []).length > 0)
|
|
||||||
})
|
|
||||||
const cannotImport = computed(() => Boolean(props.disabled || hasContent.value || props.document.importBusy.value))
|
|
||||||
|
|
||||||
function selectFile() {
|
|
||||||
if (!cannotImport.value) input.value?.click()
|
|
||||||
}
|
|
||||||
|
|
||||||
function onFileChange(event: Event) {
|
|
||||||
const file = (event.target as HTMLInputElement).files?.[0]
|
|
||||||
if (!file || cannotImport.value) return
|
|
||||||
selectedName.value = file.name
|
|
||||||
void props.document.uploadImport(file)
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearSelection() {
|
|
||||||
selectedName.value = ''
|
|
||||||
if (input.value) input.value.value = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
async function cancel() {
|
|
||||||
await props.document.cancelImport()
|
|
||||||
clearSelection()
|
|
||||||
}
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
// The panel only lives during RESUME_IMPORT_UPLOAD. When it unmounts (stage
|
|
||||||
// advanced or 重新开始 reset the session) the import view must not leak into
|
|
||||||
// the next session — a stale "导入完成" card blocks selecting a new file.
|
|
||||||
props.document.resumeImport.value = null
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<section class="resume-import" aria-label="简历导入">
|
|
||||||
<input
|
|
||||||
ref="input"
|
|
||||||
class="resume-import__input"
|
|
||||||
type="file"
|
|
||||||
:accept="accepted"
|
|
||||||
:disabled="cannotImport"
|
|
||||||
@change="onFileChange"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<template v-if="!document.resumeImport.value || importStatus === 'cancelled'">
|
|
||||||
<div class="resume-import__copy">
|
|
||||||
<p>简历导入</p>
|
|
||||||
<h2>导入已有简历</h2>
|
|
||||||
<span>支持 PDF / DOCX,不超过 10 MB</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="resume-import__select"
|
|
||||||
:disabled="cannotImport"
|
|
||||||
@click="selectFile"
|
|
||||||
>
|
|
||||||
{{ document.importBusy.value ? '解析中...' : '选择文件' }}
|
|
||||||
</button>
|
|
||||||
<small v-if="hasContent">简历预览已有内容,如需导入请先从头部重新开始。</small>
|
|
||||||
<small v-else-if="selectedName">{{ selectedName }}</small>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-else-if="importStatus === 'awaiting_review'">
|
|
||||||
<div class="resume-import__copy">
|
|
||||||
<p>导入预览</p>
|
|
||||||
<h2>{{ document.resumeImport.value.file_name }}</h2>
|
|
||||||
<span>已解析出 {{ reviewCount }} 个字段,确认后应用到简历</span>
|
|
||||||
</div>
|
|
||||||
<div class="resume-import__actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="resume-import__button resume-import__button--primary"
|
|
||||||
:disabled="document.importBusy.value"
|
|
||||||
@click="document.applyImport"
|
|
||||||
>
|
|
||||||
{{ document.importBusy.value ? '应用中...' : '应用到简历' }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="resume-import__button"
|
|
||||||
:disabled="document.importBusy.value"
|
|
||||||
@click="cancel"
|
|
||||||
>
|
|
||||||
放弃导入
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-else-if="importStatus === 'applied'">
|
|
||||||
<div class="resume-import__copy">
|
|
||||||
<p>导入完成</p>
|
|
||||||
<h2>{{ document.resumeImport.value.file_name }}</h2>
|
|
||||||
<span>导入内容已进入右侧简历预览。</span>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="resume-import__button" @click="clearSelection">
|
|
||||||
完成
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
</section>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.resume-import {
|
|
||||||
display: grid;
|
|
||||||
gap: 12px;
|
|
||||||
margin: 0 0 20px 59px;
|
|
||||||
padding: 14px;
|
|
||||||
border: 1px solid var(--line-strong);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #f9fdfc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.resume-import__input { display: none; }
|
|
||||||
.resume-import__copy { display: grid; gap: 4px; min-width: 0; }
|
|
||||||
.resume-import__copy p { margin: 0; color: var(--brand-dark); font-family: ui-monospace, Consolas, monospace; font-size: 9px; font-weight: 800; }
|
|
||||||
.resume-import__copy h2 { margin: 0; overflow-wrap: anywhere; color: var(--ink); font-size: 14px; line-height: 1.35; }
|
|
||||||
.resume-import__copy span, .resume-import small { color: var(--ink-faint); font-size: 11px; line-height: 1.45; }
|
|
||||||
.resume-import__select, .resume-import__button { min-height: 34px; width: fit-content; padding: 0 10px; border: 1px solid var(--line-strong); border-radius: 6px; color: var(--ink-soft); background: #fff; font-size: 11px; font-weight: 750; }
|
|
||||||
.resume-import__select:hover:not(:disabled), .resume-import__button:hover:not(:disabled) { border-color: #8fc4c1; color: var(--ink); background: var(--surface-muted); }
|
|
||||||
.resume-import__select:disabled, .resume-import__button:disabled { opacity: .55; }
|
|
||||||
.resume-import__actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
||||||
.resume-import__button--primary { color: #fff; border-color: #1c858a; background: #1c858a; }
|
|
||||||
.resume-import__button--primary:hover:not(:disabled) { color: #fff; border-color: #146e73; background: #146e73; }
|
|
||||||
|
|
||||||
@media (max-width: 760px) { .resume-import { margin-left: 38px; } }
|
|
||||||
</style>
|
|
||||||
@@ -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 {
|
function normalizeComposer(envelope: ResumeAgentEnvelope, turns: unknown): ComposerConfig {
|
||||||
const timelineRecord = asRecord(envelope.timeline)
|
const timelineRecord = asRecord(envelope.timeline)
|
||||||
const gate = asRecord(envelope.gate)
|
const gate = asRecord(envelope.gate)
|
||||||
@@ -205,7 +219,7 @@ export function normalizeResumeAgentResponse(response: ResumeAgentEnvelope): Nor
|
|||||||
: typeof latestTurn.sequence === 'number'
|
: typeof latestTurn.sequence === 'number'
|
||||||
? latestTurn.sequence
|
? latestTurn.sequence
|
||||||
: 0,
|
: 0,
|
||||||
timeline: rawBlocks.map(normalizeBlock),
|
timeline: rawBlocks.map(normalizeBlock).filter((block) => !isRemovedResumeSourceBlock(block)),
|
||||||
composer: normalizeComposer(envelope, rawTimeline),
|
composer: normalizeComposer(envelope, rawTimeline),
|
||||||
missingFields: Array.isArray(envelope.missing_fields)
|
missingFields: Array.isArray(envelope.missing_fields)
|
||||||
? envelope.missing_fields.filter((item): item is string => typeof item === 'string')
|
? envelope.missing_fields.filter((item): item is string => typeof item === 'string')
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { ResumeAgentApiError, resumeAgentApi } from '../api/resumeAgent'
|
|||||||
import type {
|
import type {
|
||||||
OptimizationRunView,
|
OptimizationRunView,
|
||||||
ResumeAgentEnvelope,
|
ResumeAgentEnvelope,
|
||||||
ResumeImportView,
|
|
||||||
ResumePatchOperationInput,
|
ResumePatchOperationInput,
|
||||||
ResumeView,
|
ResumeView,
|
||||||
SkillRecommendationCandidate,
|
SkillRecommendationCandidate,
|
||||||
@@ -20,8 +19,6 @@ export function useResumeDocument(sessionId: Ref<string>) {
|
|||||||
const resume = ref<ResumeView | null>(null)
|
const resume = ref<ResumeView | null>(null)
|
||||||
const busyEntryId = ref('')
|
const busyEntryId = ref('')
|
||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
const resumeImport = ref<ResumeImportView | null>(null)
|
|
||||||
const importBusy = ref(false)
|
|
||||||
const optimizationRuns = ref<Record<string, OptimizationRunView>>({})
|
const optimizationRuns = ref<Record<string, OptimizationRunView>>({})
|
||||||
const skillCandidates = ref<SkillRecommendationCandidate[]>([])
|
const skillCandidates = ref<SkillRecommendationCandidate[]>([])
|
||||||
const skillsBusy = ref(false)
|
const skillsBusy = ref(false)
|
||||||
@@ -49,9 +46,6 @@ export function useResumeDocument(sessionId: Ref<string>) {
|
|||||||
if (error.status === 403 && error.payload?.error?.code === 'deep_requires_vip') {
|
if (error.status === 403 && error.payload?.error?.code === 'deep_requires_vip') {
|
||||||
return '深度优化为 VIP 功能,升级后可继续进行多轮追问与改写。'
|
return '深度优化为 VIP 功能,升级后可继续进行多轮追问与改写。'
|
||||||
}
|
}
|
||||||
if (error.payload?.error?.code === 'resume_import_not_allowed') {
|
|
||||||
return '简历预览已有内容,如需导入请先从头部重新开始。'
|
|
||||||
}
|
|
||||||
return error.message
|
return error.message
|
||||||
}
|
}
|
||||||
if (error instanceof Error && error.message) return error.message
|
if (error instanceof Error && error.message) return error.message
|
||||||
@@ -189,62 +183,10 @@ export function useResumeDocument(sessionId: Ref<string>) {
|
|||||||
summaryBusy.value = false
|
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 {
|
return {
|
||||||
resume,
|
resume,
|
||||||
busyEntryId,
|
busyEntryId,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
resumeImport,
|
|
||||||
importBusy,
|
|
||||||
optimizationRuns,
|
optimizationRuns,
|
||||||
skillCandidates,
|
skillCandidates,
|
||||||
skillsBusy,
|
skillsBusy,
|
||||||
@@ -253,9 +195,6 @@ export function useResumeDocument(sessionId: Ref<string>) {
|
|||||||
syncFrom,
|
syncFrom,
|
||||||
setTargetPosition,
|
setTargetPosition,
|
||||||
restoreOptimizationRuns,
|
restoreOptimizationRuns,
|
||||||
uploadImport,
|
|
||||||
applyImport,
|
|
||||||
cancelImport,
|
|
||||||
updateBasics: (fields: Record<string, string>) => patch({ type: 'update_basics', fields }),
|
updateBasics: (fields: Record<string, string>) => patch({ type: 'update_basics', fields }),
|
||||||
updateSkillGroups: (skills: string[]) => patch({ type: 'update_skill_groups', skills }, 'skills'),
|
updateSkillGroups: (skills: string[]) => patch({ type: 'update_skill_groups', skills }, 'skills'),
|
||||||
updateProfileSummary: (content: string) =>
|
updateProfileSummary: (content: string) =>
|
||||||
@@ -304,4 +243,3 @@ export function useResumeDocument(sessionId: Ref<string>) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -277,34 +277,6 @@ export interface ResumeDocument {
|
|||||||
} | null
|
} | 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 {
|
export interface SkillRecommendationCandidate {
|
||||||
skill: string
|
skill: string
|
||||||
category: string
|
category: string
|
||||||
@@ -339,4 +311,4 @@ export interface BuilderStreamEvent {
|
|||||||
message?: string
|
message?: string
|
||||||
status_code?: number
|
status_code?: number
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user