schema.py 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259
  1. """
  2. Schema Generator - 从函数签名自动生成 OpenAI Tool Schema
  3. 职责:
  4. 1. 解析函数签名(参数、类型注解、默认值)
  5. 2. 解析 docstring(Google 风格)
  6. 3. 生成 OpenAI Tool Calling 格式的 JSON Schema
  7. 从 Resonote/llm/tools/schema.py 抽取
  8. """
  9. import inspect
  10. import logging
  11. from copy import deepcopy
  12. from types import UnionType
  13. from typing import (
  14. Any,
  15. Dict,
  16. List,
  17. Literal,
  18. Optional,
  19. Union,
  20. get_args,
  21. get_origin,
  22. get_type_hints,
  23. )
  24. logger = logging.getLogger(__name__)
  25. # 尝试导入 docstring_parser,如果不可用则提供降级方案
  26. try:
  27. from docstring_parser import parse as parse_docstring
  28. HAS_DOCSTRING_PARSER = True
  29. except ImportError:
  30. HAS_DOCSTRING_PARSER = False
  31. logger.warning("docstring_parser not installed, using fallback docstring parsing")
  32. def _simple_parse_docstring(docstring: str) -> tuple[str, Dict[str, str]]:
  33. """简单的 docstring 解析(降级方案)"""
  34. if not docstring:
  35. return "", {}
  36. lines = docstring.strip().split("\n")
  37. description = lines[0] if lines else ""
  38. param_descriptions = {}
  39. # 简单解析 Args: 部分
  40. in_args = False
  41. for line in lines[1:]:
  42. line = line.strip()
  43. if line.lower().startswith("args:"):
  44. in_args = True
  45. continue
  46. if line.lower().startswith(("returns:", "raises:", "example:")):
  47. in_args = False
  48. continue
  49. if in_args and ":" in line:
  50. parts = line.split(":", 1)
  51. param_name = parts[0].strip()
  52. param_desc = parts[1].strip() if len(parts) > 1 else ""
  53. param_descriptions[param_name] = param_desc
  54. return description, param_descriptions
  55. class SchemaGenerator:
  56. """从函数生成 OpenAI Tool Schema"""
  57. # Python 类型到 JSON Schema 类型的映射
  58. TYPE_MAP = {
  59. str: "string",
  60. int: "integer",
  61. float: "number",
  62. bool: "boolean",
  63. list: "array",
  64. dict: "object",
  65. List: "array",
  66. Dict: "object",
  67. }
  68. @classmethod
  69. def generate(cls, func: callable, hidden_params: Optional[List[str]] = None) -> Dict[str, Any]:
  70. """
  71. 从工具函数签名和 docstring 生成 OpenAI Tool Schema。
  72. ``@tool`` 注册时调用;Recursive 的 ``TaskBrief`` 等 Pydantic 参数会在此展开并内联引用。
  73. Args:
  74. func: 要生成 Schema 的函数
  75. hidden_params: 隐藏参数列表(不生成 schema)
  76. Returns:
  77. OpenAI Tool Schema(JSON 格式)
  78. """
  79. hidden_params = hidden_params or []
  80. # 解析函数签名
  81. sig = inspect.signature(func)
  82. func_name = func.__name__
  83. try:
  84. resolved_hints = get_type_hints(func)
  85. except (NameError, TypeError):
  86. # 局部定义或可选依赖缺失时,保留原有降级行为。
  87. resolved_hints = {}
  88. # 解析 docstring
  89. if HAS_DOCSTRING_PARSER:
  90. doc = parse_docstring(func.__doc__ or "")
  91. func_description = doc.short_description or doc.long_description or f"Call {func_name}"
  92. param_descriptions = {p.arg_name: p.description for p in doc.params if p.description}
  93. else:
  94. func_description, param_descriptions = _simple_parse_docstring(func.__doc__ or "")
  95. if not func_description:
  96. func_description = f"Call {func_name}"
  97. # 生成参数 Schema
  98. properties = {}
  99. required = []
  100. for param_name, param in sig.parameters.items():
  101. # 跳过特殊参数
  102. if param_name in ["self", "cls", "kwargs"]:
  103. continue
  104. # 跳过隐藏参数
  105. if param_name in hidden_params:
  106. continue
  107. # 获取类型注解
  108. param_type = resolved_hints.get(
  109. param_name,
  110. param.annotation if param.annotation != inspect.Parameter.empty else str,
  111. )
  112. # 生成参数 Schema
  113. param_schema = cls._type_to_schema(param_type)
  114. # 添加描述
  115. if param_name in param_descriptions:
  116. param_schema["description"] = param_descriptions[param_name]
  117. # 添加默认值
  118. if param.default != inspect.Parameter.empty:
  119. param_schema["default"] = param.default
  120. else:
  121. required.append(param_name)
  122. properties[param_name] = param_schema
  123. # 构建完整的 Schema
  124. schema = {
  125. "type": "function",
  126. "function": {
  127. "name": func_name,
  128. "description": func_description,
  129. "parameters": {
  130. "type": "object",
  131. "properties": properties,
  132. "required": required
  133. }
  134. }
  135. }
  136. return schema
  137. @classmethod
  138. def _type_to_schema(cls, python_type: Any) -> Dict[str, Any]:
  139. """将 Python 类型转换为 JSON Schema"""
  140. if python_type is Any:
  141. return {}
  142. origin = get_origin(python_type)
  143. args = get_args(python_type)
  144. # 处理 Literal[...]
  145. if origin is Literal:
  146. values = list(args)
  147. if all(isinstance(v, str) for v in values):
  148. return {"type": "string", "enum": values}
  149. elif all(isinstance(v, int) for v in values):
  150. return {"type": "integer", "enum": values}
  151. return {"enum": values}
  152. # 处理 Union[T, ...] 和 Optional[T]
  153. if origin in (Union, UnionType):
  154. if len(args) == 2 and type(None) in args:
  155. # Optional[T] = Union[T, None]
  156. inner = args[0] if args[1] is type(None) else args[1]
  157. return cls._type_to_schema(inner)
  158. non_none = [a for a in args if a is not type(None)]
  159. return {"oneOf": [cls._type_to_schema(a) for a in non_none]}
  160. # 处理 List[T]
  161. if origin is list or origin is List:
  162. if args:
  163. item_type = args[0]
  164. return {
  165. "type": "array",
  166. "items": cls._type_to_schema(item_type)
  167. }
  168. return {"type": "array"}
  169. # 处理 Dict[K, V]
  170. if origin is dict or origin is Dict:
  171. return {"type": "object"}
  172. # Pydantic 模型直接使用其严格 JSON Schema。
  173. try:
  174. from pydantic import BaseModel
  175. if isinstance(python_type, type) and issubclass(python_type, BaseModel):
  176. return cls._inline_pydantic_refs(python_type.model_json_schema())
  177. except (ImportError, TypeError):
  178. pass
  179. # 处理基础类型
  180. if python_type in cls.TYPE_MAP:
  181. return {"type": cls.TYPE_MAP[python_type]}
  182. # 检查是否是 Protocol(如 ToolContext)
  183. # Protocol 类型用于依赖注入,不应出现在 schema 中
  184. type_name = getattr(python_type, "__name__", str(python_type))
  185. if "Protocol" in str(type(python_type)) or type_name in ("ToolContext",):
  186. logger.debug(f"Skipping Protocol type {python_type} (used for dependency injection)")
  187. return {}
  188. # 默认为 string
  189. logger.debug(f"Unknown type {python_type}, defaulting to string")
  190. return {"type": "string"}
  191. @classmethod
  192. def _inline_pydantic_refs(cls, schema: Dict[str, Any]) -> Dict[str, Any]:
  193. """Inline local Pydantic $refs so a tool parameter is self-contained."""
  194. definitions = schema.get("$defs", {})
  195. def resolve(value: Any, stack: tuple[str, ...] = ()) -> Any:
  196. if isinstance(value, list):
  197. return [resolve(item, stack) for item in value]
  198. if not isinstance(value, dict):
  199. return value
  200. ref = value.get("$ref")
  201. if isinstance(ref, str) and ref.startswith("#/$defs/"):
  202. name = ref.rsplit("/", 1)[-1]
  203. if name in stack or name not in definitions:
  204. return value
  205. resolved = resolve(deepcopy(definitions[name]), (*stack, name))
  206. extras = {key: item for key, item in value.items() if key != "$ref"}
  207. if extras:
  208. resolved.update(resolve(extras, stack))
  209. return resolved
  210. return {
  211. key: resolve(item, stack)
  212. for key, item in value.items()
  213. if key != "$defs"
  214. }
  215. return resolve(schema)