feat: add resume agent MVP

This commit is contained in:
OfferPai
2026-07-20 14:48:41 +08:00
commit 48599bf55b
65 changed files with 10988 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
from __future__ import annotations
import sys
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
BACKEND_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(BACKEND_ROOT))
from app.main import create_app # noqa: E402
from app.services import RuleBasedExperienceExtractor, RuleBasedResumeRewriter # noqa: E402
@pytest.fixture
def client(tmp_path: Path) -> TestClient:
application = create_app(
database_path=tmp_path / "test.db",
cors_origins=["http://localhost:5173"],
extractor=RuleBasedExperienceExtractor(),
rewriter=RuleBasedResumeRewriter(),
)
with TestClient(application) as test_client:
yield test_client
+322
View File
@@ -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"
+277
View File
@@ -0,0 +1,277 @@
from __future__ import annotations
import json
from types import SimpleNamespace
from typing import Any
from app.llm_services import (
AnchorExtractionOutput,
OpenAICompatibleStructuredClient,
OpenAIExperienceExtractor,
OpenAIResumeRewriter,
)
from app.main import create_app
from app.settings import Settings, load_settings
class FakeCompletions:
def __init__(self, responses: list[str | Exception]) -> None:
self.responses = list(responses)
self.calls: list[dict[str, Any]] = []
def create(self, **kwargs: Any) -> Any:
self.calls.append(kwargs)
response = self.responses.pop(0)
if isinstance(response, Exception):
raise response
message = SimpleNamespace(content=response, parsed=None, refusal=None)
return SimpleNamespace(choices=[SimpleNamespace(message=message)])
class FakeOpenAI:
def __init__(self, responses: list[str | Exception]) -> None:
self.completions = FakeCompletions(responses)
self.chat = SimpleNamespace(completions=self.completions)
def llm_settings(**overrides: Any) -> Settings:
values: dict[str, Any] = {
"llm_provider": "openai",
"openai_api_key": "test-key-not-a-secret",
"openai_base_url": "https://example.test/v1",
"openai_model": "test-model",
"openai_timeout_seconds": 12.0,
"openai_max_retries": 2,
"structured_output_retries": 1,
"structured_output_mode": "json_schema",
"fallback_to_rules": False,
}
values.update(overrides)
return Settings(**values)
def anchor_response() -> str:
return json.dumps(
{
"record_type": "work_experience",
"field_updates": {
"school": None,
"major": None,
"degree": None,
"company": "星河科技有限公司",
"position": "产品经理",
"project_name": None,
"project_role": None,
"start_date": "2022-03",
"end_date_or_present": "present",
},
"evidence_spans": [
{"field": "company", "quote": "星河科技有限公司"},
{"field": "position", "quote": "产品经理"},
{"field": "start_date", "quote": "2022年3月"},
{"field": "end_date_or_present", "quote": "至今"},
],
"ambiguities": [],
},
ensure_ascii=False,
)
def test_anchor_extraction_retries_validates_and_redacts_phone() -> None:
fake = FakeOpenAI(["not-json", anchor_response()])
completion = OpenAICompatibleStructuredClient(llm_settings(), fake)
extractor = OpenAIExperienceExtractor(completion)
patch = extractor.extract_anchor(
"我从2022年3月至今在星河科技有限公司担任产品经理,电话13800138000",
"work_experience",
["company", "position", "start_date", "end_date_or_present"],
)
assert patch == {
"company": "星河科技有限公司",
"position": "产品经理",
"start_date": "2022-03",
"end_date_or_present": "present",
}
assert len(fake.completions.calls) == 2
call = fake.completions.calls[-1]
assert call["model"] == "test-model"
assert call["timeout"] == 12.0
assert call["response_format"]["type"] == "json_schema"
serialized_messages = json.dumps(call["messages"], ensure_ascii=False)
assert "13800138000" not in serialized_messages
assert "[手机号已脱敏]" in serialized_messages
def test_experience_extraction_uses_pydantic_and_exact_evidence() -> None:
response = json.dumps(
{
"title": "后端工程师",
"organization": "星河科技",
"role": "后端工程师",
"highlights": ["优化接口耗时,降低30%"],
"metrics": ["30%", "99%"],
"confidence": 0.93,
"evidence_spans": [
{"field": "organization", "quote": "星河科技"},
{"field": "role", "quote": "后端工程师"},
{"field": "highlights", "quote": "优化接口耗时,降低30%"},
],
"ambiguities": [],
},
ensure_ascii=False,
)
extractor = OpenAIExperienceExtractor(
OpenAICompatibleStructuredClient(llm_settings(), FakeOpenAI([response]))
)
result = extractor.extract("在星河科技担任后端工程师,优化接口耗时,降低30%")
assert result.organization == "星河科技"
assert result.highlights == ["优化接口耗时,降低30%"]
assert result.metrics == ["30%"]
assert result.confidence == 0.95
def test_sdk_boundary_redacts_email_wechat_and_split_phone() -> None:
fake = FakeOpenAI([anchor_response()])
completion = OpenAICompatibleStructuredClient(llm_settings(), fake)
completion.complete(
schema=AnchorExtractionOutput,
schema_name="resume_anchor_extraction",
system_prompt="extract",
payload={
"user_text": (
"手机 138-0013-8000,邮箱 user@example.com,微信号: resume_helper"
)
},
)
request_text = json.dumps(fake.completions.calls[0]["messages"], ensure_ascii=False)
assert "138-0013-8000" not in request_text
assert "user@example.com" not in request_text
assert "resume_helper" not in request_text
assert "[手机号已脱敏]" in request_text
assert "[邮箱已脱敏]" in request_text
assert "[微信号已脱敏]" in request_text
def test_json_object_mode_includes_the_pydantic_schema() -> None:
fake = FakeOpenAI([anchor_response()])
settings = llm_settings(structured_output_mode="json_object")
completion = OpenAICompatibleStructuredClient(settings, fake)
completion.complete(
schema=AnchorExtractionOutput,
schema_name="resume_anchor_extraction",
system_prompt="提取事实。",
payload={"user_text": "在星河科技担任产品经理"},
)
call = fake.completions.calls[0]
assert call["response_format"] == {"type": "json_object"}
assert "output_json_schema" in call["messages"][1]["content"]
assert "只返回" in call["messages"][0]["content"]
def test_rewriter_sends_allow_listed_facts_and_rejects_new_numbers() -> None:
response = json.dumps(
{
"items": [
{
"source_id": "experience_0",
"bullets": [
{
"text": "优化接口性能,将接口耗时降低30%",
"evidence": ["优化接口耗时,降低30%"],
},
{
"text": "支持100万用户稳定访问",
"evidence": ["优化接口耗时,降低30%"],
},
],
}
]
},
ensure_ascii=False,
)
fake = FakeOpenAI([response])
rewriter = OpenAIResumeRewriter(
OpenAICompatibleStructuredClient(llm_settings(), fake)
)
profile = {
"name": "张三",
"phone": "13800138000",
"account_phone": "13900139000",
"phone_source": "manual",
"metadata": {"private_note": "never-send-this"},
"job_type": "social",
"anchor_type": "work_experience",
"anchor": {
"company": "星河科技",
"position": "后端工程师",
"start_date": "2022-01",
"end_date_or_present": "present",
},
"experiences": [
{
"raw_text": "联系电话13800138000",
"title": "后端工程师",
"organization": "星河科技",
"role": "后端工程师",
"highlights": ["优化接口耗时,降低30%"],
"metrics": ["30%"],
"confidence": 0.9,
}
],
}
resume = rewriter.rewrite(profile)
assert resume["basics"]["masked_phone"] == "138****8000"
item = resume["sections"][1]["items"][0]
assert item["resume_bullets"] == ["优化接口性能,将接口耗时降低30%"]
request_text = json.dumps(fake.completions.calls[0]["messages"], ensure_ascii=False)
assert "13800138000" not in request_text
assert "13900139000" not in request_text
assert "never-send-this" not in request_text
assert "张三" not in request_text
def test_settings_load_dotenv_and_create_app_wires_openai_defaults(
tmp_path, monkeypatch
) -> None:
env_file = tmp_path / ".env"
env_file.write_text(
"\n".join(
[
"RESUME_AGENT_LLM_PROVIDER=openai",
"OPENAI_API_KEY=dummy-key",
"OPENAI_BASE_URL=https://gateway.test",
"OPENAI_MODEL=test-model",
"RESUME_AGENT_LLM_FALLBACK_TO_RULES=false",
]
),
encoding="utf-8",
)
for name in (
"RESUME_AGENT_LLM_PROVIDER",
"OPENAI_API_KEY",
"OPENAI_BASE_URL",
"OPENAI_MODEL",
"RESUME_AGENT_LLM_FALLBACK_TO_RULES",
):
monkeypatch.delenv(name, raising=False)
settings = load_settings(env_file)
fake = FakeOpenAI([anchor_response()])
application = create_app(
database_path=tmp_path / "llm.db",
settings=settings,
openai_client=fake,
)
assert isinstance(application.state.resume_agent.extractor, OpenAIExperienceExtractor)
assert settings.openai_base_url == "https://gateway.test"
assert "dummy-key" not in repr(settings)
+61
View File
@@ -0,0 +1,61 @@
from app.services import RuleBasedExperienceExtractor, RuleBasedResumeRewriter
from app.validators import anchor_missing_fields, can_create_resume
def test_rule_based_services_are_deterministic() -> None:
extractor = RuleBasedExperienceExtractor()
result = extractor.extract("在星河科技担任后端工程师,接口耗时降低30%")
assert result.organization == "星河科技"
assert result.metrics == ["30%"]
assert result.confidence >= 0.5
rewriter = RuleBasedResumeRewriter()
resume = rewriter.rewrite(
{
"name": "张三",
"phone": "13800138000",
"phone_source": "manual",
"job_type": "campus",
"anchor_type": "education",
"anchor": {"school": "示例大学"},
"experiences": [result.to_dict()],
}
)
assert resume["basics"]["masked_phone"] == "138****8000"
assert resume["sections"][0]["kind"] == "education"
assert resume == rewriter.rewrite(
{
"name": "张三",
"phone": "13800138000",
"phone_source": "manual",
"job_type": "campus",
"anchor_type": "education",
"anchor": {"school": "示例大学"},
"experiences": [result.to_dict()],
}
)
def test_creation_gate_requires_confirmation_and_valid_date_order() -> None:
profile = {
"privacy_accepted": True,
"phone": "13800138000",
"name": "张三",
"job_type": "social",
"anchor_type": "work_experience",
"anchor": {
"company": "星河科技",
"position": "产品经理",
"start_date": "2024-06",
"end_date_or_present": "2023-06",
},
}
required = ["company", "position", "start_date", "end_date_or_present"]
missing = anchor_missing_fields(profile, required)
assert missing == ["end_date_or_present"]
assert can_create_resume(profile, missing) is False
profile["anchor"]["end_date_or_present"] = "present"
assert can_create_resume(profile, anchor_missing_fields(profile, required)) is False
profile["anchor_confirmed"] = True
assert can_create_resume(profile, anchor_missing_fields(profile, required)) is True