feat: 完成input_context节点

This commit is contained in:
2026-09-22 19:12:31 +08:00
parent 9bddbc3aa5
commit 4d46336bc2
14 changed files with 229 additions and 14 deletions
+2
View File
@@ -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,
+21 -2
View File
@@ -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()
+18 -1
View File
@@ -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):
+17 -6
View File
@@ -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:
+60
View File
@@ -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))
+9
View File
@@ -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]
+18
View File
@@ -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",
]
+23
View File
@@ -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]
+7
View File
@@ -0,0 +1,7 @@
"""输入节点校验后的上下文"""
from .request import DesignRequest
class InputContext(DesignRequest):
"""当前复用请求协议,后续可添加解析结果而不改变前端传参"""
+21
View File
@@ -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)