generated from kgod/ai-review-template
feat: add resume agent MVP
This commit is contained in:
@@ -0,0 +1,322 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
BASE = "/ai-api/resume-agent"
|
||||
|
||||
|
||||
def active_component(body: dict[str, Any]) -> dict[str, Any]:
|
||||
turns = body.get("turns") or ([body["turn"]] if body.get("turn") else [])
|
||||
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[str, Any],
|
||||
event_name: str,
|
||||
payload: dict[str, Any] | None = None,
|
||||
):
|
||||
block = active_component(body)
|
||||
response = client.post(
|
||||
f"{BASE}/sessions/{session_id}/component-events",
|
||||
json={
|
||||
"component_id": block["id"],
|
||||
"event": event_name,
|
||||
"payload": payload or {},
|
||||
},
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
def start_manual_profile(client: TestClient, *, job_type: str) -> tuple[str, dict[str, Any]]:
|
||||
response = client.post(f"{BASE}/sessions", json={})
|
||||
assert response.status_code == 201
|
||||
body = response.json()
|
||||
session_id = body["session_id"]
|
||||
assert body["stage"] == "PRIVACY_CONSENT"
|
||||
|
||||
response = event(client, session_id, body, "accept", {"accepted": True})
|
||||
assert response.status_code == 200
|
||||
assert response.json()["stage"] == "PHONE_SELECTION"
|
||||
|
||||
response = event(client, session_id, response.json(), "select", {"source": "other"})
|
||||
assert response.status_code == 200
|
||||
assert response.json()["stage"] == "MANUAL_PHONE_INPUT"
|
||||
|
||||
response = event(
|
||||
client,
|
||||
session_id,
|
||||
response.json(),
|
||||
"submit",
|
||||
{"phone": "13800138000"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["stage"] == "NAME_CAPTURE"
|
||||
|
||||
response = event(client, session_id, response.json(), "submit", {"name": "测试用户"})
|
||||
assert response.status_code == 200
|
||||
assert response.json()["stage"] == "JOB_TYPE_SELECT"
|
||||
|
||||
response = event(
|
||||
client,
|
||||
session_id,
|
||||
response.json(),
|
||||
"select",
|
||||
{"job_type": job_type},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
return session_id, response.json()
|
||||
|
||||
|
||||
def fill_anchor(
|
||||
client: TestClient,
|
||||
session_id: str,
|
||||
body: dict[str, Any],
|
||||
values: dict[str, str],
|
||||
) -> dict[str, Any]:
|
||||
while body["stage"] == "ANCHOR_COLLECTING":
|
||||
block = active_component(body)
|
||||
data = block["data"]
|
||||
component = data["component"]
|
||||
if component == "date_range_selector":
|
||||
payload = {
|
||||
"start_date": values["start_date"],
|
||||
"end_date_or_present": values["end_date_or_present"],
|
||||
}
|
||||
response = event(client, session_id, body, "submit", payload)
|
||||
elif component == "degree_selector":
|
||||
response = event(
|
||||
client,
|
||||
session_id,
|
||||
body,
|
||||
"select",
|
||||
{"degree": values["degree"]},
|
||||
)
|
||||
else:
|
||||
field = data["field"]
|
||||
response = event(
|
||||
client,
|
||||
session_id,
|
||||
body,
|
||||
"submit",
|
||||
{"field": field, "value": values[field]},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
return body
|
||||
|
||||
|
||||
def campus_ready(client: TestClient) -> tuple[str, dict[str, Any]]:
|
||||
session_id, body = start_manual_profile(client, job_type="campus")
|
||||
assert body["stage"] == "ANCHOR_COLLECTING"
|
||||
assert body["gate"]["anchor_type"] == "education"
|
||||
assert body["missing_fields"] == [
|
||||
"school",
|
||||
"major",
|
||||
"degree",
|
||||
"start_date",
|
||||
"end_date_or_present",
|
||||
]
|
||||
described = client.post(
|
||||
f"{BASE}/sessions/{session_id}/messages",
|
||||
json={
|
||||
"content": "我就读于示例大学,专业是计算机科学,本科,2021年9月至2025年6月。"
|
||||
},
|
||||
)
|
||||
assert described.status_code == 200, described.text
|
||||
body = described.json()
|
||||
assert body["stage"] == "ANCHOR_CONFIRM"
|
||||
response = event(client, session_id, body, "confirm", {"confirmed": True})
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["stage"] == "MINIMUM_READY"
|
||||
assert body["draft_id"].startswith("draft_")
|
||||
assert body["gate"]["allowed"] is True
|
||||
return session_id, body
|
||||
|
||||
|
||||
def test_full_campus_flow_is_idempotent_and_masks_phone(client: TestClient) -> None:
|
||||
session_id, ready = campus_ready(client)
|
||||
assert active_component(ready)["data"]["component"] == "create_resume_card"
|
||||
|
||||
first = client.post(
|
||||
f"{BASE}/sessions/{session_id}/create",
|
||||
json={"idempotency_key": "create-once"},
|
||||
)
|
||||
assert first.status_code == 200, first.text
|
||||
result = first.json()
|
||||
assert result["created"] is True
|
||||
assert result["stage"] == "RESUME_ENRICHING"
|
||||
assert result["resume_id"] == result["resume"]["id"]
|
||||
assert result["resume"]["content"]["basics"]["masked_phone"] == "138****8000"
|
||||
assert "13800138000" not in json.dumps(result, ensure_ascii=False)
|
||||
|
||||
second = client.post(
|
||||
f"{BASE}/sessions/{session_id}/create",
|
||||
json={"idempotency_key": "another-key"},
|
||||
)
|
||||
assert second.status_code == 200
|
||||
assert second.json()["created"] is False
|
||||
assert second.json()["resume_id"] == result["resume_id"]
|
||||
|
||||
timeline = client.get(f"{BASE}/sessions/{session_id}/timeline")
|
||||
assert timeline.status_code == 200
|
||||
timeline_body = timeline.json()
|
||||
assert timeline_body["session"]["masked_phone"] == "138****8000"
|
||||
assert timeline_body["session"]["phone_source"] == "manual"
|
||||
assert "phone" not in timeline_body["session"]
|
||||
assert timeline_body["turns"][0]["blocks"][1]["lifecycle"] == "submitted"
|
||||
|
||||
|
||||
def test_manual_phone_is_strict_and_failed_event_is_retryable(client: TestClient) -> None:
|
||||
created = client.post(f"{BASE}/sessions", json={}).json()
|
||||
session_id = created["session_id"]
|
||||
accepted = event(client, session_id, created, "accept_privacy").json()
|
||||
manual = event(client, session_id, accepted, "use_other_phone").json()
|
||||
invalid = event(
|
||||
client,
|
||||
session_id,
|
||||
manual,
|
||||
"submit_manual_phone",
|
||||
{"phone": "+8613800138000"},
|
||||
)
|
||||
assert invalid.status_code == 422
|
||||
assert invalid.json()["error"]["code"] == "invalid_phone"
|
||||
|
||||
valid = event(
|
||||
client,
|
||||
session_id,
|
||||
manual,
|
||||
"submit_manual_phone",
|
||||
{"phone": "13900139000"},
|
||||
)
|
||||
assert valid.status_code == 200
|
||||
assert valid.json()["stage"] == "NAME_CAPTURE"
|
||||
|
||||
|
||||
def test_account_phone_is_normalized_but_never_exposed(client: TestClient) -> None:
|
||||
created_response = client.post(
|
||||
f"{BASE}/sessions", json={"account_phone": "+86 137-0013-7000"}
|
||||
)
|
||||
assert created_response.status_code == 201
|
||||
created = created_response.json()
|
||||
assert "13700137000" not in json.dumps(created)
|
||||
session_id = created["session_id"]
|
||||
selector = event(client, session_id, created, "accept", {"accepted": True}).json()
|
||||
named = event(client, session_id, selector, "select", {"source": "account"})
|
||||
assert named.status_code == 200
|
||||
assert "13700137000" not in named.text
|
||||
|
||||
timeline = client.get(f"{BASE}/sessions/{session_id}/timeline").json()
|
||||
assert timeline["session"]["masked_phone"] == "137****7000"
|
||||
assert timeline["session"]["phone_source"] == "account"
|
||||
|
||||
|
||||
def test_social_and_other_job_types_enforce_their_first_anchor(client: TestClient) -> None:
|
||||
social_id, social = start_manual_profile(client, job_type="experienced")
|
||||
assert social["gate"]["anchor_type"] == "work_experience"
|
||||
assert social["missing_fields"] == [
|
||||
"company",
|
||||
"position",
|
||||
"start_date",
|
||||
"end_date_or_present",
|
||||
]
|
||||
|
||||
other_id, other = start_manual_profile(client, job_type="other")
|
||||
assert other["stage"] == "ANCHOR_TYPE_SELECT"
|
||||
selected = event(
|
||||
client,
|
||||
other_id,
|
||||
other,
|
||||
"select_anchor_type",
|
||||
{"anchor_type": "internship_experience"},
|
||||
)
|
||||
assert selected.status_code == 200
|
||||
assert selected.json()["gate"]["anchor_type"] == "internship_experience"
|
||||
assert selected.json()["missing_fields"][0:2] == ["company", "position"]
|
||||
assert social_id != other_id
|
||||
|
||||
|
||||
def test_anchor_chat_extracts_known_facts_and_renders_only_the_next_gap(
|
||||
client: TestClient,
|
||||
) -> None:
|
||||
session_id, body = start_manual_profile(client, job_type="social")
|
||||
response = client.post(
|
||||
f"{BASE}/sessions/{session_id}/messages",
|
||||
json={"content": "我在星河科技有限公司担任产品经理。"},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
assert body["stage"] == "ANCHOR_COLLECTING"
|
||||
assert body["missing_fields"] == ["start_date", "end_date_or_present"]
|
||||
block = active_component(body)
|
||||
assert block["data"]["component"] == "date_range_selector"
|
||||
assert body["turn"]["composer_mode"] == "hybrid"
|
||||
|
||||
|
||||
def test_messages_rewrite_resume_and_short_text_requests_clarification(
|
||||
client: TestClient,
|
||||
) -> None:
|
||||
session_id, _ready = campus_ready(client)
|
||||
created = client.post(f"{BASE}/sessions/{session_id}/create", json={}).json()
|
||||
ready_component = active_component(created)
|
||||
enriching = event(
|
||||
client,
|
||||
session_id,
|
||||
created,
|
||||
"continue_enriching",
|
||||
)
|
||||
assert enriching.status_code == 200
|
||||
assert enriching.json()["stage"] == "RESUME_ENRICHING"
|
||||
|
||||
short = client.post(
|
||||
f"{BASE}/sessions/{session_id}/messages", json={"content": "做项目"}
|
||||
)
|
||||
assert short.status_code == 200
|
||||
assert short.json()["stage"] == "CONTENT_DISAMBIGUATION"
|
||||
|
||||
detailed = client.post(
|
||||
f"{BASE}/sessions/{session_id}/messages",
|
||||
json={"content": "在星河科技担任后端工程师,优化接口后延迟降低30%。"},
|
||||
)
|
||||
assert detailed.status_code == 200
|
||||
body = detailed.json()
|
||||
assert body["stage"] == "CONTENT_READY"
|
||||
assert body["gate"]["formal_content_ready"] is False
|
||||
assert active_component(body)["data"]["component"] == "experience_confirm_card"
|
||||
|
||||
confirmed = event(client, session_id, body, "confirm", {"confirmed": True})
|
||||
assert confirmed.status_code == 200, confirmed.text
|
||||
body = confirmed.json()
|
||||
patches = [block for block in body["turn"]["blocks"] if block["type"] == "resume_patch"]
|
||||
assert patches[0]["data"]["revision"] == 2
|
||||
assert body["gate"]["formal_content_ready"] is True
|
||||
assert ready_component["data"]["component"] == "content_ready_card"
|
||||
|
||||
|
||||
def test_delete_removes_session_and_cors_is_configured(client: TestClient) -> None:
|
||||
session_id = client.post(f"{BASE}/sessions", json={}).json()["session_id"]
|
||||
preflight = client.options(
|
||||
f"{BASE}/sessions/{session_id}/timeline",
|
||||
headers={
|
||||
"Origin": "http://localhost:5173",
|
||||
"Access-Control-Request-Method": "GET",
|
||||
},
|
||||
)
|
||||
assert preflight.status_code == 200
|
||||
assert preflight.headers["access-control-allow-origin"] == "http://localhost:5173"
|
||||
|
||||
deleted = client.delete(f"{BASE}/sessions/{session_id}")
|
||||
assert deleted.status_code == 204
|
||||
missing = client.get(f"{BASE}/sessions/{session_id}/timeline")
|
||||
assert missing.status_code == 404
|
||||
assert missing.json()["error"]["code"] == "session_not_found"
|
||||
Reference in New Issue
Block a user