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