feat: 完成input_context节点
This commit is contained in:
+11
-5
@@ -1,5 +1,11 @@
|
|||||||
LLM_API_KEY=replace-with-your-api-key
|
LLM_API_KEY=sk-xxx
|
||||||
LLM_MODEL=replace-with-your-model-name
|
LLM_MODEL=gpt-6
|
||||||
# Optional: your provider endpoint
|
LLM_BASE_URL=https://api.openai.com/v1
|
||||||
# LLM_BASE_URL=https://your-provider.example/v1
|
|
||||||
EMBEDDING_BATCH_SIZE=32
|
# 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
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ from app.config import settings
|
|||||||
|
|
||||||
@cache
|
@cache
|
||||||
def get_llm() -> ChatOpenAI:
|
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(
|
return ChatOpenAI(
|
||||||
model=settings.llm_model,
|
model=settings.llm_model,
|
||||||
api_key=settings.llm_api_key,
|
api_key=settings.llm_api_key,
|
||||||
|
|||||||
+21
-2
@@ -11,15 +11,34 @@ load_dotenv(Path(__file__).resolve().parent.parent / ".env")
|
|||||||
class Settings:
|
class Settings:
|
||||||
# LLM 配置
|
# LLM 配置
|
||||||
llm_api_key: str = field(
|
llm_api_key: str = field(
|
||||||
default_factory=lambda: os.environ["LLM_API_KEY"],
|
default_factory=lambda: os.getenv("LLM_API_KEY", ""),
|
||||||
repr=False,
|
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(
|
llm_base_url: str | None = field(
|
||||||
default_factory=lambda: os.getenv("LLM_BASE_URL") or None
|
default_factory=lambda: os.getenv("LLM_BASE_URL") or None
|
||||||
)
|
)
|
||||||
llm_timeout: float = 60.0
|
llm_timeout: float = 60.0
|
||||||
llm_max_retries: int = 2
|
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()
|
settings = Settings()
|
||||||
|
|||||||
+18
-1
@@ -1,5 +1,22 @@
|
|||||||
class AppError(Exception):
|
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):
|
class ImportProcessError(AppError):
|
||||||
|
|||||||
+17
-6
@@ -1,9 +1,12 @@
|
|||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from abc import ABC, abstractmethod
|
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
|
from app.exceptions import AppError, ImportProcessError
|
||||||
|
|
||||||
T = TypeVar("T")
|
T = TypeVar("T")
|
||||||
@@ -12,18 +15,26 @@ T = TypeVar("T")
|
|||||||
class BaseNode(ABC):
|
class BaseNode(ABC):
|
||||||
name: str = "base_node"
|
name: str = "base_node"
|
||||||
|
|
||||||
def __init__(self, config: Settings | None = None):
|
def __init__(self, config: "Settings | None" = None):
|
||||||
"""传入配置时使用传入值,否则使用全局配置"""
|
"""保存显式配置;全局配置在节点实际使用时才加载。"""
|
||||||
self.config = config if config is not None else settings
|
self._config = config
|
||||||
self.logger = logging.getLogger(f"import.{self.name}")
|
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:
|
def __call__(self, state: T) -> T:
|
||||||
"""执行节点,统一记录日志、耗时并保留业务异常类型"""
|
"""执行节点,统一记录日志、耗时并保留业务异常类型"""
|
||||||
start_time = time.perf_counter()
|
start_time = time.perf_counter()
|
||||||
self.logger.info("--- %s 开始 ---", self.name)
|
self.logger.info("--- %s 开始 ---", self.name)
|
||||||
try:
|
try:
|
||||||
result = self.process(state)
|
result = self.process(state)
|
||||||
except AppError:
|
except (AppError, ValidationError):
|
||||||
self.logger.exception("%s 执行失败", self.name)
|
self.logger.exception("%s 执行失败", self.name)
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -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]
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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]
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""输入节点校验后的上下文"""
|
||||||
|
|
||||||
|
from .request import DesignRequest
|
||||||
|
|
||||||
|
|
||||||
|
class InputContext(DesignRequest):
|
||||||
|
"""当前复用请求协议,后续可添加解析结果而不改变前端传参"""
|
||||||
@@ -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)
|
||||||
@@ -3,7 +3,9 @@ name = "my-v0-server"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"pydantic>=2.13.5,<3",
|
||||||
"langchain-openai>=1.6.2",
|
"langchain-openai>=1.6.2",
|
||||||
"langgraph>=1.2.12",
|
"langgraph>=1.2.12",
|
||||||
"python-dotenv>=1.2.3",
|
"python-dotenv>=1.2.3",
|
||||||
|
"typesafe-sdk>=0.7.1",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -551,14 +551,18 @@ source = { virtual = "." }
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "langchain-openai" },
|
{ name = "langchain-openai" },
|
||||||
{ name = "langgraph" },
|
{ name = "langgraph" },
|
||||||
|
{ name = "pydantic" },
|
||||||
{ name = "python-dotenv" },
|
{ name = "python-dotenv" },
|
||||||
|
{ name = "typesafe-sdk" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "langchain-openai", specifier = ">=1.6.2" },
|
{ name = "langchain-openai", specifier = ">=1.6.2" },
|
||||||
{ name = "langgraph", specifier = ">=1.2.12" },
|
{ name = "langgraph", specifier = ">=1.2.12" },
|
||||||
|
{ name = "pydantic", specifier = ">=2.13.5,<3" },
|
||||||
{ name = "python-dotenv", specifier = ">=1.2.3" },
|
{ name = "python-dotenv", specifier = ">=1.2.3" },
|
||||||
|
{ name = "typesafe-sdk", specifier = ">=0.7.1" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "typing-extensions"
|
name = "typing-extensions"
|
||||||
version = "4.16.0"
|
version = "4.16.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user