From eb3c3629c1512c03dd5b24bc3a7117df0c061c0f Mon Sep 17 00:00:00 2001 From: Christian Moellmann Date: Sat, 23 May 2026 12:08:29 +0200 Subject: [PATCH] feat(interviews): LLM stub mode for deterministic CI tests Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/app/utils/llm_client.py | 41 ++++++++++++++++++++--- backend/tests/interviews/test_llm_stub.py | 17 ++++++++++ 2 files changed, 54 insertions(+), 4 deletions(-) create mode 100644 backend/tests/interviews/test_llm_stub.py diff --git a/backend/app/utils/llm_client.py b/backend/app/utils/llm_client.py index 6c1a81f4..32285596 100644 --- a/backend/app/utils/llm_client.py +++ b/backend/app/utils/llm_client.py @@ -32,6 +32,31 @@ class LLMClient: base_url=self.base_url ) + def _stub_key(self, messages: list[dict]) -> str: + user_msg = next((m["content"] for m in reversed(messages) if m.get("role") == "user"), "") + sys_msg = next((m["content"] for m in messages if m.get("role") == "system"), "") + # Allow callers to embed an explicit stub_key=... token + for chunk in user_msg.split(): + if chunk.startswith("stub_key="): + return chunk[len("stub_key="):] + import hashlib + return hashlib.sha256((sys_msg + "|" + user_msg).encode("utf-8")).hexdigest()[:12] + + def _stub_response(self, messages: list[dict]) -> str: + import json as _json + return _json.dumps(self._stub_response_json(messages), ensure_ascii=False) + + def _stub_response_json(self, messages: list[dict]) -> dict: + key = self._stub_key(messages) + # Deterministic centered Likert + plausible open text + digit = sum(ord(c) for c in key) % 5 + 1 + return { + "stub_key": key, + "responses": {"item_001": digit, "item_002": digit, "item_003": (digit % 5) + 1}, + "confidence": {"item_001": 0.7, "item_002": 0.7, "item_003": 0.6}, + "open_comment": f"stub:{key}", + } + def chat( self, messages: List[Dict[str, str]], @@ -41,16 +66,20 @@ class LLMClient: ) -> str: """ 发送聊天请求 - + Args: messages: 消息列表 temperature: 温度参数 max_tokens: 最大token数 response_format: 响应格式(如JSON模式) - + Returns: 模型响应文本 """ + from app.config import Config + if getattr(Config, "LLM_STUB_MODE", False): + return self._stub_response(messages) + kwargs = { "model": self.model, "messages": messages, @@ -75,15 +104,19 @@ class LLMClient: ) -> Dict[str, Any]: """ 发送聊天请求并返回JSON - + Args: messages: 消息列表 temperature: 温度参数 max_tokens: 最大token数 - + Returns: 解析后的JSON对象 """ + from app.config import Config + if getattr(Config, "LLM_STUB_MODE", False): + return self._stub_response_json(messages) + response = self.chat( messages=messages, temperature=temperature, diff --git a/backend/tests/interviews/test_llm_stub.py b/backend/tests/interviews/test_llm_stub.py new file mode 100644 index 00000000..6be5ed2a --- /dev/null +++ b/backend/tests/interviews/test_llm_stub.py @@ -0,0 +1,17 @@ +import json +from app.utils.llm_client import LLMClient + + +def test_stub_mode_returns_deterministic_canned_json(monkeypatch): + monkeypatch.setenv("LLM_STUB_MODE", "true") + from app.config import Config + Config.LLM_STUB_MODE = True + client = LLMClient(api_key="x", base_url="x", model="x") + messages = [ + {"role": "system", "content": "You are persona_42. Return JSON."}, + {"role": "user", "content": "stub_key=longitudinal:item_001"}, + ] + out1 = client.chat_json(messages=messages, temperature=0.0) + out2 = client.chat_json(messages=messages, temperature=0.0) + assert out1 == out2 + assert isinstance(out1, dict)