feat: builder 简历生成 + 轻度优化 + 简历导入交付副本

自内部仓库剥离深度优化与 RAG 知识库后的交付版本:
- Builder 对话式简历生成(FSM + 意图路由 LLM 兜底增强)
- 条目级轻度优化:事实覆盖门禁 + STAR/bullet 修复链,功能/简介/成果与技术栈同级保护
- 简历导入:DOCX/PDF 解析、结构归一、手机号脱敏
- PostgreSQL 运行时 + Alembic 迁移链

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
hyp
2026-08-05 11:08:30 +08:00
co-authored by Claude
commit ae2d9b128d
191 changed files with 27719 additions and 0 deletions
+65
View File
@@ -0,0 +1,65 @@
"""Shared helpers for Builder conversation tests."""
from __future__ import annotations
from typing import Any
from fastapi.testclient import TestClient
from test_api import BASE, active_component, event, start_manual_profile # noqa: F401
EDUCATION_IDENTITY = {
"school": "Example University",
"major": "Computer Science",
"degree": "Bachelor",
"start_date": "2021-09",
"end_date_or_present": "2025-06",
}
def create_builder_session(client: TestClient, *, job_type: str = "campus") -> tuple[str, dict[str, Any]]:
session_id, _ = start_manual_profile(client, job_type=job_type)
created = client.post(f"{BASE}/sessions/{session_id}/create", json={})
assert created.status_code == 200, created.text
body = created.json()
assert body["stage"] == "BUILDER_CONVERSATION"
return session_id, body
def select_section(client: TestClient, session_id: str, body: dict[str, Any], section: str) -> dict[str, Any]:
response = event(client, session_id, body, "select", {"value": section})
assert response.status_code == 200, response.text
return response.json()
def submit_identity(client: TestClient, session_id: str, body: dict[str, Any], values: dict[str, str]) -> dict[str, Any]:
response = event(client, session_id, body, "submit", values)
assert response.status_code == 200, response.text
return response.json()
def send_message(client: TestClient, session_id: str, content: str) -> dict[str, Any]:
response = client.post(f"{BASE}/sessions/{session_id}/messages", json={"content": content})
assert response.status_code == 200, response.text
return response.json()
def confirm_card(client: TestClient, session_id: str, body: dict[str, Any], *, use_optimized: bool = False) -> dict[str, Any]:
response = event(client, session_id, body, "confirm", {"use_optimized": use_optimized})
assert response.status_code == 200, response.text
return response.json()
def start_education(client: TestClient, session_id: str, body: dict[str, Any], *, school: str = "Example University") -> dict[str, Any]:
card = select_section(client, session_id, body, "education")
identity = {**EDUCATION_IDENTITY, "school": school}
prompt = submit_identity(client, session_id, card, identity)
assert "没有也可以直接说没有" in prompt["turn"]["content"]
return prompt
def finish_education(client: TestClient, session_id: str, body: dict[str, Any], detail: str) -> dict[str, Any]:
proposal = send_message(client, session_id, detail)
assert active_component(proposal)["data"]["component"] == "experience_confirm_card"
return proposal
+45
View File
@@ -0,0 +1,45 @@
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 ( # noqa: E402
RuleBasedEntryExpander,
RuleBasedExperienceExtractor,
RuleBasedResumeRewriter,
)
from app.settings import Settings # noqa: E402
@pytest.fixture(autouse=True)
def _intent_router_off_in_tests(monkeypatch: pytest.MonkeyPatch) -> None:
"""Keep the LLM intent gate hermetic: rescue.py lazy-loads global settings,
so a developer .env with RESUME_AGENT_INTENT_ROUTER_MODE=on must not leak
real LLM calls into the suite. Tests that need the gate stub the classifier
on the agent directly."""
monkeypatch.setattr(
"app.builder_conversation.rescue.load_settings",
lambda: Settings(llm_provider="rule", intent_router_mode="off"),
)
@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(),
expander=RuleBasedEntryExpander(),
settings=Settings(llm_provider="rule"),
)
with TestClient(application) as test_client:
yield test_client
+43
View File
@@ -0,0 +1,43 @@
from __future__ import annotations
import os
from pathlib import Path
from uuid import uuid4
from alembic import command
from alembic.config import Config
from sqlalchemy import create_engine, text
def test_alembic_upgrade_creates_core_postgres_schema() -> None:
schema = f"test_alembic_{uuid4().hex}"
database_url = os.environ["RESUME_AGENT_TEST_DATABASE_URL"]
config = Config(str(Path(__file__).resolve().parents[1] / "alembic.ini"))
config.set_main_option("sqlalchemy.url", database_url)
config.set_main_option("resume_agent.schema", schema)
command.upgrade(config, "head")
engine = create_engine(database_url)
try:
with engine.connect() as connection:
tables = connection.execute(
text(
"SELECT tablename FROM pg_tables "
"WHERE schemaname = :schema ORDER BY tablename"
),
{"schema": schema},
).scalars().all()
assert tables == [
"alembic_version",
"blocks",
"optimization_runs",
"resume_imports",
"resumes",
"sessions",
"turns",
]
finally:
with engine.begin() as connection:
connection.execute(text(f'DROP SCHEMA IF EXISTS "{schema}" CASCADE'))
engine.dispose()
+170
View File
@@ -0,0 +1,170 @@
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)
return client.post(
f"{BASE}/sessions/{session_id}/component-events",
json={"component_id": block["id"], "event": event_name, "payload": payload or {}},
)
def start_manual_profile(
client: TestClient,
*,
job_type: str = "campus",
target_position: str | None = "Backend Engineer",
) -> tuple[str, dict[str, Any]]:
created = client.post(f"{BASE}/sessions", json={})
assert created.status_code == 201
body = created.json()
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"})
assert phone_selector.status_code == 200
assert phone_selector.json()["stage"] == "PHONE_SELECTION"
phone_input = event(client, session_id, phone_selector.json(), "select", {"source": "other"})
assert phone_input.status_code == 200
assert phone_input.json()["stage"] == "MANUAL_PHONE_INPUT"
personal = event(client, session_id, phone_input.json(), "submit", {"phone": "13800138000"})
assert personal.status_code == 200
assert personal.json()["stage"] == "PERSONAL_INFO"
job_selector = event(
client,
session_id,
personal.json(),
"submit",
{"name": "Zhang San", "email": "zhangsan@example.com", "city": "Shanghai"},
)
assert job_selector.status_code == 200
assert job_selector.json()["stage"] == "JOB_TYPE_SELECT"
target = event(client, session_id, job_selector.json(), "select", {"job_type": job_type})
assert target.status_code == 200
assert target.json()["stage"] == "TARGET_POSITION"
if target_position is None:
ready = event(client, session_id, target.json(), "skip")
else:
ready = event(client, session_id, target.json(), "submit", {"target_position": target_position})
assert ready.status_code == 200, ready.text
assert ready.json()["stage"] == "MINIMUM_READY"
return session_id, ready.json()
ANCHOR_CARD_VALUES = {
"school": "Example University",
"major": "Computer Science",
"degree": "Bachelor",
"start_date": "2021-09",
"end_date_or_present": "2025-06",
}
def fill_anchor(
client: TestClient,
session_id: str,
body: dict[str, Any],
values: dict[str, str],
) -> dict[str, Any]:
response = event(client, session_id, body, "submit", values)
assert response.status_code == 200, response.text
return response.json()
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:
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"}
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_input = event(client, session_id, phone_selector, "select", {"source": "other"}).json()
invalid = event(client, session_id, phone_input, "submit", {"phone": "+8613800138000"})
assert invalid.status_code == 422
assert invalid.json()["error"]["code"] == "invalid_phone"
valid = event(client, session_id, phone_input, "submit", {"phone": "13900139000"})
assert valid.status_code == 200
assert valid.json()["stage"] == "PERSONAL_INFO"
def test_target_position_creates_a_basic_resume_without_core_experience(client: TestClient) -> None:
session_id, ready = start_manual_profile(client, job_type="social")
assert ready["gate"]["allowed"] is True
assert ready["missing_fields"] == []
created = client.post(f"{BASE}/sessions/{session_id}/create", json={"idempotency_key": "basic-resume"})
assert created.status_code == 200, created.text
result = created.json()
assert result["created"] is True
assert result["stage"] == "BUILDER_CONVERSATION"
assert result["resume"]["content"]["sections"] == []
def test_full_campus_creation_is_idempotent_and_masks_phone(client: TestClient) -> None:
session_id, ready = campus_ready(client)
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"] == "BUILDER_CONVERSATION"
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"]
def test_target_position_exploration_can_create_without_core_experience(client: TestClient) -> None:
session_id, ready = start_manual_profile(client, job_type="campus", target_position=None)
assert ready["stage"] == "MINIMUM_READY"
assert ready["gate"]["allowed"] is True
assert session_id
+31
View File
@@ -0,0 +1,31 @@
"""Production docs gate: /docs, /redoc, /openapi.json 404 unless RESUME_AGENT_API_DOCS opts in."""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from app.asgi import application
@pytest.fixture(autouse=True)
def _docs_flag_cleared(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("RESUME_AGENT_API_DOCS", raising=False)
def test_api_docs_are_not_served_by_default() -> None:
client = TestClient(application)
assert client.get("/docs").status_code == 404
assert client.get("/redoc").status_code == 404
assert client.get("/openapi.json").status_code == 404
def test_app_routes_still_work_through_the_gate() -> None:
client = TestClient(application)
assert client.get("/health").status_code == 200
def test_api_docs_can_be_enabled_explicitly(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("RESUME_AGENT_API_DOCS", "1")
client = TestClient(application)
assert client.get("/openapi.json").status_code == 200
@@ -0,0 +1,111 @@
"""Candidate rewrite guards for the Builder light optimization (截图1/截图2 回归)."""
from __future__ import annotations
from typing import Any
from app.builder_conversation import _candidate_rewrite
class _StaticExpander:
def __init__(self, optimized: str) -> None:
self.optimized = optimized
def expand(self, entry: dict[str, Any], *, context: dict[str, Any]) -> dict[str, Any]:
return {"optimized_description": self.optimized, "source": "test"}
class _Agent:
def __init__(self, optimized: str) -> None:
self.expander = _StaticExpander(optimized)
def test_candidate_rewrite_does_not_inject_identity_into_education_description() -> None:
"""Identity fields have their own card slots; never merge them into the narrative (截图2)."""
proposal = _candidate_rewrite(
_Agent("在学校中学习数据结构、计算机视觉等课程。"),
{"job_type": "campus"},
{
"school": "东莞城市学院",
"major": "软件工程",
"degree": "本科",
"description": "在学校中学习数据结构、计算机视觉等课程。",
},
"education",
)
assert proposal["optimized_description"] == "在学校中学习数据结构、计算机视觉等课程。"
assert "东莞城市学院" not in proposal["optimized_description"]
def test_candidate_rewrite_reports_uncovered_facts_without_appending() -> None:
"""Uncovered user facts are reported, not stitched onto the candidate (截图1 关键词尾巴)."""
proposal = _candidate_rewrite(
_Agent("完成数据库课程项目并参与实验室实践。"),
{"job_type": "campus", "target_position": "backend engineer"},
{"description": "完成数据库课程项目。GPA: 4.3/5.0,排名前百分之10。"},
"education",
)
assert proposal["optimized_description"] == "完成数据库课程项目并参与实验室实践。"
assert proposal["uncovered_facts"] == ["GPA: 4.3/5.0", "排名前百分之10"]
def test_candidate_rewrite_reports_other_uncovered_user_facts() -> None:
original = (
"完成数据库课程项目,使用 Python 和 SQL 实现信息查询。"
"获得校级一等奖学金,服务 300 名学生。"
)
proposal = _candidate_rewrite(
_Agent("参与学习与实践活动。"),
{"job_type": "campus"},
{"description": original},
"education",
)
assert proposal["optimized_description"] == "参与学习与实践活动。"
for fact in ("完成数据库课程项目", "Python", "SQL", "获得校级一等奖学金", "服务 300 名学生"):
assert fact in proposal["uncovered_facts"]
def test_candidate_rewrite_reports_no_uncovered_facts_when_candidate_covers_all() -> None:
proposal = _candidate_rewrite(
_Agent("完成数据库课程项目。GPA: 4.3/5.0。"),
{"job_type": "campus"},
{"description": "完成数据库课程项目。GPA: 4.3/5.0。"},
"education",
)
assert proposal["uncovered_facts"] == []
def test_candidate_rewrite_reports_dropped_function_modules() -> None:
"""功能模块/平台简介被吞时必须进入未覆盖报告(只保留技术栈不算覆盖)。"""
original = (
"全栈 AI 求职助手平台,包含 5 大功能模块:\n"
"1. AI 对话式简历生成助手\n"
"2. 简历导入 (PDF/DOCX 智能解析)\n"
"技术栈: Next.js + React"
)
proposal = _candidate_rewrite(
_Agent("• 前端采用 Next.js 与 React 实现响应式界面。"),
{"job_type": "campus"},
{"description": original},
"project_experience",
)
assert any("AI 对话式简历生成助手" in fact for fact in proposal["uncovered_facts"])
assert any("简历导入" in fact for fact in proposal["uncovered_facts"])
assert not any("Next.js" in fact for fact in proposal["uncovered_facts"])
def test_candidate_rewrite_tolerates_covered_fragments_without_false_positives() -> None:
original = "1. AI 对话式简历生成助手\n2. 简历导入智能解析"
proposal = _candidate_rewrite(
_Agent("负责 AI 对话式简历生成助手与简历导入智能解析两大模块。"),
{"job_type": "campus"},
{"description": original},
"project_experience",
)
assert proposal["uncovered_facts"] == []
+166
View File
@@ -0,0 +1,166 @@
from __future__ import annotations
from typing import Any
from fastapi.testclient import TestClient
from app.resume_document_mutations import set_generated_profile_summary
from builder_flow_helpers import (
active_component,
confirm_card,
create_builder_session,
event,
finish_education,
select_section,
send_message,
start_education,
submit_identity,
)
def test_create_resume_suggests_experience_type_before_showing_identity_card(client: TestClient) -> None:
_, body = create_builder_session(client, job_type="campus")
card = active_component(body)
assert card["data"]["component"] == "choice_chips"
assert card["data"]["module"] == "builder_next_section"
assert card["data"]["value"] == "education"
assert all(block["data"].get("component") != "record_fields" for block in body["turn"]["blocks"] if block["type"] == "component")
def test_next_step_offers_skill_recommendation_and_finish_actions(client: TestClient) -> None:
_, body = create_builder_session(client)
card = active_component(body)
values = {option["value"] for option in card["data"]["options"]}
assert "builder_recommend_skills" in values
assert "builder_finish" in values
def test_builder_skill_recommendations_require_confirmation_before_write(client: TestClient) -> None:
session_id, body = create_builder_session(client)
recommended = event(client, session_id, body, "select", {"value": "builder_recommend_skills"})
assert recommended.status_code == 200, recommended.text
recommendation_card = active_component(recommended.json())
assert recommendation_card["data"]["module"] == "builder_skill_select"
assert recommendation_card["data"]["multiple"] is True
options = recommendation_card["data"]["options"]
assert options
assert recommended.json()["resume"]["content"].get("skill_groups") == []
chosen = options[0]["value"]
confirmed = event(
client,
session_id,
recommended.json(),
"select",
{"values": [chosen], "value": chosen},
)
assert confirmed.status_code == 200, confirmed.text
skills = [
skill
for group in confirmed.json()["resume"]["content"]["skill_groups"]
for skill in group["skills"]
]
assert chosen in skills
assert any(block["type"] == "resume_patch" for block in confirmed.json()["turn"]["blocks"])
def test_builder_finish_generates_summary_from_current_resume(client: TestClient) -> None:
session_id, body = create_builder_session(client)
finished = event(client, session_id, body, "select", {"value": "builder_finish"})
assert finished.status_code == 200, finished.text
payload = finished.json()
summary = payload["resume"]["content"]["profile_summary"]
assert summary["source"] == "ai_generated"
assert summary["stale"] is False
assert summary["content"]
assert "resume_patch" in {block["type"] for block in payload["turn"]["blocks"]}
assert payload.get("builder_stream_phases") == ["saving"]
def test_generated_summary_replaces_only_stale_builder_summary() -> None:
stale = {
"profile_summary": {"content": "旧的个人总结内容足够长,可以被新的总结替换。", "stale": True},
}
refreshed = set_generated_profile_summary(stale, "根据最新简历信息生成的个人总结内容足够长。", replace_stale=True)
assert refreshed["profile_summary"]["content"] == "根据最新简历信息生成的个人总结内容足够长。"
assert refreshed["profile_summary"]["stale"] is False
current = {
"profile_summary": {"content": "用户手工维护的总结内容足够长,不应被自动覆盖。", "stale": False},
}
preserved = set_generated_profile_summary(current, "新的自动总结内容足够长。", replace_stale=True)
assert preserved["profile_summary"]["content"] == current["profile_summary"]["content"]
def test_social_builder_suggests_work_but_allows_another_experience_type(client: TestClient) -> None:
session_id, body = create_builder_session(client, job_type="social")
assert active_component(body)["data"]["value"] == "work_experience"
response = select_section(client, session_id, body, "project_experience")
assert active_component(response)["data"]["record_type"] == "project_experience"
def test_education_missing_facts_asks_follow_up_before_confirmation(client: TestClient) -> None:
session_id, body = create_builder_session(client)
start_education(client, session_id, body)
follow_up = send_message(client, session_id, "学习了数据结构和数据库课程。")
assert "GPA/均分" in follow_up["turn"]["content"]
assert "课程项目" in follow_up["turn"]["content"]
assert not any(block["data"].get("component") == "experience_confirm_card" for block in follow_up["turn"]["blocks"] if block["type"] == "component")
proposal = send_message(client, session_id, "GPA 3.7/4.0,专业前 20%,完成数据库课程项目。")
card = active_component(proposal)
assert card["data"]["component"] == "experience_confirm_card"
assert "学习了数据结构" in card["data"]["value"]["description"]
assert "GPA 3.7/4.0" in card["data"]["value"]["description"]
def test_no_information_reply_skips_asked_gaps_and_then_shows_confirmation(client: TestClient) -> None:
session_id, body = create_builder_session(client)
start_education(client, session_id, body)
first_follow_up = send_message(client, session_id, "学习了软件工程相关课程。")
assert "没有也可以直接说没有" in first_follow_up["turn"]["content"]
proposal = send_message(client, session_id, "没有")
card = active_component(proposal)
assert card["data"]["component"] == "experience_confirm_card"
assert card["data"]["value"]["description"] == "学习了软件工程相关课程。"
def test_confirmed_campus_education_recommends_project_experience(client: TestClient) -> None:
session_id, body = create_builder_session(client)
start_education(client, session_id, body)
proposal = finish_education(client, session_id, body, "GPA 3.7/4.0,获得两次校级奖学金,完成数据库课程项目。")
confirmed = confirm_card(client, session_id, proposal)
entry = next(block for block in confirmed["turn"]["blocks"] if block["type"] == "resume_patch")["data"]["value"]["sections"][0]["items"][0]
assert entry["description"].startswith("GPA 3.7/4.0")
next_card = active_component(confirmed)
assert next_card["data"]["component"] == "choice_chips"
assert next_card["data"]["value"] == "project_experience"
def test_project_gaps_are_limited_and_only_candidate_after_follow_up(client: TestClient) -> None:
session_id, body = create_builder_session(client)
identity_card = select_section(client, session_id, body, "project_experience")
detail_prompt = submit_identity(
client,
session_id,
identity_card,
{
"project_name": "Resume Agent",
"project_role": "Developer",
"start_date": "2024-01",
"end_date_or_present": "2024-06",
},
)
assert "没有也可以直接说没有" in detail_prompt["turn"]["content"]
first_follow_up = send_message(client, session_id, "负责后端接口开发,使用 Python 和 FastAPI。")
questions = [line for line in first_follow_up["turn"]["content"].splitlines() if line]
assert len(questions) == 2
assert "交付物" in first_follow_up["turn"]["content"]
assert "量化信息" in first_follow_up["turn"]["content"]
proposal = send_message(client, session_id, "交付 REST API 并上线,覆盖 3 个业务流程。")
assert active_component(proposal)["data"]["component"] == "experience_confirm_card"
+108
View File
@@ -0,0 +1,108 @@
"""Detail-path guards: skip intents never pollute drafts; LLM gate routes before fact-merge."""
from __future__ import annotations
from types import SimpleNamespace
from typing import Any
from app.builder_conversation.candidate import _candidate_rewrite
from app.builder_conversation.predicates import _is_no_information_reply
from app.builder_conversation.rescue import llm_detail_route
from app.chat_intents import ChatTurnClassification
from builder_flow_helpers import create_builder_session, send_message, start_education
from test_api import active_component
class _StubClassifier:
def __init__(self, result: ChatTurnClassification) -> None:
self.result = result
def classify(self, message: str, *, state_summary: dict[str, Any]) -> ChatTurnClassification:
return self.result
def _agent(classifier: Any) -> Any:
return SimpleNamespace(
expander=SimpleNamespace(expand=lambda entry, *, context: {}),
_chat_intent_classifier=classifier,
)
def _profile_with_draft() -> dict[str, Any]:
return {
"job_type": "campus",
"builder": {
"active_section": "education",
"identity_draft": {"school": "X 大学", "description": "完成数据库课程项目。"},
"gap_state": {"asked": ["academic_result"], "skipped": [], "rounds": 1},
},
}
def test_skip_words_count_as_no_information() -> None:
for word in ("跳过", "先跳过", "跳过吧", "不用了", "不需要", "以后再说", "没有了"):
assert _is_no_information_reply(word), word
def test_skip_reply_does_not_pollute_description(client: Any) -> None:
session_id, body = create_builder_session(client)
start_education(client, session_id, body)
send_message(client, session_id, "完成数据库课程项目。") # triggers the gap prompt
reply = send_message(client, session_id, "跳过")
card = active_component(reply)["data"]
assert card["component"] == "experience_confirm_card"
assert "跳过" not in str(card["value"].get("description") or "")
def test_detail_gate_no_info_skips_gap_without_merging() -> None:
agent = _agent(_StubClassifier(ChatTurnClassification(intent="no_info", confidence=0.9)))
transition = llm_detail_route(agent, _profile_with_draft(), "先跳过这个")
assert transition is not None
state = transition.profile["builder"]
assert "先跳过这个" not in state["identity_draft"]["description"]
assert "academic_result" in state["gap_state"]["skipped"]
def test_detail_gate_revise_regenerates_candidate() -> None:
agent = _agent(_StubClassifier(ChatTurnClassification(
intent="revise_proposal", confidence=0.9, revision_instruction="再简洁一点",
)))
transition = llm_detail_route(agent, _profile_with_draft(), "帮我再精简下")
assert transition is not None
assert transition.profile["builder"]["pending_entry"]["_proposal"] is not None
assert "帮我再精简下" not in transition.profile["builder"]["identity_draft"]["description"]
def test_detail_gate_chitchat_does_not_merge() -> None:
agent = _agent(_StubClassifier(ChatTurnClassification(intent="chitchat", confidence=0.9)))
transition = llm_detail_route(agent, _profile_with_draft(), "好的谢谢")
assert transition is not None
assert transition.profile["builder"]["identity_draft"]["description"] == "完成数据库课程项目。"
def test_detail_gate_passes_facts_and_low_confidence_through() -> None:
facts = _agent(_StubClassifier(ChatTurnClassification(intent="provide_facts", confidence=0.9)))
assert llm_detail_route(facts, _profile_with_draft(), "GPA 4.3") is None
shaky = _agent(_StubClassifier(ChatTurnClassification(intent="no_info", confidence=0.4)))
assert llm_detail_route(shaky, _profile_with_draft(), "跳过") is None
def test_candidate_rewrite_ensure_facts_appends_missing() -> None:
class _Expander:
def expand(self, entry: dict[str, Any], *, context: dict[str, Any]) -> dict[str, Any]:
return {"optimized_description": "主修课程:数据结构、计算机视觉。", "source": "test"}
agent = SimpleNamespace(expander=_Expander())
proposal = _candidate_rewrite(
agent,
{"job_type": "campus"},
{"description": "学习数据结构、计算机视觉课程。GPA: 4.3/5.0,排名前百分之10。"},
"education",
ensure_facts=True,
)
assert "GPA: 4.3/5.0" in proposal["optimized_description"]
assert "排名前百分之10" in proposal["optimized_description"]
assert proposal["uncovered_facts"] == []
+171
View File
@@ -0,0 +1,171 @@
"""Builder follow-up flows: editing, continuation, selection, streaming, revision."""
from __future__ import annotations
import json
from fastapi.testclient import TestClient
from builder_flow_helpers import (
BASE,
active_component,
confirm_card,
create_builder_session,
event,
finish_education,
send_message,
start_education,
)
def test_editing_existing_entry_merges_facts_without_refilling_identity(client: TestClient) -> None:
session_id, body = create_builder_session(client)
start_education(client, session_id, body)
created = finish_education(client, session_id, body, "GPA 3.7/4.0,完成数据库课程项目。")
confirm_card(client, session_id, created)
edit_start = send_message(client, session_id, "修改 Example University 教育经历")
assert "无需重填" in edit_start["turn"]["content"]
assert not any(block["type"] == "component" for block in edit_start["turn"]["blocks"])
revised = send_message(client, session_id, "补充获得两次校级奖学金。")
card = active_component(revised)
assert card["data"]["component"] == "experience_confirm_card"
assert "GPA 3.7/4.0" in card["data"]["value"]["description"]
assert "两次校级奖学金" in card["data"]["value"]["description"]
def test_recent_confirmed_entry_accepts_natural_follow_up_without_refilling_identity(client: TestClient) -> None:
session_id, body = create_builder_session(client)
start_education(client, session_id, body)
proposal = finish_education(client, session_id, body, "GPA 3.7/4.0,完成数据库课程项目。")
confirm_card(client, session_id, proposal, use_optimized=True)
revised = send_message(client, session_id, "对了,我还获得过校级一等奖学金。")
assert "已收到这条补充信息" in revised["turn"]["content"]
card = active_component(revised)
assert card["data"]["component"] == "experience_confirm_card"
assert "GPA 3.7/4.0" in card["data"]["value"]["description"]
assert "校级一等奖学金" in card["data"]["value"]["description"]
assert not any(
block["data"].get("component") == "record_fields"
for block in revised["turn"]["blocks"]
if block["type"] == "component"
)
assert not any(
block["data"].get("module") == "builder_next_section"
for block in revised["turn"]["blocks"]
if block["type"] == "component"
)
def test_multiple_same_type_entries_offer_a_selection_card(client: TestClient) -> None:
session_id, body = create_builder_session(client)
start_education(client, session_id, body, school="First University")
first = finish_education(client, session_id, body, "GPA 3.6/4.0,完成课程项目。")
after_first = confirm_card(client, session_id, first)
start_education(client, session_id, after_first, school="Second University")
second = finish_education(client, session_id, after_first, "获得学业奖学金,参与实验室实践。")
confirm_card(client, session_id, second)
response = send_message(client, session_id, "修改教育经历")
card = active_component(response)
assert card["data"]["component"] == "choice_chips"
assert card["data"]["module"] == "builder_entry_select"
assert len(card["data"]["options"]) == 2
def test_builder_message_stream_emits_gap_and_rewrite_statuses(client: TestClient) -> None:
session_id, body = create_builder_session(client)
start_education(client, session_id, body)
with client.stream(
"POST",
f"{BASE}/sessions/{session_id}/messages/stream",
json={"content": "GPA 3.8/4.0,专业前 10%,完成数据库课程项目。"},
) as response:
assert response.status_code == 200
raw = b"".join(response.iter_bytes()).decode("utf-8")
events = [line.removeprefix("event: ") for line in raw.splitlines() if line.startswith("event: ")]
frames = [line.removeprefix("data: ") for line in raw.splitlines() if line.startswith("data: ")]
statuses = [json.loads(frame)["phase"] for event_name, frame in zip(events, frames) if event_name == "status"]
assert statuses == ["structuring", "checking_gaps", "rewriting"]
assert "delta" in events
assert events[-1] == "complete"
assert json.loads(frames[-1])["stage"] == "BUILDER_CONVERSATION"
def test_recent_entry_stream_emits_structuring_and_rewriting_only(client: TestClient) -> None:
session_id, body = create_builder_session(client)
start_education(client, session_id, body)
proposal = finish_education(client, session_id, body, "GPA 3.7/4.0,完成数据库课程项目。")
confirm_card(client, session_id, proposal)
with client.stream(
"POST",
f"{BASE}/sessions/{session_id}/messages/stream",
json={"content": "对了,我还获得过校级一等奖学金。"},
) as response:
assert response.status_code == 200
raw = b"".join(response.iter_bytes()).decode("utf-8")
events = [line.removeprefix("event: ") for line in raw.splitlines() if line.startswith("event: ")]
frames = [line.removeprefix("data: ") for line in raw.splitlines() if line.startswith("data: ")]
statuses = [json.loads(frame)["phase"] for event_name, frame in zip(events, frames) if event_name == "status"]
assert statuses == ["structuring", "rewriting"]
assert events[-1] == "complete"
def test_existing_education_supplement_routes_to_the_only_saved_entry(client: TestClient) -> None:
session_id, body = create_builder_session(client)
start_education(client, session_id, body)
proposal = finish_education(client, session_id, body, "GPA 3.7/4.0,专业前 20%,完成数据库课程项目。")
confirm_card(client, session_id, proposal)
response = send_message(client, session_id, "我要补充已经填写过的教育经历")
assert "无需重填" in response["turn"]["content"]
assert not any(
block["data"].get("component") == "record_fields"
for block in response["turn"]["blocks"]
if block["type"] == "component"
)
def test_existing_education_supplement_offers_a_choice_for_multiple_entries(client: TestClient) -> None:
session_id, body = create_builder_session(client)
start_education(client, session_id, body, school="First University")
first = finish_education(client, session_id, body, "GPA 3.6/4.0,完成课程项目。")
after_first = confirm_card(client, session_id, first)
start_education(client, session_id, after_first, school="Second University")
second = finish_education(client, session_id, after_first, "GPA 3.8/4.0,获得学业奖学金并完成机器学习课程项目。")
confirm_card(client, session_id, second)
response = send_message(client, session_id, "补充已经填写过的教育经历")
card = active_component(response)
assert card["data"]["component"] == "choice_chips"
assert card["data"]["module"] == "builder_entry_select"
assert len(card["data"]["options"]) == 2
def test_explicit_new_education_entry_still_shows_an_identity_card(client: TestClient) -> None:
session_id, body = create_builder_session(client)
start_education(client, session_id, body)
proposal = finish_education(client, session_id, body, "GPA 3.7/4.0,完成数据库课程项目。")
after_confirm = confirm_card(client, session_id, proposal)
response = send_message(client, session_id, "新增一段教育经历")
card = active_component(response)
assert card["data"]["component"] == "record_fields"
assert card["data"]["record_type"] == "education"
def test_revision_correction_is_not_treated_as_a_no_information_answer(client: TestClient) -> None:
session_id, body = create_builder_session(client)
start_education(client, session_id, body)
proposal = finish_education(client, session_id, body, "GPA 3.7/4.0,完成数据库课程项目。")
revision = event(client, session_id, proposal, "edit", {})
assert revision.status_code == 200, revision.text
response = send_message(client, session_id, "我没有说要跳过这个经历")
assert "已理解你的调整说明" in response["turn"]["content"]
assert active_component(response)["data"]["component"] == "experience_confirm_card"
@@ -0,0 +1,44 @@
"""Revise action on the confirm card: fold uncovered facts back into the proposal (问题2c)."""
from __future__ import annotations
from typing import Any
from builder_flow_helpers import create_builder_session, event, finish_education, start_education
from test_api import active_component
def _revise(client: Any, session_id: str, body: dict[str, Any], payload: dict[str, Any]) -> Any:
return event(client, session_id, body, "revise", payload)
def test_revise_regenerates_proposal_with_instruction(client: Any) -> None:
session_id, body = create_builder_session(client)
card = start_education(client, session_id, body)
proposal = finish_education(client, session_id, card, "完成数据库课程项目。GPA: 4.3/5.0。")
response = _revise(
client, session_id, proposal,
{"instruction": "请将以下未覆盖的事实补进优化稿:GPA: 4.3/5.0,其他内容保持不变。"},
)
assert response.status_code == 200, response.text
reply = response.json()
assert active_component(reply)["data"]["component"] == "experience_confirm_card"
assert "重新" in reply["turn"]["content"]
proposal_data = active_component(reply)["data"]["ai_proposal"]
assert proposal_data["optimized_description"]
def test_revise_without_instruction_rejected(client: Any) -> None:
session_id, body = create_builder_session(client)
card = start_education(client, session_id, body)
proposal = finish_education(client, session_id, card, "完成数据库课程项目。GPA: 4.3/5.0。")
response = _revise(client, session_id, proposal, {})
assert response.status_code == 422, response.text
def test_revise_without_pending_proposal_rejected(client: Any) -> None:
session_id, body = create_builder_session(client)
response = _revise(client, session_id, body, {"instruction": "重新优化"})
assert response.status_code in (404, 409, 422), response.text
@@ -0,0 +1,118 @@
"""Chat intent classifier tests: rule fallback, LLM client, fallback composition."""
from __future__ import annotations
from typing import Any
from app.chat_intents import CHAT_INTENT_REGISTRY_VERSION, ChatIntent, ChatTurnClassification
from app.chat_intent_classifier import (
ChatIntentClassifier,
FallbackChatIntentClassifier,
LLMChatIntentClassifier,
RuleBasedChatIntentClassifier,
build_chat_intent_classifier,
build_chat_state_summary,
)
from app.settings import Settings
PROFILE = {
"job_type": "campus",
"target_position": "后端工程师",
"resume_content": {
"sections": [
{"kind": "project_experience", "items": [{"project_name": "AI Career Copilot"}]},
]
},
}
SUMMARY = build_chat_state_summary(PROFILE, {"draft": {"section": "education"}})
def classify_rule(message: str) -> ChatTurnClassification:
return RuleBasedChatIntentClassifier().classify(message, state_summary=SUMMARY)
def test_rule_classifier_implements_protocol() -> None:
assert isinstance(RuleBasedChatIntentClassifier(), ChatIntentClassifier)
def test_rule_classifier_maps_legacy_keyword_signals() -> None:
assert classify_rule("没有").intent is ChatIntent.NO_INFO
revise = classify_rule("保留原文,不要用这版优化稿")
assert revise.intent is ChatIntent.REVISE_PROPOSAL
assert revise.revision_instruction
assert classify_rule("把学校名字改成东莞城市学院").intent is ChatIntent.EDIT_IDENTITY
new_entry = classify_rule("新增一段教育经历")
assert new_entry.intent is ChatIntent.NEW_ENTRY
assert new_entry.target_section == "education"
def test_rule_classifier_routes_edit_question_chitchat_and_facts() -> None:
edit = classify_rule("修改一下我之前写的那个 AI Career Copilot 项目经历")
assert edit.intent is ChatIntent.EDIT_ENTRY
assert edit.target_entry_hint == "AI Career Copilot"
question = classify_rule("这段经历怎么写比较好?")
assert question.intent is ChatIntent.ASK_QUESTION
assert question.user_question
assert classify_rule("好的,谢谢").intent is ChatIntent.CHITCHAT
facts = classify_rule("负责后端接口开发,使用 Python 和 FastAPI")
assert facts.intent is ChatIntent.PROVIDE_FACTS
assert facts.facts and facts.facts[0].text
assert facts.confidence < 0.5
def test_state_summary_compacts_profile_and_draft() -> None:
assert SUMMARY["job_type"] == "campus"
assert SUMMARY["target_position"] == "后端工程师"
assert SUMMARY["confirmed_entries"] == [
{"section": "project_experience", "label": "AI Career Copilot"}
]
assert SUMMARY["draft_section"] == "education"
assert build_chat_state_summary(PROFILE)["draft_section"] is None
class _StubClient:
def __init__(self, result: Any) -> None:
self.result = result
self.calls: list[dict[str, Any]] = []
def complete(self, **kwargs: Any) -> Any:
self.calls.append(kwargs)
return self.result
def test_llm_classifier_uses_registry_prompt_and_schema() -> None:
expected = ChatTurnClassification(intent="ask_question", user_question="怎么写?")
client = _StubClient(expected)
result = LLMChatIntentClassifier(client).classify("怎么写?", state_summary=SUMMARY)
assert result is expected
call = client.calls[0]
assert call["schema"] is ChatTurnClassification
assert call["schema_name"] == "chat_intent_classification"
assert call["payload"]["message"] == "怎么写?"
assert call["payload"]["state_summary"] is SUMMARY
assert call["payload"]["registry_version"] == CHAT_INTENT_REGISTRY_VERSION
prompt = call["system_prompt"]
assert CHAT_INTENT_REGISTRY_VERSION in prompt
for intent in ChatIntent:
assert intent.value in prompt
assert "保留原文" in prompt # few-shot examples reach the prompt
class _FailingClassifier:
def classify(self, message: str, *, state_summary: dict[str, Any]) -> ChatTurnClassification:
raise RuntimeError("boom")
def test_fallback_classifier_degrades_to_rules_on_llm_failure() -> None:
classifier = FallbackChatIntentClassifier(_FailingClassifier(), RuleBasedChatIntentClassifier())
result = classifier.classify("没有", state_summary=SUMMARY)
assert result.intent is ChatIntent.NO_INFO
def test_factory_returns_rules_without_openai_and_fallback_with_openai() -> None:
rule_only = build_chat_intent_classifier(Settings(llm_provider="rule"))
assert isinstance(rule_only, RuleBasedChatIntentClassifier)
composed = build_chat_intent_classifier(Settings(llm_provider="openai", openai_api_key="k"))
assert isinstance(composed, FallbackChatIntentClassifier)
assert isinstance(composed.fallback, RuleBasedChatIntentClassifier)
+178
View File
@@ -0,0 +1,178 @@
"""LLM rescue for messages the keyword routing drops to the generic fallback (问题1b)."""
from __future__ import annotations
from types import SimpleNamespace
from typing import Any
import pytest
from app.builder_conversation.rescue import llm_intent_rescue
from app.chat_intents import ChatTurnClassification
from app.settings import Settings
from builder_flow_helpers import confirm_card, create_builder_session, finish_education, send_message, start_education
from test_api import active_component
RESUME = {
"sections": [
{
"kind": "project_experience",
"items": [{"id": "e1", "project_name": "AI Career Copilot", "description": "全栈求职助手平台。"}],
}
]
}
class _StubClassifier:
def __init__(self, result: ChatTurnClassification | None = None, exc: Exception | None = None) -> None:
self.result = result
self.exc = exc
def classify(self, message: str, *, state_summary: dict[str, Any]) -> ChatTurnClassification:
if self.exc:
raise self.exc
assert self.result is not None
return self.result
def _agent(classifier: Any) -> Any:
return SimpleNamespace(expander=SimpleNamespace(expand=lambda entry, *, context: {}), _chat_intent_classifier=classifier)
def _components(transition: Any) -> list[dict[str, Any]]:
return [block["data"] for block in transition.turn["blocks"] if block.get("type") == "component"]
def test_rescue_off_mode_returns_none(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
"app.builder_conversation.rescue.load_settings",
lambda: Settings(llm_provider="rule", intent_router_mode="off"),
)
agent = SimpleNamespace()
assert llm_intent_rescue(agent, {"job_type": "campus"}, "帮我重新优化描述", RESUME) is None
def test_rescue_revise_regenerates_candidate_card() -> None:
agent = _agent(_StubClassifier(ChatTurnClassification(
intent="revise_proposal", confidence=0.9,
target_entry_hint="AI Career Copilot", revision_instruction="重新优化",
)))
profile: dict[str, Any] = {"job_type": "campus"}
transition = llm_intent_rescue(agent, profile, "帮我重新优化AI Career Copilot描述内容", RESUME)
assert transition is not None
card = next(data for data in _components(transition) if data.get("component_name") == "ExperienceConfirmCard")
assert card["ai_proposal"]["optimized_description"] == "全栈求职助手平台。"
state = transition.profile["builder"]
assert state["editing_entry_id"] == "e1"
assert state["pending_entry"]["_proposal"]
def test_rescue_edit_entry_begins_edit_flow() -> None:
agent = _agent(_StubClassifier(ChatTurnClassification(
intent="edit_entry", confidence=0.8, target_entry_hint="AI Career Copilot",
)))
transition = llm_intent_rescue(agent, {"job_type": "campus"}, "帮我改下AI Career Copilot这段", RESUME)
assert transition is not None
assert "我找到了这段" in transition.turn["content"]
assert transition.profile["builder"]["editing_entry_id"] == "e1"
def test_rescue_edit_prefers_named_section_over_recent_entry() -> None:
"""点名板块的修改必须落到该板块条目,而不是最近确认条目(教育→校园 错位根因)。"""
resume = {
"sections": [
{"kind": "campus_experience", "items": [{"id": "c1", "organization": "学生会", "description": "招新宣传。"}]},
{"kind": "education", "items": [{"id": "e9", "school": "Example University", "description": "主修课程。"}]},
]
}
agent = _agent(_StubClassifier(ChatTurnClassification(
intent="edit_entry", confidence=0.9, target_section="education",
)))
profile: dict[str, Any] = {"job_type": "campus", "builder": {"last_confirmed_entry": {"entry_id": "c1"}}}
transition = llm_intent_rescue(agent, profile, "帮我重新优化教育经历", resume)
assert transition is not None
assert transition.profile["builder"]["editing_entry_id"] == "e9"
def test_rescue_edit_derives_section_from_message_when_classifier_omits_it() -> None:
"""分类器没给 target_section 时,消息里的板块名必须确定性生效(项目→教育 错位根因)。"""
resume = {
"sections": [
{"kind": "education", "items": [{"id": "e9", "school": "Example University", "description": "主修课程。"}]},
{"kind": "project_experience", "items": [{"id": "p1", "project_name": "AI Career Copilot", "description": "全栈平台。"}]},
]
}
agent = _agent(_StubClassifier(ChatTurnClassification(intent="edit_entry", confidence=0.9)))
profile: dict[str, Any] = {"job_type": "campus", "builder": {"last_confirmed_entry": {"entry_id": "e9"}}}
transition = llm_intent_rescue(agent, profile, "帮我重新优化项目经历", resume)
assert transition is not None
assert transition.profile["builder"]["editing_entry_id"] == "p1"
def test_rescue_edit_normalizes_chinese_section_label() -> None:
"""分类器把 target_section 填成中文板块名时,先归一化到内部 kind 再定位。"""
resume = {
"sections": [
{"kind": "education", "items": [{"id": "e9", "school": "Example University", "description": "主修课程。"}]},
{"kind": "project_experience", "items": [{"id": "p1", "project_name": "AI Career Copilot", "description": "全栈平台。"}]},
]
}
agent = _agent(_StubClassifier(ChatTurnClassification(
intent="edit_entry", confidence=0.9, target_section="项目经历",
)))
profile: dict[str, Any] = {"job_type": "campus", "builder": {"last_confirmed_entry": {"entry_id": "e9"}}}
transition = llm_intent_rescue(agent, profile, "帮我重新优化项目经历", resume)
assert transition is not None
assert transition.profile["builder"]["editing_entry_id"] == "p1"
def test_rescue_new_entry_offers_section_card() -> None:
agent = _agent(_StubClassifier(ChatTurnClassification(
intent="new_entry", confidence=0.8, target_section="internship_experience",
)))
transition = llm_intent_rescue(agent, {"job_type": "campus"}, "我还想补一段实习", RESUME)
assert transition is not None
assert "实习经历" in transition.turn["content"]
assert any(data.get("component_name") == "RecordFields" for data in _components(transition))
def test_rescue_declines_low_confidence_and_failures() -> None:
low = _agent(_StubClassifier(ChatTurnClassification(intent="edit_entry", confidence=0.4, target_entry_hint="AI Career Copilot")))
assert llm_intent_rescue(low, {"job_type": "campus"}, "改下AI Career Copilot", RESUME) is None
failing = _agent(_StubClassifier(exc=RuntimeError("boom")))
assert llm_intent_rescue(failing, {"job_type": "campus"}, "随便一句", RESUME) is None
unknown = _agent(_StubClassifier(ChatTurnClassification(intent="unclear", confidence=0.9)))
assert llm_intent_rescue(unknown, {"job_type": "campus"}, "", RESUME) is None
def test_bottom_fallback_unchanged_without_opt_in(client: Any) -> None:
session_id, body = create_builder_session(client)
card = start_education(client, session_id, body)
proposal = finish_education(client, session_id, card, "完成数据库课程项目。GPA: 4.3/5.0。")
confirm_card(client, session_id, proposal)
reply = send_message(client, session_id, "帮我重新优化Example University这段经历的描述")
assert reply["turn"]["content"].startswith("可以。")
def test_bottom_fallback_rescued_by_llm_classifier(client: Any, monkeypatch: pytest.MonkeyPatch) -> None:
agent = client.app.state.resume_agent
stub = _StubClassifier(ChatTurnClassification(
intent="revise_proposal", confidence=0.92,
target_entry_hint="Example University", revision_instruction="重新优化描述",
))
monkeypatch.setattr(agent, "_chat_intent_classifier", stub, raising=False)
session_id, body = create_builder_session(client)
card = start_education(client, session_id, body)
proposal = finish_education(client, session_id, card, "完成数据库课程项目。GPA: 4.3/5.0。")
confirm_card(client, session_id, proposal)
reply = send_message(client, session_id, "帮我重新优化Example University这段经历的描述")
assert active_component(reply)["data"]["component"] == "experience_confirm_card"
assert "重新" in reply["turn"]["content"]
+132
View File
@@ -0,0 +1,132 @@
"""B-Step1c: intent router settings, shadow logger, and Builder flow mount (P0 observe-only)."""
from __future__ import annotations
from typing import Any
import pytest
from app.chat_intent_classifier import RuleBasedChatIntentClassifier
from app.chat_intent_shadow import (
ChatIntentShadowLogger,
build_chat_intent_shadow,
)
from app.chat_intents import ChatTurnClassification
from app.settings import Settings, load_settings
from builder_flow_helpers import create_builder_session, send_message
def test_intent_router_settings_defaults() -> None:
settings = Settings()
assert settings.intent_router_mode == "off"
assert settings.intent_model is None
def test_intent_router_settings_from_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Any) -> None:
monkeypatch.setenv("RESUME_AGENT_INTENT_ROUTER_MODE", "shadow")
monkeypatch.setenv("RESUME_AGENT_INTENT_MODEL", "kimi-k3")
settings = load_settings(tmp_path / "missing.env")
assert settings.intent_router_mode == "shadow"
assert settings.intent_model == "kimi-k3"
monkeypatch.setenv("RESUME_AGENT_INTENT_ROUTER_MODE", "bogus")
with pytest.raises(ValueError, match="INTENT_ROUTER_MODE"):
load_settings(tmp_path / "missing.env")
class _StubClassifier:
def __init__(self, result: ChatTurnClassification | None = None, exc: Exception | None = None) -> None:
self.result = result
self.exc = exc
def classify(self, message: str, *, state_summary: dict[str, Any]) -> ChatTurnClassification:
if self.exc:
raise self.exc
assert self.result is not None
return self.result
def _captured_events(monkeypatch: pytest.MonkeyPatch) -> list[tuple[str, dict[str, Any]]]:
events: list[tuple[str, dict[str, Any]]] = []
monkeypatch.setattr(
"app.chat_intent_shadow.log_ai_event",
lambda event, **fields: events.append((event, fields)),
)
return events
def test_shadow_logs_llm_vs_rule_disagreement(monkeypatch: pytest.MonkeyPatch) -> None:
events = _captured_events(monkeypatch)
shadow = ChatIntentShadowLogger(
_StubClassifier(ChatTurnClassification(intent="ask_question", confidence=0.9)),
RuleBasedChatIntentClassifier(),
)
shadow.observe("没有", state_summary={})
assert events == [
(
"chat_intent_shadow",
{
"registry_version": "1",
"rule_intent": "no_info",
"llm_intent": "ask_question",
"llm_confidence": 0.9,
"disagreement": True,
},
)
]
def test_shadow_logs_agreement_and_survives_llm_failure(monkeypatch: pytest.MonkeyPatch) -> None:
events = _captured_events(monkeypatch)
agree = ChatIntentShadowLogger(
_StubClassifier(ChatTurnClassification(intent="no_info")),
RuleBasedChatIntentClassifier(),
)
agree.observe("没有", state_summary={})
assert events[0][1]["disagreement"] is False
failing = ChatIntentShadowLogger(
_StubClassifier(exc=RuntimeError("boom")),
RuleBasedChatIntentClassifier(),
)
failing.observe("没有", state_summary={}) # must not raise
assert events[1][0] == "chat_intent_shadow_error"
def test_build_chat_intent_shadow_requires_shadow_mode_and_openai() -> None:
openai = {"llm_provider": "openai", "openai_api_key": "k"}
assert build_chat_intent_shadow(Settings(**openai, intent_router_mode="off")) is None
assert build_chat_intent_shadow(Settings(llm_provider="rule", intent_router_mode="shadow")) is None
shadow = build_chat_intent_shadow(Settings(**openai, intent_router_mode="shadow"))
assert isinstance(shadow, ChatIntentShadowLogger)
tuned = build_chat_intent_shadow(Settings(**openai, intent_router_mode="shadow", intent_model="kimi-k3"))
assert tuned is not None
assert tuned.primary._client.settings.openai_model == "kimi-k3"
class _Spy:
def __init__(self, *, raises: bool = False) -> None:
self.raises = raises
self.calls: list[dict[str, Any]] = []
def observe(self, message: str, *, state_summary: dict[str, Any]) -> None:
if self.raises:
raise RuntimeError("spy boom")
self.calls.append({"message": message, "state_summary": state_summary})
def test_process_message_notifies_mounted_shadow(client: Any, monkeypatch: pytest.MonkeyPatch) -> None:
agent = client.app.state.resume_agent
spy = _Spy()
monkeypatch.setattr(agent, "_chat_intent_shadow", spy, raising=False)
session_id, _ = create_builder_session(client)
send_message(client, session_id, "新增一段教育经历")
assert spy.calls[0]["message"] == "新增一段教育经历"
assert "confirmed_entries" in spy.calls[0]["state_summary"]
def test_shadow_failure_never_breaks_routing(client: Any, monkeypatch: pytest.MonkeyPatch) -> None:
agent = client.app.state.resume_agent
monkeypatch.setattr(agent, "_chat_intent_shadow", _Spy(raises=True), raising=False)
session_id, _ = create_builder_session(client)
body = send_message(client, session_id, "新增一段教育经历")
assert body["turn"]["role"] == "assistant"
+64
View File
@@ -0,0 +1,64 @@
"""Chat intent registry and classification schema tests."""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from app.chat_intents import (
CHAT_INTENT_REGISTRY_VERSION,
INTENT_DESCRIPTIONS,
INTENT_FEWSHOTS,
ChatIntent,
ChatTurnClassification,
ExtractedFact,
)
def test_registry_describes_every_intent_in_chinese() -> None:
assert CHAT_INTENT_REGISTRY_VERSION
assert set(INTENT_DESCRIPTIONS) == set(ChatIntent)
for intent, description in INTENT_DESCRIPTIONS.items():
assert description.strip(), intent
assert any("" <= char <= "鿿" for char in description), intent
def test_fewshots_cover_each_intent_and_use_known_intents() -> None:
covered = {example["intent"] for example in INTENT_FEWSHOTS}
assert covered == set(ChatIntent)
for example in INTENT_FEWSHOTS:
assert example["message"].strip()
assert isinstance(example["intent"], ChatIntent)
def test_classification_schema_accepts_a_full_payload() -> None:
parsed = ChatTurnClassification(
intent="provide_facts",
confidence=0.9,
target_section="project_experience",
target_entry_hint="AI Career Copilot",
facts=[{"text": "负责后端接口开发", "kind": "action"}],
identity_updates=None,
revision_instruction=None,
user_question=None,
reason="用户在补充项目事实",
)
assert parsed.intent is ChatIntent.PROVIDE_FACTS
assert parsed.facts[0].kind == "action"
def test_classification_schema_defaults_and_rejects_extras() -> None:
minimal = ChatTurnClassification(intent="chitchat")
assert minimal.confidence == 0.5
assert minimal.facts == []
assert minimal.target_section is None
with pytest.raises(ValidationError):
ChatTurnClassification(intent="chitchat", bogus_field=1)
with pytest.raises(ValidationError):
ChatTurnClassification(intent="not_an_intent")
with pytest.raises(ValidationError):
ChatTurnClassification(intent="chitchat", confidence=1.5)
def test_extracted_fact_defaults_kind_to_other() -> None:
assert ExtractedFact(text="GPA 3.7").kind == "other"
+49
View File
@@ -0,0 +1,49 @@
"""Import upload guards: decompression-bomb docx must be rejected before parsing (H2 DoS)."""
from __future__ import annotations
import io
import zipfile
import pytest
from docx import Document
from app.document_extractors import ImportExtractionError, extract_text
CT = ('<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
'<Default Extension="xml" ContentType="application/xml"/>'
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
'<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>')
RELS = ('<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>')
def _custom_docx(document_xml: str) -> bytes:
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr("[Content_Types].xml", CT)
zf.writestr("_rels/.rels", RELS)
zf.writestr("word/document.xml", document_xml)
return buffer.getvalue()
def test_decompression_bomb_docx_is_rejected_before_parsing() -> None:
"""~20MB decompressed XML must fail fast instead of burning CPU in the parser."""
para = "<w:p><w:r><w:t>放大攻击</w:t></w:r></w:p>"
body = para * (20 * 1024 * 1024 // len(para.encode()))
bomb = _custom_docx('<?xml version="1.0" encoding="UTF-8"?>'
'<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">'
f"<w:body>{body}</w:body></w:document>")
assert len(bomb) < 1024 * 1024 # small compressed payload is the point of the attack
with pytest.raises(ImportExtractionError):
extract_text(extension=".docx", content=bomb)
def test_normal_docx_still_parses() -> None:
document = Document()
document.add_paragraph("张三 后端工程师")
buffer = io.BytesIO()
document.save(buffer)
assert "张三" in extract_text(extension=".docx", content=buffer.getvalue())
+54
View File
@@ -0,0 +1,54 @@
"""队列护栏测试:/create 幂等不重置队列、旧格式 profile 惰性初始化。"""
from __future__ import annotations
from fastapi.testclient import TestClient
from test_api import BASE, active_component, event
from test_enrichment_flow import (
continue_enriching,
created_session,
skip_current,
submit_to,
)
from test_enrichment_records import (
INTERNSHIP_ENTRY,
choose,
confirm_active,
latest_patch_revision,
)
def test_create_idempotent_with_queue_initialized(client: TestClient):
session_id, body = created_session(client)
body = continue_enriching(client, session_id, body)
body = submit_to(client, session_id, body, "record_fields", dict(INTERNSHIP_ENTRY)).json()
body = confirm_active(client, session_id, body)
assert latest_patch_revision(body) == 2
# 重复创建:幂等返回,不重置队列与 revision
duplicate = client.post(f"{BASE}/sessions/{session_id}/create", json={})
assert duplicate.status_code == 200, duplicate.text
assert duplicate.json()["created"] is False
# 队列仍停留在 AddAnother,可正常推进到 project
body = choose(client, session_id, body, "next")
assert active_component(body)["data"]["module"] == "project"
def test_legacy_session_without_enrichment_keys_lazy_initializes(client: TestClient):
# 创建后的 profile 不含 records/tags/enrichment 键(等同旧会话格式),
# 首个 continue_enriching 应惰性建队列并正常走完整链路。
session_id, body = created_session(client)
body = continue_enriching(client, session_id, body)
assert active_component(body)["data"]["component"] == "record_fields"
assert active_component(body)["data"]["module"] == "internship"
for _ in range(5):
body = skip_current(client, session_id, body)
assert body["stage"] == "RESUME_ENRICHING"
assert active_component(body)["data"]["component"] == "custom_card_picker"
body = choose(client, session_id, body, "finish")
assert body["stage"] == "CONTENT_READY"
assert active_component(body)["data"]["component"] == "content_ready_card"
+162
View File
@@ -0,0 +1,162 @@
"""结构化丰富模块测试:竞赛表单、技能/证书标签、联系方式、跳过、稍后再说。"""
from __future__ import annotations
from typing import Any
from fastapi.testclient import TestClient
from test_api import BASE, active_component, campus_ready, event
def created_session(client: TestClient) -> tuple[str, dict[str, Any]]:
session_id, _ = campus_ready(client)
response = client.post(f"{BASE}/sessions/{session_id}/create", json={})
assert response.status_code == 200, response.text
return session_id, response.json()
def find_component(body: dict[str, Any], slug: str) -> 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["data"].get("component") == slug:
return block
raise AssertionError(f"component {slug} not found")
def submit_to(
client: TestClient,
session_id: str,
body: dict[str, Any],
slug: str,
payload: dict[str, Any],
event_name: str = "submit",
):
block = find_component(body, slug)
return client.post(
f"{BASE}/sessions/{session_id}/component-events",
json={"component_id": block["id"], "event": event_name, "payload": payload},
)
def continue_enriching(client: TestClient, session_id: str, body: dict[str, Any]) -> dict[str, Any]:
response = event(client, session_id, body, "continue_enriching")
assert response.status_code == 200, response.text
return response.json()
def skip_current(client: TestClient, session_id: str, body: dict[str, Any]) -> dict[str, Any]:
response = event(client, session_id, body, "skip")
assert response.status_code == 200, response.text
return response.json()
def test_competition_fields_validation(client: TestClient):
session_id, body = created_session(client)
body = continue_enriching(client, session_id, body)
assert active_component(body)["data"]["component"] == "record_fields"
assert active_component(body)["data"]["module"] == "internship"
body = skip_current(client, session_id, body) # → project
assert active_component(body)["data"]["module"] == "project"
body = skip_current(client, session_id, body) # → competition
assert active_component(body)["data"]["component"] == "competition_fields"
bad_date = submit_to(
client, session_id, body, "competition_fields",
{"name": "蓝桥杯", "award": "二等奖", "date": "2024-13"},
)
assert bad_date.status_code == 422
missing_award = submit_to(
client, session_id, body, "competition_fields",
{"name": "蓝桥杯", "award": "", "date": "2024-04"},
)
assert missing_award.status_code == 422
ok = submit_to(
client, session_id, body, "competition_fields",
{
"name": "蓝桥杯",
"award": "二等奖",
"date": "2024-04",
"description": "使用 Python 完成算法题训练,获得省级二等奖。",
},
)
assert ok.status_code == 200, ok.text
body = ok.json()
card = active_component(body)
assert card["data"]["component"] == "experience_confirm_card"
assert card["data"]["ai_proposal"]["optimized_description"] == (
"基于 Python 完成算法题训练,并获得省级二等奖。"
)
confirmed = event(
client,
session_id,
body,
"confirm",
{"confirmed": True, "use_optimized": True},
)
assert confirmed.status_code == 200, confirmed.text
body = confirmed.json()
assert active_component(body)["data"]["component"] == "add_another"
nxt = event(client, session_id, body, "select", {"value": "next"})
assert nxt.status_code == 200, nxt.text
assert nxt.json()["stage"] == "RESUME_ENRICHING"
def test_skills_and_certificates_are_independent_modules(client: TestClient):
session_id, body = created_session(client)
body = continue_enriching(client, session_id, body)
for _ in range(3):
body = skip_current(client, session_id, body)
block = active_component(body)
assert block["data"]["component"] == "tags_input"
assert block["data"]["module"] == "skills"
assert block["data"]["field"] == "skills"
assert block["data"]["suggestions"] # 按意向职位推荐
body = submit_to(
client, session_id, body, "tags_input",
{"field": "skills", "value": [" Python ", "python", "FastAPI"]},
).json()
# 技能提交后立即进入独立的证书模块,进度条已完成数 +1
block = active_component(body)
assert block["data"]["component"] == "tags_input"
assert block["data"]["module"] == "certificates"
assert block["data"]["field"] == "certificates"
progress = find_component(body, "progress_card")
assert progress["data"]["module"] == "certificates"
assert progress["data"]["completed"] == 1
body = submit_to(
client, session_id, body, "tags_input",
{"field": "certificates", "value": []},
).json()
assert body["stage"] == "RESUME_ENRICHING"
assert active_component(body)["data"]["component"] == "custom_card_picker"
body = event(client, session_id, body, "select", {"value": "finish"}).json()
assert body["stage"] == "CONTENT_READY"
def test_defer_exits_and_resumes_at_breakpoint(client: TestClient):
session_id, body = created_session(client)
body = continue_enriching(client, session_id, body)
response = event(client, session_id, body, "defer")
assert response.status_code == 200, response.text
body = response.json()
assert body["stage"] == "CONTENT_READY"
assert active_component(body)["data"]["component"] == "content_ready_card"
body = continue_enriching(client, session_id, body)
assert body["stage"] == "RESUME_ENRICHING"
assert active_component(body)["data"]["component"] == "record_fields"
assert active_component(body)["data"]["module"] == "internship"
+69
View File
@@ -0,0 +1,69 @@
"""进度卡规则:total 固定为队列长度;skip 只切卡不推进度;进度卡无 skip 动作。"""
from __future__ import annotations
from fastapi.testclient import TestClient
from app.enrichment_modules import module_by_name
from app.fsm_enrichment import (
ensure_enrichment_state,
enrichment_progress,
mark_completed,
skip_module,
)
from test_api import BASE, active_component
from test_enrichment_flow import (
continue_enriching,
created_session,
find_component,
skip_current,
)
def _campus_profile() -> dict:
return {"job_type": "campus", "anchor_type": "education", "anchor": {}, "experiences": []}
def test_progress_fixed_total_and_skip_does_not_advance():
profile = _campus_profile()
ensure_enrichment_state(profile)
mark_completed(profile, "internship")
progress = enrichment_progress(profile)
assert progress["total"] == 5
assert progress["ratio"] == 1 / 5
skip_module(profile, module_by_name("competition"))
progress = enrichment_progress(profile)
assert progress["total"] == 5
assert progress["completed"] == 1
assert progress["skipped"] == 1
assert progress["ratio"] == 1 / 5
def test_skip_keeps_total_fixed_and_progress_card_has_no_skip(client: TestClient):
session_id, body = created_session(client)
body = continue_enriching(client, session_id, body)
stale_block = active_component(body)
body = skip_current(client, session_id, body)
progress = find_component(body, "progress_card")
assert progress["data"]["total"] == 5
assert progress["data"]["skipped"] == 1
assert progress["data"]["completed"] == 0
assert progress["data"]["percent"] == 0
assert progress["data"]["actions"] == ["defer"]
replay = client.post(
f"{BASE}/sessions/{session_id}/component-events",
json={
"component_id": stale_block["id"],
"event": "submit",
"payload": {
"company": "星河科技",
"position": "后端实习生",
"start_date": "2024-03",
"end_date_or_present": "2024-09",
},
},
)
assert replay.status_code == 409
+239
View File
@@ -0,0 +1,239 @@
"""记录模块卡片流测试:校招/社招/实习队列、AI 整理确认、卡内调整、revision 单调性。"""
from __future__ import annotations
from typing import Any
from fastapi.testclient import TestClient
from test_api import (
ANCHOR_CARD_VALUES,
BASE,
active_component,
event,
fill_anchor,
start_manual_profile,
)
from test_enrichment_flow import (
continue_enriching,
created_session,
find_component,
skip_current,
submit_to,
)
def choose(client: TestClient, session_id: str, body: dict[str, Any], value: str) -> dict[str, Any]:
response = event(client, session_id, body, "select", {"value": value})
assert response.status_code == 200, response.text
return response.json()
def confirm_active(
client: TestClient,
session_id: str,
body: dict[str, Any],
*,
use_optimized: bool = False,
) -> dict[str, Any]:
response = event(
client,
session_id,
body,
"confirm",
{"confirmed": True, "use_optimized": use_optimized},
)
assert response.status_code == 200, response.text
return response.json()
def latest_patch(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"] == "resume_patch" and "revision" in block["data"]:
return block["data"]
raise AssertionError("no resume patch with revision")
def latest_patch_revision(body: dict[str, Any]) -> int:
return latest_patch(body)["revision"]
def anchor_via_card(
client: TestClient, job_type: str, anchor_type: str | None, values: dict[str, str]
):
session_id, body = start_manual_profile(client, job_type=job_type)
if anchor_type is not None:
assert body["stage"] == "ANCHOR_TYPE_SELECT"
response = event(client, session_id, body, "select", {"anchor_type": anchor_type})
assert response.status_code == 200, response.text
body = response.json()
body = fill_anchor(client, session_id, body, values)
assert body["stage"] == "ANCHOR_CONFIRM"
body = confirm_active(client, session_id, body)
created = client.post(f"{BASE}/sessions/{session_id}/create", json={})
assert created.status_code == 200, created.text
return session_id, created.json()
INTERNSHIP_ENTRY = {
"company": "星河科技",
"position": "后端实习生",
"start_date": "2024-03",
"end_date_or_present": "2024-09",
"description": "负责接口开发,将响应时间降低了30%",
}
WORK_ANCHOR = {
"company": "星河科技",
"position": "后端工程师",
"start_date": "2020-01",
"end_date_or_present": "2023-05",
}
def test_campus_queue_full_happy_path(client: TestClient):
session_id, body = created_session(client)
body = continue_enriching(client, session_id, body)
block = active_component(body)
assert block["data"]["component"] == "record_fields"
assert block["data"]["module"] == "internship"
assert block["data"]["record_type"] == "internship_experience"
assert block["data"]["skippable"] is True
# 卡片标题与内容一致(项1回归)
assert "实习" in block["data"]["title"]
bad = submit_to(client, session_id, body, "record_fields", {"company": "星河科技"})
assert bad.status_code == 422
body = submit_to(client, session_id, body, "record_fields", dict(INTERNSHIP_ENTRY)).json()
card = active_component(body)
assert card["data"]["component"] == "experience_confirm_card"
assert card["data"]["confirmation_kind"] == "module_entry"
assert "highlights" not in card["data"]["value"]
proposal = card["data"]["ai_proposal"]
assert proposal["optimized_description"] == "承担接口开发,推动响应时间降低30%"
assert proposal["source"] == "rule_polish"
body = confirm_active(client, session_id, body, use_optimized=True)
internship = latest_patch(body)["value"]["sections"][-1]["items"][0]
assert internship["description"] == proposal["optimized_description"]
assert "resume_bullets" not in internship
assert internship["provenance"] == "rule_polish"
assert body["stage"] == "RESUME_ENRICHING"
assert active_component(body)["data"]["component"] == "add_another"
assert latest_patch_revision(body) == 2
body = choose(client, session_id, body, "next")
# 实习之后项目作为独立模块出现(项4回归)
assert active_component(body)["data"]["module"] == "project"
body = skip_current(client, session_id, body) # → competition
assert find_component(body, "competition_fields")
body = skip_current(client, session_id, body) # → skills
assert active_component(body)["data"]["component"] == "tags_input"
body = submit_to(client, session_id, body, "tags_input", {"field": "skills", "value": ["Python"]}).json()
assert latest_patch_revision(body) == 3
body = submit_to(client, session_id, body, "tags_input", {"field": "certificates", "value": []}).json()
assert latest_patch_revision(body) == 4
assert body["stage"] == "RESUME_ENRICHING"
assert active_component(body)["data"]["component"] == "custom_card_picker"
body = choose(client, session_id, body, "finish")
assert body["stage"] == "CONTENT_READY"
def test_social_queue_starts_with_more_work(client: TestClient):
session_id, body = anchor_via_card(client, "social", None, dict(WORK_ANCHOR))
body = continue_enriching(client, session_id, body)
assert find_component(body, "progress_card")["data"]["module"] == "more_work"
block = active_component(body)
assert block["data"]["component"] == "record_fields"
assert block["data"]["record_type"] == "work_experience"
body = submit_to(client, session_id, body, "record_fields", {
"company": "云图网络", "position": "开发工程师",
"start_date": "2023-06", "end_date_or_present": "present",
}).json()
body = confirm_active(client, session_id, body)
assert active_component(body)["data"]["component"] == "add_another"
body = choose(client, session_id, body, "again")
assert find_component(body, "progress_card")["data"]["module"] == "more_work"
body = skip_current(client, session_id, body)
assert find_component(body, "progress_card")["data"]["module"] == "project"
def test_internship_queue_campus_and_project_modules_are_independent(client: TestClient):
session_id, body = anchor_via_card(
client, "internship", None, dict(ANCHOR_CARD_VALUES)
)
body = continue_enriching(client, session_id, body)
assert find_component(body, "progress_card")["data"]["module"] == "campus_experience"
block = active_component(body)
assert block["data"]["record_type"] == "campus_experience"
body = submit_to(client, session_id, body, "record_fields", {
"organization": "计算机协会", "role": "技术部负责人",
"start_date": "2023-09", "end_date_or_present": "2024-06",
"description": "组织校内编程训练营。",
}).json()
body = confirm_active(client, session_id, body, use_optimized=True)
campus_item = latest_patch(body)["value"]["sections"][-1]["items"][0]
assert campus_item["organization"] == "计算机协会"
body = choose(client, session_id, body, "next")
assert active_component(body)["data"]["module"] == "project"
body = submit_to(client, session_id, body, "record_fields", {
"project_name": "校园二手交易平台", "project_role": "前端负责人",
"start_date": "2023-03", "end_date_or_present": "2023-09",
}).json()
body = confirm_active(client, session_id, body)
assert active_component(body)["data"]["component"] == "add_another"
kinds = [section["kind"] for section in latest_patch(body)["value"]["sections"]]
assert "campus_experience" in kinds
assert "project_experience" in kinds
def test_module_confirm_edit_reissues_prefilled_card(client: TestClient):
session_id, body = created_session(client)
body = continue_enriching(client, session_id, body)
body = submit_to(client, session_id, body, "record_fields", dict(INTERNSHIP_ENTRY)).json()
edited = event(client, session_id, body, "edit", {})
assert edited.status_code == 200, edited.text
body = edited.json()
block = active_component(body)
assert block["data"]["component"] == "record_fields"
assert block["data"]["value"]["company"] == "星河科技"
entry = dict(INTERNSHIP_ENTRY, company="云图网络")
body = submit_to(client, session_id, body, "record_fields", entry).json()
assert active_component(body)["data"]["value"]["company"] == "云图网络"
def test_module_confirmation_can_keep_original_description(client: TestClient):
session_id, body = created_session(client)
body = continue_enriching(client, session_id, body)
body = submit_to(client, session_id, body, "record_fields", dict(INTERNSHIP_ENTRY)).json()
proposal = active_component(body)["data"]["ai_proposal"]
assert proposal["optimized_description"] != INTERNSHIP_ENTRY["description"]
body = confirm_active(client, session_id, body, use_optimized=False)
internship = latest_patch(body)["value"]["sections"][-1]["items"][0]
assert internship["description"] == INTERNSHIP_ENTRY["description"]
assert internship["provenance"] == "user_provided"
def test_empty_description_gets_conservative_proposal(client: TestClient):
session_id, body = created_session(client)
body = continue_enriching(client, session_id, body)
entry = {key: value for key, value in INTERNSHIP_ENTRY.items() if key != "description"}
body = submit_to(client, session_id, body, "record_fields", entry).json()
card = active_component(body)
assert card["data"]["value"].get("description") is None
assert card["data"]["ai_proposal"]["optimized_description"] == (
"在星河科技担任后端实习生。"
)
body = confirm_active(client, session_id, body, use_optimized=False)
internship = latest_patch(body)["value"]["sections"][-1]["items"][0]
assert "description" not in internship
+240
View File
@@ -0,0 +1,240 @@
"""M1 数据基座单元测试:模块规格表、校验器、Transition 扩展、rewriter 安全读取。"""
from __future__ import annotations
from app.enrichment_modules import ENRICHMENT_MODULES, ENRICHMENT_QUEUES, module_by_name
from app.fsm import Transition, component
from app.models import JobType, Stage
from app.services import RuleBasedResumeRewriter
from app.validators import (
competition_entry_errors,
normalize_tags,
valid_email,
valid_url,
)
def test_queues_match_prd_priorities():
assert ENRICHMENT_QUEUES[JobType.CAMPUS] == (
"internship",
"project",
"competition",
"skills",
"certificates",
)
assert ENRICHMENT_QUEUES[JobType.SOCIAL] == (
"more_work",
"project",
"education",
"skills",
"certificates",
)
assert ENRICHMENT_QUEUES[JobType.INTERNSHIP] == (
"campus_experience",
"project",
"competition",
"skills",
"certificates",
)
assert "education_highlight" not in ENRICHMENT_MODULES
assert "anchor_description" not in ENRICHMENT_MODULES
# picker 与社招职责补充模块已移除
for removed in ("internship_or_project", "next_experience", "work_description", "skills_certs"):
assert removed not in ENRICHMENT_MODULES
def test_every_queue_entry_resolves_to_a_spec():
for queue in ENRICHMENT_QUEUES.values():
for name in queue:
spec = ENRICHMENT_MODULES[name]
assert spec.name == name
assert spec.skippable is True
def test_module_spec_shapes():
competition = module_by_name("competition")
assert competition.kind == "record_form"
assert competition.record_type == "competition"
assert competition.multi is True
assert set(competition.core_fields) == {"name", "award", "date"}
skills = module_by_name("skills")
assert skills.kind == "tags"
assert skills.multi is False
certificates = module_by_name("certificates")
assert certificates.kind == "tags"
assert certificates.multi is False
internship = module_by_name("internship")
assert internship.kind == "record_fields"
assert internship.record_type == "internship_experience"
assert internship.multi is True
def test_skill_suggestions_match_keywords_and_profile_facts():
from app.enrichment_modules import skill_suggestions
assert "Vue" in skill_suggestions("前端工程师")
assert "MySQL" in skill_suggestions("Java后端开发")
fallback = skill_suggestions("")
assert fallback == skill_suggestions(None)
assert "沟通协调" in fallback
assert skill_suggestions("某种冷门职位") == fallback
profile = {
"records": {
"project_experience": [
{
"description": "使用 Python、FastAPI 和 Redis 开发接口服务。",
"rewrite_confirmed": True,
}
]
},
"tags": {"skills": ["Python"]},
}
suggestions = skill_suggestions("后端工程师", profile)
assert "FastAPI" in suggestions
assert "Redis" in suggestions
assert "Python" not in suggestions
def test_new_component_slugs_registered():
assert component("TagsInput")["data"]["component"] == "tags_input"
assert component("CompetitionFields")["data"]["component"] == "competition_fields"
assert component("AddAnother")["data"]["component"] == "add_another"
assert component("ProgressCard")["data"]["component"] == "progress_card"
def test_transition_resume_content_defaults_none():
transition = Transition(stage=Stage.CONTENT_READY, profile={}, turn={})
assert transition.resume_content is None
def test_valid_email():
assert valid_email("user@example.com")
assert not valid_email("not-an-email")
assert not valid_email("")
assert not valid_email(None)
def test_valid_url():
assert valid_url("https://portfolio.example.com")
assert valid_url("http://example.com/a")
assert not valid_url("ftp://example.com")
assert not valid_url("")
assert not valid_url(None)
def test_normalize_tags_strips_dedups_and_limits():
raw = [" Python ", "python", "FastAPI", "", " ", "Python"] + [f"tag{i}" for i in range(30)]
result = normalize_tags(raw)
assert result[:3] == ["Python", "FastAPI", "tag0"]
assert len(result) == 20
def test_normalize_tags_drops_overlong_items():
assert normalize_tags(["x" * 33, "ok"]) == ["ok"]
def test_competition_entry_errors():
assert competition_entry_errors({"name": "蓝桥杯", "award": "二等奖", "date": "2024-04"}) == []
assert competition_entry_errors({"name": "", "award": "二等奖", "date": "2024-04"}) == ["name"]
assert competition_entry_errors({"name": "蓝桥杯", "award": "", "date": "2024-04"}) == ["award"]
assert competition_entry_errors({"name": "蓝桥杯", "award": "二等奖", "date": "2024-13"}) == ["date"]
assert competition_entry_errors({"name": "蓝桥杯", "award": "二等奖", "date": "bad"}) == ["date"]
def _profile_without_enrichment_keys() -> dict:
return {
"phone": "13800138000",
"phone_source": "account",
"name": "测试用户",
"job_type": "campus",
"anchor_type": "education",
"anchor": {"school": "示例大学"},
"experiences": [],
}
def test_rewriter_output_unchanged_without_enrichment_keys():
rewritten = RuleBasedResumeRewriter().rewrite(_profile_without_enrichment_keys())
kinds = [section["kind"] for section in rewritten["sections"]]
assert kinds == ["education"]
assert "contacts" not in rewritten["basics"]
def test_rewriter_emits_sections_for_confirmed_records_only():
profile = _profile_without_enrichment_keys()
profile["records"] = {
"competition": [
{"name": "蓝桥杯", "award": "二等奖", "date": "2024-04", "rewrite_confirmed": True},
{"name": "未确认竞赛", "award": "参与奖", "date": "2024-05", "rewrite_confirmed": False},
],
"internship_experience": [
{"organization": "星河科技", "role": "实习生", "rewrite_confirmed": True},
],
}
profile["tags"] = {"skills": ["Python"], "certificates": ["CET-6"]}
profile["email"] = "me@example.com"
profile["city"] = "上海"
profile["portfolio_url"] = "https://me.com"
rewritten = RuleBasedResumeRewriter().rewrite(profile)
sections = {section["kind"]: section for section in rewritten["sections"]}
assert sections["competition"]["items"] == [
{"name": "蓝桥杯", "award": "二等奖", "date": "2024-04", "rewrite_confirmed": True}
]
assert sections["internship_experience"]["heading"] == "实习经历"
assert rewritten["skill_groups"] == [
{"category": "编程语言与框架", "skills": ["Python"]}
]
assert sections["certificates"]["items"] == [{"value": "CET-6"}]
assert rewritten["basics"]["email"] == "me@example.com"
assert rewritten["basics"]["city"] == "上海"
assert rewritten["basics"]["portfolio_url"] == "https://me.com"
def test_profile_facts_for_llm_includes_enrichment_safely():
from app.llm_services import profile_facts_for_llm
profile = {
"experiences": [],
"records": {
"internship_experience": [
{"organization": "星河科技", "role": "实习生", "rewrite_confirmed": True},
{"organization": "未确认公司", "role": "待定", "rewrite_confirmed": False},
],
"competition": [
{"name": "蓝桥杯", "award": "二等奖", "date": "2024-04", "rewrite_confirmed": True}
],
},
"tags": {"skills": ["Python"], "certificates": ["CET-6"]},
"city": "上海",
"portfolio_url": "https://me.com",
}
dto = profile_facts_for_llm(profile)
record_types = [item["record_type"] for item in dto["records"]]
assert record_types == ["internship_experience", "competition"]
assert "未确认公司" not in str(dto)
assert dto["tags"] == {"skills": ["Python"], "certificates": ["CET-6"]}
assert dto["contacts"] == {"city": "上海", "portfolio_url": "https://me.com"}
assert "me@example.com" not in str(dto)
assert "some-wechat-id" not in str(dto)
def test_skill_groups_are_classified_for_confirmed_user_skills():
from app.skill_classifier import classify_skills
assert classify_skills(["Python", "FastAPI", "Vue", "PostgreSQL", "Docker", "Figma", "SQL analysis", "Unusual Tool"]) == [
{"category": "编程语言与框架", "skills": ["Python", "FastAPI"]},
{"category": "前端", "skills": ["Vue"]},
{"category": "后端与数据存储", "skills": ["PostgreSQL"]},
{"category": "云、DevOps 与工具", "skills": ["Docker"]},
{"category": "产品、设计与分析", "skills": ["Figma", "SQL analysis"]},
{"category": "其他技能", "skills": ["Unusual Tool"]},
]
+72
View File
@@ -0,0 +1,72 @@
"""Entry-level gap report persistence and staleness detection."""
from app.resume_document_core import (
attach_gap_report_staleness,
entry_fingerprint,
find_entry,
gap_report_is_stale,
)
from app.resume_document_mutations import set_entry_gap_report
def _content() -> dict:
return {
"sections": [
{
"id": "sec1",
"kind": "project_experience",
"items": [{"id": "e1", "project_name": "项目 A", "description": "完成了开发。"}],
}
]
}
GAPS = [
{
"dimension": "quantified_outcome",
"severity": 4,
"askability": 0.5,
"job_weight": 3,
"evidence": "缺少量化成果。",
"value": 6.0,
}
]
def test_set_entry_gap_report_does_not_change_fingerprint() -> None:
content = _content()
before = entry_fingerprint(find_entry(content, "e1")[1])
content = set_entry_gap_report(content, "e1", GAPS)
entry = find_entry(content, "e1")[1]
assert entry["gap_report"]["gaps"] == GAPS
assert entry["gap_report"]["based_on"] == before
assert entry_fingerprint(entry) == before
assert gap_report_is_stale(entry) is False
def test_gap_report_stale_after_entry_edit() -> None:
content = set_entry_gap_report(_content(), "e1", GAPS)
entry = find_entry(content, "e1")[1]
entry["description"] = "手动编辑后的描述。"
assert gap_report_is_stale(entry) is True
def test_gap_report_missing_is_not_stale() -> None:
entry = find_entry(_content(), "e1")[1]
assert gap_report_is_stale(entry) is False
def test_attach_gap_report_staleness_returns_a_presentation_copy() -> None:
content = set_entry_gap_report(_content(), "e1", GAPS)
entry = find_entry(content, "e1")[1]
entry["description"] = "已编辑。"
presentation = attach_gap_report_staleness(content)
assert find_entry(presentation, "e1")[1]["gap_report"]["stale"] is True
assert "stale" not in find_entry(content, "e1")[1]["gap_report"]
+425
View File
@@ -0,0 +1,425 @@
from __future__ import annotations
from typing import Any
from app.claim_validator import validate_proposal
from app.experience_optimizer import (
FallbackExperienceOptimizer,
OpenAIExperienceOptimizer,
RuleStructuredExperienceOptimizer,
_OPTIMIZATION_REPAIR_PROMPT,
normalize_fact_ledger,
required_material_fact_ids,
)
class FakeCompletion:
def __init__(self, output: dict[str, Any]) -> None:
self.output = output
self.calls: list[dict[str, Any]] = []
self.prompts: list[str] = []
def complete(self, *, schema, schema_name, system_prompt, payload):
self.calls.append({"schema_name": schema_name, "payload": payload})
self.prompts.append(system_prompt)
return schema.model_validate(self.output)
class SequentialFakeCompletion:
def __init__(self, outputs: list[dict[str, Any]]) -> None:
self.outputs = outputs
self.calls: list[dict[str, Any]] = []
self.prompts: list[str] = []
def complete(self, *, schema, schema_name, system_prompt, payload):
self.calls.append({"schema_name": schema_name, "payload": payload})
self.prompts.append(system_prompt)
output_index = min(len(self.calls) - 1, len(self.outputs) - 1)
return schema.model_validate(self.outputs[output_index])
class FakeRetriever:
def retrieve(self, **kwargs):
return [
{
"id": "rag_1",
"title": "Backend reference",
"content": "A reference example reports an 80% throughput gain.",
"original": "Built a service.",
"optimized": "Improved throughput by 80%.",
"points": "Action and result",
}
]
class FakeEmbedder:
pass
def fact_ledger() -> list[dict[str, str]]:
return [
{
"id": "fact_1",
"source": "user_form",
"field": "description",
"text": "Built the backend for a course submission system.",
},
{
"id": "fact_2",
"source": "user_answer",
"field": "answer",
"text": "It supported 20 classmates submitting assignments.",
},
{
"id": "fact_3",
"source": "user_answer",
"field": "answer",
"text": "Used FastAPI and PostgreSQL.",
},
]
def valid_output() -> dict[str, Any]:
text = (
"Used FastAPI and PostgreSQL to build the course submission backend, "
"supporting 20 classmates submitting assignments."
)
return {
"optimized_description": text,
"bullets": [text],
"star": {
"situation": "Course submission scenario",
"task": "Backend development",
"action": "Implemented the API with FastAPI and PostgreSQL",
"result": "Supported 20 classmates",
},
"changes": ["Reorganized the action and result"],
"missing_facts": [],
"claims": [
{
"text": text,
"evidence_ids": ["fact_1", "fact_2", "fact_3"],
"claim_type": "action",
}
],
}
def test_openai_optimizer_keeps_rag_as_style_reference_only() -> None:
completion = FakeCompletion(valid_output())
optimizer = OpenAIExperienceOptimizer(completion, FakeRetriever(), FakeEmbedder())
proposal = optimizer.optimize(
{"description": "Built the backend for a course submission system."},
context={"target_position": "Backend Engineer", "entry_type": "project_experience"},
facts=fact_ledger(),
)
payload = completion.calls[0]["payload"]
assert payload["user_fact_ledger"] == fact_ledger()
assert payload["style_references"][0]["id"] == "rag_1"
assert "80%" in str(payload["style_references"])
assert all("80%" not in fact["text"] for fact in payload["user_fact_ledger"])
assert "用户提供的经历" in completion.prompts[0]
assert proposal["source"] == "ai_expanded"
def test_claim_validator_filters_rag_claim_without_rejecting_grounded_text() -> None:
proposal = valid_output()
proposal["claims"].append(
{
"text": "Improved throughput by 80%.",
"evidence_ids": ["rag_1"],
"claim_type": "result",
}
)
validated = validate_proposal(proposal, fact_ledger())
assert validated["optimized_description"] == valid_output()["optimized_description"]
assert len(validated["claims"]) == 1
assert "unsupported_evidence_reference" in validated["validation_warnings"]
def test_unconfirmed_metric_is_retained_as_model_written_resume_prose() -> None:
proposal = valid_output()
proposal["optimized_description"] = (
"Used FastAPI and PostgreSQL to build the course submission backend. "
"Improved submission efficiency by 80%."
)
proposal["bullets"] = [proposal["optimized_description"]]
validated = validate_proposal(proposal, fact_ledger())
assert "80%" in validated["optimized_description"]
assert not validated["unconfirmed_suggestions"]
def test_counted_object_expansion_is_retained_not_a_hard_failure() -> None:
proposal = valid_output()
proposal["optimized_description"] = "Delivered 20 features for the course platform."
proposal["bullets"] = [proposal["optimized_description"]]
validated = validate_proposal(proposal, fact_ledger())
assert validated["optimized_description"] == "Delivered 20 features for the course platform."
assert not validated["unconfirmed_suggestions"]
def test_new_technical_term_is_retained_for_controlled_role_expansion() -> None:
proposal = valid_output()
proposal["optimized_description"] = "Built the backend with FastAPI, PostgreSQL, and Redis."
proposal["bullets"] = [proposal["optimized_description"]]
validated = validate_proposal(proposal, fact_ledger())
assert "Redis" in validated["optimized_description"]
def test_omitted_material_fact_triggers_single_repair() -> None:
initial = valid_output()
initial["optimized_description"] = "Built the course submission backend with FastAPI and PostgreSQL."
initial["bullets"] = [initial["optimized_description"]]
completion = SequentialFakeCompletion([initial, valid_output()])
optimizer = OpenAIExperienceOptimizer(completion, FakeRetriever(), FakeEmbedder())
proposal = optimizer.optimize(
{"description": "Built the backend for a course submission system."},
context={"entry_type": "project_experience"},
facts=fact_ledger(),
)
assert len(completion.calls) == 2
assert completion.calls[1]["schema_name"] == "experience_optimization_repair"
assert "20 classmates" in proposal["optimized_description"]
assert proposal["omitted_fact_ids"] == []
def test_omitted_material_fact_after_repair_is_flagged_and_relaxed() -> None:
incomplete = valid_output()
incomplete["optimized_description"] = "Built the course submission backend with FastAPI and PostgreSQL."
incomplete["bullets"] = [incomplete["optimized_description"]]
completion = SequentialFakeCompletion([incomplete, incomplete])
optimizer = OpenAIExperienceOptimizer(completion, FakeRetriever(), FakeEmbedder())
proposal = optimizer.optimize(
{"description": "Built the backend for a course submission system."},
context={"entry_type": "project_experience"},
facts=fact_ledger(),
)
assert len(completion.calls) == 2
assert proposal["omitted_fact_ids"] == ["fact_2"]
assert "material_fact_omitted_after_repair" in proposal.get("validation_warnings", [])
def test_imported_long_description_triggers_repair_when_candidate_is_compressed() -> None:
imported_facts = [
{
"id": "imported_description",
"source": "user_form",
"field": "description",
"text": (
"搭建全栈求职平台,支持 WebSocket 流式预览;使用 sentence-transformers "
"与 pgvector 实现岗位语义检索;通过 ASGI 部署和 asyncpg 缓解并发瓶颈;"
"交付 58 个 API 接口,使用 Docker Compose 编排 7 个服务,并支持 PDF/DOCX 导出。"
),
}
]
compressed = valid_output()
compressed["optimized_description"] = "构建全栈求职平台,集成简历生成、JD 分析和模拟面试功能。"
compressed["bullets"] = [compressed["optimized_description"]]
completion = SequentialFakeCompletion([compressed, compressed])
optimizer = OpenAIExperienceOptimizer(completion, FakeRetriever(), FakeEmbedder())
proposal = optimizer.optimize(
{"description": imported_facts[0]["text"]},
context={"entry_type": "project_experience"},
facts=imported_facts,
)
assert len(completion.calls) == 2
assert "material_fact_omitted_after_repair" in proposal.get("validation_warnings", [])
assert proposal["omitted_fact_ids"] == [
"imported_description_part_1",
"imported_description_part_2",
"imported_description_part_3",
"imported_description_part_4",
]
def test_optimizer_passes_completed_deep_interview_context_to_model() -> None:
completion = FakeCompletion(valid_output())
optimizer = OpenAIExperienceOptimizer(completion, FakeRetriever(), FakeEmbedder())
history = [{"question_id": "q_1", "dimension": "personal_contribution", "answer": "Implemented API endpoints.", "status": "answered"}]
optimizer.optimize(
{"description": "Built the backend for a course submission system."},
context={
"entry_type": "project_experience",
"optimization_mode": "deep",
"interview_completion": {"is_sufficient": True, "blocking_gaps": []},
"completed_dimensions": ["personal_contribution"],
"question_history": history,
},
facts=fact_ledger(),
)
payload = completion.calls[0]["payload"]
assert payload["deep_interview"]["completion"]["is_sufficient"] is True
assert payload["deep_interview"]["completed_dimensions"] == ["personal_contribution"]
assert payload["deep_interview"]["question_history"] == history
def test_optimizer_keeps_model_prose_when_it_expands_beyond_literal_evidence() -> None:
output = valid_output()
output["optimized_description"] = (
"Built the backend for a course submission system. "
"It supported 20 classmates submitting assignments. "
"Used FastAPI and PostgreSQL. "
"Migrated 20 services to Redis and improved throughput by 80%."
)
output["bullets"] = [output["optimized_description"]]
completion = FakeCompletion(output)
optimizer = OpenAIExperienceOptimizer(completion, FakeRetriever(), FakeEmbedder())
proposal = optimizer.optimize(
{"description": "Built the backend for a course submission system."},
context={"entry_type": "project_experience"},
facts=fact_ledger(),
)
assert "Migrated 20 services" in proposal["optimized_description"]
assert not proposal["unconfirmed_suggestions"]
assert len(completion.calls) == 1
def test_optimizer_succeeds_when_retriever_has_no_documents() -> None:
class EmptyRetriever:
def retrieve(self, **kwargs):
return []
completion = FakeCompletion(valid_output())
optimizer = OpenAIExperienceOptimizer(completion, EmptyRetriever(), FakeEmbedder())
proposal = optimizer.optimize(
{"description": "Built the backend for a course submission system."},
context={"entry_type": "project_experience"},
facts=fact_ledger(),
)
assert proposal["optimized_description"]
assert completion.calls[0]["payload"]["style_references"] == []
def test_optimizer_succeeds_when_retriever_is_unavailable() -> None:
class BrokenRetriever:
def retrieve(self, **kwargs):
raise RuntimeError("vector store unavailable")
completion = FakeCompletion(valid_output())
optimizer = OpenAIExperienceOptimizer(completion, BrokenRetriever(), FakeEmbedder())
proposal = optimizer.optimize(
{"description": "Built the backend for a course submission system."},
context={"entry_type": "project_experience"},
facts=fact_ledger(),
)
assert proposal["optimized_description"]
assert completion.calls[0]["payload"]["style_references"] == []
def test_rule_optimizer_reports_insufficient_facts_without_creating_content() -> None:
proposal = RuleStructuredExperienceOptimizer().optimize({}, context={}, facts=[])
assert proposal["optimized_description"] == ""
assert proposal["fallback_reason"] == "insufficient_user_facts"
def test_fallback_optimizer_marks_rule_source_when_model_fails() -> None:
class BrokenOptimizer:
def optimize(self, entry, *, context, facts):
raise RuntimeError("model unavailable")
optimizer = FallbackExperienceOptimizer(
BrokenOptimizer(), RuleStructuredExperienceOptimizer()
)
proposal = optimizer.optimize(
{"description": "Built an API."}, context={}, facts=fact_ledger()[:1]
)
assert proposal["source"] == "rule_structured"
assert proposal["optimized_description"]
def test_claim_validator_decodes_literal_unicode_escapes_in_suggestions() -> None:
proposal = valid_output()
proposal["unconfirmed_suggestions"] = [r"\u8FD8\u53EF\u8865\u5145\u7ED3\u679C"]
validated = validate_proposal(proposal, fact_ledger())
assert validated["unconfirmed_suggestions"] == ["还可补充结果"]
def test_normalize_fact_ledger_splits_multiline_description_into_part_facts() -> None:
facts = [
{
"id": "fact_1",
"source": "user_form",
"field": "description",
"text": (
"全栈 AI 求职助手平台,包含 5 大功能模块:\n"
"1. AI 对话式简历生成助手\n"
"2. 简历导入 (PDF/DOCX 智能解析)\n"
"技术栈: 前端 Next.js 14.2 + React 18.3"
),
}
]
ledger = normalize_fact_ledger(facts)
parts = [fact for fact in ledger if fact["field"] == "description_part"]
assert [part["id"] for part in parts] == [
"fact_1_part_1",
"fact_1_part_2",
"fact_1_part_3",
"fact_1_part_4",
]
assert parts[1]["text"] == "AI 对话式简历生成助手"
assert parts[3]["text"] == "前端 Next.js 14.2 + React 18.3"
assert any(fact["field"] == "description" for fact in ledger)
def test_normalize_fact_ledger_keeps_single_sentence_description_unsplit() -> None:
facts = [
{
"id": "fact_1",
"source": "user_form",
"field": "description",
"text": "Built the backend for a course submission system.",
}
]
ledger = normalize_fact_ledger(facts)
assert [fact["id"] for fact in ledger] == ["fact_1"]
def test_required_fact_ids_prefer_description_parts_over_parent() -> None:
facts = [
{
"id": "fact_1",
"source": "user_form",
"field": "description",
"text": "全栈 AI 求职助手平台,包含 5 大功能模块:\n1. AI 对话式简历生成助手\n2. 简历导入智能解析",
},
{"id": "fact_2", "source": "user_answer", "field": "answer", "text": "服务 300 名学生。"},
]
ledger = normalize_fact_ledger(facts)
required = required_material_fact_ids(ledger)
assert "fact_1" not in required
assert {"fact_1_part_1", "fact_1_part_2", "fact_1_part_3", "fact_2"} <= set(required)
def test_optimization_repair_prompt_keeps_star_structure() -> None:
"""修复稿与首发同构:STAR 结构要求不得在修复阶段丢失。"""
assert "STAR" in _OPTIMIZATION_REPAIR_PROMPT
+105
View File
@@ -0,0 +1,105 @@
from __future__ import annotations
from typing import Any
from app.import_parser import ImportParseOutput, OpenAIResumeImportParser
from app.resume_import_service import RuleBasedResumeImportParser
class FakeCompletion:
def __init__(self, result: ImportParseOutput | Exception) -> None:
self.result = result
self.payload: dict[str, Any] | None = None
def complete(self, **kwargs: Any) -> ImportParseOutput:
self.payload = kwargs["payload"]
if isinstance(self.result, Exception):
raise self.result
return self.result
def test_llm_parser_redacts_sensitive_content_and_builds_reviewable_sections() -> None:
completion = FakeCompletion(
ImportParseOutput.model_validate(
{
"basics": {"name": "张三", "city": "广州"},
"target": {"job_type": "校招", "position": "后端开发工程师"},
"sections": [
{
"kind": "education",
"heading": "教育经历",
"items": [
{
"fields": {
"school": "示例大学",
"major": "软件工程",
"start_date": "2022-09",
"end_date_or_present": "2026-06",
},
"evidence": ["示例大学 软件工程 2022-09 至 2026-06"],
}
],
},
{
"kind": "project_experience",
"heading": "项目经历",
"items": [
{
"fields": {
"project_name": "简历助手",
"project_role": "后端开发",
"description": "使用 FastAPI 开发简历解析接口",
},
"evidence": ["简历助手 后端开发 使用 FastAPI 开发简历解析接口"],
}
],
},
],
"skill_groups": [
{
"category": "编程语言",
"skills": ["Python", "SQL"],
"evidence": ["Python SQL"],
}
],
}
)
)
parser = OpenAIResumeImportParser(completion=completion)
draft = parser.parse(
source_name="resume.docx",
text=(
"张三 13800138000 zhang@example.com 微信: zhangsan88\n"
"示例大学 软件工程 2022-09 至 2026-06\n"
"简历助手 后端开发 使用 FastAPI 开发简历解析接口\nPython SQL"
),
)
assert completion.payload is not None
sent = completion.payload["resume_text"]
assert "13800138000" not in sent
assert "zhang@example.com" not in sent
assert "zhangsan88" not in sent
assert draft.document["basics"] == {"name": "张三", "city": "广州"}
assert [section["heading"] for section in draft.document["sections"]] == [
"教育经历",
"项目经历",
]
assert draft.document["sections"][1]["items"][0]["project_name"] == "简历助手"
assert draft.document["skill_groups"] == [{"category": "编程语言", "skills": ["Python", "SQL"]}]
assert all(review.status == "needs_review" for review in draft.field_reviews)
assert any(review.field_path == "sections[1].items[0].project_name" for review in draft.field_reviews)
assert all(review.evidence for review in draft.field_reviews)
def test_llm_parser_falls_back_to_rule_parser_when_model_is_unavailable() -> None:
parser = OpenAIResumeImportParser(
completion=FakeCompletion(RuntimeError("model gateway unavailable")),
fallback=RuleBasedResumeImportParser(),
)
draft = parser.parse(source_name="resume.docx", text="这是导入的简历正文")
assert draft.document["sections"][0]["heading"] == "导入内容"
assert draft.field_reviews[0].status == "needs_review"
+76
View File
@@ -0,0 +1,76 @@
"""Import parsing speed-ups: slim schema without model evidence (A) + sha256 parse cache (B)."""
from __future__ import annotations
import io
from typing import Any
from docx import Document
from app.import_parser import ImportParseOutput, OpenAIResumeImportParser
from app.resume_import_service import ResumeImportService
class FakeCompletion:
def __init__(self) -> None:
self.calls: list[dict[str, Any]] = []
def complete(self, **kwargs: Any) -> Any:
self.calls.append(kwargs)
return kwargs["schema"].model_validate(
{
"basics": {"name": "张三"},
"sections": [
{
"kind": "education",
"heading": "教育经历",
"items": [{"fields": {"school": "示例大学", "major": "软件工程"}}],
}
],
"skill_groups": [],
}
)
def _docx(text: str) -> bytes:
document = Document()
document.add_paragraph(text)
buffer = io.BytesIO()
document.save(buffer)
return buffer.getvalue()
def _service(tmp_path, completion: FakeCompletion) -> ResumeImportService:
return ResumeImportService(
storage_root=tmp_path / "imports",
parser=OpenAIResumeImportParser(completion=completion),
)
def test_service_uses_slim_schema_without_model_evidence(tmp_path) -> None:
"""模型不再逐条输出 evidence 引用(本地匹配已覆盖),输出 token 与时间同降。"""
from app.import_parser_fast import SlimImportParseOutput
completion = FakeCompletion()
prepared = _service(tmp_path, completion).prepare(
file_name="r.docx", declared_mime=None, content=_docx("张三 示例大学 软件工程")
)
assert completion.calls[0]["schema"] is SlimImportParseOutput
assert "evidence" not in completion.calls[0]["system_prompt"].casefold()
assert prepared["document"]["sections"][0]["items"][0]["school"] == "示例大学"
assert all(item["evidence"] for item in prepared["field_reviews"]) # 本地匹配仍然提供证据
def test_repeated_upload_of_same_file_skips_llm_parse(tmp_path) -> None:
"""同一文件跨会话重复上传命中 sha256 缓存,不再调 LLM 解析。"""
completion = FakeCompletion()
service = _service(tmp_path, completion)
content = _docx("张三 示例大学 软件工程")
first = service.prepare(file_name="a.docx", declared_mime=None, content=content)
second = service.prepare(file_name="b.docx", declared_mime=None, content=content)
assert len(completion.calls) == 1
assert second["document"] == first["document"]
assert second["sha256"] == first["sha256"]
+19
View File
@@ -0,0 +1,19 @@
"""Tests for deterministic position-to-dimension weighting."""
from app.job_rubric import dimension_weights, position_family
def test_position_family_alias_mapping():
assert position_family("数据分析师") == "data"
assert position_family("后端工程师") == "tech"
assert position_family("产品经理") == "product"
assert position_family("不存在的岗位") == "default"
assert position_family(None) == "default"
assert position_family("高级后端开发工程师") == "tech"
def test_data_position_weights_quantified_outcome_highest():
weights = dimension_weights("数据分析师")
assert weights["quantified_outcome"] == 3
assert weights["quantified_outcome"] > weights["activity_execution"]
+438
View File
@@ -0,0 +1,438 @@
from __future__ import annotations
import io
import json
import logging
from types import SimpleNamespace
from typing import Any
from app.llm_services import (
AnchorExtractionOutput,
LLMServiceError,
OpenAICompatibleStructuredClient,
OpenAIExperienceExtractor,
OpenAIResumeRewriter,
_diagnostic_logger,
log_ai_event,
)
from app.experience_optimizer import OpenAIExperienceOptimizer
from app.main import create_app
from app.settings import Settings, load_settings
from app.skill_suggester import OpenAISkillSuggester, RuleBasedSkillSuggester
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_openai_skill_suggester_uses_target_and_record_facts() -> None:
fake = FakeOpenAI([json.dumps({"skills": ["FastAPI", "Redis", "OpenAPI"]})])
suggester = OpenAISkillSuggester(
OpenAICompatibleStructuredClient(llm_settings(), fake),
RuleBasedSkillSuggester(),
)
profile = {
"target_position": "后端工程师",
"records": {
"project_experience": [
{
"record_type": "project_experience",
"description": "使用 Python 和 FastAPI 开发订单接口,接入 Redis 缓存。",
}
]
},
"tags": {"skills": ["Python"]},
}
suggestions = suggester.suggest(profile)
assert suggestions[:3] == ["FastAPI", "Redis", "OpenAPI"]
assert "Python" not in suggestions
request = fake.completions.calls[0]
assert request["model"] == "test-model"
request_text = str(request["messages"])
assert "后端工程师" in request_text
assert "订单接口" in request_text
def test_openai_skill_suggester_falls_back_to_rules() -> None:
fake = FakeOpenAI([RuntimeError("offline")])
profile = {"target_position": "后端工程师", "tags": {"skills": []}}
suggester = OpenAISkillSuggester(
OpenAICompatibleStructuredClient(llm_settings(), fake),
RuleBasedSkillSuggester(),
)
assert "MySQL" in suggester.suggest(profile)
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_preserves_confirmed_descriptions_without_second_model_call() -> None:
fake = FakeOpenAI([])
rewriter = OpenAIResumeRewriter(
OpenAICompatibleStructuredClient(llm_settings(), fake)
)
profile = {
"name": "张三",
"phone": "13800138000",
"phone_source": "manual",
"job_type": "social",
"anchor_type": "work_experience",
"anchor": {
"company": "星河科技",
"position": "后端工程师",
"start_date": "2022-01",
"end_date_or_present": "present",
},
"experiences": [
{
"title": "后端工程师",
"organization": "星河科技",
"role": "后端工程师",
"description": "优化接口耗时,降低30%",
}
],
}
resume = rewriter.rewrite(profile)
assert resume["basics"]["masked_phone"] == "138****8000"
item = resume["sections"][1]["items"][0]
assert item["description"] == "优化接口耗时,降低30%"
assert "resume_bullets" not in item
assert fake.completions.calls == []
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 isinstance(
application.state.resume_agent.experience_optimizer,
OpenAIExperienceOptimizer,
)
assert settings.openai_base_url == "https://gateway.test"
assert "dummy-key" not in repr(settings)
def test_settings_loads_volcengine_ark_configuration(tmp_path, monkeypatch) -> None:
env_file = tmp_path / ".env"
env_file.write_text(
"\n".join(
[
"RESUME_AGENT_LLM_PROVIDER=volcengine",
"VOLCENGINE_API_KEY=test-volcengine-key",
"VOLCENGINE_BASE_URL=https://ark.example.test/api/v3",
"VOLCENGINE_MODEL=ep-test-endpoint",
]
),
encoding="utf-8",
)
for name in (
"RESUME_AGENT_LLM_PROVIDER",
"OPENAI_API_KEY",
"OPENAI_BASE_URL",
"OPENAI_MODEL",
"VOLCENGINE_API_KEY",
"VOLCENGINE_BASE_URL",
"VOLCENGINE_MODEL",
):
monkeypatch.delenv(name, raising=False)
settings = load_settings(env_file)
assert settings.llm_provider == "volcengine"
assert settings.use_openai is True
assert settings.openai_base_url == "https://ark.example.test/api/v3"
assert settings.openai_model == "ep-test-endpoint"
assert "test-volcengine-key" not in repr(settings)
def test_volcengine_requires_an_inference_endpoint_id(tmp_path, monkeypatch) -> None:
env_file = tmp_path / ".env"
env_file.write_text(
"\n".join(
[
"RESUME_AGENT_LLM_PROVIDER=volcengine",
"VOLCENGINE_API_KEY=test-volcengine-key",
"VOLCENGINE_BASE_URL=https://ark.example.test/api/v3",
]
),
encoding="utf-8",
)
for name in (
"RESUME_AGENT_LLM_PROVIDER",
"VOLCENGINE_API_KEY",
"VOLCENGINE_BASE_URL",
"VOLCENGINE_MODEL",
):
monkeypatch.delenv(name, raising=False)
try:
load_settings(env_file)
except ValueError as exc:
assert str(exc) == "VOLCENGINE_MODEL cannot be blank"
else:
raise AssertionError("Expected a missing Volcengine model configuration error")
def test_invalid_structured_output_reports_precise_reason() -> None:
client = OpenAICompatibleStructuredClient(
llm_settings(structured_output_retries=0), FakeOpenAI(["not-json"])
)
try:
client.complete(
schema=AnchorExtractionOutput,
schema_name="invalid_output_test",
system_prompt="Return structured data.",
payload={"value": "safe"},
)
except LLMServiceError as exc:
assert exc.reason_code == "structured_output_invalid"
assert exc.trace_id and exc.trace_id.startswith("ai_")
else:
raise AssertionError("LLMServiceError was not raised")
def test_timeout_reports_gateway_timeout() -> None:
client = OpenAICompatibleStructuredClient(
llm_settings(structured_output_retries=0), FakeOpenAI([TimeoutError()])
)
try:
client.complete(
schema=AnchorExtractionOutput,
schema_name="timeout_test",
system_prompt="Return structured data.",
payload={"value": "safe"},
)
except LLMServiceError as exc:
assert exc.reason_code == "gateway_timeout"
else:
raise AssertionError("LLMServiceError was not raised")
def test_empty_model_content_reports_empty_result() -> None:
client = OpenAICompatibleStructuredClient(
llm_settings(structured_output_retries=0), FakeOpenAI([""])
)
try:
client.complete(
schema=AnchorExtractionOutput,
schema_name="empty_result_test",
system_prompt="Return structured data.",
payload={"value": "safe"},
)
except LLMServiceError as exc:
assert exc.reason_code == "empty_result"
else:
raise AssertionError("LLMServiceError was not raised")
def test_diagnostic_log_excludes_sensitive_message_fields() -> None:
stream = io.StringIO()
handler = logging.StreamHandler(stream)
logger = _diagnostic_logger()
logger.addHandler(handler)
try:
log_ai_event(
"redaction_test",
trace_id="trace-safe",
prompt="PROMPT_SECRET",
payload="PAYLOAD_SECRET",
response="RESPONSE_SECRET",
content="CONTENT_SECRET",
)
finally:
logger.removeHandler(handler)
output = stream.getvalue()
assert "redaction_test" in output
assert "trace-safe" in output
assert "PROMPT_SECRET" not in output
assert "PAYLOAD_SECRET" not in output
assert "RESPONSE_SECRET" not in output
assert "CONTENT_SECRET" not in output
@@ -0,0 +1,29 @@
"""Characterization tests for optimization context anchoring."""
from app.optimization_flow import OptimizationFlowMixin
def test_context_carries_target_position_and_major():
session = {
"profile": {
"job_type": "校招",
"target_position": "数据分析师",
"anchor": {"major": "统计学"},
}
}
section = {"kind": "internship_experience"}
context = OptimizationFlowMixin._context(session, section, None)
assert context["target_position"] == "数据分析师"
assert context["major"] == "统计学"
assert context["entry_type"] == "internship_experience"
def test_context_tolerates_missing_target_position():
session = {"profile": {"job_type": "社招", "anchor": {}}}
section = {"kind": "work_experience"}
context = OptimizationFlowMixin._context(session, section, None)
assert context["target_position"] is None
+49
View File
@@ -0,0 +1,49 @@
"""Tests for membership-tier pipeline parameterization."""
from app.optimization_tiers import tier_config_for_session
def test_default_session_uses_free_tier(monkeypatch) -> None:
monkeypatch.delenv("RESUME_AGENT_DEFAULT_TIER", raising=False)
config = tier_config_for_session({"profile": {}})
assert config.tier == "free"
assert config.deep_allowed is False
assert config.include_gap_report is True
assert config.max_questions == 0
def test_vip_tier_enables_deep_interview_with_complete_preset() -> None:
config = tier_config_for_session({"profile": {"entitlement_tier": "VIP"}})
assert config.tier == "vip"
assert config.deep_allowed is True
assert config.max_questions == 6
assert config.min_questions == 2
assert config.gap_threshold == 8.0
def test_unknown_tier_falls_back_to_free() -> None:
config = tier_config_for_session({"profile": {"entitlement_tier": "enterprise_x"}})
assert config.tier == "free"
assert config.deep_allowed is False
def test_unentitled_session_uses_local_vip_default(monkeypatch) -> None:
monkeypatch.setenv("RESUME_AGENT_DEFAULT_TIER", "vip")
config = tier_config_for_session({"profile": {}})
assert config.tier == "vip"
assert config.deep_allowed is True
assert config.gap_threshold == 8.0
def test_explicit_free_entitlement_overrides_local_vip_default(monkeypatch) -> None:
monkeypatch.setenv("RESUME_AGENT_DEFAULT_TIER", "vip")
config = tier_config_for_session({"profile": {"entitlement_tier": "free"}})
assert config.tier == "free"
assert config.deep_allowed is False
+78
View File
@@ -0,0 +1,78 @@
from __future__ import annotations
from fastapi.testclient import TestClient
from test_resume_patch_api import create_resume_with_anchor, first_entry
BASE = "/ai-api/resume-agent"
def test_optimize_confirm_undo_flow(client: TestClient) -> None:
session_id, body = create_resume_with_anchor(client)
original = first_entry(body)["description"]
entry_id = first_entry(body)["id"]
response = client.post(
f"{BASE}/sessions/{session_id}/resume/optimize", json={"entry_id": entry_id}
)
assert response.status_code == 200
entry = first_entry(response.json())
proposal = entry["pending_proposal"]
assert proposal["source"] == "rule_polish"
assert proposal["optimized_description"] == "完成课程设计。"
assert entry["description"] == original
response = client.post(
f"{BASE}/sessions/{session_id}/resume/optimize/confirm",
json={"entry_id": entry_id},
)
assert response.status_code == 200
entry = first_entry(response.json())
assert "pending_proposal" not in entry
assert entry["description"] == "完成课程设计。"
assert entry["provenance"] == "rule_polish"
assert entry["previous_version"]["description"] == original
response = client.post(
f"{BASE}/sessions/{session_id}/resume/optimize/undo",
json={"entry_id": entry_id},
)
assert response.status_code == 200
entry = first_entry(response.json())
assert entry["description"] == original
assert "previous_version" not in entry
def test_optimize_reject(client: TestClient) -> None:
session_id, body = create_resume_with_anchor(client)
original = first_entry(body)["description"]
entry_id = first_entry(body)["id"]
client.post(f"{BASE}/sessions/{session_id}/resume/optimize", json={"entry_id": entry_id})
response = client.post(
f"{BASE}/sessions/{session_id}/resume/optimize/reject",
json={"entry_id": entry_id},
)
assert response.status_code == 200
entry = first_entry(response.json())
assert "pending_proposal" not in entry
assert entry["description"] == original
def test_optimize_unknown_entry_404(client: TestClient) -> None:
session_id, _ = create_resume_with_anchor(client)
response = client.post(
f"{BASE}/sessions/{session_id}/resume/optimize", json={"entry_id": "entry_nope"}
)
assert response.status_code == 404
assert response.json()["error"]["code"] == "entry_not_found"
def test_confirm_without_proposal_422(client: TestClient) -> None:
session_id, body = create_resume_with_anchor(client)
entry_id = first_entry(body)["id"]
response = client.post(
f"{BASE}/sessions/{session_id}/resume/optimize/confirm",
json={"entry_id": entry_id},
)
assert response.status_code == 422
assert response.json()["error"]["code"] == "optimize_not_pending"
@@ -0,0 +1,35 @@
"""partition_entry_text grounding: paraphrased percentages must survive (截图 GPA 丢失根因)."""
from __future__ import annotations
from app.claim_validator import partition_entry_text
FACTS = [{"id": "entry_description", "field": "description", "text": "学习数据结构、计算机视觉课程。GPA: 4.3/5.0,排名前百分之10。"}]
def test_percentage_paraphrase_is_not_quarantined() -> None:
"""LLM 把「前百分之10」改写成「前 10%」是同一事实,不得隔离。"""
candidate = "主修数据结构、计算机视觉等课程。GPA 4.3/5.0,年级排名前 10%"
optimized, suggestions, _warnings = partition_entry_text(candidate, FACTS)
assert "10%" in optimized
assert "4.3" in optimized
assert suggestions == []
def test_truly_new_numbers_are_still_quarantined() -> None:
"""用户没提过的数字(如「提升 37%」)必须继续被隔离。"""
facts = [{"id": "entry_description", "field": "description", "text": "完成数据库课程项目。"}]
optimized, suggestions, _warnings = partition_entry_text("完成数据库课程项目,性能提升 37%", facts)
assert "37" not in optimized
assert suggestions
def test_bullet_line_structure_is_preserved() -> None:
"""LLM 按行输出的 bullet 不得在防虚构分区时被拍平成一行(前端排版根因)。"""
facts = [{"id": "entry_description", "field": "description", "text": "负责需求分析与全链路开发。使用 LangGraph 编排优化流程。完成部署上线。"}]
candidate = "• 负责需求分析与全链路开发。\n• 使用 LangGraph 编排优化流程。\n• 完成部署上线。"
optimized, _suggestions, _warnings = partition_entry_text(candidate, facts)
assert optimized == candidate
@@ -0,0 +1,29 @@
from __future__ import annotations
import os
from sqlalchemy import create_engine, text
def test_langgraph_runtime_is_available() -> None:
from langgraph.graph import START, StateGraph
graph = StateGraph(dict)
graph.add_node("finish", lambda state: state)
graph.add_edge(START, "finish")
assert graph.compile().invoke({}) == {}
def test_postgres_test_database_has_pgvector() -> None:
database_url = os.environ["RESUME_AGENT_TEST_DATABASE_URL"]
engine = create_engine(database_url)
try:
with engine.connect() as connection:
extension = connection.execute(
text("SELECT extname FROM pg_extension WHERE extname = 'vector'")
).scalar_one_or_none()
finally:
engine.dispose()
assert extension == "vector"
+158
View File
@@ -0,0 +1,158 @@
from __future__ import annotations
import os
from uuid import uuid4
import pytest
from sqlalchemy import create_engine, text
@pytest.fixture
def repository():
from app.db.repositories import PostgresSessionRepository
schema = f"test_repository_{uuid4().hex}"
engine = create_engine(os.environ["RESUME_AGENT_TEST_DATABASE_URL"])
with engine.begin() as connection:
connection.execute(text(f'CREATE SCHEMA "{schema}"'))
store = PostgresSessionRepository(engine, schema=schema)
store.initialize()
try:
yield store
finally:
with engine.begin() as connection:
connection.execute(text(f'DROP SCHEMA "{schema}" CASCADE'))
engine.dispose()
def test_turns_are_ordered_and_blocks_follow_turns(repository) -> None:
repository.create_session("session-a", "PRIVACY_CONSENT", {"name": "Ada"})
first = repository.insert_turn(
"session-a",
role="assistant",
content="first",
composer_mode="ui_only",
blocks=[{"type": "component", "data": {"component": "one"}}],
)
second = repository.insert_turn(
"session-a",
role="user",
content="second",
composer_mode="chat",
blocks=[],
)
turns = repository.list_turns("session-a")
assert [turn["id"] for turn in turns] == [first["id"], second["id"]]
assert [turn["sequence"] for turn in turns] == [1, 2]
assert turns[0]["blocks"][0]["data"] == {"component": "one"}
def test_session_deletion_cascades_to_turns_blocks_and_resume(repository) -> None:
repository.create_session("session-a", "PRIVACY_CONSENT", {})
repository.insert_turn(
"session-a",
role="assistant",
content=None,
composer_mode="ui_only",
blocks=[{"type": "component", "data": {}}],
)
repository.create_resume(
"session-a", "resume-a", "create-a", {"schema_version": 3}
)
assert repository.delete_session("session-a") is True
assert repository.get_session("session-a") is None
assert repository.list_turns("session-a") == []
assert repository.get_resume("session-a") is None
def test_resume_creation_is_idempotent_for_a_session(repository) -> None:
repository.create_session("session-a", "MINIMUM_READY", {})
created = repository.create_resume(
"session-a", "resume-a", "first", {"schema_version": 3}
)
repeated = repository.create_resume(
"session-a", "resume-b", "second", {"schema_version": 3}
)
assert created["id"] == "resume-a"
assert repeated == created
def test_resume_update_uses_expected_revision(repository) -> None:
from app.db.repositories import RevisionConflict
repository.create_session("session-a", "MINIMUM_READY", {})
repository.create_resume("session-a", "resume-a", None, {"schema_version": 3})
updated = repository.update_resume(
"session-a", {"schema_version": 3, "basics": {"name": "Ada"}}, expected_revision=1
)
assert updated["revision"] == 2
with pytest.raises(RevisionConflict):
repository.update_resume("session-a", {"schema_version": 3}, expected_revision=1)
def test_resume_import_is_deduplicated_and_retains_review_payload(repository) -> None:
repository.create_session("session-a", "MINIMUM_READY", {})
document = {
"schema_version": 3,
"basics": {"name": "Ada"},
"target": {},
"sections": [],
"skill_groups": [],
}
reviews = [{"field_path": "basics.name", "confidence": 0.9, "evidence": []}]
created = repository.create_resume_import(
"session-a",
import_id="import-a",
file_name="resume.docx",
mime_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
size_bytes=42,
sha256="a" * 64,
object_key="aa/import-a.docx",
document=document,
field_reviews=reviews,
)
repeated = repository.create_resume_import(
"session-a",
import_id="import-b",
file_name="duplicate.docx",
mime_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
size_bytes=42,
sha256="a" * 64,
object_key="aa/import-b.docx",
document=document,
field_reviews=reviews,
)
assert created["id"] == "import-a"
assert repeated["id"] == "import-a"
assert created["document"] == document
assert created["field_reviews"] == reviews
assert created["status"] == "awaiting_review"
def test_resume_import_status_update_is_scoped_to_its_session(repository) -> None:
repository.create_session("session-a", "MINIMUM_READY", {})
repository.create_session("session-b", "MINIMUM_READY", {})
repository.create_resume_import(
"session-a",
import_id="import-a",
file_name="resume.pdf",
mime_type="application/pdf",
size_bytes=10,
sha256="b" * 64,
object_key="bb/import-a.pdf",
document=None,
field_reviews=[],
)
updated = repository.update_resume_import_status("session-a", "import-a", "applied")
assert updated["status"] == "applied"
assert repository.get_resume_import("session-b", "import-a") is None
+40
View File
@@ -0,0 +1,40 @@
from __future__ import annotations
import os
from uuid import uuid4
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, text
from app.main import create_app
from app.postgres_database import PostgresDatabase
from app.services import RuleBasedEntryExpander, RuleBasedExperienceExtractor, RuleBasedResumeRewriter
from app.settings import Settings
def test_create_app_uses_postgres_when_database_path_is_not_supplied(monkeypatch) -> None:
schema = f"test_runtime_{uuid4().hex}"
database_url = os.environ["RESUME_AGENT_TEST_DATABASE_URL"]
monkeypatch.setenv("RESUME_AGENT_DATABASE_SCHEMA", schema)
monkeypatch.setenv("RESUME_AGENT_DEFAULT_TIER", "free")
application = create_app(
extractor=RuleBasedExperienceExtractor(),
rewriter=RuleBasedResumeRewriter(),
expander=RuleBasedEntryExpander(),
settings=Settings(llm_provider="rule", database_url=database_url),
)
try:
assert isinstance(application.state.database, PostgresDatabase)
with TestClient(application) as client:
response = client.post("/ai-api/resume-agent/sessions", json={})
assert response.status_code == 201
session_id = response.json()["session_id"]
assert application.state.database.get_session(session_id) is not None
finally:
application.state.database.engine.dispose()
engine = create_engine(database_url)
try:
with engine.begin() as connection:
connection.execute(text(f'DROP SCHEMA IF EXISTS "{schema}" CASCADE'))
finally:
engine.dispose()
+144
View File
@@ -0,0 +1,144 @@
from __future__ import annotations
from typing import Any
from fastapi.testclient import TestClient
from app.profile_summary import ProfileSummaryGenerator
from app.services import RuleBasedExperienceExtractor, RuleBasedResumeRewriter
from app.main import create_app
from app.settings import Settings
from test_api import event, start_manual_profile
BASE = "/ai-api/resume-agent"
ANCHOR = {
"school": "????",
"major": "?????",
"degree": "??",
"start_date": "2021-09",
"end_date_or_present": "2025-06",
}
class CountingSummaryGenerator(ProfileSummaryGenerator):
def __init__(self) -> None:
self.calls = 0
def generate(self, content: dict[str, Any]) -> str:
self.calls += 1
return f"? {self.calls} ??????????????????????????????"
def summary_client(tmp_path: Any) -> tuple[TestClient, CountingSummaryGenerator]:
generator = CountingSummaryGenerator()
app = create_app(
database_path=tmp_path / "summary.db",
extractor=RuleBasedExperienceExtractor(),
rewriter=RuleBasedResumeRewriter(),
settings=Settings(llm_provider="rule"),
profile_summary_generator=generator,
)
return TestClient(app), generator
def create_resume(client: TestClient) -> tuple[str, dict[str, Any]]:
session_id, body = start_manual_profile(client, job_type="campus")
response = event(client, session_id, body, "submit", {**ANCHOR, "description": "?????????????"})
response = event(client, session_id, response.json(), "confirm", {"confirmed": True})
response = client.post(f"{BASE}/sessions/{session_id}/create", json={})
assert response.status_code == 200
return session_id, response.json()
def finish(client: TestClient, session_id: str, body: dict[str, Any]) -> dict[str, Any]:
card = next(
block for block in body["turn"]["blocks"]
if block["data"].get("component_name") == "ContentReadyCard"
)
response = client.post(
f"{BASE}/sessions/{session_id}/component-events",
json={"component_id": card["id"], "event": "finish_enrichment", "payload": {}},
)
assert response.status_code == 200, response.text
return response.json()
def test_finish_generates_profile_summary_once_and_marks_it_stale_on_resume_change(tmp_path: Any) -> None:
with summary_client(tmp_path)[0] as client:
session_id, body = create_resume(client)
finished = finish(client, session_id, body)
summary = finished["resume"]["content"]["profile_summary"]
assert summary["source"] == "ai_generated"
assert summary["stale"] is False
assert summary["content"]
patched = client.patch(
f"{BASE}/sessions/{session_id}/resume",
json={
"expected_revision": finished["resume"]["revision"],
"operation": {"type": "update_basics", "fields": {"name": "??"}},
},
)
assert patched.status_code == 200, patched.text
changed = patched.json()["resume"]["content"]["profile_summary"]
assert changed["content"] == summary["content"]
assert changed["stale"] is True
def test_summary_edit_regenerate_confirm_and_reject_keep_user_control(tmp_path: Any) -> None:
test_client, generator = summary_client(tmp_path)
with test_client as client:
session_id, body = create_resume(client)
finished = finish(client, session_id, body)
revision = finished["resume"]["revision"]
edit = client.patch(
f"{BASE}/sessions/{session_id}/resume",
json={
"expected_revision": revision,
"operation": {
"type": "update_profile_summary",
"fields": {"content": "?????????????????????????????"},
},
},
)
assert edit.status_code == 200, edit.text
edited = edit.json()["resume"]["content"]["profile_summary"]
assert edited["source"] == "user_edited"
assert edited["stale"] is False
generated = client.post(f"{BASE}/sessions/{session_id}/resume/profile-summary/generate")
assert generated.status_code == 200, generated.text
proposal = generated.json()["resume"]["content"]["profile_summary"]
assert proposal["content"] == edited["content"]
assert proposal["pending_proposal"]["content"].startswith("? 2 ?")
rejected = client.post(f"{BASE}/sessions/{session_id}/resume/profile-summary/reject")
assert rejected.status_code == 200, rejected.text
assert rejected.json()["resume"]["content"]["profile_summary"]["content"] == edited["content"]
assert "pending_proposal" not in rejected.json()["resume"]["content"]["profile_summary"]
generated = client.post(f"{BASE}/sessions/{session_id}/resume/profile-summary/generate")
assert generated.status_code == 200, generated.text
confirmed = client.post(f"{BASE}/sessions/{session_id}/resume/profile-summary/confirm")
assert confirmed.status_code == 200, confirmed.text
summary = confirmed.json()["resume"]["content"]["profile_summary"]
assert summary["content"].startswith("? 3 ?")
assert summary["source"] == "ai_generated"
assert summary["stale"] is False
assert "pending_proposal" not in summary
assert generator.calls == 3
def test_repeat_generation_keeps_current_summary_until_user_confirms(tmp_path: Any) -> None:
test_client, generator = summary_client(tmp_path)
with test_client as client:
session_id, body = create_resume(client)
finished = finish(client, session_id, body)
current = finished["resume"]["content"]["profile_summary"]["content"]
first = client.post(f"{BASE}/sessions/{session_id}/resume/profile-summary/generate")
assert first.status_code == 200, first.text
second = client.post(f"{BASE}/sessions/{session_id}/resume/profile-summary/generate")
assert second.status_code == 200, second.text
summary = second.json()["resume"]["content"]["profile_summary"]
assert summary["content"] == current
assert summary["pending_proposal"]["content"].startswith("? 3 ?")
assert generator.calls == 3
+90
View File
@@ -0,0 +1,90 @@
"""Per-session sliding-window rate limiting for light optimization."""
from fastapi import FastAPI
from fastapi.testclient import TestClient
from fastapi.responses import JSONResponse
from app.fsm import FSMError
from app.optimization_models import OptimizationRunView
from app.rate_limit import SlidingWindowRateLimiter
from app.resume_routes import register_resume_routes
class FakeClock:
def __init__(self) -> None:
self.now = 1000.0
def __call__(self) -> float:
return self.now
def test_allows_up_to_limit_then_rejects() -> None:
clock = FakeClock()
limiter = SlidingWindowRateLimiter(limit=3, window_seconds=3600, clock=clock)
assert limiter.allow("s1") is True
assert limiter.allow("s1") is True
assert limiter.allow("s1") is True
assert limiter.allow("s1") is False
def test_window_slides_and_allows_again() -> None:
clock = FakeClock()
limiter = SlidingWindowRateLimiter(limit=2, window_seconds=3600, clock=clock)
assert limiter.allow("s1") is True
assert limiter.allow("s1") is True
assert limiter.allow("s1") is False
clock.now += 3601
assert limiter.allow("s1") is True
def test_sessions_are_independent() -> None:
limiter = SlidingWindowRateLimiter(
limit=1, window_seconds=3600, clock=FakeClock()
)
assert limiter.allow("s1") is True
assert limiter.allow("s2") is True
assert limiter.allow("s1") is False
def test_light_route_returns_429_without_calling_optimizer_when_limited() -> None:
view = OptimizationRunView(
id="r1",
mode="light",
status="proposal_pending",
entry_id="e1",
)
class StubAgent:
def __init__(self) -> None:
self.calls = 0
def optimize_light(
self, session_id: str, request: object
) -> OptimizationRunView:
self.calls += 1
return view
application = FastAPI()
@application.exception_handler(FSMError)
async def handle_fsm_error(_request: object, exc: FSMError) -> JSONResponse:
return JSONResponse(status_code=exc.status_code, content={"detail": exc.message})
application.state.light_opt_limiter = SlidingWindowRateLimiter(
limit=2, window_seconds=3600
)
agent = StubAgent()
register_resume_routes(application, agent, "/ai-api/resume-agent")
client = TestClient(application, raise_server_exceptions=False)
url = "/ai-api/resume-agent/sessions/s1/resume/optimize/light"
assert client.post(url, json={"entry_id": "e1"}).status_code == 200
assert client.post(url, json={"entry_id": "e1"}).status_code == 200
blocked = client.post(url, json={"entry_id": "e1"})
assert blocked.status_code == 429
assert blocked.json()["detail"] == "操作过于频繁,请稍后再试(轻度优化每小时最多 20 次)。"
assert agent.calls == 2
+323
View File
@@ -0,0 +1,323 @@
from __future__ import annotations
import pytest
from app.resume_document import (
DocumentError,
apply_delete_bullet,
apply_delete_entry,
apply_update_basics,
apply_update_bullet,
apply_update_entry,
confirm_proposal,
entry_fingerprint,
find_entry,
merge_ids,
merge_profile_refresh,
reject_proposal,
set_pending_proposal,
undo_entry,
)
from app.services import RuleBasedEntryExpander
def old_doc() -> dict:
return {
"schema_version": 1,
"basics": {"name": "Test User", "masked_phone": "138****8000"},
"target": {"job_type": "campus"},
"sections": [
{
"kind": "education",
"heading": "Education",
"items": [
{
"school": "Example University",
"major": "Computer Science",
"degree": "Bachelor",
"start_date": "2021-09",
"end_date_or_present": "2025-06",
}
],
}
],
}
def test_merge_ids_assigns_three_level_ids() -> None:
merged = merge_ids(None, old_doc())
section = merged["sections"][0]
entry = section["items"][0]
assert merged["schema_version"] == 3
assert merged["skill_groups"] == []
assert section["id"].startswith("sec_")
assert entry["id"].startswith("entry_")
assert entry["provenance"] == "user_provided"
def test_merge_ids_preserves_matched_ids() -> None:
first = merge_ids(None, old_doc())
changed = old_doc()
changed["sections"][0]["items"][0]["major"] = "Software Engineering"
second = merge_ids(first, changed)
assert second["sections"][0]["id"] == first["sections"][0]["id"]
assert second["sections"][0]["items"][0]["id"] == first["sections"][0]["items"][0]["id"]
def test_merge_ids_new_item_gets_new_id() -> None:
first = merge_ids(None, old_doc())
changed = old_doc()
changed["sections"][0]["items"].append(
{"school": "Another University", "major": "Mathematics", "start_date": "2017-09"}
)
second = merge_ids(first, changed)
ids = [item["id"] for item in second["sections"][0]["items"]]
assert ids[0] == first["sections"][0]["items"][0]["id"]
assert ids[1] != ids[0]
assert len(set(ids)) == 2
def test_profile_refresh_preserves_confirmed_entry_content_and_adds_new_records() -> None:
existing = merge_ids(None, old_doc())
entry_id = existing["sections"][0]["items"][0]["id"]
existing = apply_update_entry(
existing, entry_id, {"description": "Built the original course project."}
)
existing = set_pending_proposal(
existing,
entry_id,
"Led the course project delivery and completed the core implementation.",
source="ai_expanded",
)
existing = confirm_proposal(existing, entry_id)
regenerated = old_doc()
regenerated["sections"][0]["items"][0]["description"] = "Built the original course project."
regenerated["sections"].append(
{
"kind": "internship_experience",
"heading": "Internship",
"items": [
{
"company": "Example Labs",
"position": "Backend Intern",
"start_date": "2024-03",
"end_date_or_present": "2024-09",
"description": "Implemented API endpoints.",
}
],
}
)
refreshed = merge_profile_refresh(existing, regenerated)
education = refreshed["sections"][0]["items"][0]
internship = refreshed["sections"][1]["items"][0]
assert education["id"] == entry_id
assert education["description"] == (
"Led the course project delivery and completed the core implementation."
)
assert education["provenance"] == "ai_expanded"
assert internship["company"] == "Example Labs"
assert internship["id"].startswith("entry_")
def test_merge_ids_normalizes_bullets_to_objects() -> None:
doc = old_doc()
doc["sections"][0]["items"][0]["resume_bullets"] = ["Built an order service", "Improved QPS by 30%"]
bullets = merge_ids(None, doc)["sections"][0]["items"][0]["resume_bullets"]
assert all(set(bullet) == {"id", "text"} for bullet in bullets)
assert bullets[0]["text"] == "Built an order service"
def test_update_basics_and_entry_fields() -> None:
doc = merge_ids(None, old_doc())
entry_id = doc["sections"][0]["items"][0]["id"]
doc = apply_update_basics(doc, {"name": "Updated User", "phone": "13900139000"})
assert doc["basics"]["name"] == "Updated User"
assert doc["basics"]["masked_phone"] == "139****9000"
assert "phone" not in doc["basics"]
doc = apply_update_entry(doc, entry_id, {"major": "Software Engineering"})
_, entry = find_entry(doc, entry_id)
assert entry["major"] == "Software Engineering"
assert entry["provenance"] == "user_edited"
def test_update_entry_rejects_unknown_field() -> None:
doc = merge_ids(None, old_doc())
entry_id = doc["sections"][0]["items"][0]["id"]
with pytest.raises(DocumentError) as exc:
apply_update_entry(doc, entry_id, {"hack_field": "x"})
assert exc.value.code == "field_not_writable"
def test_proposal_lifecycle_confirm_and_undo() -> None:
doc = merge_ids(None, old_doc())
entry_id = doc["sections"][0]["items"][0]["id"]
doc = apply_update_entry(doc, entry_id, {"description": "Original description"})
doc = set_pending_proposal(doc, entry_id, "Optimized experience description.", source="ai_expanded")
_, entry = find_entry(doc, entry_id)
assert entry["pending_proposal"]["based_on"] == entry_fingerprint(entry)
doc = confirm_proposal(doc, entry_id)
_, entry = find_entry(doc, entry_id)
assert "pending_proposal" not in entry
assert entry["description"] == "Optimized experience description."
assert "resume_bullets" not in entry
assert entry["provenance"] == "ai_expanded"
assert entry["previous_version"]["description"] == "Original description"
assert entry["previous_version"]["provenance"] == "user_edited"
doc = undo_entry(doc, entry_id)
_, entry = find_entry(doc, entry_id)
assert "previous_version" not in entry
assert entry["description"] == "Original description"
assert entry["provenance"] == "user_edited"
def test_confirm_rejects_stale_proposal() -> None:
doc = merge_ids(None, old_doc())
entry_id = doc["sections"][0]["items"][0]["id"]
doc = set_pending_proposal(doc, entry_id, "Optimized proposal", source="ai_expanded")
doc = apply_update_entry(doc, entry_id, {"major": "Changed"})
with pytest.raises(DocumentError) as exc:
confirm_proposal(doc, entry_id)
assert exc.value.code == "proposal_stale"
def test_confirm_allows_user_to_apply_proposal_with_omission_diagnostics() -> None:
doc = merge_ids(None, old_doc())
entry_id = doc["sections"][0]["items"][0]["id"]
doc = apply_update_entry(doc, entry_id, {"description": "Original imported description"})
doc = set_pending_proposal(
doc,
entry_id,
"Compressed candidate",
source="ai_expanded",
omitted_fact_ids=["fact_1"],
)
confirmed = confirm_proposal(doc, entry_id)
_, entry = find_entry(confirmed, entry_id)
assert entry["description"] == "Compressed candidate"
assert entry["previous_version"]["description"] == "Original imported description"
def test_reject_proposal_keeps_original_description() -> None:
doc = merge_ids(None, old_doc())
entry_id = doc["sections"][0]["items"][0]["id"]
doc = apply_update_entry(doc, entry_id, {"description": "Original text"})
doc = set_pending_proposal(doc, entry_id, "Optimized proposal", source="rule_polish")
doc = reject_proposal(doc, entry_id)
_, entry = find_entry(doc, entry_id)
assert "pending_proposal" not in entry
assert entry["description"] == "Original text"
def test_legacy_bullet_edit_and_delete_remain_supported() -> None:
source = old_doc()
source["sections"][0]["items"][0]["resume_bullets"] = ["b1", "b2"]
doc = merge_ids(None, source)
entry_id = doc["sections"][0]["items"][0]["id"]
_, entry = find_entry(doc, entry_id)
bullet_id = entry["resume_bullets"][0]["id"]
doc = apply_update_bullet(doc, entry_id, bullet_id, "Updated bullet")
_, entry = find_entry(doc, entry_id)
assert entry["resume_bullets"][0]["text"] == "Updated bullet"
doc = apply_delete_bullet(doc, entry_id, bullet_id)
_, entry = find_entry(doc, entry_id)
assert len(entry["resume_bullets"]) == 1
doc = apply_delete_entry(doc, entry_id)
assert find_entry(doc, entry_id) is None
def test_rule_expander_uses_highlights() -> None:
expander = RuleBasedEntryExpander()
entry = {"title": "Backend internship", "highlights": ["built A", "built B"], "metrics": []}
proposal = expander.expand(entry, context={})
assert proposal["source"] == "rule_polish"
assert "built A" in proposal["optimized_description"]
assert "built B" in proposal["optimized_description"]
assert "bullets" not in proposal
def test_rule_expander_falls_back_to_description() -> None:
expander = RuleBasedEntryExpander()
proposal = expander.expand({"description": "Handled A. Improved B."}, context={})
assert proposal["optimized_description"].startswith("Handled A. Improved B.")
def test_rule_expander_empty_when_no_material() -> None:
expander = RuleBasedEntryExpander()
assert expander.expand({"title": "x"}, context={})["optimized_description"] == ""
def test_rule_expander_visibly_rewrites_common_formal_sentence() -> None:
expander = RuleBasedEntryExpander()
proposal = expander.expand(
{"description": "Used Python to complete algorithm practice and won a provincial second prize."},
context={"entry_type": "competition"},
)
assert "Python" in proposal["optimized_description"]
assert "provincial second prize" in proposal["optimized_description"]
def test_confirm_keeps_unconfirmed_suggestions_out_of_resume_description() -> None:
doc = merge_ids(None, old_doc())
entry_id = doc["sections"][0]["items"][0]["id"]
doc = apply_update_entry(doc, entry_id, {"description": "Implemented the service API."})
doc = set_pending_proposal(
doc,
entry_id,
"Implemented and maintained the service API for the project.",
source="ai_expanded",
unconfirmed_suggestions=["Confirm whether Redis caching was used."],
validation_warnings=["suggestion_requires_confirmation"],
)
_, pending_entry = find_entry(doc, entry_id)
pending = pending_entry["pending_proposal"]
assert pending["unconfirmed_suggestions"] == ["Confirm whether Redis caching was used."]
assert pending["validation_warnings"] == ["suggestion_requires_confirmation"]
confirmed = confirm_proposal(doc, entry_id)
_, confirmed_entry = find_entry(confirmed, entry_id)
assert confirmed_entry["description"] == "Implemented and maintained the service API for the project."
assert "Redis" not in confirmed_entry["description"]
assert "pending_proposal" not in confirmed_entry
def test_profile_refresh_retains_imported_sections_and_unmatched_entries() -> None:
existing = merge_ids(
None,
{
**old_doc(),
"sections": [
*old_doc()["sections"],
{
"kind": "project_experience",
"heading": "Projects",
"items": [
{"project_name": "Imported Project", "description": "Imported detail."},
],
},
],
},
)
existing["sections"][0]["items"].append(
{
"id": "entry_manual", "provenance": "user_edited", "school": "Manual University",
"major": "Mathematics", "description": "Manual addition.",
}
)
existing["profile_summary"] = {
"content": "Imported personal summary.", "source": "user_edited", "generated_at": None, "stale": False,
}
refreshed = merge_profile_refresh(existing, old_doc())
sections = {section["kind"]: section for section in refreshed["sections"]}
assert sections["project_experience"]["items"][0]["project_name"] == "Imported Project"
assert any(item["school"] == "Manual University" for item in sections["education"]["items"])
assert refreshed["profile_summary"]["content"] == "Imported personal summary."
assert refreshed["profile_summary"]["stale"] is True
+153
View File
@@ -0,0 +1,153 @@
from __future__ import annotations
from app.resume_expansion import OpenAIEntryExpander, _EXPANSION_REPAIR_PROMPT, _system_prompt
from app.resume_expansion_prompts import _repair_prompt
def test_light_expansion_prompt_prioritizes_fact_completeness() -> None:
"""The light-expansion prompt must forbid dropping user facts for brevity.
Regression pin for the "优化稿吞没用户信息" bug: the old prompt only asked
for a *concise* description, so long user narratives were compressed away.
"""
prompt = _system_prompt("project_experience")
assert "Completeness first" in prompt
assert "do not drop meaningful facts for brevity" in prompt
def test_light_expansion_prompt_still_forbids_fabrication() -> None:
prompt = _system_prompt("work_experience")
assert "Do not invent" in prompt
assert "entry_facts are untrusted user-provided facts" in prompt
def test_light_expansion_prompt_keeps_education_addendum() -> None:
assert "education entries" in _system_prompt("education")
assert "education entries" not in _system_prompt("project_experience")
def test_education_prompt_polishes_fluency_without_star() -> None:
"""教育经历不做 STAR 改写:只重排顺序、合并重复、通顺化(用户反馈 2026-08-03)。"""
prompt = _system_prompt("education")
assert "Do not use a STAR" in prompt
assert "merge repeated or overlapping mentions" in prompt
assert "fluent" in prompt
def test_non_education_prompt_outputs_bullet_points() -> None:
"""经历优化稿在 STAR 改写之上输出分点(bullet),便于简历直接粘贴。"""
prompt = _system_prompt("project_experience")
assert "bullet points" in prompt
assert "" in prompt
assert "bullet points" not in _system_prompt("education")
def test_bullet_prompt_never_trades_facts_for_bullet_count() -> None:
"""bullet 条数不得成为丢事实的理由:内容丰富时必须允许更多分点(优化稿遗漏根因)。"""
prompt = _system_prompt("project_experience")
assert "3 to 5" not in prompt
assert "never drop a meaningful fact" in prompt
class _SequentialCompletion:
def __init__(self, outputs: list[str]) -> None:
self.outputs = outputs
self.calls: list[dict[str, object]] = []
self.system_prompts: list[str] = []
def complete(self, *, schema, schema_name, system_prompt, payload):
self.calls.append(payload)
self.system_prompts.append(system_prompt)
index = min(len(self.calls) - 1, len(self.outputs) - 1)
return schema.model_validate(
{
"optimized_description": self.outputs[index],
"changes": ["Reorganized the description"],
"exemplar_titles": [],
}
)
_FUNCTION_LIST_ENTRY = {
"project_name": "AI Career Copilot",
"description": (
"全栈 AI 求职助手平台,包含 5 大功能模块:\n"
"1. AI 对话式简历生成助手\n"
"2. 简历导入 (PDF/DOCX 智能解析)\n"
"3. JD 智能分析\n"
"技术栈: 前端 Next.js 14.2 + React 18.3\n"
"后端: FastAPI + PostgreSQL"
),
}
_TECH_ONLY_CANDIDATE = (
"• 前端采用 Next.js 14.2 + React 18.3 实现响应式界面。\n"
"• 后端基于 FastAPI 与 PostgreSQL 提供接口。"
)
_FULL_COVERAGE_CANDIDATE = (
"• 全栈 AI 求职助手平台,覆盖 5 大功能模块:AI 对话式简历生成助手、"
"简历导入 (PDF/DOCX 智能解析)、JD 智能分析。\n"
"• 前端采用 Next.js 14.2 + React 18.3,后端基于 FastAPI 与 PostgreSQL。"
)
def test_expander_repairs_candidate_that_drops_function_facts() -> None:
"""只保留技术栈、吞掉功能模块的候选稿必须触发一次修复(而非直接放行)。"""
completion = _SequentialCompletion([_TECH_ONLY_CANDIDATE, _FULL_COVERAGE_CANDIDATE])
expander = OpenAIEntryExpander(completion)
proposal = expander.expand(dict(_FUNCTION_LIST_ENTRY), context={"entry_type": "project_experience"})
assert len(completion.calls) == 2
assert _EXPANSION_REPAIR_PROMPT in completion.system_prompts[1]
assert "" in completion.system_prompts[1] # repair keeps the bullet layout
assert "AI 对话式简历生成助手" in proposal["optimized_description"]
assert "material_fact_omitted_after_repair" not in proposal.get("validation_warnings", [])
def test_expander_relaxes_with_warning_when_repair_still_omits() -> None:
"""修复后仍遗漏:保留候选稿并附 warning,遗漏永不否决候选稿。"""
completion = _SequentialCompletion([_TECH_ONLY_CANDIDATE, _TECH_ONLY_CANDIDATE])
expander = OpenAIEntryExpander(completion)
proposal = expander.expand(dict(_FUNCTION_LIST_ENTRY), context={"entry_type": "project_experience"})
assert len(completion.calls) == 2
assert proposal["optimized_description"]
assert "material_fact_omitted_after_repair" in proposal["validation_warnings"]
def test_repair_prompt_uses_bullet_format_for_non_education() -> None:
"""修复稿必须与首稿同版式:项目/实习等非教育条目输出 bullet。"""
prompt = _repair_prompt("project_experience")
assert _EXPANSION_REPAIR_PROMPT in prompt
assert "STAR" in prompt # STAR extraction comes before the bullet layout
assert prompt.index("STAR") < prompt.index("")
assert "" in prompt
assert "bullet points" in prompt
def test_repair_prompt_keeps_education_narrative_without_bullets() -> None:
"""教育条目不做 STAR/bullet:修复提示词沿用教育约束。"""
prompt = _repair_prompt("education")
assert _EXPANSION_REPAIR_PROMPT in prompt
assert "education entries" in prompt
assert "" not in prompt
def test_expander_education_repair_uses_education_prompt() -> None:
completion = _SequentialCompletion([_TECH_ONLY_CANDIDATE, _FULL_COVERAGE_CANDIDATE])
expander = OpenAIEntryExpander(completion)
entry = {
"school": "Example University",
"major": "Computer Science",
"description": _FUNCTION_LIST_ENTRY["description"],
}
expander.expand(entry, context={"entry_type": "education"})
assert len(completion.calls) == 2
assert "education entries" in completion.system_prompts[1]
assert "" not in completion.system_prompts[1]
+171
View File
@@ -0,0 +1,171 @@
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
BASE = "/ai-api/resume-agent"
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"),
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",
files={"file": (name, docx_bytes("Imported Name\nExample University"), "application/vnd.openxmlformats-officedocument.wordprocessingml.document")},
)
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
@@ -0,0 +1,223 @@
"""Structured fallback and quality-gate coverage for resume imports."""
from __future__ import annotations
from typing import Any
from app.import_parser import ImportParseOutput, OpenAIResumeImportParser
from app.resume_import_service import RuleBasedResumeImportParser
class FakeCompletion:
def __init__(self, result: ImportParseOutput) -> None:
self.result = result
self.calls: list[dict[str, Any]] = []
def complete(self, **kwargs: Any) -> ImportParseOutput:
self.calls.append(kwargs)
return self.result
def _sample_resume() -> str:
long_detail = "A" * 650
return "\n".join(
[
"Li Ming",
"li.ming@example.com | 13800138000 | Guangzhou",
"Education",
"Example University | Computer Science | Bachelor | 2022-09 - 2026-06",
"GPA 3.8/4.0; ranked in the top 10%.",
"Project Experience",
"Resume Copilot | Backend Developer | 2025-01 - 2025-06",
f"Built the resume parsing API and optimization workflow. {long_detail}",
"\u5b9e\u4e60\u7ecf\u5386",
"Example Tech | AI Engineering Intern | 2025-07 - 2025-09",
"Implemented evaluation scripts and integrated retrieval.",
"Skills",
"Python, FastAPI, PostgreSQL, Docker",
]
)
def test_rule_parser_structures_sections_and_does_not_truncate_text() -> None:
draft = RuleBasedResumeImportParser().parse(
source_name="resume.docx", text=_sample_resume()
)
document = draft.document
assert document["basics"]["name"] == "Li Ming"
assert document["basics"]["email"] == "li.ming@example.com"
assert document["basics"]["phone"] == "13800138000"
assert [section["kind"] for section in document["sections"]] == [
"education",
"project_experience",
"internship_experience",
]
project = document["sections"][1]["items"][0]
assert project["project_name"] == "Resume Copilot"
assert len(project["description"]) > 650
assert document["skill_groups"]
assert any("Python" in group["skills"] for group in document["skill_groups"])
def test_llm_skill_only_result_is_completed_with_local_sections() -> None:
completion = FakeCompletion(
ImportParseOutput.model_validate(
{
"basics": {},
"target": {},
"sections": [],
"skill_groups": [
{"category": "\u7f16\u7a0b\u8bed\u8a00\u4e0e\u6846\u67b6", "skills": ["Python"]}
],
}
)
)
parser = OpenAIResumeImportParser(
completion=completion,
fallback=RuleBasedResumeImportParser(),
)
draft = parser.parse(source_name="resume.docx", text=_sample_resume())
assert completion.calls
assert {section["kind"] for section in draft.document["sections"]} >= {
"education",
"project_experience",
"internship_experience",
}
assert draft.document["basics"]["phone"] == "13800138000"
def test_llm_unstructured_blob_is_replaced_by_detected_sections() -> None:
completion = FakeCompletion(
ImportParseOutput.model_validate(
{
"basics": {},
"target": {},
"sections": [
{
"kind": "additional_experience",
"heading": "导入内容",
"items": [{"fields": {"title": "resume.docx", "description": "raw text"}}],
}
],
"skill_groups": [],
}
)
)
parser = OpenAIResumeImportParser(
completion=completion,
fallback=RuleBasedResumeImportParser(),
)
draft = parser.parse(source_name="resume.docx", text=_sample_resume())
assert [section["kind"] for section in draft.document["sections"]] == [
"education",
"project_experience",
"internship_experience",
]
def test_llm_backfill_preserves_each_project_and_original_summary() -> None:
resume_text = "\n".join(
[
"Li Ming",
"li.ming@example.com | 13800138000",
"Project Experience",
"Project Alpha | Backend Developer | 2025-01 - 2025-03",
"Built the first service.",
"Project Beta | Platform Engineer | 2025-04 - 2025-06",
"Built the second service.",
"Personal Summary",
"Original summary paragraph one.",
"Original summary paragraph two.",
]
)
completion = FakeCompletion(
ImportParseOutput.model_validate(
{
"basics": {"name": "Li Ming"},
"target": {},
"profile_summary": "rewritten summary",
"sections": [
{
"kind": "project_experience",
"heading": "Project Experience",
"items": [{"fields": {"project_name": "Project Alpha"}}],
}
],
"skill_groups": [],
}
)
)
parser = OpenAIResumeImportParser(completion=completion, fallback=RuleBasedResumeImportParser())
draft = parser.parse(source_name="resume.docx", text=resume_text)
projects = next(section for section in draft.document["sections"] if section["kind"] == "project_experience")
assert [item["project_name"] for item in projects["items"]] == ["Project Alpha", "Project Beta"]
assert draft.document["profile_summary"] == {
"content": "Original summary paragraph one.\nOriginal summary paragraph two.",
"source": "user_edited",
"generated_at": None,
"stale": False,
}
def test_llm_discards_unidentified_entries_and_merges_duplicate_education() -> None:
resume_text = "\n".join(
[
"Li Ming",
"li.ming@example.com | 13800138000",
"Education",
"Example University | Computer Science | Bachelor | 2022-09 - 2026-06",
"GPA 3.8/4.0; ranked in the top 10%.",
"Project Experience",
"Project Alpha | Backend Developer | 2025-01 - 2025-03",
"Built the first service.",
"Project Beta | Platform Engineer | 2025-04 - 2025-06",
"Built the second service.",
]
)
completion = FakeCompletion(
ImportParseOutput.model_validate(
{
"basics": {"phone": "[redacted]", "email": "redacted@example.com"},
"target": {},
"sections": [
{
"kind": "education",
"heading": "Education",
"items": [
{"fields": {"school": "Example University"}},
{"fields": {"description": "orphaned education detail"}},
],
},
{
"kind": "project_experience",
"heading": "Project Experience",
"items": [
{"fields": {"description": "orphaned project detail"}},
{"fields": {"project_name": "Project Alpha"}},
],
},
],
"skill_groups": [],
}
)
)
parser = OpenAIResumeImportParser(completion=completion, fallback=RuleBasedResumeImportParser())
draft = parser.parse(source_name="resume.docx", text=resume_text)
education = next(section for section in draft.document["sections"] if section["kind"] == "education")
projects = next(section for section in draft.document["sections"] if section["kind"] == "project_experience")
assert len(education["items"]) == 1
assert education["items"][0]["school"] == "Example University"
assert education["items"][0]["major"] == "Computer Science"
assert "description" not in education["items"][0] or education["items"][0]["description"] != "orphaned education detail"
assert [item["project_name"] for item in projects["items"]] == ["Project Alpha", "Project Beta"]
assert draft.document["basics"]["phone"] == "13800138000"
assert draft.document["basics"]["email"] == "li.ming@example.com"
assert draft.document["import_metadata"]["parse_status"] == "needs_review"
+55
View File
@@ -0,0 +1,55 @@
"""Contract tests for resume editing API request and response models."""
from datetime import UTC, datetime
import pytest
from pydantic import ValidationError
from app.models import (
ActionResponse,
BusinessResume,
OptimizeEntryRequest,
OptimizeRequest,
ResumePatchOperation,
ResumePatchRequest,
TimelineResponse,
)
def test_resume_patch_request_validates_revision_and_extra_fields() -> None:
request = ResumePatchRequest(
expected_revision=2,
operation={"type": "update_entry", "entry_id": "entry_1", "fields": {"major": "AI"}},
)
assert request.operation.type == "update_entry"
with pytest.raises(ValidationError):
ResumePatchRequest(
expected_revision=0,
operation={"type": "delete_entry", "entry_id": "entry_1"},
)
with pytest.raises(ValidationError):
ResumePatchOperation(type="delete_entry", entry_id="entry_1", unexpected=True)
def test_optimize_requests_require_entry_id() -> None:
assert OptimizeRequest(entry_id="entry_1", instruction="更量化").entry_id == "entry_1"
assert OptimizeEntryRequest(entry_id="entry_1").entry_id == "entry_1"
with pytest.raises(ValidationError):
OptimizeRequest(entry_id="")
def test_action_and_timeline_responses_expose_optional_resume_field() -> None:
assert "resume" in ActionResponse.model_fields
assert "resume" in TimelineResponse.model_fields
assert ActionResponse.model_fields["resume"].default is None
assert TimelineResponse.model_fields["resume"].default is None
now = datetime.now(UTC)
resume = BusinessResume(
id="resume_1",
session_id="session_1",
revision=1,
content={"schema_version": 2},
created_at=now,
updated_at=now,
)
assert resume.content["schema_version"] == 2
+177
View File
@@ -0,0 +1,177 @@
from __future__ import annotations
from typing import Any
from fastapi.testclient import TestClient
from test_api import event, start_manual_profile
BASE = "/ai-api/resume-agent"
ANCHOR = {
"school": "示例大学",
"major": "计算机科学",
"degree": "本科",
"start_date": "2021-09",
"end_date_or_present": "2025-06",
}
def create_resume_with_anchor(client: TestClient) -> tuple[str, dict[str, Any]]:
session_id, body = start_manual_profile(client, job_type="campus")
assert body["stage"] == "ANCHOR_COLLECTING"
response = event(
client,
session_id,
body,
"submit",
{**ANCHOR, "description": "做过课程设计"},
)
assert response.status_code == 200
assert response.json()["stage"] == "ANCHOR_CONFIRM"
response = event(client, session_id, response.json(), "confirm", {"confirmed": True})
assert response.status_code == 200
assert response.json()["stage"] == "MINIMUM_READY"
response = client.post(f"{BASE}/sessions/{session_id}/create", json={})
assert response.status_code == 200
body = response.json()
assert body["created"] is True
return session_id, body
def first_entry(body: dict[str, Any]) -> dict[str, Any]:
return body["resume"]["content"]["sections"][0]["items"][0]
def test_response_carries_resume_with_ids(client: TestClient) -> None:
_, body = create_resume_with_anchor(client)
content = body["resume"]["content"]
assert content["schema_version"] == 3
assert content["skill_groups"] == []
assert content["sections"][0]["id"].startswith("sec_")
assert first_entry(body)["id"].startswith("entry_")
def test_patch_update_basics_and_entry(client: TestClient) -> None:
session_id, body = create_resume_with_anchor(client)
entry_id = first_entry(body)["id"]
response = client.patch(
f"{BASE}/sessions/{session_id}/resume",
json={
"expected_revision": body["resume"]["revision"],
"operation": {"type": "update_basics", "fields": {"name": "李四"}},
},
)
assert response.status_code == 200
body = response.json()
assert body["resume"]["content"]["basics"]["name"] == "李四"
response = client.patch(
f"{BASE}/sessions/{session_id}/resume",
json={
"expected_revision": body["resume"]["revision"],
"operation": {
"type": "update_entry",
"entry_id": entry_id,
"fields": {"major": "软件工程"},
},
},
)
assert response.status_code == 200
entry = first_entry(response.json())
assert entry["major"] == "软件工程"
assert entry["provenance"] == "user_edited"
assert entry["id"] == entry_id
def test_patch_revision_conflict(client: TestClient) -> None:
session_id, _ = create_resume_with_anchor(client)
response = client.patch(
f"{BASE}/sessions/{session_id}/resume",
json={
"expected_revision": 999,
"operation": {"type": "update_basics", "fields": {"name": "x"}},
},
)
assert response.status_code == 409
assert response.json()["error"]["code"] == "revision_conflict"
def test_patch_delete_entry(client: TestClient) -> None:
session_id, body = create_resume_with_anchor(client)
response = client.patch(
f"{BASE}/sessions/{session_id}/resume",
json={
"expected_revision": body["resume"]["revision"],
"operation": {"type": "delete_entry", "entry_id": first_entry(body)["id"]},
},
)
assert response.status_code == 200
assert response.json()["resume"]["content"]["sections"] == []
def test_patch_before_create_returns_409(client: TestClient) -> None:
session_id, _ = start_manual_profile(client, job_type="campus")
response = client.patch(
f"{BASE}/sessions/{session_id}/resume",
json={
"expected_revision": 1,
"operation": {"type": "update_basics", "fields": {"name": "x"}},
},
)
assert response.status_code == 409
assert response.json()["error"]["code"] == "resume_not_created"
def test_patch_skill_groups_and_recommendations_do_not_auto_apply(client: TestClient) -> None:
session_id, body = create_resume_with_anchor(client)
revision = body["resume"]["revision"]
response = client.patch(
f"{BASE}/sessions/{session_id}/resume",
json={
"expected_revision": revision,
"operation": {
"type": "update_skill_groups",
"skills": ["Python", "FastAPI", "Vue", "Docker"],
},
},
)
assert response.status_code == 200, response.text
updated = response.json()["resume"]
groups = updated["content"]["skill_groups"]
assert {skill for group in groups for skill in group["skills"]} == {
"Python", "FastAPI", "Vue", "Docker"
}
assert all(group["category"] != "其他技能" for group in groups)
recommendation = client.post(
f"{BASE}/sessions/{session_id}/resume/skills/recommend",
json={"question": "还有哪些与后端工程师岗位匹配的技术栈?"},
)
assert recommendation.status_code == 200, recommendation.text
payload = recommendation.json()
assert payload["candidates"]
assert all("skill" in item and "category" in item for item in payload["candidates"])
timeline = client.get(f"{BASE}/sessions/{session_id}/timeline").json()
assert timeline["resume"]["content"]["skill_groups"] == groups
def test_patch_phone_masks_resume_content_and_updates_session_profile(client: TestClient) -> None:
session_id, body = create_resume_with_anchor(client)
response = client.patch(
f"{BASE}/sessions/{session_id}/resume",
json={
"expected_revision": body["resume"]["revision"],
"operation": {"type": "update_basics", "fields": {"phone": "13900139000"}},
},
)
assert response.status_code == 200, response.text
basics = response.json()["resume"]["content"]["basics"]
assert basics["masked_phone"] == "139****9000"
assert "phone" not in basics
session = client.app.state.database.get_session(session_id)
assert session is not None
assert session["profile"]["phone"] == "13900139000"
assert session["profile"]["phone_source"] == "resume_edit"
+72
View File
@@ -0,0 +1,72 @@
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
def test_creation_gate_allows_a_basic_resume_after_core_experience_skip() -> None:
profile = {
"privacy_accepted": True,
"phone": "13800138000",
"name": "Zhang San",
"job_type": "social",
"core_experience_skipped": True,
}
assert can_create_resume(profile, []) is True
+46
View File
@@ -0,0 +1,46 @@
"""Skill grouping: recommender-assigned categories win over keyword rules (问题3③)."""
from __future__ import annotations
from app.builder_conversation.skills import _process_skill_selection
from app.builder_conversation.state import ensure_builder_state
from app.skill_classifier import classify_skills
from app.skill_groups import update_skill_groups
def test_classify_skills_prefers_recommender_categories() -> None:
groups = classify_skills(
["Python", "Vector Database", "Prompt Engineering"],
preferred={"Vector Database": "AI 基础设施", "Prompt Engineering": "AI 基础设施"},
)
assert {"category": "AI 基础设施", "skills": ["Vector Database", "Prompt Engineering"]} in groups
assert {"category": "编程语言与框架", "skills": ["Python"]} in groups
assert all(group["category"] != "其他技能" for group in groups)
def test_classify_skills_falls_back_to_keywords_without_preferred() -> None:
groups = classify_skills(["Python", "Teamwork"])
assert {"category": "编程语言与框架", "skills": ["Python"]} in groups
assert {"category": "其他技能", "skills": ["Teamwork"]} in groups
def test_apply_update_skill_groups_passes_preferred_categories() -> None:
content = update_skill_groups(
{"basics": {}},
["Vector Database"],
preferred_categories={"Vector Database": "AI 基础设施"},
)
assert content["skill_groups"] == [{"category": "AI 基础设施", "skills": ["Vector Database"]}]
def test_builder_skill_selection_keeps_recommender_categories() -> None:
profile: dict = {"job_type": "campus"}
state = ensure_builder_state(profile)
state["pending_skill_candidates"] = [{"skill": "Vector Database", "category": "AI 基础设施"}]
transition = _process_skill_selection(
profile, state, "select", {"values": ["Vector Database"]}, {"sections": []}
)
assert transition.resume_content is not None
assert transition.resume_content["skill_groups"] == [
{"category": "AI 基础设施", "skills": ["Vector Database"]}
]
+143
View File
@@ -0,0 +1,143 @@
from __future__ import annotations
import os
import sqlite3
from pathlib import Path
from uuid import uuid4
from sqlalchemy import create_engine, text
def _source_database(path: Path) -> None:
connection = sqlite3.connect(path)
connection.executescript(
"""
CREATE TABLE sessions (
id TEXT PRIMARY KEY, stage TEXT NOT NULL, revision INTEGER NOT NULL,
profile_json TEXT NOT NULL, draft_id TEXT, resume_id TEXT,
created_at TEXT NOT NULL, updated_at TEXT NOT NULL
);
CREATE TABLE turns (
id TEXT PRIMARY KEY, session_id TEXT NOT NULL, sequence INTEGER NOT NULL,
role TEXT NOT NULL, content TEXT, composer_mode TEXT NOT NULL, created_at TEXT NOT NULL
);
CREATE TABLE blocks (
id TEXT PRIMARY KEY, session_id TEXT NOT NULL, turn_id TEXT NOT NULL,
block_index INTEGER NOT NULL, type TEXT NOT NULL, lifecycle TEXT NOT NULL,
data_json TEXT NOT NULL, version INTEGER NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL
);
CREATE TABLE resumes (
id TEXT PRIMARY KEY, session_id TEXT NOT NULL, idempotency_key TEXT,
revision INTEGER NOT NULL, content_json TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL
);
CREATE TABLE resume_imports (
id TEXT PRIMARY KEY, session_id TEXT NOT NULL, file_name TEXT NOT NULL,
mime_type TEXT NOT NULL, size_bytes INTEGER NOT NULL, sha256 TEXT NOT NULL,
object_key TEXT NOT NULL, status TEXT NOT NULL, document_json TEXT,
field_reviews_json TEXT NOT NULL, error_code TEXT,
created_at TEXT NOT NULL, updated_at TEXT NOT NULL
);
CREATE TABLE optimization_runs (
id TEXT PRIMARY KEY, session_id TEXT NOT NULL, entry_id TEXT NOT NULL,
mode TEXT NOT NULL, status TEXT NOT NULL, source_revision INTEGER NOT NULL,
state_json TEXT NOT NULL, proposal_json TEXT,
created_at TEXT NOT NULL, updated_at TEXT NOT NULL
);
"""
)
connection.execute(
"INSERT INTO sessions VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(
"session-1", "CONTENT_READY", 7,
'{"job_type":"other","name":"张三","metadata":{"city":"上海"}}',
"draft-1", "resume-1", "2026-01-01T00:00:00+00:00", "2026-01-02T00:00:00+00:00",
),
)
connection.execute(
"INSERT INTO turns VALUES (?, ?, ?, ?, ?, ?, ?)",
("turn-1", "session-1", 1, "assistant", "欢迎", "ui_only", "2026-01-01T00:00:00+00:00"),
)
connection.execute(
"INSERT INTO blocks VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
("block-1", "session-1", "turn-1", 0, "component", "active", '{"label":"基本信息"}', 2, "2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00"),
)
connection.execute(
"INSERT INTO resumes VALUES (?, ?, ?, ?, ?, ?, ?)",
("resume-1", "session-1", "create-1", 3, '{"schema_version":3,"basics":{"name":"张三"}}', "2026-01-01T00:00:00+00:00", "2026-01-02T00:00:00+00:00"),
)
connection.execute(
"INSERT INTO resume_imports VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
("import-1", "session-1", "resume.pdf", "application/pdf", 42, "a" * 64,
"aa/import-1.pdf", "awaiting_review", '{"schema_version":3}',
'[{"field_path":"basics.name"}]', None,
"2026-01-01T00:00:00+00:00", "2026-01-02T00:00:00+00:00"),
)
connection.execute(
"INSERT INTO optimization_runs VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
("run-1", "session-1", "entry-1", "deep", "proposal_pending", 3,
'{"question_index":1}', '{"summary":"improved"}',
"2026-01-01T00:00:00+00:00", "2026-01-02T00:00:00+00:00"),
)
connection.commit()
connection.close()
def test_sqlite_migration_preserves_ids_order_and_normalizes_legacy_job_type(tmp_path: Path) -> None:
from app.db.sqlite_migration import migrate_sqlite_to_postgres
source = tmp_path / "legacy.db"
_source_database(source)
schema = f"test_migration_{uuid4().hex}"
target_url = os.environ["RESUME_AGENT_TEST_DATABASE_URL"]
report = migrate_sqlite_to_postgres(source, target_url, schema=schema)
assert report.source_counts == {
"sessions": 1,
"turns": 1,
"blocks": 1,
"resumes": 1,
"resume_imports": 1,
"optimization_runs": 1,
}
assert report.target_counts == report.source_counts
assert report.source_checksum == report.target_checksum
engine = create_engine(target_url)
try:
with engine.connect() as connection:
profile = connection.execute(
text(f'SELECT profile FROM "{schema}".sessions WHERE id = :id'), {"id": "session-1"}
).scalar_one()
sequence = connection.execute(
text(f'SELECT sequence FROM "{schema}".turns WHERE id = :id'), {"id": "turn-1"}
).scalar_one()
assert profile["job_type"] == "internship"
assert profile["name"] == "张三"
assert sequence == 1
finally:
with engine.begin() as connection:
connection.execute(text(f'DROP SCHEMA IF EXISTS "{schema}" CASCADE'))
engine.dispose()
def test_sqlite_migration_dry_run_does_not_create_target_schema(tmp_path: Path) -> None:
from app.db.sqlite_migration import migrate_sqlite_to_postgres
source = tmp_path / "legacy.db"
_source_database(source)
schema = f"test_migration_{uuid4().hex}"
report = migrate_sqlite_to_postgres(
source, os.environ["RESUME_AGENT_TEST_DATABASE_URL"], schema=schema, dry_run=True
)
assert report.target_counts == {}
engine = create_engine(os.environ["RESUME_AGENT_TEST_DATABASE_URL"])
try:
with engine.connect() as connection:
exists = connection.execute(
text("SELECT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = :schema)"),
{"schema": schema},
).scalar_one()
assert exists is False
finally:
engine.dispose()
+93
View File
@@ -0,0 +1,93 @@
"""个人总结再生成:显式请求(finish 按钮 / 对话指令)必须无视已有总结强制重生成。"""
from __future__ import annotations
from typing import Any
import pytest
from fastapi.testclient import TestClient
from app.builder_conversation.summary_regen import requests_summary_regen
from builder_flow_helpers import active_component, create_builder_session, event, send_message
@pytest.mark.parametrize(
"text",
["重新生成个人总结", "帮我重新生成一下个人总结", "更新个人总结", "生成个人总结", "再生成一版总结", "换一版个人总结"],
)
def test_summary_regen_predicate_matches_requests(text: str) -> None:
assert requests_summary_regen(text)
@pytest.mark.parametrize(
"text",
["我在项目中生成了总结报告", "我的个人总结是:认真负责", "帮忙看看这段经历", "总结一下这个项目怎么写"],
)
def test_summary_regen_predicate_rejects_non_requests(text: str) -> None:
assert not requests_summary_regen(text)
def _counting_generator(agent: Any, calls: dict[str, int]) -> Any:
original = agent.profile_summary_generator
class _Counting:
def generate(self, content: dict[str, Any]) -> str:
calls["n"] += 1
return original.generate(content)
return _Counting()
def test_finish_button_regenerates_existing_summary(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
"""已有(非 stale)总结时点 finish 按钮也必须重新生成(闸门放行根因修复)。"""
calls = {"n": 0}
agent = client.app.state.resume_agent
monkeypatch.setattr(agent, "profile_summary_generator", _counting_generator(agent, calls))
session_id, body = create_builder_session(client)
first = event(client, session_id, body, "select", {"value": "builder_finish"})
assert first.status_code == 200, first.text
assert calls["n"] == 1
reply = send_message(client, session_id, "好的")
second = event(client, session_id, reply, "select", {"value": "builder_finish"})
assert second.status_code == 200, second.text
assert calls["n"] == 2
def test_chat_regen_summary_writes_after_confirm(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
"""对话"重新生成个人总结":立即生成候选文本,确认后写入简历。"""
calls = {"n": 0}
agent = client.app.state.resume_agent
monkeypatch.setattr(agent, "profile_summary_generator", _counting_generator(agent, calls))
session_id, body = create_builder_session(client)
finished = event(client, session_id, body, "select", {"value": "builder_finish"})
assert finished.status_code == 200, finished.text
assert calls["n"] == 1
reply = send_message(client, session_id, "重新生成个人总结")
assert calls["n"] == 2
assert "个人总结" in reply["turn"]["content"]
card = active_component(reply)
assert card["data"]["module"] == "builder_summary_apply"
applied = event(client, session_id, reply, "select", {"value": "apply"})
assert applied.status_code == 200, applied.text
summary = applied.json()["resume"]["content"]["profile_summary"]
first_summary = finished.json()["resume"]["content"]["profile_summary"]["content"]
assert summary["content"] == first_summary # rule 生成器确定性输出,内容一致但确实重新生成过
assert summary["stale"] is False
assert calls["n"] == 2 # 确认写入复用候选文本,不重复调用生成器
def test_chat_regen_summary_dismiss_keeps_current(client: TestClient) -> None:
session_id, body = create_builder_session(client)
finished = event(client, session_id, body, "select", {"value": "builder_finish"})
assert finished.status_code == 200, finished.text
reply = send_message(client, session_id, "重新生成个人总结")
dismissed = event(client, session_id, reply, "select", {"value": "dismiss"})
assert dismissed.status_code == 200, dismissed.text
summary = dismissed.json()["resume"]["content"]["profile_summary"]
assert summary["content"] == finished.json()["resume"]["content"]["profile_summary"]["content"]
+55
View File
@@ -0,0 +1,55 @@
"""Tests for the unified target-position write path."""
from app.optimization_models import TargetPositionRequest
def test_target_position_request_validation():
request = TargetPositionRequest(target_position=" 数据分析师 ")
assert request.target_position == " 数据分析师 "
def test_set_target_position_updates_profile():
from app.optimization_flow import OptimizationFlowMixin
class FakeDatabase:
def __init__(self):
self.session = {
"id": "s1",
"stage": "RESUME_ENRICHING",
"revision": 3,
"profile": {"job_type": "校招"},
"draft_id": None,
"resume_id": "r1",
}
def transaction(self, immediate=False):
class Context:
def __enter__(self_inner):
return object()
def __exit__(self_inner, *args):
return False
return Context()
def update_session(self, connection, session_id, *, stage, profile, **kwargs):
self.session["profile"] = profile
return self.session
class Service(OptimizationFlowMixin):
pass
service = Service()
service.database = FakeDatabase()
service._session_or_404 = lambda connection, session_id: service.database.session
result = service.set_target_position("s1", " 数据分析师 ")
assert result == {
"target_position": "数据分析师",
"target_position_confirmed": True,
}
assert service.database.session["profile"]["target_position"] == "数据分析师"
assert service.database.session["profile"]["target_position_confirmed"] is True
assert service.database.session["profile"]["job_type"] == "校招"
+173
View File
@@ -0,0 +1,173 @@
from __future__ import annotations
from typing import Any
from fastapi.testclient import TestClient
from app.models import JobType
from test_api import ANCHOR_CARD_VALUES, BASE, active_component, event, fill_anchor, start_manual_profile
from test_enrichment_flow import find_component, submit_to
from test_enrichment_records import confirm_active, latest_patch
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(
client,
session_id,
body,
"submit",
{"name": "娴嬭瘯鐢ㄦ埛", "email": "user@example.com"},
).json()
return session_id, body
def _created_internship_session(client: TestClient) -> tuple[str, dict[str, Any]]:
session_id, body = start_manual_profile(client, job_type="internship")
assert body["stage"] == "ANCHOR_COLLECTING"
assert body["gate"]["anchor_type"] == "education"
body = fill_anchor(client, session_id, body, dict(ANCHOR_CARD_VALUES))
body = confirm_active(client, session_id, body)
response = client.post(f"{BASE}/sessions/{session_id}/create", json={})
assert response.status_code == 200, response.text
return session_id, response.json()
def _select_custom_card(
client: TestClient, session_id: str, body: dict[str, Any], value: str
) -> dict[str, Any]:
response = event(client, session_id, body, "select", {"value": value})
assert response.status_code == 200, response.text
return response.json()
def test_job_types_are_campus_social_and_internship(client: TestClient) -> None:
assert [item.value for item in JobType] == ["campus", "social", "internship"]
session_id, body = _reach_job_type(client)
options = active_component(body)["data"]["options"]
assert options == ["campus", "social", "internship"]
rejected = event(client, session_id, body, "select", {"job_type": "other"})
assert rejected.status_code == 422
assert rejected.json()["error"]["code"] == "invalid_job_type"
def test_legacy_other_session_is_migrated_when_its_timeline_is_read(
client: TestClient,
) -> None:
created = client.post(f"{BASE}/sessions", json={})
assert created.status_code == 201
session_id = created.json()["session_id"]
database = client.app.state.database
with database.transaction(immediate=True) as connection:
session = database.fetch_session(connection, session_id)
assert session is not None
profile = dict(session["profile"])
profile["job_type"] = "other"
database.update_session(
connection,
session_id,
stage=session["stage"],
profile=profile,
increment_revision=False,
)
timeline = client.get(f"{BASE}/sessions/{session_id}/timeline")
assert timeline.status_code == 200, timeline.text
assert timeline.json()["session"]["job_type"] == "internship"
assert database.get_session(session_id)["profile"]["job_type"] == "internship"
def test_internship_flow_uses_education_anchor_and_campus_experience(client: TestClient) -> None:
session_id, body = _created_internship_session(client)
body = event(client, session_id, body, "continue_enriching").json()
card = active_component(body)
assert card["data"]["component"] == "record_fields"
assert card["data"]["module"] == "campus_experience"
assert card["data"]["record_type"] == "campus_experience"
assert [field["key"] for field in card["data"]["fields"]] == [
"organization",
"role",
"start_date",
"end_date_or_present",
]
submitted = submit_to(
client,
session_id,
body,
"record_fields",
{
"organization": "Campus Tech Club",
"role": "Technical Lead",
"start_date": "2023-09",
"end_date_or_present": "2024-06",
"description": "Organized campus programming workshops.",
},
)
assert submitted.status_code == 200, submitted.text
body = confirm_active(client, session_id, submitted.json())
section = latest_patch(body)["value"]["sections"][-1]
assert section["kind"] == "campus_experience"
assert section["heading"] == "校园经历"
assert section["items"][0]["organization"] == "Campus Tech Club"
def test_fixed_flow_ends_at_custom_card_picker_and_can_add_internship(client: TestClient) -> None:
session_id, body = _created_internship_session(client)
body = event(client, session_id, body, "continue_enriching").json()
for expected in ("campus_experience", "project", "competition", "skills", "certificates"):
assert find_component(body, "progress_card")["data"]["module"] == expected
response = event(client, session_id, body, "skip")
assert response.status_code == 200, response.text
body = response.json()
picker = active_component(body)
assert body["stage"] == "RESUME_ENRICHING"
assert picker["data"]["component"] == "custom_card_picker"
assert [option["value"] for option in picker["data"]["options"]] == [
"education",
"work_experience",
"internship_experience",
"campus_experience",
"project_experience",
"competition",
"finish",
]
body = _select_custom_card(client, session_id, body, "internship_experience")
card = active_component(body)
assert card["data"]["module"] == "internship"
assert card["data"]["record_type"] == "internship_experience"
body = submit_to(
client,
session_id,
body,
"record_fields",
{
"company": "鏄熸渤绉戞妧",
"position": "Software Engineering Intern",
"start_date": "2025-01",
"end_date_or_present": "2025-04",
},
).json()
body = confirm_active(client, session_id, body)
assert active_component(body)["data"]["component"] == "add_another"
body = _select_custom_card(client, session_id, body, "next")
assert active_component(body)["data"]["component"] == "custom_card_picker"
kinds = [section["kind"] for section in latest_patch(body)["value"]["sections"]]
assert "internship_experience" in kinds
body = _select_custom_card(client, session_id, body, "finish")
assert body["stage"] == "CONTENT_READY"
assert active_component(body)["data"]["component"] == "content_ready_card"