From b6e3e1aa5e2a34bbe19530e7f74bc01e8b1f338f Mon Sep 17 00:00:00 2001 From: xiaocheny214 <187097481+xiaocheny214@users.noreply.github.com> Date: Sat, 15 Aug 2026 18:29:38 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(character):=20=E5=88=9B=E5=BB=BA?= =?UTF-8?q?=E8=A7=92=E8=89=B2=E6=97=B6=E7=94=A8=20LLM=20=E4=BB=8E=E6=8F=8F?= =?UTF-8?q?=E8=BF=B0=E8=A1=A5=E5=90=8D=E7=A7=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /characters 在 name 为空时,经 ai_engine 的 LangChain 起名器从 description 生成不超过 20 字的名称。用户填写的名称优先;LLM 失败则截断描述,都空则用「未命名角色」。 Refs #188 Co-authored-by: Cursor --- .../src/windup_ai_engine/impl/__init__.py | 3 +- .../windup_ai_engine/impl/character_namer.py | 40 ++++++++++ .../src/windup_ai_engine/ports/__init__.py | 7 ++ .../app/src/windup_app/bootstrap/app.py | 6 ++ .../src/windup_app/server/character/naming.py | 42 +++++++++++ .../windup_app/server/character/service.py | 8 ++ backend/tests/test_character_api.py | 20 ++++- backend/tests/test_character_namer.py | 32 ++++++++ backend/tests/test_character_naming.py | 74 +++++++++++++++++++ 9 files changed, 230 insertions(+), 2 deletions(-) create mode 100644 backend/packages/ai_engine/src/windup_ai_engine/impl/character_namer.py create mode 100644 backend/packages/app/src/windup_app/server/character/naming.py create mode 100644 backend/tests/test_character_namer.py create mode 100644 backend/tests/test_character_naming.py diff --git a/backend/packages/ai_engine/src/windup_ai_engine/impl/__init__.py b/backend/packages/ai_engine/src/windup_ai_engine/impl/__init__.py index 456b868a..12f538d2 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/impl/__init__.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/impl/__init__.py @@ -1,5 +1,6 @@ """impl:CharacterGeneratorPort 的装配实现(串联 strategy + 最后一公里)。""" from .character_generator import CharacterGenerator +from .character_namer import LangChainCharacterNamer -__all__ = ["CharacterGenerator"] +__all__ = ["CharacterGenerator", "LangChainCharacterNamer"] diff --git a/backend/packages/ai_engine/src/windup_ai_engine/impl/character_namer.py b/backend/packages/ai_engine/src/windup_ai_engine/impl/character_namer.py new file mode 100644 index 00000000..7d0c427f --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/impl/character_namer.py @@ -0,0 +1,40 @@ +"""用 LangChain Chat 模型从角色描述抽出短名称。""" + +from __future__ import annotations + +from typing import Any + +from langchain_core.messages import HumanMessage, SystemMessage + +from windup_framework.providers import create_chat_model + +NAME_MAX_LEN = 20 + +_SYSTEM_PROMPT = ( + "你从角色外观或人设描述中抽出一个适合资产库展示的称呼。" + "只输出名称本身,不要引号、标点或解释。" + f"名称不超过 {NAME_MAX_LEN} 个字,优先中文。" +) + + +def _clean_name(raw: str) -> str: + return raw.strip().strip("\"'“”‘’").strip()[:NAME_MAX_LEN] + + +class LangChainCharacterNamer: + """``CharacterNamerPort`` 的 LangChain 实现。""" + + def __init__(self, chat_model: Any | None = None) -> None: + self._model = chat_model if chat_model is not None else create_chat_model() + + def name_from_description(self, description: str) -> str: + result = self._model.invoke( + [ + SystemMessage(content=_SYSTEM_PROMPT), + HumanMessage(content=description), + ] + ) + content = getattr(result, "content", result) + if not isinstance(content, str): + content = str(content or "") + return _clean_name(content) diff --git a/backend/packages/ai_engine/src/windup_ai_engine/ports/__init__.py b/backend/packages/ai_engine/src/windup_ai_engine/ports/__init__.py index c3eb1684..1205eaf6 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/ports/__init__.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/ports/__init__.py @@ -169,3 +169,10 @@ def generate( progress: ProgressPort, canvas: tuple[int, int] | None = None, ) -> GeneratedAction: ... + + +@runtime_checkable +class CharacterNamerPort(Protocol): + """根据角色描述生成短名称。不是 Agent,只是一次 LLM 调用。""" + + def name_from_description(self, description: str) -> str: ... diff --git a/backend/packages/app/src/windup_app/bootstrap/app.py b/backend/packages/app/src/windup_app/bootstrap/app.py index dfa3c39c..72a39932 100644 --- a/backend/packages/app/src/windup_app/bootstrap/app.py +++ b/backend/packages/app/src/windup_app/bootstrap/app.py @@ -15,7 +15,9 @@ from windup_framework.db import Base, engine # 模型导入:触发 Base.metadata 注册,确保 create_all 能发现所有表 +from windup_ai_engine.impl.character_namer import LangChainCharacterNamer from windup_app.server.character.model import Character # noqa: F401 +from windup_app.server.character.service import service as character_service from windup_app.server.orchestrator.dispatcher import GenerationDispatcher from windup_app.server.project.model import Project # noqa: F401 from windup_app.server.quota.model import CreditAccount, CreditTransaction # noqa: F401 @@ -83,6 +85,10 @@ async def _lifespan(app: FastAPI): def create_app() -> FastAPI: app = FastAPI(title="windup", version="0.1.0", lifespan=_lifespan) app.state.generation_dispatcher = GenerationDispatcher() + # 起名器在 composition root 注入,避免 web→character.service 碰到 ai_engine。 + # 测试若已注入假 namer,不要覆盖。 + if character_service._namer is None: + character_service._namer = LangChainCharacterNamer() @app.get("/health", include_in_schema=False) def health() -> dict[str, str]: diff --git a/backend/packages/app/src/windup_app/server/character/naming.py b/backend/packages/app/src/windup_app/server/character/naming.py new file mode 100644 index 00000000..6b5dff4a --- /dev/null +++ b/backend/packages/app/src/windup_app/server/character/naming.py @@ -0,0 +1,42 @@ +"""创建角色时解析最终名称:用户输入优先,否则 LLM,再否则描述兜底。""" + +from __future__ import annotations + +from typing import Protocol + +NAME_MAX_LEN = 20 +FALLBACK_NAME = "未命名角色" + + +class CharacterNamer(Protocol): + """server 侧起名器契约,避免 web→service 链路碰到 ai_engine。""" + + def name_from_description(self, description: str) -> str: ... + + +def _clip(value: str) -> str: + return value[:NAME_MAX_LEN] + + +def resolve_character_name( + name: str | None, + description: str | None, + namer: CharacterNamer | None = None, +) -> str: + """把可空的 name / description 收成入库用的非空短名称。""" + cleaned_name = (name or "").strip() + if cleaned_name: + return _clip(cleaned_name) + + cleaned_description = (description or "").strip() + if cleaned_description and namer is not None: + try: + generated = (namer.name_from_description(cleaned_description) or "").strip() + except Exception: + generated = "" + if generated: + return _clip(generated) + + if cleaned_description: + return _clip(cleaned_description) + return FALLBACK_NAME diff --git a/backend/packages/app/src/windup_app/server/character/service.py b/backend/packages/app/src/windup_app/server/character/service.py index 5a39e9b0..a4fc59ea 100644 --- a/backend/packages/app/src/windup_app/server/character/service.py +++ b/backend/packages/app/src/windup_app/server/character/service.py @@ -13,12 +13,20 @@ from windup_app.server.character.interface import CharacterService from windup_app.server.character.model import Character +from windup_app.server.character.naming import CharacterNamer, resolve_character_name class SqlAlchemyCharacterService(CharacterService): """基于 SQLAlchemy session 的角色 CRUD 实现。""" + def __init__(self, namer: CharacterNamer | None = None) -> None: + self._namer = namer + def create_character(self, session: Session, **fields) -> Character: + fields = dict(fields) + name = fields.get("name") + namer = None if (name or "").strip() else self._namer + fields["name"] = resolve_character_name(name, fields.get("description"), namer) character = Character(**fields) session.add(character) session.flush() diff --git a/backend/tests/test_character_api.py b/backend/tests/test_character_api.py index 486a4aba..900b85ab 100644 --- a/backend/tests/test_character_api.py +++ b/backend/tests/test_character_api.py @@ -1,9 +1,27 @@ """角色 CRUD API 集成测试。""" +import pytest + from windup_app.server.character.model import Character +from windup_app.server.character.service import service as character_service from windup_common.enums.character import CharacterStatus +class _FakeNamer: + def name_from_description(self, description: str) -> str: + return f"名:{description}"[:20] + + +@pytest.fixture(autouse=True) +def _inject_fake_character_namer(): + original = character_service._namer + character_service._namer = _FakeNamer() + try: + yield + finally: + character_service._namer = original + + def _create_project(auth_client, name: str = "默认项目") -> dict: """创建一个项目并返回响应 data。""" return auth_client.post("/projects", json={ @@ -81,7 +99,7 @@ def test_create_without_name(auth_client): resp = auth_client.post("/characters", json=_payload(project["id"], name=None)) assert resp.json()["code"] == 200 - assert resp.json()["data"]["name"] is None + assert resp.json()["data"]["name"] == "名:主角" def test_create_name_roundtrip(auth_client): diff --git a/backend/tests/test_character_namer.py b/backend/tests/test_character_namer.py new file mode 100644 index 00000000..9de2606b --- /dev/null +++ b/backend/tests/test_character_namer.py @@ -0,0 +1,32 @@ +"""LangChain 角色起名器:注入假 chat model,不打真实 LLM。""" + +from types import SimpleNamespace + +from windup_ai_engine.impl.character_namer import LangChainCharacterNamer + + +class _FakeChat: + def __init__(self, content: object, error: Exception | None = None) -> None: + self.content = content + self.error = error + self.messages = None + + def invoke(self, messages): + self.messages = messages + if self.error is not None: + raise self.error + return SimpleNamespace(content=self.content) + + +def test_namer_returns_cleaned_model_text(): + chat = _FakeChat(' "赤发旅人" ') + namer = LangChainCharacterNamer(chat_model=chat) + + assert namer.name_from_description("红发少年站在雾港") == "赤发旅人" + assert chat.messages is not None + + +def test_namer_truncates_to_20_chars(): + chat = _FakeChat("风" * 25) + namer = LangChainCharacterNamer(chat_model=chat) + assert namer.name_from_description("一段描述") == "风" * 20 diff --git a/backend/tests/test_character_naming.py b/backend/tests/test_character_naming.py new file mode 100644 index 00000000..a6aa6c75 --- /dev/null +++ b/backend/tests/test_character_naming.py @@ -0,0 +1,74 @@ +"""角色名称解析:用户输入优先,空名称才走 LLM / 兜底。""" + +from windup_app.server.character.naming import FALLBACK_NAME, resolve_character_name + + +class _FakeNamer: + def __init__(self, result: str = "赤发旅人", error: Exception | None = None) -> None: + self.result = result + self.error = error + self.calls: list[str] = [] + + def name_from_description(self, description: str) -> str: + self.calls.append(description) + if self.error is not None: + raise self.error + return self.result + + +def test_keeps_user_name_and_does_not_call_namer(): + namer = _FakeNamer() + assert resolve_character_name(" 勇者 ", "一段很长的描述", namer) == "勇者" + assert namer.calls == [] + + +def test_trims_user_name_to_20_chars(): + assert resolve_character_name("龙" * 25, None, _FakeNamer()) == "龙" * 20 + + +def test_blank_name_uses_namer_on_description(): + namer = _FakeNamer("雾港少年") + assert resolve_character_name(" ", "红发少年站在雾港码头", namer) == "雾港少年" + assert namer.calls == ["红发少年站在雾港码头"] + + +def test_namer_output_is_trimmed_to_20_chars(): + namer = _FakeNamer("超" * 30) + assert resolve_character_name(None, "很长的描述", namer) == "超" * 20 + + +def test_namer_failure_falls_back_to_description(): + namer = _FakeNamer(error=RuntimeError("timeout")) + assert resolve_character_name(None, "码头上的红发剑士在等船", namer) == "码头上的红发剑士在等船" + + +def test_namer_failure_truncates_long_description(): + namer = _FakeNamer(error=RuntimeError("timeout")) + description = "这是一段超过二十个字的角色描述用来兜底" + assert resolve_character_name(None, description, namer) == description[:20] + + +def test_empty_name_and_description_use_fallback(): + namer = _FakeNamer() + assert resolve_character_name(None, None, namer) == FALLBACK_NAME + assert namer.calls == [] + + +def test_empty_namer_result_falls_back_to_description(): + namer = _FakeNamer(" ") + assert resolve_character_name(None, "银发法师", namer) == "银发法师" + + +def test_service_create_uses_namer_when_name_missing(db_session): + from windup_app.server.character.service import SqlAlchemyCharacterService + + service = SqlAlchemyCharacterService(namer=_FakeNamer("雾港少年")) + character = service.create_character( + db_session, + project_id=1, + workflow_run_id=901, + name=None, + description="红发少年站在雾港码头", + character_data={}, + ) + assert character.name == "雾港少年" From f28055b4b82d3665bcec59e283c981aae14505d9 Mon Sep 17 00:00:00 2001 From: xiaocheny214 <187097481+xiaocheny214@users.noreply.github.com> Date: Sat, 15 Aug 2026 18:45:54 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(character):=20=E8=B5=B7=E5=90=8D?= =?UTF-8?q?=E5=99=A8=E6=94=B9=E4=B8=BA=E6=87=92=E5=8A=A0=E8=BD=BD=EF=BC=8C?= =?UTF-8?q?=E9=81=BF=E5=85=8D=20create=5Fapp=20=E4=BE=9D=E8=B5=96=20API=20?= =?UTF-8?q?key?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI 没有 AI_API_KEY 时,装配期创建 ChatOpenAI 会让所有走 create_app 的测试在 setup 失败。LangChain 客户端推迟到第一次起名再构建。 Co-authored-by: Cursor --- .../src/windup_ai_engine/impl/character_namer.py | 10 ++++++++-- backend/tests/test_character_namer.py | 6 ++++++ backend/tests/test_smoke.py | 16 ++++++++++++++++ 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/backend/packages/ai_engine/src/windup_ai_engine/impl/character_namer.py b/backend/packages/ai_engine/src/windup_ai_engine/impl/character_namer.py index 7d0c427f..692c4eb7 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/impl/character_namer.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/impl/character_namer.py @@ -25,10 +25,16 @@ class LangChainCharacterNamer: """``CharacterNamerPort`` 的 LangChain 实现。""" def __init__(self, chat_model: Any | None = None) -> None: - self._model = chat_model if chat_model is not None else create_chat_model() + # 装配期不创建 ChatOpenAI:CI / 本地无 AI_API_KEY 时 create_app 仍能起来。 + self._model = chat_model + + def _chat_model(self) -> Any: + if self._model is None: + self._model = create_chat_model() + return self._model def name_from_description(self, description: str) -> str: - result = self._model.invoke( + result = self._chat_model().invoke( [ SystemMessage(content=_SYSTEM_PROMPT), HumanMessage(content=description), diff --git a/backend/tests/test_character_namer.py b/backend/tests/test_character_namer.py index 9de2606b..f16a10ac 100644 --- a/backend/tests/test_character_namer.py +++ b/backend/tests/test_character_namer.py @@ -30,3 +30,9 @@ def test_namer_truncates_to_20_chars(): chat = _FakeChat("风" * 25) namer = LangChainCharacterNamer(chat_model=chat) assert namer.name_from_description("一段描述") == "风" * 20 + + +def test_namer_construction_does_not_touch_chat_provider(): + """装配应用时不能因为没有 AI_API_KEY 就炸。""" + namer = LangChainCharacterNamer() + assert namer._model is None diff --git a/backend/tests/test_smoke.py b/backend/tests/test_smoke.py index f60940ee..d9f83f1c 100644 --- a/backend/tests/test_smoke.py +++ b/backend/tests/test_smoke.py @@ -10,6 +10,22 @@ def test_create_app(): assert app.title == "windup" +def test_create_app_does_not_construct_chat_model(monkeypatch): + """CI 没有 AI_API_KEY,装配期不能去建 ChatOpenAI。""" + from windup_app.server.character.service import service as character_service + + def boom(*_args, **_kwargs): + raise AssertionError("create_chat_model should not run during create_app") + + monkeypatch.setattr( + "windup_ai_engine.impl.character_namer.create_chat_model", + boom, + ) + character_service._namer = None + app = create_app() + assert app.title == "windup" + + def test_health_endpoint_reports_ok_without_auth(client): response = client.get("/health") From 25aa8853cb68773116345e8745eff57389ad41e3 Mon Sep 17 00:00:00 2001 From: xiaocheny214 <187097481+xiaocheny214@users.noreply.github.com> Date: Sat, 15 Aug 2026 18:53:55 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(character):=20=E8=B5=B7=E5=90=8D?= =?UTF-8?q?=E7=94=A8=E7=8B=AC=E7=AB=8B=20chat=20=E5=9E=8B=E5=8F=B7?= =?UTF-8?q?=EF=BC=8C=E5=B9=82=E7=AD=89=E9=87=8D=E8=AF=95=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E5=85=88=E6=89=93=20LLM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit create_chat_model 在空 key/空型号时先拒绝,避免 ChatOpenAI(model="")。 同 workflow_run_id 先查已有角色再起名。装配期仍不创建 ChatOpenAI。 Co-authored-by: Cursor --- .env.example | 5 +- .../app/src/windup_app/bootstrap/app.py | 1 + .../windup_app/server/character/service.py | 11 +++- .../src/windup_framework/config/provider.py | 5 +- .../src/windup_framework/providers/chat.py | 13 +++- backend/tests/test_character_naming.py | 59 +++++++++++++++++++ backend/tests/test_chat_provider.py | 28 +++++++++ 7 files changed, 115 insertions(+), 7 deletions(-) create mode 100644 backend/tests/test_chat_provider.py diff --git a/.env.example b/.env.example index b986b308..fe72424a 100644 --- a/.env.example +++ b/.env.example @@ -33,8 +33,9 @@ QINIU_PRIVATE_SPACE=false # 键名前缀必须是 AI_,由 framework/config/provider.py 的 env_prefix 决定。 AI_BASE_URL=https://api.qnaigc.com/v1 AI_API_KEY=your-ai-api-key -# 各能力分开配型号:三条能力同时在用不同模型,共用一个字段会换一个连带换全部。 -# 取值即默认值——不写这两行时跑的就是它们。 +# 各能力分开配型号:同时在用不同模型,共用一个字段会换一个连带换全部。 +# 取值即默认值——不写这几行时跑的就是它们。 +AI_CHAT_MODEL=gpt-4o-mini AI_IMAGE_MODEL=gemini-2.5-flash-image AI_VIDEO_MODEL=kling-v2-5-turbo diff --git a/backend/packages/app/src/windup_app/bootstrap/app.py b/backend/packages/app/src/windup_app/bootstrap/app.py index 72a39932..ff2cf677 100644 --- a/backend/packages/app/src/windup_app/bootstrap/app.py +++ b/backend/packages/app/src/windup_app/bootstrap/app.py @@ -86,6 +86,7 @@ def create_app() -> FastAPI: app = FastAPI(title="windup", version="0.1.0", lifespan=_lifespan) app.state.generation_dispatcher = GenerationDispatcher() # 起名器在 composition root 注入,避免 web→character.service 碰到 ai_engine。 + # LangChainCharacterNamer 构造期不创建 ChatOpenAI;缺 AI_API_KEY 时应用仍能启动。 # 测试若已注入假 namer,不要覆盖。 if character_service._namer is None: character_service._namer = LangChainCharacterNamer() diff --git a/backend/packages/app/src/windup_app/server/character/service.py b/backend/packages/app/src/windup_app/server/character/service.py index a4fc59ea..68bb6814 100644 --- a/backend/packages/app/src/windup_app/server/character/service.py +++ b/backend/packages/app/src/windup_app/server/character/service.py @@ -24,8 +24,17 @@ def __init__(self, namer: CharacterNamer | None = None) -> None: def create_character(self, session: Session, **fields) -> Character: fields = dict(fields) + workflow_run_id = fields.get("workflow_run_id") + existing = ( + self.get_character_by_workflow_run(session, workflow_run_id) + if workflow_run_id is not None + else None + ) + if existing is not None and existing.project_id == fields.get("project_id"): + return existing name = fields.get("name") - namer = None if (name or "").strip() else self._namer + # 已有同 workflow_run(含跨项目冲突)不再打 LLM,插入交给唯一约束。 + namer = None if (name or "").strip() or existing is not None else self._namer fields["name"] = resolve_character_name(name, fields.get("description"), namer) character = Character(**fields) session.add(character) diff --git a/backend/packages/framework/src/windup_framework/config/provider.py b/backend/packages/framework/src/windup_framework/config/provider.py index faeede24..72758f95 100644 --- a/backend/packages/framework/src/windup_framework/config/provider.py +++ b/backend/packages/framework/src/windup_framework/config/provider.py @@ -22,15 +22,16 @@ class AIProviderSettings(BaseSettings): chat_completions_path: str = "/chat/completions" # ── 各能力用哪个模型 ────────────────────────────────────────────────── - # 分成三个字段而不是共用上面那个 ``model``:三条能力同时在用不同模型,共用一个 + # 分成字段而不是共用上面那个 ``model``:各能力同时在用不同模型,共用一个 # 字段意味着换其中一个就把另外两个也换了。默认值即当前实测在用的型号, - # 部署侧可用 AI_VIDEO_MODEL / AI_IMAGE_MODEL 覆盖。 + # 部署侧可用 AI_CHAT_MODEL / AI_VIDEO_MODEL / AI_IMAGE_MODEL 覆盖。 # # **只有型号可配,请求形状不可配**:哪个模型吃 image_list、哪个吃 # input_reference、FAL 队列路径长什么样,都是该模型的 API 事实而非运行参数, # 写在 providers.sufy 的映射表里。放进配置会把"填错了会怎样"从部署期推到 # 运行期 —— 字段塞错不会立刻报错,任务照常 queued,直到生成阶段才 failed, # 而费用可能已经产生(2026-07-29 实测)。 + chat_model: str = "gpt-4o-mini" video_model: str = "kling-v2-5-turbo" image_model: str = "gemini-2.5-flash-image" diff --git a/backend/packages/framework/src/windup_framework/providers/chat.py b/backend/packages/framework/src/windup_framework/providers/chat.py index acf98709..bd9bc4e3 100644 --- a/backend/packages/framework/src/windup_framework/providers/chat.py +++ b/backend/packages/framework/src/windup_framework/providers/chat.py @@ -15,10 +15,19 @@ def create_chat_model( 这里仅统一 Windup 配置到 LangChain 官方客户端的映射,不重新实现 ``BaseChatModel``、消息转换、工具调用或结构化输出。 + + 空 ``AI_API_KEY`` 或空型号直接拒绝,避免 langchain-openai 1.4 抛 + ``OpenAIError`` 或留下 ``ChatOpenAI(model="")``。 """ + model = (config.chat_model or config.model or "").strip() + api_key = (config.api_key or "").strip() + if not api_key: + raise ValueError("AI_API_KEY 未配置") + if not model: + raise ValueError("AI_CHAT_MODEL / AI_MODEL 未配置") return ChatOpenAI( - model=config.model, - api_key=config.api_key or None, + model=model, + api_key=api_key, base_url=config.normalized_base_url, timeout=config.timeout, max_retries=config.max_retries, diff --git a/backend/tests/test_character_naming.py b/backend/tests/test_character_naming.py index a6aa6c75..88c738b7 100644 --- a/backend/tests/test_character_naming.py +++ b/backend/tests/test_character_naming.py @@ -1,5 +1,7 @@ """角色名称解析:用户输入优先,空名称才走 LLM / 兜底。""" +import pytest + from windup_app.server.character.naming import FALLBACK_NAME, resolve_character_name @@ -59,6 +61,63 @@ def test_empty_namer_result_falls_back_to_description(): assert resolve_character_name(None, "银发法师", namer) == "银发法师" +def test_service_create_skips_namer_when_workflow_run_exists(db_session): + from windup_app.server.character.service import SqlAlchemyCharacterService + + namer = _FakeNamer("第一次") + service = SqlAlchemyCharacterService(namer=namer) + first = service.create_character( + db_session, + project_id=1, + workflow_run_id=77, + name=None, + description="红发少年", + character_data={}, + ) + namer.result = "第二次" + second = service.create_character( + db_session, + project_id=1, + workflow_run_id=77, + name=None, + description="红发少年", + character_data={}, + ) + + assert second.id == first.id + assert second.name == "第一次" + assert namer.calls == ["红发少年"] + + +def test_service_create_skips_namer_on_cross_project_workflow_run(db_session): + from sqlalchemy.exc import IntegrityError + + from windup_app.server.character.service import SqlAlchemyCharacterService + + namer = _FakeNamer("第一次") + service = SqlAlchemyCharacterService(namer=namer) + service.create_character( + db_session, + project_id=1, + workflow_run_id=88, + name=None, + description="红发少年", + character_data={}, + ) + + with pytest.raises(IntegrityError): + service.create_character( + db_session, + project_id=2, + workflow_run_id=88, + name=None, + description="另一段描述", + character_data={}, + ) + + assert namer.calls == ["红发少年"] + + def test_service_create_uses_namer_when_name_missing(db_session): from windup_app.server.character.service import SqlAlchemyCharacterService diff --git a/backend/tests/test_chat_provider.py b/backend/tests/test_chat_provider.py new file mode 100644 index 00000000..e5a8a91a --- /dev/null +++ b/backend/tests/test_chat_provider.py @@ -0,0 +1,28 @@ +"""Chat provider:空凭据 / 空型号不得构造 ChatOpenAI。""" + +import pytest + +from windup_framework.config.provider import AIProviderSettings +from windup_framework.providers.chat import create_chat_model + + +def test_create_chat_model_rejects_missing_api_key(): + config = AIProviderSettings(api_key="", chat_model="gpt-4o-mini", model="") + with pytest.raises(ValueError, match="AI_API_KEY"): + create_chat_model(config) + + +def test_create_chat_model_rejects_empty_model(): + config = AIProviderSettings(api_key="test-key", chat_model="", model="") + with pytest.raises(ValueError, match="AI_CHAT_MODEL"): + create_chat_model(config) + + +def test_create_chat_model_prefers_chat_model_over_generic_model(): + config = AIProviderSettings( + api_key="test-key", + chat_model="gpt-4o-mini", + model="should-not-use", + ) + chat = create_chat_model(config) + assert chat.model_name == "gpt-4o-mini"