diff --git a/.env.example b/.env.example index d15026c..e0c190c 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,11 @@ -LLM_API_KEY=replace-with-your-api-key -LLM_MODEL=replace-with-your-model-name -# Optional: your provider endpoint -# LLM_BASE_URL=https://your-provider.example/v1 -EMBEDDING_BATCH_SIZE=32 +LLM_API_KEY=sk-xxx +LLM_MODEL=gpt-6 +LLM_BASE_URL=https://api.openai.com/v1 + +# Jev / TypeSafe +TYPESAFE_API_KEY=apikey_xxx +TYPESAFE_DEFAULT_MODEL=jev-latest +# API 根地址,不带 /v1/systemone +# TYPESAFE_BASE_URL=https://api.typesafe.ai +TYPESAFE_TIMEOUT=30 +TYPESAFE_MAX_RETRIES=2 diff --git a/app/clients/llm.py b/app/clients/llm.py index c4bcf84..9628306 100644 --- a/app/clients/llm.py +++ b/app/clients/llm.py @@ -7,6 +7,8 @@ from app.config import settings @cache def get_llm() -> ChatOpenAI: + if not settings.llm_api_key.strip() or not settings.llm_model.strip(): + raise ValueError("未配置 LLM_API_KEY 或 LLM_MODEL") return ChatOpenAI( model=settings.llm_model, api_key=settings.llm_api_key, diff --git a/app/config.py b/app/config.py index 942bbec..f623598 100644 --- a/app/config.py +++ b/app/config.py @@ -11,15 +11,34 @@ load_dotenv(Path(__file__).resolve().parent.parent / ".env") class Settings: # LLM 配置 llm_api_key: str = field( - default_factory=lambda: os.environ["LLM_API_KEY"], + default_factory=lambda: os.getenv("LLM_API_KEY", ""), repr=False, ) - llm_model: str = field(default_factory=lambda: os.environ["LLM_MODEL"]) + llm_model: str = field(default_factory=lambda: os.getenv("LLM_MODEL", "")) llm_base_url: str | None = field( default_factory=lambda: os.getenv("LLM_BASE_URL") or None ) llm_timeout: float = 60.0 llm_max_retries: int = 2 + # Jev + typesafe_api_key: str = field( + default_factory=lambda: os.getenv("TYPESAFE_API_KEY", ""), repr=False + ) + typesafe_model: str = field( + default_factory=lambda: os.getenv("TYPESAFE_DEFAULT_MODEL") or "jev-latest" + ) + typesafe_base_url: str = field( + default_factory=lambda: ( + os.getenv("TYPESAFE_BASE_URL") or "https://api.typesafe.ai" + ) + ) + typesafe_timeout: float = field( + default_factory=lambda: float(os.getenv("TYPESAFE_TIMEOUT", "30")) + ) + typesafe_max_retries: int = field( + default_factory=lambda: int(os.getenv("TYPESAFE_MAX_RETRIES", "2")) + ) + settings = Settings() diff --git a/app/exceptions.py b/app/exceptions.py index 1abedc3..589de4d 100644 --- a/app/exceptions.py +++ b/app/exceptions.py @@ -1,5 +1,22 @@ class AppError(Exception): - """应用业务异常。""" + """应用业务异常""" + + +class DecisionError(AppError): + """决策服务错误;仅暴露安全摘要及服务端追踪信息""" + + def __init__( + self, + message: str, + *, + kind: str, + status: int | None = None, + request_id: str | None = None, + ): + self.kind = kind + self.status = status + self.request_id = request_id + super().__init__(message) class ImportProcessError(AppError): diff --git a/app/processor/base.py b/app/processor/base.py index bfcbeb4..33ca581 100644 --- a/app/processor/base.py +++ b/app/processor/base.py @@ -1,9 +1,12 @@ import logging import time from abc import ABC, abstractmethod -from typing import TypeVar +from typing import TYPE_CHECKING, TypeVar -from app.config import Settings, settings +from pydantic import ValidationError + +if TYPE_CHECKING: + from app.config import Settings from app.exceptions import AppError, ImportProcessError T = TypeVar("T") @@ -12,18 +15,26 @@ T = TypeVar("T") class BaseNode(ABC): name: str = "base_node" - def __init__(self, config: Settings | None = None): - """传入配置时使用传入值,否则使用全局配置""" - self.config = config if config is not None else settings + def __init__(self, config: "Settings | None" = None): + """保存显式配置;全局配置在节点实际使用时才加载。""" + self._config = config self.logger = logging.getLogger(f"import.{self.name}") + @property + def config(self) -> "Settings": + if self._config is None: + from app.config import settings + + self._config = settings + return self._config + def __call__(self, state: T) -> T: """执行节点,统一记录日志、耗时并保留业务异常类型""" start_time = time.perf_counter() self.logger.info("--- %s 开始 ---", self.name) try: result = self.process(state) - except AppError: + except (AppError, ValidationError): self.logger.exception("%s 执行失败", self.name) raise except Exception as e: diff --git a/app/processor/nodes/input_node.py b/app/processor/nodes/input_node.py new file mode 100644 index 0000000..1739935 --- /dev/null +++ b/app/processor/nodes/input_node.py @@ -0,0 +1,60 @@ +"""输入解析与校验,可直接运行本文件修改示例进行调试""" + +from app.processor.base import BaseNode +from app.processor.state import QueryState +from app.schemas.input_context import InputContext + + +class InputNode(BaseNode): + """纯输入节点,不依赖模型配置;校验失败保留 ValidationError""" + + name = "input_node" + + def process(self, state: QueryState) -> QueryState: + # 深拷贝隔离原始请求,后续修改上下文不会改变提交时的快照 + context = InputContext.model_validate(state["request"]).model_copy(deep=True) + updated_state: QueryState = {**state, "input_context": context} + return updated_state + + +if __name__ == "__main__": + import json + + # 1. 创建 state:可将 screen 替换成前端的完整大屏 JSON + state: QueryState = { + "request": { + "prompt": "把销售额统计向右移动 50px", + "screen": { + "canvas": { + "width": 1920, + "height": 1080, + "backgroundColor": "#ffffff10", + }, + "nodes": [ + { + "id": "sales-chart", + "type": "bar-chart", + "name": "销售额统计", + "layout": {"x": 26, "y": 34.8, "width": 400, "height": 260}, + "props": {"option": {"title": {"text": "销售额统计"}}}, + } + ], + "dataSource": [], + }, + "context": {"selected_node_ids": ["sales-chart"]}, + "attachments": [], + } + } + + # 2. 执行节点 + node = InputNode() + final_state = node.process(state) + + # 3. 打印完整状态 + printable_state = { + **final_state, + "input_context": final_state["input_context"].model_dump( + mode="json", exclude_unset=True + ), + } + print(json.dumps(printable_state, ensure_ascii=False, indent=2)) diff --git a/app/schema/__init__.py b/app/processor/nodes/requirement_node.py similarity index 100% rename from app/schema/__init__.py rename to app/processor/nodes/requirement_node.py diff --git a/app/processor/state.py b/app/processor/state.py new file mode 100644 index 0000000..1e755fb --- /dev/null +++ b/app/processor/state.py @@ -0,0 +1,9 @@ +from typing import NotRequired, TypedDict + +from app.schemas.base import JsonObject +from app.schemas.input_context import InputContext + + +class QueryState(TypedDict): + request: JsonObject + input_context: NotRequired[InputContext] diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..5de356b --- /dev/null +++ b/app/schemas/__init__.py @@ -0,0 +1,18 @@ +"""前端请求与大屏公共模型""" + +from .input_context import InputContext +from .request import DesignRequest, RequestContext +from .requirement import Requirement, RequirementChange +from .screen import Canvas, Layout, Screen, ScreenNode + +__all__ = [ + "Canvas", + "DesignRequest", + "InputContext", + "Layout", + "RequestContext", + "Requirement", + "RequirementChange", + "Screen", + "ScreenNode", +] diff --git a/app/schemas/base.py b/app/schemas/base.py new file mode 100644 index 0000000..9a2c2b4 --- /dev/null +++ b/app/schemas/base.py @@ -0,0 +1,23 @@ +"""输入协议的公共类型""" + +from typing import Annotated + +from pydantic import AfterValidator, BaseModel, ConfigDict, JsonValue + + +def require_non_blank(value: str) -> str: + if not value.strip(): + raise ValueError("不能为空或仅包含空白字符") + return value + + +NonBlankString = Annotated[str, AfterValidator(require_non_blank)] +JsonObject = dict[str, JsonValue] + + +class ExtensibleModel(BaseModel): + """校验已知字段,保留未知 JSON 字段;不隐式转换输入类型""" + + model_config = ConfigDict(extra="allow", strict=True, allow_inf_nan=False) + + __pydantic_extra__: dict[str, JsonValue] diff --git a/app/schemas/input_context.py b/app/schemas/input_context.py new file mode 100644 index 0000000..71a75b3 --- /dev/null +++ b/app/schemas/input_context.py @@ -0,0 +1,7 @@ +"""输入节点校验后的上下文""" + +from .request import DesignRequest + + +class InputContext(DesignRequest): + """当前复用请求协议,后续可添加解析结果而不改变前端传参""" diff --git a/app/schemas/request.py b/app/schemas/request.py new file mode 100644 index 0000000..1448c2a --- /dev/null +++ b/app/schemas/request.py @@ -0,0 +1,21 @@ +"""前端设计请求:原始需求、当前大屏及可选补充上下文""" + +from pydantic import Field + +from .base import ExtensibleModel, JsonObject, NonBlankString +from .screen import Screen + + +class RequestContext(ExtensibleModel): + selected_node_ids: list[NonBlankString] = Field(default_factory=list) + screen_id: NonBlankString | None = None + screen_revision: int | None = Field(default=None, ge=0) + conversation_id: NonBlankString | None = None + + +class DesignRequest(ExtensibleModel): + prompt: NonBlankString + screen: Screen + context: RequestContext = Field(default_factory=RequestContext) + # 只预留 JSON 描述入口,暂不规定材料种类,也不读取文件或远程资源 + attachments: list[JsonObject] = Field(default_factory=list) diff --git a/pyproject.toml b/pyproject.toml index 080a6fa..2af038a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,9 @@ name = "my-v0-server" version = "0.1.0" requires-python = ">=3.11" dependencies = [ + "pydantic>=2.13.5,<3", "langchain-openai>=1.6.2", "langgraph>=1.2.12", "python-dotenv>=1.2.3", + "typesafe-sdk>=0.7.1", ] diff --git a/uv.lock b/uv.lock index 6f67c95..87f44ed 100644 --- a/uv.lock +++ b/uv.lock @@ -551,14 +551,18 @@ source = { virtual = "." } dependencies = [ { name = "langchain-openai" }, { name = "langgraph" }, + { name = "pydantic" }, { name = "python-dotenv" }, + { name = "typesafe-sdk" }, ] [package.metadata] requires-dist = [ { name = "langchain-openai", specifier = ">=1.6.2" }, { name = "langgraph", specifier = ">=1.2.12" }, + { name = "pydantic", specifier = ">=2.13.5,<3" }, { name = "python-dotenv", specifier = ">=1.2.3" }, + { name = "typesafe-sdk", specifier = ">=0.7.1" }, ] [[package]] @@ -1114,6 +1118,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, ] +[[package]] +name = "typesafe-sdk" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx2" }, + { name = "pydantic" }, + { name = "pydantic-core" }, + { name = "tenacity" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/d4/14d9a00c759c126c814f7cfc0b2a760768214cda0708a9af49577b321ee3/typesafe_sdk-0.7.1.tar.gz", hash = "sha256:495ce9d81733b4f95edcb156e95042a7ec4bfd59972a1150ffa8ce1e11f85a54", size = 23062, upload-time = "2026-09-21T15:57:53.579Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/72/7e57648d27e45260b04762dd2393a81604b516a282fc5efe94cdf90ab971/typesafe_sdk-0.7.1-py3-none-any.whl", hash = "sha256:9d04eee13b5f64bbbe6f8e96cf40e3f39ca4efc934a1f4f39490cb9a43e9d2ad", size = 36366, upload-time = "2026-09-21T15:57:52.314Z" }, +] + [[package]] name = "typing-extensions" version = "4.16.0"