Files
replypier/backend/app/agents/structured_output.py
WangShen007 2dd062787d feat(agent): 检索工具、模型接入与结构化回复
Agent 按组织与知识源范围调用检索工具,密钥加密保存,回复含来源与转人工意图。
2026-10-02 21:25:00 +08:00

158 lines
5.3 KiB
Python

"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Groq-safe structured output helpers.
Groq's API rejects response_format (JSON mode) whenever tools are present
("json mode cannot be combined with tool/function calling"), so agno's
structured-output path degrades to prompt-only JSON — which GPT-OSS/Llama
drift away from. GPT-OSS's native structured-output convention on Groq IS a
tool call named `json`, so callers register a real `json` tool whose
parameters are the response-model fields, mark it stop_after_tool_call, and
read the result from the captured arguments. Shared by the chat agent and the
FAQ generator; other providers keep using agno's native response_model path.
"""
import json
import re
from app.core.logger import get_logger
logger = get_logger(__name__)
def build_groq_json_tool(capture: dict, parameters: dict, description: str):
"""Return an agno `json` tool that records its call arguments into `capture`.
`capture` is mutated in place with the model's tool-call arguments; the
caller reads it after `agent.arun()` and builds its response model from it.
`parameters` is the JSON schema of the expected structured output.
"""
from agno.tools.function import Function
def _record(**kwargs):
capture.clear()
capture.update(kwargs)
return "recorded"
return Function(
name="json",
description=description,
parameters=parameters,
entrypoint=_record,
stop_after_tool_call=True,
skip_entrypoint_processing=True,
)
def _scan_json(s: str):
"""Single pass over a JSON fragment tracking string state, the stack of
open brackets, and the last comma outside any string. The building block
for repairing truncated output of ANY shape (nested arrays included), not
just flat objects."""
stack = []
in_string = False
escaped = False
last_comma = -1
for i, ch in enumerate(s):
if in_string:
if escaped:
escaped = False
elif ch == "\\":
escaped = True
elif ch == '"':
in_string = False
elif ch == '"':
in_string = True
elif ch in "{[":
stack.append(ch)
elif ch in "}]":
if stack:
stack.pop()
elif ch == ",":
last_comma = i
closers = ('"' if in_string else "") + "".join(
"}" if opener == "{" else "]" for opener in reversed(stack)
)
return closers, last_comma
def lenient_json_load(s: str):
"""Best-effort parse of a possibly-truncated JSON object. Returns dict or None.
When Groq truncates a `json` tool call, the trailing field is cut mid-value.
Close any open string/brackets; if the tail is still malformed (e.g. a
dangling key), progressively drop the last comma-separated fragment so the
earlier complete fields survive. Complete objects followed by trailing
wrapper braces parse via raw_decode.
"""
s = (s or "").strip()
if not s:
return None
try:
return json.loads(s)
except json.JSONDecodeError:
pass
# Complete value with trailing junk (e.g. the tool-call wrapper's braces).
try:
obj, _ = json.JSONDecoder().raw_decode(s)
if isinstance(obj, dict):
return obj
except json.JSONDecodeError:
pass
candidate = s
for _ in range(16):
closers, last_comma = _scan_json(candidate)
try:
return json.loads(candidate + closers)
except json.JSONDecodeError:
pass
if last_comma == -1:
break
candidate = candidate[:last_comma]
return None
def salvage_groq_json_error(exc: Exception):
"""Recover structured-output arguments from a Groq `tool_use_failed` error.
Groq returns the (truncated) tool-call text in `failed_generation`; every
field before the truncation point is intact. Returns a dict of arguments
or None.
"""
text = getattr(exc, "message", None) or str(exc)
if "failed_generation" not in text:
return None
fg = None
try:
fg = json.loads(text).get("error", {}).get("failed_generation")
except Exception:
m = re.search(r'"failed_generation"\s*:\s*"(.*)"\s*\}\s*\}\s*$', text, re.DOTALL)
if m:
try:
# Decode JSON string escapes (\n, \", \uXXXX, non-ASCII) correctly —
# unicode_escape would corrupt emoji/accented characters.
fg = json.loads('"' + m.group(1) + '"')
except Exception:
fg = m.group(1)
if not fg:
return None
# fg looks like: {"name": "json", "arguments": {<fields, possibly truncated>}}
idx = fg.find('"arguments"')
brace = fg.find('{', idx) if idx != -1 else -1
if brace == -1:
return None
return lenient_json_load(fg[brace:])