schema.py 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257
  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. 从函数生成 OpenAI Tool Schema
  72. Args:
  73. func: 要生成 Schema 的函数
  74. hidden_params: 隐藏参数列表(不生成 schema)
  75. Returns:
  76. OpenAI Tool Schema(JSON 格式)
  77. """
  78. hidden_params = hidden_params or []
  79. # 解析函数签名
  80. sig = inspect.signature(func)
  81. func_name = func.__name__
  82. try:
  83. resolved_hints = get_type_hints(func)
  84. except (NameError, TypeError):
  85. # 局部定义或可选依赖缺失时,保留原有降级行为。
  86. resolved_hints = {}
  87. # 解析 docstring
  88. if HAS_DOCSTRING_PARSER:
  89. doc = parse_docstring(func.__doc__ or "")
  90. func_description = doc.short_description or doc.long_description or f"Call {func_name}"
  91. param_descriptions = {p.arg_name: p.description for p in doc.params if p.description}
  92. else:
  93. func_description, param_descriptions = _simple_parse_docstring(func.__doc__ or "")
  94. if not func_description:
  95. func_description = f"Call {func_name}"
  96. # 生成参数 Schema
  97. properties = {}
  98. required = []
  99. for param_name, param in sig.parameters.items():
  100. # 跳过特殊参数
  101. if param_name in ["self", "cls", "kwargs"]:
  102. continue
  103. # 跳过隐藏参数
  104. if param_name in hidden_params:
  105. continue
  106. # 获取类型注解
  107. param_type = resolved_hints.get(
  108. param_name,
  109. param.annotation if param.annotation != inspect.Parameter.empty else str,
  110. )
  111. # 生成参数 Schema
  112. param_schema = cls._type_to_schema(param_type)
  113. # 添加描述
  114. if param_name in param_descriptions:
  115. param_schema["description"] = param_descriptions[param_name]
  116. # 添加默认值
  117. if param.default != inspect.Parameter.empty:
  118. param_schema["default"] = param.default
  119. else:
  120. required.append(param_name)
  121. properties[param_name] = param_schema
  122. # 构建完整的 Schema
  123. schema = {
  124. "type": "function",
  125. "function": {
  126. "name": func_name,
  127. "description": func_description,
  128. "parameters": {
  129. "type": "object",
  130. "properties": properties,
  131. "required": required
  132. }
  133. }
  134. }
  135. return schema
  136. @classmethod
  137. def _type_to_schema(cls, python_type: Any) -> Dict[str, Any]:
  138. """将 Python 类型转换为 JSON Schema"""
  139. if python_type is Any:
  140. return {}
  141. origin = get_origin(python_type)
  142. args = get_args(python_type)
  143. # 处理 Literal[...]
  144. if origin is Literal:
  145. values = list(args)
  146. if all(isinstance(v, str) for v in values):
  147. return {"type": "string", "enum": values}
  148. elif all(isinstance(v, int) for v in values):
  149. return {"type": "integer", "enum": values}
  150. return {"enum": values}
  151. # 处理 Union[T, ...] 和 Optional[T]
  152. if origin in (Union, UnionType):
  153. if len(args) == 2 and type(None) in args:
  154. # Optional[T] = Union[T, None]
  155. inner = args[0] if args[1] is type(None) else args[1]
  156. return cls._type_to_schema(inner)
  157. non_none = [a for a in args if a is not type(None)]
  158. return {"oneOf": [cls._type_to_schema(a) for a in non_none]}
  159. # 处理 List[T]
  160. if origin is list or origin is List:
  161. if args:
  162. item_type = args[0]
  163. return {
  164. "type": "array",
  165. "items": cls._type_to_schema(item_type)
  166. }
  167. return {"type": "array"}
  168. # 处理 Dict[K, V]
  169. if origin is dict or origin is Dict:
  170. return {"type": "object"}
  171. # Pydantic 模型直接使用其严格 JSON Schema。
  172. try:
  173. from pydantic import BaseModel
  174. if isinstance(python_type, type) and issubclass(python_type, BaseModel):
  175. return cls._inline_pydantic_refs(python_type.model_json_schema())
  176. except (ImportError, TypeError):
  177. pass
  178. # 处理基础类型
  179. if python_type in cls.TYPE_MAP:
  180. return {"type": cls.TYPE_MAP[python_type]}
  181. # 检查是否是 Protocol(如 ToolContext)
  182. # Protocol 类型用于依赖注入,不应出现在 schema 中
  183. type_name = getattr(python_type, "__name__", str(python_type))
  184. if "Protocol" in str(type(python_type)) or type_name in ("ToolContext",):
  185. logger.debug(f"Skipping Protocol type {python_type} (used for dependency injection)")
  186. return {}
  187. # 默认为 string
  188. logger.debug(f"Unknown type {python_type}, defaulting to string")
  189. return {"type": "string"}
  190. @classmethod
  191. def _inline_pydantic_refs(cls, schema: Dict[str, Any]) -> Dict[str, Any]:
  192. """Inline local Pydantic $refs so a tool parameter is self-contained."""
  193. definitions = schema.get("$defs", {})
  194. def resolve(value: Any, stack: tuple[str, ...] = ()) -> Any:
  195. if isinstance(value, list):
  196. return [resolve(item, stack) for item in value]
  197. if not isinstance(value, dict):
  198. return value
  199. ref = value.get("$ref")
  200. if isinstance(ref, str) and ref.startswith("#/$defs/"):
  201. name = ref.rsplit("/", 1)[-1]
  202. if name in stack or name not in definitions:
  203. return value
  204. resolved = resolve(deepcopy(definitions[name]), (*stack, name))
  205. extras = {key: item for key, item in value.items() if key != "$ref"}
  206. if extras:
  207. resolved.update(resolve(extras, stack))
  208. return resolved
  209. return {
  210. key: resolve(item, stack)
  211. for key, item in value.items()
  212. if key != "$defs"
  213. }
  214. return resolve(schema)