generated from kgod/ai-review-template
feat: builder 简历生成 + 轻度优化 + 简历导入交付副本
自内部仓库剥离深度优化与 RAG 知识库后的交付版本: - Builder 对话式简历生成(FSM + 意图路由 LLM 兜底增强) - 条目级轻度优化:事实覆盖门禁 + STAR/bullet 修复链,功能/简介/成果与技术栈同级保护 - 简历导入:DOCX/PDF 解析、结构归一、手机号脱敏 - PostgreSQL 运行时 + Alembic 迁移链 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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"] == []
|
||||
@@ -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"
|
||||
@@ -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"] == []
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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())
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"]},
|
||||
]
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"]}
|
||||
]
|
||||
@@ -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()
|
||||
@@ -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"]
|
||||
@@ -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"] == "校招"
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user