first
This commit is contained in:
@@ -0,0 +1,321 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Function parser for extracting function names, parameter names, and values from string function calls.
|
||||
|
||||
Uses Python's ``tokenize`` and ``ast`` modules so that nested parentheses,
|
||||
strings containing ')' characters, tuples, dicts, etc. are handled correctly.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import tokenize
|
||||
from collections import OrderedDict
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
from openai.types.responses import ResponseFunctionToolCall
|
||||
from pydantic import BaseModel
|
||||
|
||||
from speech_to_speech.LLM.tool_call.function_tool import FunctionTool
|
||||
from speech_to_speech.utils.utils import _generate_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_POSITIONAL_RE = re.compile(r"^__arg_\d+__$")
|
||||
_LENIENT_CALL_RE = re.compile(
|
||||
r"\b[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*\s*"
|
||||
r"\((?:[^()\"']+|\"(?:\\.|[^\"])*\"|'(?:\\.|[^'])*')*\)"
|
||||
)
|
||||
|
||||
|
||||
# ── AST / tokenize helpers ───────────────────────────────────────────
|
||||
|
||||
|
||||
def _split_top_level_calls(source: str) -> List[str]:
|
||||
"""Split *source* into individual ``name(...)`` expression strings.
|
||||
|
||||
Uses the tokenizer to walk tokens and track parenthesis depth so that
|
||||
nested parens, strings with ')' chars, etc. are handled correctly.
|
||||
"""
|
||||
tokens = list(tokenize.generate_tokens(io.StringIO(source).readline))
|
||||
calls: List[str] = []
|
||||
i = 0
|
||||
|
||||
while i < len(tokens):
|
||||
tok = tokens[i]
|
||||
if tok.type != tokenize.NAME:
|
||||
i += 1
|
||||
continue
|
||||
|
||||
start = i
|
||||
j = i + 1
|
||||
|
||||
# Walk past dotted attribute access (e.g. ``mobile.click``)
|
||||
while j + 1 < len(tokens) and tokens[j].string == "." and tokens[j + 1].type == tokenize.NAME:
|
||||
j += 2
|
||||
|
||||
if j >= len(tokens) or tokens[j].string != "(":
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Track balanced parens
|
||||
depth = 0
|
||||
end = None
|
||||
k = j
|
||||
while k < len(tokens):
|
||||
t = tokens[k]
|
||||
if t.type == tokenize.OP and t.string == "(":
|
||||
depth += 1
|
||||
elif t.type == tokenize.OP and t.string == ")":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
end = k
|
||||
break
|
||||
k += 1
|
||||
|
||||
if end is None:
|
||||
i += 1
|
||||
continue
|
||||
|
||||
calls.append(tokenize.untokenize(tokens[start : end + 1]).strip())
|
||||
i = end + 1
|
||||
|
||||
return calls
|
||||
|
||||
|
||||
def _split_simple_calls_with_regex(source: str) -> List[str]:
|
||||
"""Extract complete simple ``name(args)`` spans from malformed model output.
|
||||
|
||||
This fallback can recover well-formed siblings before a tokenizer error,
|
||||
but not the incomplete call that caused the tokenizer error.
|
||||
"""
|
||||
return [match.group(0).strip() for match in _LENIENT_CALL_RE.finditer(source)]
|
||||
|
||||
|
||||
def _parse_function_exprs(
|
||||
expressions: List[str],
|
||||
pattern_to_match: list[str],
|
||||
*,
|
||||
skip_invalid: bool = False,
|
||||
) -> List["FunctionToolCall"]:
|
||||
results: List[FunctionToolCall] = []
|
||||
for expr in expressions:
|
||||
try:
|
||||
call = _parse_call_expr(expr)
|
||||
except Exception:
|
||||
if skip_invalid:
|
||||
continue
|
||||
raise
|
||||
if pattern_to_match and all(pattern not in call.function_name for pattern in pattern_to_match):
|
||||
continue
|
||||
results.append(call)
|
||||
return results
|
||||
|
||||
|
||||
def _extract_function_name(node: ast.expr) -> str:
|
||||
"""Return the dotted function name from a Call node's ``func`` attribute."""
|
||||
if isinstance(node, ast.Name):
|
||||
return node.id
|
||||
if isinstance(node, ast.Attribute):
|
||||
base = _extract_function_name(node.value)
|
||||
return f"{base}.{node.attr}" if base else node.attr
|
||||
raise ValueError(f"Unsupported function target: {ast.dump(node)}")
|
||||
|
||||
|
||||
def _literal_from_ast(node: ast.AST) -> Any:
|
||||
"""Convert an AST node to a Python literal value."""
|
||||
if isinstance(node, ast.Constant):
|
||||
return node.value
|
||||
|
||||
if isinstance(node, ast.Name):
|
||||
return node.id
|
||||
|
||||
if isinstance(node, ast.List):
|
||||
return [_literal_from_ast(elt) for elt in node.elts]
|
||||
|
||||
if isinstance(node, ast.Tuple):
|
||||
return [_literal_from_ast(elt) for elt in node.elts]
|
||||
|
||||
if isinstance(node, ast.Dict):
|
||||
return {
|
||||
_literal_from_ast(key): _literal_from_ast(value)
|
||||
for key, value in zip(node.keys, node.values)
|
||||
if key is not None
|
||||
}
|
||||
|
||||
if isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.USub, ast.UAdd)):
|
||||
value = _literal_from_ast(node.operand)
|
||||
if not isinstance(value, (int, float)):
|
||||
raise ValueError(f"Unsupported unary literal: {ast.dump(node)}")
|
||||
return -value if isinstance(node.op, ast.USub) else value
|
||||
|
||||
raise ValueError(f"Unsupported literal: {ast.dump(node)}")
|
||||
|
||||
|
||||
def _parse_call_expr(expr: str) -> "FunctionToolCall":
|
||||
"""Parse a single ``name(args...)`` expression string into a FunctionToolCall."""
|
||||
parsed = ast.parse(expr, mode="eval").body
|
||||
if not isinstance(parsed, ast.Call):
|
||||
raise ValueError(f"Expression is not a function call: {expr!r}")
|
||||
|
||||
parameters: "OrderedDict[str, Any]" = OrderedDict()
|
||||
|
||||
for idx, arg in enumerate(parsed.args):
|
||||
parameters[f"__arg_{idx}__"] = _literal_from_ast(arg)
|
||||
|
||||
for kw in parsed.keywords:
|
||||
if kw.arg is None:
|
||||
raise ValueError("**kwargs are not supported")
|
||||
parameters[kw.arg] = _literal_from_ast(kw.value)
|
||||
|
||||
return FunctionToolCall(
|
||||
function_name=_extract_function_name(parsed.func),
|
||||
parameters=parameters,
|
||||
original_string=expr,
|
||||
)
|
||||
|
||||
|
||||
# ── Data model ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class FunctionToolCall(BaseModel):
|
||||
"""Represents a parsed function call with its parameters."""
|
||||
|
||||
function_name: str
|
||||
parameters: Dict[str, Any]
|
||||
original_string: str
|
||||
description: str = ""
|
||||
|
||||
def to_realtime_function_tool_call(
|
||||
self,
|
||||
function_tools: list[FunctionTool] | None = None,
|
||||
) -> ResponseFunctionToolCall:
|
||||
positional = {k for k in self.parameters if _POSITIONAL_RE.match(k)}
|
||||
if positional:
|
||||
logger.warning(
|
||||
"Dropping positional arguments for '%s': %s",
|
||||
self.function_name,
|
||||
positional,
|
||||
)
|
||||
arguments = {k: v for k, v in self.parameters.items() if not _POSITIONAL_RE.match(k)}
|
||||
|
||||
if function_tools is not None:
|
||||
tool = next(
|
||||
(t for t in function_tools if t.name == self.function_name),
|
||||
None,
|
||||
)
|
||||
if tool is None:
|
||||
available = [t.name for t in function_tools]
|
||||
raise ValueError(f"Function '{self.function_name}' not found in available tools: {available}")
|
||||
|
||||
schema = tool.parameters if isinstance(tool.parameters, dict) else {}
|
||||
properties = schema.get("properties", {})
|
||||
required = set(schema.get("required", []))
|
||||
|
||||
undeclared = {k for k in arguments if k not in properties}
|
||||
if undeclared:
|
||||
logger.warning(
|
||||
"Dropping undeclared parameters for '%s': %s",
|
||||
self.function_name,
|
||||
undeclared,
|
||||
)
|
||||
arguments = {k: v for k, v in arguments.items() if k in properties}
|
||||
|
||||
missing = required - set(arguments.keys())
|
||||
if missing:
|
||||
raise ValueError(f"Missing required parameters for '{self.function_name}': {missing}")
|
||||
|
||||
return ResponseFunctionToolCall(
|
||||
name=self.function_name,
|
||||
arguments=json.dumps(arguments),
|
||||
call_id=_generate_id("call"),
|
||||
type="function_call",
|
||||
id=_generate_id("fc"),
|
||||
status="in_progress",
|
||||
)
|
||||
|
||||
|
||||
# ── Public API ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def parse_function_call(function_string: str, pattern_to_match: list[str] = []) -> List[FunctionToolCall]:
|
||||
"""Parse a function call string and extract all function calls found.
|
||||
|
||||
Args:
|
||||
function_string: String representation of function calls.
|
||||
pattern_to_match: If non-empty, only calls whose function name
|
||||
contains at least one of these substrings are returned.
|
||||
|
||||
Returns:
|
||||
List of FunctionToolCall objects with parsed information.
|
||||
"""
|
||||
function_string = function_string.strip()
|
||||
if not function_string:
|
||||
return []
|
||||
|
||||
try:
|
||||
expressions = _split_top_level_calls(function_string)
|
||||
except tokenize.TokenError:
|
||||
return _parse_function_exprs(
|
||||
_split_simple_calls_with_regex(function_string),
|
||||
pattern_to_match,
|
||||
skip_invalid=True,
|
||||
)
|
||||
|
||||
return _parse_function_exprs(expressions, pattern_to_match)
|
||||
|
||||
|
||||
def parse_multiple_functions(function_strings: List[str]) -> List[FunctionToolCall]:
|
||||
"""Parse multiple function call strings.
|
||||
|
||||
Args:
|
||||
function_strings: List of function call strings.
|
||||
|
||||
Returns:
|
||||
List of FunctionToolCall objects.
|
||||
"""
|
||||
results: List[FunctionToolCall] = []
|
||||
for func_str in function_strings:
|
||||
try:
|
||||
results.extend(parse_function_call(func_str))
|
||||
except Exception:
|
||||
continue
|
||||
return results
|
||||
|
||||
|
||||
def extract_function_calls_from_text(text: str, block_regex: str = ".*") -> Tuple[str, List[FunctionToolCall]]:
|
||||
"""Extract function calls from delimited code blocks inside *text*.
|
||||
|
||||
The LLM is prompted to wrap tool calls inside code-block delimiters
|
||||
(e.g. ``<code>func(x=1)</code>``). This function finds those blocks,
|
||||
parses the function calls within them, and returns the remaining text
|
||||
(with blocks stripped) alongside the parsed calls.
|
||||
|
||||
Args:
|
||||
text: Full model output potentially containing code blocks.
|
||||
block_regex: Regex matching the code-block delimiters **and** their
|
||||
content (e.g. ``r"<code>.*?</code>"``). Only text **inside**
|
||||
matched blocks is scanned for function calls.
|
||||
|
||||
Returns:
|
||||
``(outside_text, function_calls)`` -- the text with blocks stripped
|
||||
and the parsed function calls found inside the blocks.
|
||||
"""
|
||||
if not block_regex:
|
||||
return text, []
|
||||
|
||||
matches = list(re.finditer(block_regex, text, flags=re.DOTALL))
|
||||
if not matches:
|
||||
return text, []
|
||||
|
||||
outside = re.sub(block_regex, "", text, flags=re.DOTALL)
|
||||
inside = " ".join(match.group(0) for match in matches).strip()
|
||||
if not inside:
|
||||
return outside, []
|
||||
|
||||
try:
|
||||
return outside, parse_function_call(inside)
|
||||
except Exception:
|
||||
return outside, []
|
||||
Reference in New Issue
Block a user