refactor(server): adopt uv and modular typed FastAPI structure
This commit is contained in:
@@ -0,0 +1,25 @@
|
|||||||
|
minimum_pre_commit_version: "4.4.0"
|
||||||
|
default_install_hook_types: [pre-commit]
|
||||||
|
default_stages: [pre-commit]
|
||||||
|
|
||||||
|
repos:
|
||||||
|
- repo: local
|
||||||
|
hooks:
|
||||||
|
- id: ruff-check
|
||||||
|
name: Ruff lint
|
||||||
|
entry: uv run --locked ruff check --force-exclude
|
||||||
|
language: unsupported
|
||||||
|
types_or: [python, pyi]
|
||||||
|
require_serial: true
|
||||||
|
- id: ruff-format
|
||||||
|
name: Ruff formatting
|
||||||
|
entry: uv run --locked ruff format --check --force-exclude
|
||||||
|
language: unsupported
|
||||||
|
types_or: [python, pyi]
|
||||||
|
require_serial: true
|
||||||
|
- id: mypy
|
||||||
|
name: Python types
|
||||||
|
entry: uv run --locked mypy
|
||||||
|
language: unsupported
|
||||||
|
pass_filenames: false
|
||||||
|
files: ^(s2s_client/|s2s-client/server\.py|pyproject\.toml|uv\.lock)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
3.12
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
UV ?= uv
|
||||||
|
UV_RUN = $(UV) run --locked
|
||||||
|
|
||||||
|
.PHONY: help install lock lock-check format format-check lint lint-fix type-check test check dev hooks-install hooks-run test-launcher test-image
|
||||||
|
|
||||||
|
help:
|
||||||
|
@printf '%s\n' \
|
||||||
|
'install Sync the uv environment, including development tools' \
|
||||||
|
'lock Resolve dependencies into uv.lock' \
|
||||||
|
'format Apply Ruff fixes and formatting' \
|
||||||
|
'check Check lock freshness, formatting, lint, types, and backend tests' \
|
||||||
|
'dev Start the existing React/FastAPI development launcher' \
|
||||||
|
'hooks-install Install optional local pre-commit checks' \
|
||||||
|
'test-launcher Exercise the Bash supervisor with local fixture processes' \
|
||||||
|
'test-image Build and smoke-test the backend Docker image'
|
||||||
|
|
||||||
|
install:
|
||||||
|
$(UV) sync --locked
|
||||||
|
|
||||||
|
lock:
|
||||||
|
$(UV) lock
|
||||||
|
|
||||||
|
lock-check:
|
||||||
|
$(UV) lock --check
|
||||||
|
|
||||||
|
format:
|
||||||
|
$(UV_RUN) ruff check --fix-only .
|
||||||
|
$(UV_RUN) ruff format .
|
||||||
|
|
||||||
|
format-check:
|
||||||
|
$(UV_RUN) ruff format --check .
|
||||||
|
|
||||||
|
lint:
|
||||||
|
$(UV_RUN) ruff check .
|
||||||
|
|
||||||
|
lint-fix:
|
||||||
|
$(UV_RUN) ruff check --fix .
|
||||||
|
|
||||||
|
type-check:
|
||||||
|
$(UV_RUN) mypy
|
||||||
|
|
||||||
|
test:
|
||||||
|
$(UV_RUN) pytest
|
||||||
|
|
||||||
|
check: lock-check format-check lint type-check test
|
||||||
|
|
||||||
|
dev:
|
||||||
|
./start_ui.sh
|
||||||
|
|
||||||
|
hooks-install:
|
||||||
|
$(UV_RUN) pre-commit install --install-hooks
|
||||||
|
|
||||||
|
hooks-run:
|
||||||
|
$(UV_RUN) pre-commit run --all-files
|
||||||
|
|
||||||
|
test-launcher:
|
||||||
|
npm --prefix s2s-frontend run test:launcher
|
||||||
|
|
||||||
|
test-image:
|
||||||
|
bash scripts/test_docker_image.sh
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=68", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "aquaportal-s2s-client"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "FastAPI integration and realtime proxy for the Vocero frontend"
|
||||||
|
readme = "s2s-client/README.md"
|
||||||
|
requires-python = ">=3.12,<3.13"
|
||||||
|
dependencies = [
|
||||||
|
"fastapi==0.115.6",
|
||||||
|
"uvicorn[standard]==0.34.0",
|
||||||
|
"httpx==0.28.1",
|
||||||
|
"python-dotenv==1.2.2",
|
||||||
|
"pydantic==2.13.5",
|
||||||
|
"websockets==17.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"mypy>=1.16,<2",
|
||||||
|
"pre-commit>=4.4,<5",
|
||||||
|
"pytest>=9,<10",
|
||||||
|
"ruff>=0.12,<1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.uv]
|
||||||
|
required-version = "==0.12.5"
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
include = ["s2s_client*"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
target-version = "py312"
|
||||||
|
line-length = 120
|
||||||
|
include = ["pyproject.toml", "s2s_client/**/*.py", "s2s-client/server.py", "tests/**/*.py", "scripts/**/*.py"]
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E4", "E7", "E9", "F", "I", "B", "UP"]
|
||||||
|
ignore = ["UP040", "UP046"]
|
||||||
|
|
||||||
|
[tool.ruff.format]
|
||||||
|
quote-style = "double"
|
||||||
|
indent-style = "space"
|
||||||
|
line-ending = "auto"
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
python_version = "3.12"
|
||||||
|
plugins = ["pydantic.mypy"]
|
||||||
|
files = ["s2s_client", "s2s-client/server.py"]
|
||||||
|
check_untyped_defs = true
|
||||||
|
disallow_untyped_defs = true
|
||||||
|
no_implicit_optional = true
|
||||||
|
strict_equality = true
|
||||||
|
warn_redundant_casts = true
|
||||||
|
warn_return_any = true
|
||||||
|
warn_unused_configs = true
|
||||||
|
warn_unused_ignores = true
|
||||||
|
show_error_codes = true
|
||||||
|
pretty = true
|
||||||
|
|
||||||
|
[tool.pydantic-mypy]
|
||||||
|
init_typed = true
|
||||||
|
warn_untyped_fields = true
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
pythonpath = [".", "s2s-client"]
|
||||||
|
|
||||||
@@ -9,6 +9,9 @@ the implementation at the recorded date; current operating instructions live in
|
|||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
- Added a uv-managed Python 3.12 environment and Ruff, mypy, and pytest quality
|
||||||
|
checks. Split the FastAPI service into configuration, route, provider,
|
||||||
|
context/reasoning, and realtime modules while retaining `server:app` startup.
|
||||||
- Removed the legacy browser site in favor of the React application in
|
- Removed the legacy browser site in favor of the React application in
|
||||||
`s2s-frontend`. FastAPI now serves only backend and documentation routes.
|
`s2s-frontend`. FastAPI now serves only backend and documentation routes.
|
||||||
- Retired daily talk-time accounting and the associated Hugging Face account
|
- Retired daily talk-time accounting and the associated Hugging Face account
|
||||||
|
|||||||
+6
-6302
File diff suppressed because it is too large
Load Diff
@@ -1,275 +0,0 @@
|
|||||||
"""Golden wire behavior captured before the tooling/structure migration.
|
|
||||||
|
|
||||||
Fixtures include permissive payloads and existing failures deliberately. Changing
|
|
||||||
them requires a separate, explicitly approved API behavior change.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import importlib
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
import sys
|
|
||||||
import unittest
|
|
||||||
from unittest.mock import AsyncMock, patch
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
||||||
with patch.dict(os.environ, {}, clear=True), patch("dotenv.load_dotenv", return_value=False):
|
|
||||||
server = importlib.import_module("server")
|
|
||||||
|
|
||||||
ASYNC_CLIENT = httpx.AsyncClient
|
|
||||||
FIXTURE = Path(__file__).parent / "fixtures" / "legacy-contracts.json"
|
|
||||||
BASE_CONFIG = {
|
|
||||||
"LOAD_BALANCER_URL": "",
|
|
||||||
"SPEECH_TO_SPEECH_URL": "",
|
|
||||||
"S2S_PROXY_TARGET": "",
|
|
||||||
"S2S_PRIMARY_PROMPT": "",
|
|
||||||
"SERPER_KEY": "",
|
|
||||||
"S2S_REASONING_TYPE": "PROXY",
|
|
||||||
"S2S_INJECT_PROFILE": False,
|
|
||||||
"S2S_INJECT_REPORTS": False,
|
|
||||||
"S2S_DEBUG_PROMPT": False,
|
|
||||||
"S2S_START_SESSION_MODE": "",
|
|
||||||
"S2S_THEME": "",
|
|
||||||
"VOCERO_USER_PHONE_AUTH_URL": "",
|
|
||||||
"S2S_REPORT_LIST": "",
|
|
||||||
"S2S_LOGIN_BY_PHONE": "",
|
|
||||||
"S2S_LATEST_SUMMARY": "",
|
|
||||||
"S2S_LATEST_SUMMARY_TEXT": "",
|
|
||||||
"S2S_APIPILOT_TOOLS_URL": "",
|
|
||||||
"S2S_APIPILOT_TOOLS_EXECUTE_URL": "",
|
|
||||||
"S2S_APIPILOT_SERVICE_TOKEN": "",
|
|
||||||
"S2S_APIPILOT_API_KEY": "",
|
|
||||||
"OPENAI_API_KEY": "",
|
|
||||||
"NODE_RED_REASONING_URL": "",
|
|
||||||
"S2S_REPORTS_QUICK_HINT_MAX": 5,
|
|
||||||
"S2S_REPORTS_INJECT_MODE": "minimal",
|
|
||||||
}
|
|
||||||
TOOL = {
|
|
||||||
"name": "fixture_tool",
|
|
||||||
"description": "A fixture tool.",
|
|
||||||
"parameters": {"type": "object", "properties": {"count": {"type": "integer"}}, "required": ["count"]},
|
|
||||||
"requires_auth": False,
|
|
||||||
"future_definition_field": {"nested": True},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def cases():
|
|
||||||
"""Each case controls all upstream IO; no deployment services are contacted."""
|
|
||||||
yield "config-default", "GET", "/api/config", {}, {}, []
|
|
||||||
yield "config-proxy", "GET", "/api/config", {
|
|
||||||
"headers": {"x-forwarded-proto": "https", "x-forwarded-host": "voice.test, internal.test"},
|
|
||||||
}, {"S2S_PROXY_TARGET": "ws://private.test/voice"}, []
|
|
||||||
yield "config-direct", "GET", "/api/config", {}, {"SPEECH_TO_SPEECH_URL": "wss://voice.test/realtime?token=fixture"}, []
|
|
||||||
yield "config-lb", "GET", "/api/config", {}, {"LOAD_BALANCER_URL": "http://lb.test"}, []
|
|
||||||
for path in ("/api/me", "/api/session/heartbeat", "/api/session/end", "/", "/server.py"):
|
|
||||||
yield f"removed-{path}", "GET", path, {}, {}, []
|
|
||||||
for path in ("/project", "/project/", "/project/index.html", "/public/docs.html"):
|
|
||||||
yield f"redirect-{path}", "GET", path, {}, {}, []
|
|
||||||
for path in ("/api/session", "/api/queue/end"):
|
|
||||||
yield f"disabled-{path}", "POST", path, {}, {}, []
|
|
||||||
for method in ("GET", "DELETE"):
|
|
||||||
yield f"disabled-queue-{method}", method, "/api/queue/ticket", {}, {}, []
|
|
||||||
|
|
||||||
for name, payload in (
|
|
||||||
("missing", {}), ("blank", {"query": " "}), ("wrong-type", {"query": 17}),
|
|
||||||
("null", {"query": None}), ("unconfigured", {"query": "hello"}),
|
|
||||||
):
|
|
||||||
yield f"search-{name}", "POST", "/api/search", {"json": payload}, {}, []
|
|
||||||
search_config = {"SERPER_KEY": "server-fixture-key"}
|
|
||||||
yield "search-success-extra-fields", "POST", "/api/search", {"json": {
|
|
||||||
"query": " hello ", "key": "caller-fixture-key", "unknown_request_field": [1, 2],
|
|
||||||
}}, search_config, [(200, {"answerBox": {"answer": "Answer"}, "organic": [
|
|
||||||
{"title": "Title", "snippet": "Text", "link": "https://example.test", "unknown": 1},
|
|
||||||
], "future_upstream": {"nested": True}})]
|
|
||||||
yield "search-loose-values", "POST", "/api/search", {"json": {"query": "hello"}}, search_config, [(200, {
|
|
||||||
"answerBox": {"answer": {"future": [1, None]}},
|
|
||||||
"organic": [{"title": None, "snippet": 3, "link": {"href": "unusual"}}, {}],
|
|
||||||
})]
|
|
||||||
yield "search-upstream-error", "POST", "/api/search", {"json": {"query": "hello"}}, search_config, [(429, {"message": "Fixture credits exhausted"})]
|
|
||||||
yield "search-invalid-upstream-json", "POST", "/api/search", {"json": {"query": "hello"}}, search_config, [(200, b"not-json")]
|
|
||||||
|
|
||||||
yield "profile-missing-phone", "GET", "/api/user/phone", {}, {}, []
|
|
||||||
yield "profile-unconfigured", "GET", "/api/user/phone?phoneNumber=123", {}, {}, []
|
|
||||||
profile_config = {"VOCERO_USER_PHONE_AUTH_URL": "http://profile.test/user"}
|
|
||||||
yield "profile-blank-phone", "GET", "/api/user/phone?phoneNumber=%20", {}, profile_config, []
|
|
||||||
for name, status, payload in (
|
|
||||||
("object", 200, {"result": {"name": "Caller", "unknown": {"nested": [1, None]}}}),
|
|
||||||
("array", 200, [1, {"future": "field"}]), ("null", 200, None),
|
|
||||||
("string", 200, "opaque profile"), ("error", 403, {"future_error": True}),
|
|
||||||
("invalid-json", 200, b"not-json"),
|
|
||||||
):
|
|
||||||
yield f"profile-{name}", "GET", "/api/user/phone?phoneNumber=%2B56000000000", {}, profile_config, [(status, payload)]
|
|
||||||
yield "profile-login-token", "GET", "/api/user/phone?phoneNumber=123", {}, {
|
|
||||||
**profile_config, "S2S_LOGIN_BY_PHONE": "http://profile.test/login",
|
|
||||||
}, [(200, {"result": {"data": {"accessToken": "fixture-token", "future": True}}}), (200, {"name": "Caller"})]
|
|
||||||
|
|
||||||
yield "reports-disabled", "GET", "/api/user/reports", {}, {}, []
|
|
||||||
yield "reports-unconfigured", "GET", "/api/user/reports", {}, {"S2S_INJECT_REPORTS": True}, []
|
|
||||||
reports_config = {"S2S_INJECT_REPORTS": True, "S2S_REPORT_LIST": "http://reports.test/list"}
|
|
||||||
yield "reports-empty", "GET", "/api/user/reports", {}, reports_config, [(200, [])]
|
|
||||||
yield "reports-upstream-failure", "GET", "/api/user/reports", {}, reports_config, [(503, {"error": "fixture"})]
|
|
||||||
yield "reports-normalization", "GET", "/api/user/reports?phoneNumber=123", {}, reports_config, [(200, {"result": [
|
|
||||||
{"sourceScheduleLabel": "Daily", "date": "2026-01-02T12:00:00Z", "podcastScript": "private script", "future": True},
|
|
||||||
{"sourceScheduleLabel": "Daily", "date": "2026-01-01T12:00:00Z", "podcastScript": "older script"},
|
|
||||||
{"sourceSchedule": {"name": "Weekly", "date": "2026-01-03T12:00:00Z"}, "text": "weekly script"},
|
|
||||||
], "future": True})]
|
|
||||||
|
|
||||||
lb_config = {"LOAD_BALANCER_URL": "http://lb.test"}
|
|
||||||
for name, status, payload in (
|
|
||||||
("grant", 200, {"session_id": "session", "connect_url": "ws://voice.test?token=fixture", "future": {"nested": True}}),
|
|
||||||
("camelcase", 200, {"sessionId": "session", "connectUrl": "ws://voice.test", "unknown": None}),
|
|
||||||
("queued", 200, {"state": "queued", "queue_id": "ticket", "position": 3, "future": [1]}),
|
|
||||||
("empty", 200, {}), ("capacity", 503, {"state": "at_capacity", "future": True}),
|
|
||||||
("other-error", 429, {"error": "fixture"}), ("invalid-json", 200, b"not-json"),
|
|
||||||
):
|
|
||||||
yield f"session-{name}", "POST", "/api/session", {"content": b"arbitrary ignored request body"}, lb_config, [(status, payload)]
|
|
||||||
for name, status, payload in (
|
|
||||||
("waiting", 200, {"state": "queued", "queue_id": "ticket", "position": None, "future": True}),
|
|
||||||
("grant", 200, {"session_id": "session", "connect_url": "ws://voice.test"}),
|
|
||||||
("expired", 404, {"unknown": True}), ("error", 500, {}), ("invalid-json", 200, b"bad"),
|
|
||||||
):
|
|
||||||
yield f"queue-{name}", "GET", "/api/queue/ticket", {}, lb_config, [(status, payload)]
|
|
||||||
yield "queue-delete", "DELETE", "/api/queue/ticket", {}, lb_config, [(500, {})]
|
|
||||||
for name, kwargs, upstream in (
|
|
||||||
("valid", {"json": {"queueId": "ticket", "future": True}}, [(204, b"")]),
|
|
||||||
("empty", {"content": b""}, []), ("invalid-json", {"content": b"bad"}, []),
|
|
||||||
("array", {"json": ["ticket"]}, []), ("null-id", {"json": {"queueId": None}}, []),
|
|
||||||
("numeric-id", {"json": {"queueId": 42}}, []),
|
|
||||||
):
|
|
||||||
yield f"queue-end-{name}", "POST", "/api/queue/end", kwargs, lb_config, upstream
|
|
||||||
|
|
||||||
for name, payload in (("missing", {}), ("proxy", {"transcript": "hello"}), ("proxy-blank", {"transcript": " "})):
|
|
||||||
yield f"reasoning-{name}", "POST", "/api/reasoning", {"json": payload}, {}, []
|
|
||||||
for mode in ("OPENAI", "NODE_RED"):
|
|
||||||
yield f"reasoning-{mode}-legacy-unbound-token", "POST", "/api/reasoning", {"json": {"transcript": "hello"}}, {"S2S_REASONING_TYPE": mode}, []
|
|
||||||
yield "reasoning-legacy-unbound-profile", "POST", "/api/reasoning", {"json": {"transcript": "hello"}}, {
|
|
||||||
"S2S_REASONING_TYPE": "OPENAI", "S2S_INJECT_PROFILE": True, "S2S_LATEST_SUMMARY_TEXT": "fixture summary",
|
|
||||||
}, []
|
|
||||||
reasoning_config = {**profile_config, "S2S_INJECT_PROFILE": True, "S2S_REASONING_TYPE": "NODE_RED", "NODE_RED_REASONING_URL": "http://reasoning.test"}
|
|
||||||
reasoning_request = {"json": {"transcript": " hello ", "phoneNumber": "123", "instructions": "Fixture instructions", "language": "es", "future": True}}
|
|
||||||
for name, payload in (
|
|
||||||
("answer", {"answer": " answer ", "latency_ms": "loose", "intent": {"future": 1}, "usage": {"future": True}, "future": True}),
|
|
||||||
("log-only", {"answer": "", "log_only": True, "debug": {"future": True}}),
|
|
||||||
("invalid-answer", {"answer": 5}),
|
|
||||||
):
|
|
||||||
yield f"reasoning-node-{name}", "POST", "/api/reasoning", reasoning_request, reasoning_config, [(200, {"name": "Caller"}), (200, payload)]
|
|
||||||
yield "reasoning-openai-answer", "POST", "/api/reasoning", reasoning_request, {
|
|
||||||
**reasoning_config, "S2S_REASONING_TYPE": "OPENAI", "OPENAI_API_KEY": "fixture-key",
|
|
||||||
}, [(200, {"name": "Caller"}), (200, {"choices": [{"message": {"content": " answer ", "future": True}}], "usage": {"total_tokens": 12}, "future": True})]
|
|
||||||
|
|
||||||
for method, path, kwargs in (("GET", "/api/apipilot/tools", {}), ("POST", "/api/apipilot/execute", {"json": {"name": "fixture_tool"}})):
|
|
||||||
yield f"apipilot-disabled-{method}", method, path, kwargs, {}, []
|
|
||||||
tool_config = {"S2S_APIPILOT_TOOLS_URL": "http://tools.test/apipilot"}
|
|
||||||
yield "tools-query-validation", "GET", "/api/apipilot/tools?refresh=invalid", {}, tool_config, []
|
|
||||||
for name, payload in (("list", [TOOL]), ("result", {"result": [TOOL]}), ("envelope", {"data": {"tools": [TOOL]}, "future": True}), ("opaque", "anything")):
|
|
||||||
yield f"tools-{name}", "GET", "/api/apipilot/tools?include_unauthorized=false&refresh=true", {"headers": {"Authorization": "Bearer fixture-token", "x-demo-session": "fixture-session"}}, tool_config, [(200, payload)]
|
|
||||||
yield "tools-upstream-error", "GET", "/api/apipilot/tools", {}, tool_config, [(403, {"error": "fixture"})]
|
|
||||||
yield "execute-discovery-error-envelope", "POST", "/api/apipilot/execute", {"json": {"name": "fixture_tool"}}, tool_config, [(503, {})]
|
|
||||||
yield "execute-unknown-tool", "POST", "/api/apipilot/execute", {"json": {"name": "unknown"}}, tool_config, [(200, [TOOL]), (200, [TOOL])]
|
|
||||||
yield "execute-missing-argument", "POST", "/api/apipilot/execute", {"json": {"name": "fixture_tool"}}, tool_config, [(200, [TOOL])]
|
|
||||||
yield "execute-api-key-precheck", "POST", "/api/apipilot/execute", {"json": {"name": "fixture_tool", "arguments": {"count": 1}}}, {
|
|
||||||
**tool_config, "S2S_APIPILOT_API_KEY": "fixture-key",
|
|
||||||
}, [(200, [{**TOOL, "requires_auth": True}])]
|
|
||||||
for name, payload in (
|
|
||||||
("envelope", {"success": True, "data": {"future": [1, None]}, "error": None, "future_envelope": True}),
|
|
||||||
("raw-list", [{"future": True}]), ("minimal-envelope", {"success": True}),
|
|
||||||
("error", {"success": False, "data": None, "error": {"status_code": 409, "code": "FIXTURE", "message": "Fixture", "future": True}}),
|
|
||||||
):
|
|
||||||
yield f"execute-{name}", "POST", "/api/apipilot/execute", {"json": {"name": "fixture_tool", "arguments": {"count": "wrong-type-is-accepted", "unknown": 1}, "future": True}}, tool_config, [(200, [TOOL]), (200, payload)]
|
|
||||||
|
|
||||||
|
|
||||||
def capture_http():
|
|
||||||
captures = {}
|
|
||||||
for name, method, path, kwargs, config, responses in cases():
|
|
||||||
remaining = iter(responses)
|
|
||||||
requests = []
|
|
||||||
|
|
||||||
def upstream(request):
|
|
||||||
requests.append({
|
|
||||||
"method": request.method,
|
|
||||||
"url": str(request.url),
|
|
||||||
"body": request.content.decode(),
|
|
||||||
"headers": {key: request.headers[key] for key in (
|
|
||||||
"authorization", "x-demo-session", "x-api-key", "content-type", "x-s2s-caller",
|
|
||||||
) if key in request.headers},
|
|
||||||
})
|
|
||||||
status, payload = next(remaining)
|
|
||||||
if isinstance(payload, bytes):
|
|
||||||
return httpx.Response(status, content=payload)
|
|
||||||
return httpx.Response(status, content=json.dumps(payload).encode(), headers={"content-type": "application/json"})
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch.multiple(server, **{**BASE_CONFIG, **config}),
|
|
||||||
patch.object(server, "_APIPILOT_CACHE", {}),
|
|
||||||
patch.object(server, "_build_today_fixed_context_block", return_value="Fixture current date: 2026-01-04"),
|
|
||||||
patch.object(server.httpx, "AsyncClient", side_effect=lambda **opts: ASYNC_CLIENT(transport=httpx.MockTransport(upstream), **opts)),
|
|
||||||
TestClient(server.app, raise_server_exceptions=False) as client,
|
|
||||||
):
|
|
||||||
response = client.request(method, path, follow_redirects=False, **kwargs)
|
|
||||||
captures[name] = {
|
|
||||||
"status": response.status_code,
|
|
||||||
"body": response.text,
|
|
||||||
"headers": {key: response.headers[key] for key in ("content-type", "location", "set-cookie") if key in response.headers},
|
|
||||||
"upstream": requests,
|
|
||||||
}
|
|
||||||
if list(remaining):
|
|
||||||
raise AssertionError(f"Unused upstream fixtures in {name}")
|
|
||||||
return captures
|
|
||||||
|
|
||||||
|
|
||||||
def capture_openapi_requests():
|
|
||||||
schema = server.app.openapi()
|
|
||||||
return {
|
|
||||||
"operations": {
|
|
||||||
f"{method.upper()} {path}": {key: operation[key] for key in ("operationId", "parameters", "requestBody") if key in operation}
|
|
||||||
for path, methods in schema["paths"].items()
|
|
||||||
for method, operation in methods.items()
|
|
||||||
},
|
|
||||||
"models": {name: schema["components"]["schemas"][name] for name in (
|
|
||||||
"SearchRequest", "ReasoningRequest", "ApipilotExecuteRequest", "HTTPValidationError", "ValidationError",
|
|
||||||
)},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def capture_realtime():
|
|
||||||
frames = []
|
|
||||||
|
|
||||||
class Socket:
|
|
||||||
async def send_text(self, message):
|
|
||||||
frames.append(message)
|
|
||||||
|
|
||||||
async def send_bytes(self, message):
|
|
||||||
frames.append({"binary": message.hex()})
|
|
||||||
|
|
||||||
async def upstream():
|
|
||||||
yield b"\x00\x01\x02\x03"
|
|
||||||
yield json.dumps({"type": "future.event", "future": {"nested": [None, 1]}})
|
|
||||||
yield json.dumps({"type": "response.text.done", "text": "Hello [rightspeak]world[endspeak]", "future": True})
|
|
||||||
yield json.dumps({"type": "error", "error": {"message": "Unknown or invalid event: session.update"}})
|
|
||||||
yield json.dumps({"type": "future.after.suppressed.error"})
|
|
||||||
|
|
||||||
with patch.object(server, "_forward_agent_text_to_nodered", new_callable=AsyncMock):
|
|
||||||
await server._upstream_to_client(Socket(), upstream(), "fixture", {})
|
|
||||||
return frames
|
|
||||||
|
|
||||||
|
|
||||||
def capture():
|
|
||||||
return {"http": capture_http(), "openapi_requests": capture_openapi_requests(), "realtime": asyncio.run(capture_realtime())}
|
|
||||||
|
|
||||||
|
|
||||||
class LegacyCompatibilityTests(unittest.TestCase):
|
|
||||||
def test_wire_contracts_match_pre_migration_capture(self):
|
|
||||||
expected = json.loads(FIXTURE.read_text())
|
|
||||||
actual = capture()
|
|
||||||
for name, response in actual["http"].items():
|
|
||||||
with self.subTest(case=name):
|
|
||||||
self.assertEqual(response, expected["http"][name])
|
|
||||||
self.assertEqual(actual["openapi_requests"], expected["openapi_requests"])
|
|
||||||
self.assertEqual(actual["realtime"], expected["realtime"])
|
|
||||||
self.assertEqual(set(actual["http"]), set(expected["http"]))
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
"""Register routes in their original order without changing public paths."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from s2s_client.api.routes import apipilot, caller, config, search, sessions
|
||||||
|
from s2s_client.realtime.proxy import realtime_proxy
|
||||||
|
from s2s_client.services.reasoning import reasoning
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
router.include_router(config.router)
|
||||||
|
router.include_router(apipilot.router)
|
||||||
|
router.add_api_route("/api/reasoning", reasoning, methods=["POST"])
|
||||||
|
router.add_api_websocket_route("/v1/realtime", realtime_proxy)
|
||||||
|
router.include_router(caller.router)
|
||||||
|
router.include_router(search.router)
|
||||||
|
router.include_router(sessions.router)
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from s2s_client.core import config as settings
|
||||||
|
from s2s_client.core import logging as app_logging
|
||||||
|
from s2s_client.models import requests as models_requests
|
||||||
|
from s2s_client.providers import apipilot as providers_apipilot
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/apipilot/tools", response_model=None)
|
||||||
|
async def apipilot_list_tools(
|
||||||
|
request: Request,
|
||||||
|
include_unauthorized: bool = True,
|
||||||
|
refresh: bool = False,
|
||||||
|
) -> dict[str, Any] | JSONResponse:
|
||||||
|
"""Public proxy for ApiPilot tool discovery (guide §2.1).
|
||||||
|
|
||||||
|
Forwards Authorization / x-demo-session headers from the caller to the
|
||||||
|
upstream backend verbatim and returns the list of `ToolDefinition` records
|
||||||
|
exactly as documented:
|
||||||
|
|
||||||
|
{ "name": "...", "description": "...", "parameters": {...}, "requires_auth": true/false }
|
||||||
|
|
||||||
|
A short in-process TTL cache is applied (default 5 min, configurable via
|
||||||
|
S2S_APIPILOT_TTL_SEC). The client may force a refresh via `?refresh=true`
|
||||||
|
(e.g. after a user just logged in and previously saw only requires_auth tools)."""
|
||||||
|
if not settings.S2S_APIPILOT_TOOLS_URL:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=501,
|
||||||
|
content={
|
||||||
|
"success": False,
|
||||||
|
"data": None,
|
||||||
|
"error": {
|
||||||
|
"code": "APIPILOT_NOT_CONFIGURED",
|
||||||
|
"message": "ApiPilot no está disponible en este despliegue (S2S_APIPILOT_TOOLS_URL no establecido).",
|
||||||
|
"status_code": 501,
|
||||||
|
"raw": None,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
auth, demo = providers_apipilot._apipilot_parse_authorization(request)
|
||||||
|
try:
|
||||||
|
tools = await providers_apipilot._apipilot_fetch_tools(
|
||||||
|
auth_header=auth,
|
||||||
|
demo_session=demo,
|
||||||
|
include_unauthorized=bool(include_unauthorized),
|
||||||
|
force_refresh=bool(refresh),
|
||||||
|
)
|
||||||
|
except HTTPException as exc:
|
||||||
|
# Normalize to envelope-style payload so downstream code is uniform.
|
||||||
|
detail = (
|
||||||
|
exc.detail
|
||||||
|
if isinstance(exc.detail, dict)
|
||||||
|
else {
|
||||||
|
"code": "APIPILOT_ERROR",
|
||||||
|
"message": str(exc.detail) if exc.detail else "Error",
|
||||||
|
"status_code": exc.status_code,
|
||||||
|
"raw": None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=exc.status_code,
|
||||||
|
content={"success": False, "data": None, "error": detail},
|
||||||
|
)
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[APIPILOT] /api/apipilot/tools OK tools=%d include_unauthorized=%s refresh=%s",
|
||||||
|
len(tools),
|
||||||
|
bool(include_unauthorized),
|
||||||
|
bool(refresh),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"data": {
|
||||||
|
"count": len(tools),
|
||||||
|
"include_unauthorized": bool(include_unauthorized),
|
||||||
|
"tools": tools,
|
||||||
|
},
|
||||||
|
"error": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/apipilot/execute")
|
||||||
|
async def apipilot_execute(request: Request, body: models_requests.ApipilotExecuteRequest) -> JSONResponse:
|
||||||
|
"""Public proxy for ApiPilot /execute (guide §2.2).
|
||||||
|
|
||||||
|
Accepts `{name, arguments}`, runs the mandatory PRE-CHECK flow (fresh tool
|
||||||
|
discovery, `requires_auth` gate, strict JSON-Schema argument validation),
|
||||||
|
forwards the request to the upstream backend, and returns the envelope
|
||||||
|
`{success, data, error}`.
|
||||||
|
|
||||||
|
Show only `error.message` to the end user. `error.code`, `error.status_code`
|
||||||
|
and `error.raw` are for internal auditing (guide §2.2, recommended UI errors)."""
|
||||||
|
if not settings.S2S_APIPILOT_TOOLS_URL:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=501,
|
||||||
|
content={
|
||||||
|
"success": False,
|
||||||
|
"data": None,
|
||||||
|
"error": {
|
||||||
|
"code": "APIPILOT_NOT_CONFIGURED",
|
||||||
|
"message": "ApiPilot no está disponible en este despliegue (S2S_APIPILOT_TOOLS_URL no establecido).",
|
||||||
|
"status_code": 501,
|
||||||
|
"raw": None,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
auth, demo = providers_apipilot._apipilot_parse_authorization(request)
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[APIPILOT-CALL-NAME] USE=API_ENDPOINT_EXECUTE name=%r argsKeys=%s authReceived=%s",
|
||||||
|
(body.name or ""),
|
||||||
|
sorted(list((body.arguments or {}).keys())) if isinstance(body.arguments, dict) else [],
|
||||||
|
("Y" if auth or demo else "none"),
|
||||||
|
)
|
||||||
|
envelope = await providers_apipilot._apipilot_execute_tool(
|
||||||
|
auth_header=auth,
|
||||||
|
demo_session=demo,
|
||||||
|
name=body.name,
|
||||||
|
arguments=body.arguments if isinstance(body.arguments, dict) else {},
|
||||||
|
)
|
||||||
|
status_code = 200
|
||||||
|
if (not envelope.get("success")) and isinstance(envelope.get("error"), dict):
|
||||||
|
code = envelope["error"].get("status_code")
|
||||||
|
try:
|
||||||
|
code_int = int(code)
|
||||||
|
if 400 <= code_int < 599:
|
||||||
|
status_code = code_int
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[APIPILOT] /api/apipilot/execute name=%s success=%s status=%s",
|
||||||
|
body.name,
|
||||||
|
envelope.get("success"),
|
||||||
|
status_code,
|
||||||
|
)
|
||||||
|
return JSONResponse(status_code=status_code, content=envelope)
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from s2s_client.core import config as settings
|
||||||
|
from s2s_client.core import logging as app_logging
|
||||||
|
from s2s_client.providers import caller as providers_caller
|
||||||
|
from s2s_client.services import context as services_context
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/user/phone")
|
||||||
|
async def user_by_phone(phoneNumber: str) -> JSONResponse:
|
||||||
|
"""HTTP passthrough: fetches caller identity / profile from the external
|
||||||
|
info endpoint set via VOCERO_USER_PHONE_AUTH_URL and forwards the raw
|
||||||
|
JSON to the browser.
|
||||||
|
|
||||||
|
The proxy NEVER inspects this payload nor injects it into the realtime
|
||||||
|
WebSocket session. It is only used by:
|
||||||
|
- the browser UI to render a caller name header (#ident-user-name)
|
||||||
|
- the /api/reasoning HTTP body user_context field (browser's choice).
|
||||||
|
|
||||||
|
When VOCERO_USER_PHONE_AUTH_URL is unset we return 503 so the UI can
|
||||||
|
silently skip identity loading (backward compatible with generic deploys).
|
||||||
|
|
||||||
|
When S2S_LOGIN_BY_PHONE is set, the proxy first performs a login-by-phone
|
||||||
|
POST with the caller phone and (if a bearer token is extracted) forwards
|
||||||
|
it as Authorization: Bearer on the subsequent profile GET.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not settings.VOCERO_USER_PHONE_AUTH_URL:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="VOCERO_USER_PHONE_AUTH_URL no está configurado en este despliegue.",
|
||||||
|
)
|
||||||
|
phone = (phoneNumber or "").strip()
|
||||||
|
if not phone:
|
||||||
|
raise HTTPException(status_code=400, detail="Se requiere el campo phoneNumber.")
|
||||||
|
|
||||||
|
# 0) Login-by-phone (optional — only if S2S_LOGIN_BY_PHONE env is set).
|
||||||
|
login_token = None
|
||||||
|
if settings.S2S_LOGIN_BY_PHONE:
|
||||||
|
login_token, _login_obj = await providers_caller._login_by_phone(phone)
|
||||||
|
|
||||||
|
target_url = f"{settings.VOCERO_USER_PHONE_AUTH_URL}?{urlencode({'phoneNumber': phone})}"
|
||||||
|
app_logging.logger.debug(
|
||||||
|
"[EXT][USER-BY-PHONE] GET upstream=%s phoneLen=%d bearer=%s",
|
||||||
|
settings.VOCERO_USER_PHONE_AUTH_URL,
|
||||||
|
len(phone),
|
||||||
|
"yes" if login_token else "no",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
headers = {}
|
||||||
|
if login_token:
|
||||||
|
headers["Authorization"] = f"Bearer {login_token}"
|
||||||
|
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as http:
|
||||||
|
resp = await http.get(target_url, headers=headers or None)
|
||||||
|
except httpx.RequestError as exc:
|
||||||
|
app_logging.logger.warning("[EXT][USER-BY-PHONE] unreachable: %r", exc)
|
||||||
|
raise HTTPException(status_code=502, detail="No se puede conectar con el servicio de información del usuario.") # noqa: B904
|
||||||
|
# Forward the upstream status + JSON verbatim; never rewrite the payload.
|
||||||
|
if resp.status_code >= 500:
|
||||||
|
app_logging.logger.warning(
|
||||||
|
"[EXT][USER-BY-PHONE] upstream HTTP %s: %.200s",
|
||||||
|
resp.status_code,
|
||||||
|
resp.text,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
payload = resp.json()
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException( # noqa: B904
|
||||||
|
status_code=502,
|
||||||
|
detail="El servicio de información del usuario devolvió un JSON no válido.",
|
||||||
|
)
|
||||||
|
return JSONResponse(status_code=resp.status_code if resp.status_code != 204 else 200, content=payload)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/user/reports")
|
||||||
|
async def user_reports(phoneNumber: str | None = None) -> JSONResponse:
|
||||||
|
"""Fetch + normalize the scheduled reports list for the caller UI.
|
||||||
|
|
||||||
|
Mirrors the same sequence used in the WebSocket connect handler and
|
||||||
|
/api/reasoning:
|
||||||
|
0. login-by-phone (optional) → bearer token
|
||||||
|
1. GET S2S_REPORT_LIST with ?phoneNumber= (if phone provided) + Bearer auth
|
||||||
|
2. run the same resolver used for prompt injection: filter 3 fields,
|
||||||
|
sort DESC, build hints + entries → JSON returned verbatim so the
|
||||||
|
browser can render a "reports loaded" summary in the UI header.
|
||||||
|
|
||||||
|
Returns 503 when S2S_REPORT_LIST is not configured (generic deploy with no
|
||||||
|
reports feature enabled) so the browser silently skips UI rendering.
|
||||||
|
Response shape on success:
|
||||||
|
{
|
||||||
|
"count": N,
|
||||||
|
"wrappedLen": X,
|
||||||
|
"hints": [ "• Report #1: '<label>' — scheduled on <date>", ... ],
|
||||||
|
"entries": [
|
||||||
|
{"sourceScheduleLabel": "...", "date": "...", "podcastScript": "..."},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
USER FIX 2026-09-11: remove mass injection of scheduled reports.
|
||||||
|
• Even this BROWSER-FACING endpoint (/api/user/reports) now honours the new
|
||||||
|
S2S_INJECT_REPORTS default=False gate: when S2S_INJECT_REPORTS is not
|
||||||
|
explicitly true, we return a polite 403 + empty response — the endpoint
|
||||||
|
does NOT execute any upstream S2S_REPORT_LIST HTTP call, does NOT expose
|
||||||
|
any podcastScript bodies, and does NOT emit entries/hints. This avoids
|
||||||
|
a subtle loophole where the browser could still fetch scheduled reports
|
||||||
|
through /api/user/reports while the LLM prompt paths were gated.
|
||||||
|
"""
|
||||||
|
# ── MASS-INJECTION KILL SWITCH (user request) ─────────────────────────
|
||||||
|
if not settings.S2S_INJECT_REPORTS:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=200,
|
||||||
|
content={
|
||||||
|
"count": 0,
|
||||||
|
"wrappedLen": 0,
|
||||||
|
"hints": [],
|
||||||
|
"entries": [],
|
||||||
|
"skipped": True,
|
||||||
|
"reason": "S2S_INJECT_REPORTS está deshabilitado en este despliegue; no se muestran reportes programados en la interfaz ni se envían al modelo.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# ───────────────────────────────────────────────────────────────────────
|
||||||
|
if not settings.S2S_REPORT_LIST:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="S2S_REPORT_LIST no está configurado en este despliegue.",
|
||||||
|
)
|
||||||
|
phone = (phoneNumber or "").strip() or None
|
||||||
|
# 0) Login-by-phone if enabled. Bearer token used only if login succeeded
|
||||||
|
# (same rule as WS connect / reasoning handlers — never-fail).
|
||||||
|
login_token = None
|
||||||
|
if settings.S2S_LOGIN_BY_PHONE and phone:
|
||||||
|
login_token, _login_obj = await providers_caller._login_by_phone(phone)
|
||||||
|
|
||||||
|
reports_raw = await providers_caller._fetch_reports_list(phone, login_token)
|
||||||
|
# Re-run the same resolver used for prompt injection → identical logic
|
||||||
|
# guarantees UI and LLM see exactly the same filtered/sorted list.
|
||||||
|
ctx_str = services_context._resolve_reports_context(reports_raw)
|
||||||
|
if not ctx_str or not isinstance(reports_raw, list):
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"count": 0,
|
||||||
|
"wrappedLen": 0,
|
||||||
|
"hints": [],
|
||||||
|
"entries": [],
|
||||||
|
"skipped": True,
|
||||||
|
"reason": "Lista vacía, error del servicio de origen o ningún contenido disponible.",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
# Rebuild hints + normalized entries with the exact filtered output
|
||||||
|
items_list: list = reports_raw
|
||||||
|
# Re-derive filtered + sorted list (same as _resolve_reports_context) so
|
||||||
|
# we don't accidentally expose raw unfiltered upstream payload to the UI.
|
||||||
|
filtered: list[dict] = []
|
||||||
|
for item in items_list:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
cleaned: dict = {}
|
||||||
|
label_val: object = None
|
||||||
|
if item.get("sourceScheduleLabel") is not None:
|
||||||
|
label_val = item["sourceScheduleLabel"]
|
||||||
|
elif isinstance(item.get("sourceSchedule"), dict):
|
||||||
|
ss = item["sourceSchedule"]
|
||||||
|
for k in ("label", "name", "title"):
|
||||||
|
if ss.get(k) is not None:
|
||||||
|
label_val = ss[k]
|
||||||
|
break
|
||||||
|
if label_val is not None:
|
||||||
|
cleaned["sourceScheduleLabel"] = label_val
|
||||||
|
date_val: object = None
|
||||||
|
if item.get("date") is not None:
|
||||||
|
date_val = item["date"]
|
||||||
|
elif isinstance(item.get("sourceSchedule"), dict):
|
||||||
|
ss = item["sourceSchedule"]
|
||||||
|
for k in ("date", "scheduledAt", "publishedAt"):
|
||||||
|
if ss.get(k) is not None:
|
||||||
|
date_val = ss[k]
|
||||||
|
break
|
||||||
|
if date_val is not None:
|
||||||
|
cleaned["date"] = date_val
|
||||||
|
script_val: object = None
|
||||||
|
for k in ("podcastScript", "script", "text", "content"):
|
||||||
|
if item.get(k) is not None:
|
||||||
|
script_val = item[k]
|
||||||
|
break
|
||||||
|
if script_val is not None:
|
||||||
|
cleaned["podcastScript"] = script_val
|
||||||
|
if cleaned:
|
||||||
|
filtered.append(cleaned)
|
||||||
|
filtered.sort(key=lambda r: services_context._parse_date_key(r.get("date")))
|
||||||
|
# 2026-09-11 user fix: configure report quick-hint limits through .env.
|
||||||
|
# Apply the SAME cap used by the LLM prompt resolver: after sort by newest,
|
||||||
|
# keep only first S2S_REPORTS_QUICK_HINT_MAX entries. This keeps counts between
|
||||||
|
# /api/user/reports UI JSON and the LLM prompt identical — avoids confusing
|
||||||
|
# mismatches where the UI shows 20 hints but the prompt only saw 5.
|
||||||
|
total_raw_ui = len(filtered)
|
||||||
|
cap_ui = max(1, int(settings.S2S_REPORTS_QUICK_HINT_MAX or 5))
|
||||||
|
truncated_ui_note: str | None = None
|
||||||
|
if total_raw_ui > cap_ui:
|
||||||
|
filtered = filtered[:cap_ui]
|
||||||
|
truncated_ui_note = (
|
||||||
|
f"Se muestran los {cap_ui} reportes más recientes de un total de {total_raw_ui}. "
|
||||||
|
"Ajuste la variable de entorno S2S_REPORTS_QUICK_HINT_MAX para cambiar la cantidad."
|
||||||
|
)
|
||||||
|
import datetime as _dt
|
||||||
|
|
||||||
|
# Deduplicate by label for the UI JSON response only.
|
||||||
|
# NOTE: the LLM prompt injection uses the full non-deduped list (different
|
||||||
|
# dates = different content even when the scheduled report name repeats).
|
||||||
|
# We only dedup entries_out, hints_list, and entries for the browser payload.
|
||||||
|
seen_labels: set[str] = set()
|
||||||
|
dedup_filtered: list[dict] = []
|
||||||
|
for r in filtered:
|
||||||
|
lbl_raw = r.get("sourceScheduleLabel")
|
||||||
|
if lbl_raw is None:
|
||||||
|
# Items with no label are kept as-is (can't meaningfully dedupe)
|
||||||
|
dedup_filtered.append(r)
|
||||||
|
continue
|
||||||
|
key = str(lbl_raw).strip()
|
||||||
|
if not key:
|
||||||
|
dedup_filtered.append(r)
|
||||||
|
continue
|
||||||
|
if key in seen_labels:
|
||||||
|
continue
|
||||||
|
seen_labels.add(key)
|
||||||
|
dedup_filtered.append(r)
|
||||||
|
hints_list: list[str] = []
|
||||||
|
entries_out: list[dict] = []
|
||||||
|
for idx, r in enumerate(dedup_filtered, start=1):
|
||||||
|
entries_out.append(r)
|
||||||
|
label_piece = str(r["sourceScheduleLabel"]).strip() if r.get("sourceScheduleLabel") else ""
|
||||||
|
date_piece = ""
|
||||||
|
if r.get("date") is not None:
|
||||||
|
raw_d = r["date"]
|
||||||
|
try:
|
||||||
|
if isinstance(raw_d, (int, float)) and not isinstance(raw_d, bool):
|
||||||
|
ts_sec = float(raw_d)
|
||||||
|
if ts_sec > 1e12:
|
||||||
|
ts_sec = ts_sec / 1000.0
|
||||||
|
dt = _dt.datetime.fromtimestamp(ts_sec, tz=_dt.UTC)
|
||||||
|
date_piece = dt.strftime("%Y-%m-%d %H:%M UTC")
|
||||||
|
else:
|
||||||
|
s = str(raw_d).strip()
|
||||||
|
if "T" in s and len(s) >= 16:
|
||||||
|
date_piece = s.replace("T", " ")[:16]
|
||||||
|
else:
|
||||||
|
date_piece = s
|
||||||
|
except Exception:
|
||||||
|
date_piece = str(raw_d)[:32]
|
||||||
|
if label_piece and date_piece:
|
||||||
|
hints_list.append(f"• Reporte #{idx}: '{label_piece}' — programado para {date_piece}")
|
||||||
|
elif label_piece:
|
||||||
|
hints_list.append(f"• Reporte #{idx}: '{label_piece}'")
|
||||||
|
elif date_piece:
|
||||||
|
hints_list.append(f"• Reporte #{idx}: (sin título) — programado para {date_piece}")
|
||||||
|
resp_body = {
|
||||||
|
"count": len(entries_out),
|
||||||
|
"rawCount": total_raw_ui,
|
||||||
|
"cap": cap_ui,
|
||||||
|
"wrappedLen": len(ctx_str),
|
||||||
|
"hints": hints_list,
|
||||||
|
# Strip the long podcastScript from the summary JSON to keep the
|
||||||
|
# browser response lightweight (UI only needs label/date).
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"sourceScheduleLabel": r.get("sourceScheduleLabel"),
|
||||||
|
"date": r.get("date"),
|
||||||
|
}
|
||||||
|
for r in entries_out
|
||||||
|
],
|
||||||
|
}
|
||||||
|
if truncated_ui_note is not None:
|
||||||
|
resp_body["truncated"] = True
|
||||||
|
resp_body["note"] = truncated_ui_note
|
||||||
|
return JSONResponse(resp_body)
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
|
||||||
|
from s2s_client.core import config as settings
|
||||||
|
from s2s_client.core import logging as app_logging
|
||||||
|
from s2s_client.core import urls as core_urls
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _effective_s2s_url(request: Request) -> str:
|
||||||
|
return settings.SPEECH_TO_SPEECH_URL or core_urls._proxy_public_url(request)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/config")
|
||||||
|
async def config(request: Request) -> dict[str, Any]:
|
||||||
|
"""Client bootstrap: whether web search is available, whether the deploy runs
|
||||||
|
behind a load balancer (so the browser uses the /api/session proxy),
|
||||||
|
and whether the user may instead set a direct
|
||||||
|
s2s server URL. The LB address itself is intentionally NOT included.
|
||||||
|
|
||||||
|
2026-09-24: new top-level `apipilot` flag. When True the deploy exposes the
|
||||||
|
new `/api/apipilot/tools` and `/api/apipilot/execute` proxy endpoints (see
|
||||||
|
docs/client/tool-calling.md) that the browser may call directly instead of
|
||||||
|
hardcoding upstream tool URLs / names."""
|
||||||
|
app_logging.logger.debug(
|
||||||
|
"[INTERNAL] /api/config requested client=%s", request.client.host if request.client else "?"
|
||||||
|
)
|
||||||
|
primary_prompt = settings.S2S_PRIMARY_PROMPT
|
||||||
|
out = {
|
||||||
|
"search": bool(settings.SERPER_KEY),
|
||||||
|
"lb": bool(settings.LOAD_BALANCER_URL),
|
||||||
|
"allowDirect": not settings.LOAD_BALANCER_URL,
|
||||||
|
"s2sUrl": _effective_s2s_url(request),
|
||||||
|
"primaryPrompt": primary_prompt,
|
||||||
|
"reasoningType": settings.S2S_REASONING_TYPE,
|
||||||
|
"injectProfile": settings.S2S_INJECT_PROFILE,
|
||||||
|
"debugPrompt": settings.S2S_DEBUG_PROMPT,
|
||||||
|
"startSessionMode": settings.S2S_START_SESSION_MODE,
|
||||||
|
"theme": settings.S2S_THEME,
|
||||||
|
"apipilot": bool(settings.S2S_APIPILOT_TOOLS_URL),
|
||||||
|
}
|
||||||
|
app_logging.logger.debug(
|
||||||
|
"[INTERNAL] /api/config returned promptLen=%d reasoning=%s startSessionMode=%s theme=%s apipilot=%s",
|
||||||
|
len(primary_prompt),
|
||||||
|
settings.S2S_REASONING_TYPE,
|
||||||
|
settings.S2S_START_SESSION_MODE or "-",
|
||||||
|
settings.S2S_THEME or "-",
|
||||||
|
out["apipilot"],
|
||||||
|
)
|
||||||
|
return out
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from s2s_client.core import config as settings
|
||||||
|
from s2s_client.core import logging as app_logging
|
||||||
|
from s2s_client.models import requests as models_requests
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/search")
|
||||||
|
async def search(req: models_requests.SearchRequest) -> JSONResponse:
|
||||||
|
"""Proxy a Google search via Serper.dev. The key stays on the server unless
|
||||||
|
the user brought their own (then theirs is used for this request only)."""
|
||||||
|
query = (req.query or "").strip()
|
||||||
|
if not query:
|
||||||
|
raise HTTPException(status_code=400, detail="La consulta está vacía.")
|
||||||
|
|
||||||
|
key = (req.key or "").strip() or settings.SERPER_KEY
|
||||||
|
has_user_key = bool((req.key or "").strip())
|
||||||
|
if not key:
|
||||||
|
# No server key and the user didn't supply one — search is unavailable.
|
||||||
|
raise HTTPException(status_code=503, detail="La búsqueda no está configurada.")
|
||||||
|
|
||||||
|
headers = {"X-API-KEY": key, "Content-Type": "application/json"}
|
||||||
|
payload = {"q": query, "num": settings.MAX_RESULTS}
|
||||||
|
app_logging.logger.debug(
|
||||||
|
"[EXT][SERPER] search POST %s query=%s userKey=%s num=%d",
|
||||||
|
settings.SERPER_URL,
|
||||||
|
json.dumps(query, ensure_ascii=True)[:200],
|
||||||
|
has_user_key,
|
||||||
|
settings.MAX_RESULTS,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=12.0) as http:
|
||||||
|
resp = await http.post(settings.SERPER_URL, headers=headers, json=payload)
|
||||||
|
except httpx.RequestError as exc:
|
||||||
|
app_logging.logger.warning("Serper unreachable: %r", exc)
|
||||||
|
raise HTTPException(status_code=502, detail="No se puede conectar con el proveedor de búsqueda.") # noqa: B904
|
||||||
|
app_logging.logger.debug(
|
||||||
|
"[EXT][SERPER] search RESP status=%s len=%dB",
|
||||||
|
resp.status_code,
|
||||||
|
len(resp.content or b""),
|
||||||
|
)
|
||||||
|
|
||||||
|
if resp.status_code != 200:
|
||||||
|
# Serper's error body carries the real reason (e.g. "Not enough
|
||||||
|
# credits") and contains no key, so it's safe to log and relay.
|
||||||
|
body = resp.text[:300]
|
||||||
|
app_logging.logger.warning("Serper error %s: %s", resp.status_code, body)
|
||||||
|
msg = None
|
||||||
|
try:
|
||||||
|
msg = resp.json().get("message")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
detail = f"Error del proveedor de búsqueda ({resp.status_code})"
|
||||||
|
if msg:
|
||||||
|
detail += f": {msg}"
|
||||||
|
raise HTTPException(status_code=502, detail=detail)
|
||||||
|
|
||||||
|
data = resp.json()
|
||||||
|
results = []
|
||||||
|
for item in (data.get("organic") or [])[: settings.MAX_RESULTS]:
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"title": item.get("title", ""),
|
||||||
|
"snippet": item.get("snippet", ""),
|
||||||
|
"url": item.get("link", ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# A direct answer when Google has one — saves the model a hop.
|
||||||
|
box = data.get("answerBox") or {}
|
||||||
|
answer = box.get("answer") or box.get("snippet") or None
|
||||||
|
if not answer:
|
||||||
|
kg = data.get("knowledgeGraph") or {}
|
||||||
|
answer = kg.get("description") or None
|
||||||
|
|
||||||
|
app_logging.logger.debug(
|
||||||
|
"[EXT][SERPER] search PARSED hasAnswer=%s resultCount=%d titles=%s",
|
||||||
|
bool(answer),
|
||||||
|
len(results),
|
||||||
|
[r.get("title", "")[:40] for r in results],
|
||||||
|
)
|
||||||
|
return JSONResponse({"query": query, "answer": answer, "results": results})
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from s2s_client.core import config as settings
|
||||||
|
from s2s_client.core import logging as app_logging
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/session")
|
||||||
|
async def session(request: Request) -> JSONResponse:
|
||||||
|
"""Relay a session grant or queue ticket, keeping the LB address server-side.
|
||||||
|
|
||||||
|
The grant's `connect_url` contains the compute host and short-lived token the
|
||||||
|
browser needs to connect directly to the allocated speech service."""
|
||||||
|
client_ip = request.client.host if request.client else "?"
|
||||||
|
if not settings.LOAD_BALANCER_URL:
|
||||||
|
# No LB configured — this deploy is direct-mode only; the browser should
|
||||||
|
# never call this. 404 so it's indistinguishable from a missing route.
|
||||||
|
raise HTTPException(status_code=404, detail="No encontrado.")
|
||||||
|
|
||||||
|
app_logging.logger.debug("[EXT][LB] session: client=%s", client_ip)
|
||||||
|
url = f"{settings.LOAD_BALANCER_URL.rstrip('/')}/session"
|
||||||
|
app_logging.logger.debug("[EXT][LB] session POST %s (handshake)", url)
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=15.0) as http:
|
||||||
|
lb = await http.post(url, headers={"Content-Type": "application/json"}, content="{}")
|
||||||
|
except httpx.RequestError as exc:
|
||||||
|
app_logging.logger.warning("Load balancer unreachable: %r", exc)
|
||||||
|
raise HTTPException(status_code=502, detail="No se puede conectar con el servicio de voz.") # noqa: B904
|
||||||
|
app_logging.logger.debug(
|
||||||
|
"[EXT][LB] session RESP status=%s len=%dB",
|
||||||
|
lb.status_code,
|
||||||
|
len(lb.content or b""),
|
||||||
|
)
|
||||||
|
|
||||||
|
# The queue is full: the LB replies 503 {state:"at_capacity"}. Relay it as-is
|
||||||
|
# so the client shows a soft "try again shortly", not a hard error.
|
||||||
|
if lb.status_code == 503:
|
||||||
|
body = _safe_json(lb)
|
||||||
|
if body.get("state") == "at_capacity":
|
||||||
|
app_logging.logger.debug("[EXT][LB] session: at_capacity — queue full")
|
||||||
|
return JSONResponse({"state": "at_capacity"}, status_code=503)
|
||||||
|
|
||||||
|
if lb.status_code != 200:
|
||||||
|
# The LB's error body may name the reason (e.g. capacity); it carries no
|
||||||
|
# secret, so relay a trimmed copy.
|
||||||
|
app_logging.logger.warning("Session handshake failed %s: %s", lb.status_code, lb.text[:300])
|
||||||
|
raise HTTPException(status_code=502, detail=f"No se pudo establecer la sesión ({lb.status_code}).")
|
||||||
|
|
||||||
|
data = lb.json()
|
||||||
|
state = data.get("state", "?")
|
||||||
|
connect_url = (data.get("connect_url") or data.get("connectUrl") or "")[:120]
|
||||||
|
session_id = data.get("session_id") or data.get("sessionId")
|
||||||
|
app_logging.logger.debug(
|
||||||
|
"[EXT][LB] session PARSED state=%s sessionId=%s hasConnectUrl=%s connectUrl=%s",
|
||||||
|
state,
|
||||||
|
session_id,
|
||||||
|
bool(connect_url),
|
||||||
|
connect_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Busy pool: the LB queued us. Relay the ticket for subsequent polling.
|
||||||
|
if data.get("state") == "queued":
|
||||||
|
app_logging.logger.debug(
|
||||||
|
"[EXT][LB] session: queued queueId=%s position=%s",
|
||||||
|
data.get("queue_id") or data.get("queueId"),
|
||||||
|
data.get("position"),
|
||||||
|
)
|
||||||
|
return JSONResponse(data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/queue/{queue_id}")
|
||||||
|
async def queue_status(queue_id: str, request: Request) -> JSONResponse:
|
||||||
|
"""Relay a waiting ticket's position or its newly claimed session grant."""
|
||||||
|
client_ip = request.client.host if request.client else "?"
|
||||||
|
if not settings.LOAD_BALANCER_URL:
|
||||||
|
raise HTTPException(status_code=404, detail="No encontrado.")
|
||||||
|
|
||||||
|
app_logging.logger.debug(
|
||||||
|
"[EXT][LB] queue poll: client=%s queueId=%s",
|
||||||
|
client_ip,
|
||||||
|
queue_id[:16],
|
||||||
|
)
|
||||||
|
|
||||||
|
url = f"{settings.LOAD_BALANCER_URL.rstrip('/')}/queue/{queue_id}"
|
||||||
|
app_logging.logger.debug("[EXT][LB] queue GET %s", url)
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=15.0) as http:
|
||||||
|
lb = await http.get(url)
|
||||||
|
except httpx.RequestError as exc:
|
||||||
|
app_logging.logger.warning("Load balancer unreachable: %r", exc)
|
||||||
|
raise HTTPException(status_code=502, detail="No se puede conectar con el servicio de voz.") # noqa: B904
|
||||||
|
app_logging.logger.debug(
|
||||||
|
"[EXT][LB] queue RESP status=%s len=%dB",
|
||||||
|
lb.status_code,
|
||||||
|
len(lb.content or b""),
|
||||||
|
)
|
||||||
|
|
||||||
|
if lb.status_code == 404:
|
||||||
|
# Ticket unknown/expired (reaped after we stopped polling). Tell the client
|
||||||
|
# to start over rather than spin.
|
||||||
|
app_logging.logger.debug("[EXT][LB] queue: queueId=%s EXPIRED (404)", queue_id[:16])
|
||||||
|
return JSONResponse({"state": "expired"}, status_code=404)
|
||||||
|
|
||||||
|
if lb.status_code != 200:
|
||||||
|
app_logging.logger.warning("Queue poll failed %s: %s", lb.status_code, lb.text[:300])
|
||||||
|
raise HTTPException(status_code=502, detail=f"No se pudo consultar la cola de espera ({lb.status_code}).")
|
||||||
|
|
||||||
|
data = lb.json()
|
||||||
|
state = data.get("state", "?")
|
||||||
|
position = data.get("position")
|
||||||
|
app_logging.logger.debug(
|
||||||
|
"[EXT][LB] queue PARSED queueId=%s state=%s position=%s sessionId=%s",
|
||||||
|
queue_id[:16],
|
||||||
|
state,
|
||||||
|
position,
|
||||||
|
data.get("session_id") or data.get("sessionId"),
|
||||||
|
)
|
||||||
|
|
||||||
|
return JSONResponse(data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/api/queue/{queue_id}")
|
||||||
|
async def queue_leave(queue_id: str) -> dict[str, bool]:
|
||||||
|
"""Leave the queue from the explicit 'Leave queue' button (a real fetch)."""
|
||||||
|
if not settings.LOAD_BALANCER_URL:
|
||||||
|
raise HTTPException(status_code=404, detail="No encontrado.")
|
||||||
|
app_logging.logger.debug("[EXT][LB] queue LEAVE (button) queueId=%s", queue_id[:16])
|
||||||
|
await _lb_leave(queue_id)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/queue/end")
|
||||||
|
async def queue_end(request: Request) -> dict[str, bool]:
|
||||||
|
"""Leave the queue on teardown/tab-close (navigator.sendBeacon, which can only
|
||||||
|
POST). Body: { queueId }. Best-effort; the LB reaps the ticket on TTL anyway."""
|
||||||
|
if not settings.LOAD_BALANCER_URL:
|
||||||
|
raise HTTPException(status_code=404, detail="No encontrado.")
|
||||||
|
qid = await _queue_id(request)
|
||||||
|
if qid:
|
||||||
|
app_logging.logger.debug("[EXT][LB] queue LEAVE (teardown) queueId=%s", qid[:16])
|
||||||
|
await _lb_leave(qid)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
async def _lb_leave(queue_id: str) -> None:
|
||||||
|
"""Best-effort: tell the LB to drop a waiting ticket."""
|
||||||
|
url = f"{settings.LOAD_BALANCER_URL.rstrip('/')}/queue/{queue_id}"
|
||||||
|
app_logging.logger.debug("[EXT][LB] queue DELETE %s", url)
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=5.0) as http:
|
||||||
|
resp = await http.delete(url)
|
||||||
|
app_logging.logger.debug(
|
||||||
|
"[EXT][LB] queue DELETE RESP queueId=%s status=%s",
|
||||||
|
queue_id[:16],
|
||||||
|
resp.status_code,
|
||||||
|
)
|
||||||
|
except httpx.RequestError as exc:
|
||||||
|
app_logging.logger.warning("Queue leave failed: %r", exc)
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_json(response: httpx.Response) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
body = response.json()
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
return body if isinstance(body, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
async def _queue_id(request: Request) -> str:
|
||||||
|
"""Pull `queueId` from a JSON body, tolerating sendBeacon's blob posts."""
|
||||||
|
try:
|
||||||
|
data = await request.json()
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
return (data or {}).get("queueId", "") if isinstance(data, dict) else ""
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
from s2s_client.core import logging as app_logging
|
||||||
|
|
||||||
|
ROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||||
|
load_dotenv(os.path.join(ROOT_DIR, ".env"), override=False)
|
||||||
|
app_logging.logger.debug("Loaded .env from %s", ROOT_DIR)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_reasoning_type(value: str) -> str:
|
||||||
|
normalized = (value or "").strip().strip('"').strip("'").replace("-", "_").upper()
|
||||||
|
if normalized == "OPENAI":
|
||||||
|
return "OPENAI"
|
||||||
|
if normalized in {"NODE_RED", "NODERED"}:
|
||||||
|
return "NODE_RED"
|
||||||
|
return "PROXY"
|
||||||
|
|
||||||
|
|
||||||
|
SERPER_KEY = os.environ.get("SERPER_API_KEY", "").strip()
|
||||||
|
S2S_PRIMARY_PROMPT = os.environ.get("S2S_PRIMARY_PROMPT", "").strip()
|
||||||
|
if S2S_PRIMARY_PROMPT:
|
||||||
|
S2S_PRIMARY_PROMPT = S2S_PRIMARY_PROMPT.replace("\\n", "\n")
|
||||||
|
# External user-by-phone info endpoint. When set, GET /api/user/phone proxies
|
||||||
|
# to this URL (passing ?phoneNumber=) and forwards the JSON payload to the
|
||||||
|
# browser so the UI can render caller identity / caller context.
|
||||||
|
VOCERO_USER_PHONE_AUTH_URL = os.environ.get("VOCERO_USER_PHONE_AUTH_URL", "").strip().rstrip("/")
|
||||||
|
# Feature flag: when "true" (case-insensitive) => injects the resolved caller
|
||||||
|
# profile into the realtime session (patch session.update client->upstream,
|
||||||
|
# append profile to the LLM system instructions prompt instructions, and prepend it to the
|
||||||
|
# reasoning user_context for the /api/reasoning HTTP body.
|
||||||
|
# Default = disabled (generic deploy; pure passthrough; all characterization lives
|
||||||
|
# in the backend RAG per original architectural decision.)
|
||||||
|
S2S_INJECT_PROFILE = (os.environ.get("S2S_INJECT_PROFILE", "") or "").strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
# Endpoint URL for fetching the latest per-caller conversation summary, used when
|
||||||
|
# both S2S_INJECT_PROFILE=true AND S2S_LATEST_SUMMARY is set. Completely
|
||||||
|
# generic: the proxy performs a plain GET S2S_LATEST_SUMMARY?phoneNumber=<x>
|
||||||
|
# and expects JSON back (optionally wrapped as {"result": <value>}). Empty
|
||||||
|
# string means the feature is off (no network call, nothing injected).
|
||||||
|
S2S_LATEST_SUMMARY = (os.environ.get("S2S_LATEST_SUMMARY", "") or "").strip()
|
||||||
|
# Static / hardcoded latest-summary text. When set (non-empty string), it takes
|
||||||
|
# PRIORITY over S2S_LATEST_SUMMARY: the proxy SKIPS the HTTP fetch entirely and
|
||||||
|
# injects this raw plain-text value directly into the prompt as the latest
|
||||||
|
# conversation summary. Designed for cases where the operator wants a clean,
|
||||||
|
# essential, pre-cleaned summary snippet without depending on an upstream API.
|
||||||
|
# Bypasses all HTTP logic (no login-by-phone bearer forwarding, no timeout, no
|
||||||
|
# JSON unwrapping) — the string is passed as-is to _resolve_latest_summary_context
|
||||||
|
# (which still applies the 1600-char cap + the LATEST_SUMMARY_HINT wrapper).
|
||||||
|
S2S_LATEST_SUMMARY_TEXT = (os.environ.get("S2S_LATEST_SUMMARY_TEXT", "") or "").strip()
|
||||||
|
if S2S_LATEST_SUMMARY_TEXT:
|
||||||
|
# Interpret \n escape sequences so multi-line summaries can be written as a
|
||||||
|
# single env line (matches S2S_PRIMARY_PROMPT convention above).
|
||||||
|
S2S_LATEST_SUMMARY_TEXT = S2S_LATEST_SUMMARY_TEXT.replace("\\n", "\n")
|
||||||
|
# Optional: login-by-phone endpoint. When set (and S2S_INJECT_PROFILE=true and
|
||||||
|
# phoneNumber is available), before fetching user profile / latest summary the proxy
|
||||||
|
# first performs a POST S2S_LOGIN_BY_PHONE with {"phoneNumber": "..."} JSON body,
|
||||||
|
# extracts an auth token from the response (if any), and forwards it as
|
||||||
|
# Authorization: Bearer <token> for subsequent profile / summary / reports HTTP calls.
|
||||||
|
S2S_LOGIN_BY_PHONE = (os.environ.get("S2S_LOGIN_BY_PHONE", "") or "").strip().rstrip("/")
|
||||||
|
# Optional: list of scheduled reports / podcasts endpoint. When set (and S2S_INJECT_PROFILE=true)
|
||||||
|
# the proxy performs a plain GET S2S_REPORT_LIST, unwraps the JSON array (or {"result": [...]}),
|
||||||
|
# filters per-item fields (sourceScheduleLabel, date, podcastScript), sorts by date DESC,
|
||||||
|
# and injects the compiled block into the LLM instructions / user_context between
|
||||||
|
# the caller profile block and the latest conversation summary block.
|
||||||
|
S2S_REPORT_LIST = (os.environ.get("S2S_REPORT_LIST", "") or "").strip().rstrip("/")
|
||||||
|
# Feature flag: when "true" (case-insensitive) => actually injects the REPORT LIST
|
||||||
|
# contents (podcastScript) into the LLM instructions / context. When "false" (DEFAULT)
|
||||||
|
# the proxy NEVER injects scheduled reports into the prompt — not even the TOC — and
|
||||||
|
# skips the upstream S2S_REPORT_LIST network call entirely, fixing the user issue:
|
||||||
|
# "All reports are currently injected with their complete podcastScript."
|
||||||
|
#
|
||||||
|
# Historical note (2026-09-11): before this flag existed, if S2S_INJECT_PROFILE=true AND
|
||||||
|
# S2S_REPORT_LIST was set the server eagerly fetched every scheduled report for the
|
||||||
|
# caller, serialized all their full podcastScript fields into a single block (capped at
|
||||||
|
# 12000 chars) and appended it to every LLM turn. That was a huge prompt-bloat injection
|
||||||
|
# the user explicitly flagged as unwanted. The safe default is therefore DISABLED: to
|
||||||
|
# restore mass-report-injection, operators must opt in with S2S_INJECT_REPORTS=true.
|
||||||
|
S2S_INJECT_REPORTS = (os.environ.get("S2S_INJECT_REPORTS", "") or "").strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
# Max number of scheduled reports to surface in the QUICK-HINT line block (the TOC of
|
||||||
|
# label + date shown before the serialized JSON entries). Also applied as a hard cap to
|
||||||
|
# the final filtered+serialized entries list — so the wrapped prompt block never carries
|
||||||
|
# content for more than N reports, even when the upstream API returns 20+. Configurable
|
||||||
|
# from .env; value is integer-parsed and clamped to [1, 50] (invalid values fall back to
|
||||||
|
# the default = 5, which is the user's requested "max 3-5" default range). User request
|
||||||
|
# 2026-09-11: "Limit report quick hints to 3-5 entries, configurable through .env."
|
||||||
|
try:
|
||||||
|
_S2S_REPORTS_QUICK_HINT_MAX_RAW = int((os.environ.get("S2S_REPORTS_QUICK_HINT_MAX", "") or "5").strip())
|
||||||
|
except Exception:
|
||||||
|
_S2S_REPORTS_QUICK_HINT_MAX_RAW = 5
|
||||||
|
S2S_REPORTS_QUICK_HINT_MAX = max(1, min(50, _S2S_REPORTS_QUICK_HINT_MAX_RAW))
|
||||||
|
del _S2S_REPORTS_QUICK_HINT_MAX_RAW
|
||||||
|
# Feature flag: when "true" → prepend a TODAY date + anti-hallucination guard to
|
||||||
|
# every LLM prompt and session instructions. Fixes user bug 3 2026-09-11:
|
||||||
|
# "Qwen2.5 has a training cutoff around mid-2024 and produces Chinese meta-comments
|
||||||
|
# about 2026 reports: 'Today is 2023; why are these reports dated 2026?'"
|
||||||
|
# Default = ON (safe even when empty/unset). Explicit S2S_INJECT_TODAY="false" to disable.
|
||||||
|
# Injected text contains:
|
||||||
|
# • The actual date: "TODAY is YYYY-MM-DD" (ISO, server local date).
|
||||||
|
# • Earlier reports are historical records; do not question their dates.
|
||||||
|
# • No internal meta-comments or prompt introspection.
|
||||||
|
# • No thinking leaks with GPTQ Int4: only words Vocero says aloud.
|
||||||
|
# • Retrieve report content through RAG rather than inventing it from summaries.
|
||||||
|
S2S_INJECT_TODAY = (os.environ.get("S2S_INJECT_TODAY", "") or "").strip().lower() not in {"0", "false", "no", "off"}
|
||||||
|
# Feature flag: S2S_REPORTS_INJECT_MODE controls what the server sends into the LLM
|
||||||
|
# prompt for scheduled reports. Values (case-insensitive, default="minimal"):
|
||||||
|
# • "minimal" → ONLY last N = S2S_REPORTS_QUICK_HINT_MAX items as a pure
|
||||||
|
# TOC (label + date ONLY). NO podcastScript is ever exposed. This
|
||||||
|
# the user's "Keep reports only as a MINIMAL summary"
|
||||||
|
# fix. Token budget: ~50 tokens instead of ~3200.
|
||||||
|
# • "full" → legacy behaviour: full JSON array with podcastScript
|
||||||
|
# (still capped to S2S_REPORTS_QUICK_HINT_MAX items). Used to be
|
||||||
|
# the default before 2026-09-11; kept only for explicit opt-in
|
||||||
|
# because of the user explicitly flagged the massive script injection
|
||||||
|
# as unwanted on this session.
|
||||||
|
# • "off" / "none" → no reports injection at all (same as S2S_INJECT_REPORTS=false)
|
||||||
|
# even when S2S_INJECT_REPORTS=true. Use this to keep the HTTP
|
||||||
|
# fetch running while keeping the prompt clean, or as a redundant kill.
|
||||||
|
S2S_REPORTS_INJECT_MODE = (os.environ.get("S2S_REPORTS_INJECT_MODE", "") or "").strip().lower()
|
||||||
|
if S2S_REPORTS_INJECT_MODE not in {"minimal", "full", "off", "none"}:
|
||||||
|
S2S_REPORTS_INJECT_MODE = "minimal"
|
||||||
|
# Feature flag: when "true" the proxy logs the FULL PROMPT TEXT (not just
|
||||||
|
# lengths) going to the upstream LLM/realtime backends. Includes:
|
||||||
|
# - WS session.update session.instructions (before/after patch)
|
||||||
|
# - /api/reasoning NODE_RED instructions + user_context full text
|
||||||
|
# - /api/reasonING OPENAI full system message + user transcript
|
||||||
|
# Default = off; only length summaries are logged (current behaviour).
|
||||||
|
# WARNING: setting this on will log potentially PII / sensitive caller
|
||||||
|
# profile data / user transcripts. Use only in development / debugging.
|
||||||
|
S2S_DEBUG_PROMPT = (os.environ.get("S2S_DEBUG_PROMPT", "") or "").strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
S2S_START_SESSION_MODE = (os.environ.get("S2S_START_SESSION_MODE", "") or "").strip().upper()
|
||||||
|
S2S_THEME = (os.environ.get("S2S_THEME", "") or "").strip().lower()
|
||||||
|
if S2S_THEME and S2S_THEME not in {"white", "dark"}:
|
||||||
|
S2S_THEME = ""
|
||||||
|
S2S_REASONING_TYPE = _normalize_reasoning_type(os.environ.get("S2S_REASONING_TYPE", "PROXY"))
|
||||||
|
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "").strip()
|
||||||
|
OPENAI_BASE_URL = os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1").strip().rstrip("/")
|
||||||
|
OPENAI_REASONING_MODEL = os.environ.get("OPENAI_REASONING_MODEL", "gpt-4.1-mini").strip()
|
||||||
|
OPENAI_ORGANIZATION = os.environ.get("OPENAI_ORGANIZATION", "").strip()
|
||||||
|
OPENAI_PROJECT = os.environ.get("OPENAI_PROJECT", "").strip()
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# APIPILOT — Backend AquaPortal dynamic-tool bridge (docs/client/tool-calling.md)
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Base URL for ApiPilot dynamic tool discovery and execution. When configured,
|
||||||
|
# the proxy sends GET <url>/tools with include_unauthorized=true (guide §2.1)
|
||||||
|
# to discover tools, and POST <url>/execute (§2.2) to invoke one. The trailing
|
||||||
|
# slash is optional; the proxy normalizes it with rstrip("/") below.
|
||||||
|
#
|
||||||
|
# Example: S2S_APIPILOT_TOOLS_URL=https://aquachile.azurianlab.com/api/v1/apipilot
|
||||||
|
S2S_APIPILOT_TOOLS_URL = os.environ.get("S2S_APIPILOT_TOOLS_URL", "").strip().rstrip("/")
|
||||||
|
# Explicit execution URL override (guide §2.2). When set, it takes precedence
|
||||||
|
# over S2S_APIPILOT_TOOLS_URL for POST execution calls. Remove trailing slashes
|
||||||
|
# and append "/execute" only if the URL does not already end with that suffix.
|
||||||
|
# Example 1 (base): S2S_APIPILOT_TOOLS_EXECUTE_URL=https://aquachile.azurianlab.com/api/v1/apipilot
|
||||||
|
# → POST https://aquachile.azurianlab.com/api/v1/apipilot/execute
|
||||||
|
# Example 2: S2S_APIPILOT_TOOLS_EXECUTE_URL=https://aquachile.azurianlab.com/api/v1/apipilot/tools
|
||||||
|
# → POST https://aquachile.azurianlab.com/api/v1/apipilot/tools/execute
|
||||||
|
S2S_APIPILOT_TOOLS_EXECUTE_URL = os.environ.get("S2S_APIPILOT_TOOLS_EXECUTE_URL", "").strip().rstrip("/")
|
||||||
|
# Optional fixed bearer token, used only when the caller supplies no session
|
||||||
|
# token (Authorization / x-demo-session). Useful for staging service tokens.
|
||||||
|
S2S_APIPILOT_SERVICE_TOKEN = os.environ.get("S2S_APIPILOT_SERVICE_TOKEN", "").strip()
|
||||||
|
# 2026-09-25 SUPER API KEY (TEMPORARY AQUAPORTAL AUTHENTICATION BYPASS).
|
||||||
|
# When set, send all three header forms for maximum compatibility:
|
||||||
|
# 1) X-API-Key: <key>
|
||||||
|
# 2) Authorization: Bearer <key>
|
||||||
|
# 3) x-demo-session: <key>
|
||||||
|
# Highest priority; completely disables the unnecessary login_by_phone call.
|
||||||
|
# Clear the value to restore standard login.
|
||||||
|
S2S_APIPILOT_API_KEY = os.environ.get("S2S_APIPILOT_API_KEY", "").strip().strip("\"'")
|
||||||
|
# Tool discovery cache TTL in seconds. The guide recommends a five-minute
|
||||||
|
# client cache; the server uses the same default: 300 seconds.
|
||||||
|
try:
|
||||||
|
_APIPILOT_TTL_RAW = int((os.environ.get("S2S_APIPILOT_TTL_SEC", "") or "300").strip())
|
||||||
|
except Exception:
|
||||||
|
_APIPILOT_TTL_RAW = 300
|
||||||
|
APIPILOT_CACHE_TTL_SEC = max(15, min(3600, _APIPILOT_TTL_RAW))
|
||||||
|
del _APIPILOT_TTL_RAW
|
||||||
|
# HTTP timeout for ApiPilot discovery and execution calls, in seconds.
|
||||||
|
try:
|
||||||
|
_APIPILOT_TO_RAW = float((os.environ.get("S2S_APIPILOT_TIMEOUT_SEC", "") or "15.0").strip())
|
||||||
|
except Exception:
|
||||||
|
_APIPILOT_TO_RAW = 15.0
|
||||||
|
APIPILOT_HTTP_TIMEOUT = max(1.0, min(120.0, _APIPILOT_TO_RAW))
|
||||||
|
del _APIPILOT_TO_RAW
|
||||||
|
|
||||||
|
|
||||||
|
# Node-RED reasoning engine endpoint (NODE_RED mode).
|
||||||
|
# Examples: http://localhost:1881 or http://s2s-reasoning:1880 (Docker network).
|
||||||
|
NODE_RED_REASONING_URL = os.environ.get("S2S_REASONING_NODE_RED_URL", "").strip().rstrip("/")
|
||||||
|
# Speech-to-speech load balancer URL. When set, the browser POSTs /api/session
|
||||||
|
# (which proxies <lb>/session here, server-side) and connects to the URL the LB
|
||||||
|
# returns (the original flow). The LB address itself is never sent to the browser.
|
||||||
|
# When empty, the user may instead set a direct s2s server URL in Settings and the
|
||||||
|
# browser connects to it straight (no load balancer).
|
||||||
|
LOAD_BALANCER_URL = os.environ.get("LOAD_BALANCER_URL", "").strip()
|
||||||
|
# Direct s2s server URL pinned by the deploy. Takes priority over the load
|
||||||
|
# balancer: when set, LB allocation and queuing are disabled, and the browser
|
||||||
|
# connects to this URL directly. Unlike the LB address it is NOT a secret —
|
||||||
|
# /api/config sends it to the client, which
|
||||||
|
# shows it read-only in Settings.
|
||||||
|
SPEECH_TO_SPEECH_URL = os.environ.get("SPEECH_TO_SPEECH_URL", "").strip()
|
||||||
|
# Optional same-origin WebSocket relay target. When set, the browser dials this
|
||||||
|
# app over ws(s)://<this-app>/v1/realtime and the server relays frames to the
|
||||||
|
# upstream speech-to-speech endpoint, keeping the internal host private.
|
||||||
|
S2S_PROXY_TARGET = os.environ.get("S2S_PROXY_TARGET", "").strip()
|
||||||
|
if SPEECH_TO_SPEECH_URL or S2S_PROXY_TARGET:
|
||||||
|
LOAD_BALANCER_URL = ""
|
||||||
|
SERPER_URL = "https://google.serper.dev/search"
|
||||||
|
# Cap results so the tool output stays small enough to feed back to the model.
|
||||||
|
MAX_RESULTS = 5
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from s2s_client.core import config as settings
|
||||||
|
from s2s_client.core import logging as app_logging
|
||||||
|
|
||||||
|
|
||||||
|
# Resolve the execution URL for the startup banner using the same normalization
|
||||||
|
# as _apipilot_resolve_execute_url.
|
||||||
|
def _resolve_exec_url_for_startup_banner() -> str:
|
||||||
|
base = (settings.S2S_APIPILOT_TOOLS_EXECUTE_URL or settings.S2S_APIPILOT_TOOLS_URL).strip().rstrip("/")
|
||||||
|
if not base:
|
||||||
|
return ""
|
||||||
|
if base.lower().endswith("/execute"):
|
||||||
|
return base
|
||||||
|
return f"{base}/execute"
|
||||||
|
|
||||||
|
|
||||||
|
_STARTUP_APIPILOT_EXEC_URL = _resolve_exec_url_for_startup_banner()
|
||||||
|
del _resolve_exec_url_for_startup_banner
|
||||||
|
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[STARTUP] Other externals — reasoning=%s nodeRed=%s serper=%s lb=%s s2sProxy=%s s2sDirect=%s userAuth=%s reportsApi=%s injectReports=%s reportsHintMax=%d reportsInjectMode=%s injectToday=%s summaryApi=%s summaryText=%s loginApi=%s injectProfile=%s debugPrompt=%s startSessionMode=%s theme=%s apipilotToolsUrl=%s apipilotExecuteUrlOverride=%s apipilotServiceToken=%s apipilotTtl=%ds apipilotTimeout=%.1fs apipilotReasoningPass2=%s apipilotApiKeyBypass=%s",
|
||||||
|
settings.S2S_REASONING_TYPE,
|
||||||
|
settings.NODE_RED_REASONING_URL or "-",
|
||||||
|
bool(settings.SERPER_KEY),
|
||||||
|
bool(settings.LOAD_BALANCER_URL),
|
||||||
|
bool(settings.S2S_PROXY_TARGET),
|
||||||
|
bool(settings.SPEECH_TO_SPEECH_URL),
|
||||||
|
bool(settings.VOCERO_USER_PHONE_AUTH_URL),
|
||||||
|
bool(settings.S2S_REPORT_LIST),
|
||||||
|
settings.S2S_INJECT_REPORTS,
|
||||||
|
settings.S2S_REPORTS_QUICK_HINT_MAX,
|
||||||
|
settings.S2S_REPORTS_INJECT_MODE,
|
||||||
|
settings.S2S_INJECT_TODAY,
|
||||||
|
bool(settings.S2S_LATEST_SUMMARY),
|
||||||
|
bool(settings.S2S_LATEST_SUMMARY_TEXT),
|
||||||
|
bool(settings.S2S_LOGIN_BY_PHONE),
|
||||||
|
settings.S2S_INJECT_PROFILE,
|
||||||
|
settings.S2S_DEBUG_PROMPT,
|
||||||
|
settings.S2S_START_SESSION_MODE or "-",
|
||||||
|
settings.S2S_THEME or "-",
|
||||||
|
settings.S2S_APIPILOT_TOOLS_URL or "-",
|
||||||
|
(settings.S2S_APIPILOT_TOOLS_EXECUTE_URL + f"(resolved:{_STARTUP_APIPILOT_EXEC_URL})")
|
||||||
|
if settings.S2S_APIPILOT_TOOLS_EXECUTE_URL
|
||||||
|
else "-",
|
||||||
|
"Y" if bool(settings.S2S_APIPILOT_SERVICE_TOKEN) else "-",
|
||||||
|
settings.APIPILOT_CACHE_TTL_SEC,
|
||||||
|
settings.APIPILOT_HTTP_TIMEOUT,
|
||||||
|
"Y" if (bool(settings.S2S_APIPILOT_TOOLS_URL) or bool(settings.S2S_APIPILOT_TOOLS_EXECUTE_URL)) else "-",
|
||||||
|
(
|
||||||
|
"YES head="
|
||||||
|
+ (
|
||||||
|
settings.S2S_APIPILOT_API_KEY[:6] + "..."
|
||||||
|
if len(settings.S2S_APIPILOT_API_KEY) > 6
|
||||||
|
else settings.S2S_APIPILOT_API_KEY
|
||||||
|
)
|
||||||
|
+ " (3 FORMS!)"
|
||||||
|
)
|
||||||
|
if settings.S2S_APIPILOT_API_KEY
|
||||||
|
else "-",
|
||||||
|
)
|
||||||
|
del _STARTUP_APIPILOT_EXEC_URL
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
logger = logging.getLogger("s2s")
|
||||||
|
_logging_configured = False
|
||||||
|
if not _logging_configured:
|
||||||
|
_handler = logging.StreamHandler()
|
||||||
|
_handler.setFormatter(
|
||||||
|
logging.Formatter(
|
||||||
|
"%(asctime)s %(levelname)-7s [%(name)s] %(message)s",
|
||||||
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
logging.basicConfig(level=logging.DEBUG, handlers=[_handler], force=True)
|
||||||
|
_lvl_env = (os.environ.get("LOG_LEVEL") or os.environ.get("S2S_LOG_LEVEL") or "INFO").strip().upper()
|
||||||
|
_is_debug = _lvl_env in {"DEBUG", "TRACE", "10"}
|
||||||
|
# Keep noisy loggers quiet unless LOG_LEVEL explicitly enables debug.
|
||||||
|
logging.getLogger("httpx").setLevel(logging.DEBUG if _is_debug else logging.WARNING)
|
||||||
|
logging.getLogger("websockets").setLevel(logging.DEBUG if _is_debug else logging.INFO)
|
||||||
|
logging.getLogger("uvicorn").setLevel(logging.DEBUG if _is_debug else logging.INFO)
|
||||||
|
logging.getLogger("uvicorn.access").setLevel(logging.DEBUG if _is_debug else logging.INFO)
|
||||||
|
logging.getLogger("httpcore").setLevel(logging.DEBUG if _is_debug else logging.WARNING)
|
||||||
|
logger.setLevel(logging.DEBUG if _is_debug else logging.DEBUG) # s2s always uses at least DEBUG in development
|
||||||
|
_logging_configured = True
|
||||||
|
logger.debug(
|
||||||
|
"LOG_LEVEL env=%s — noisy-logger levels: httpx=%s uvicorn=%s websockets=%s",
|
||||||
|
_lvl_env,
|
||||||
|
logging.getLevelName(logging.getLogger("httpx").level),
|
||||||
|
logging.getLevelName(logging.getLogger("uvicorn").level),
|
||||||
|
logging.getLevelName(logging.getLogger("websockets").level),
|
||||||
|
)
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||||
|
|
||||||
|
from fastapi import Request, WebSocket
|
||||||
|
|
||||||
|
from s2s_client.core import config as settings
|
||||||
|
|
||||||
|
|
||||||
|
def _url_with_query(url: str, **params: str) -> str:
|
||||||
|
target = urlsplit(url)
|
||||||
|
merged_query = dict(parse_qsl(target.query, keep_blank_values=True))
|
||||||
|
for key, value in params.items():
|
||||||
|
if value:
|
||||||
|
merged_query[key] = value
|
||||||
|
return urlunsplit(
|
||||||
|
(
|
||||||
|
target.scheme,
|
||||||
|
target.netloc,
|
||||||
|
target.path,
|
||||||
|
urlencode(merged_query, doseq=True),
|
||||||
|
target.fragment,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _proxy_public_url(request: Request) -> str:
|
||||||
|
if not settings.S2S_PROXY_TARGET:
|
||||||
|
return ""
|
||||||
|
forwarded_proto = (request.headers.get("x-forwarded-proto") or request.url.scheme).split(",")[0].strip()
|
||||||
|
forwarded_host = (
|
||||||
|
(request.headers.get("x-forwarded-host") or request.headers.get("host") or request.url.netloc)
|
||||||
|
.split(",")[0]
|
||||||
|
.strip()
|
||||||
|
)
|
||||||
|
ws_scheme = "wss" if forwarded_proto in {"https", "wss"} else "ws"
|
||||||
|
root_path = (request.scope.get("root_path") or "").rstrip("/")
|
||||||
|
return f"{ws_scheme}://{forwarded_host}{root_path}/v1/realtime"
|
||||||
|
|
||||||
|
|
||||||
|
def _proxy_upstream_url(ws: WebSocket) -> str:
|
||||||
|
target = urlsplit(settings.S2S_PROXY_TARGET)
|
||||||
|
merged_query = dict(parse_qsl(target.query, keep_blank_values=True))
|
||||||
|
merged_query.update(parse_qsl(ws.url.query, keep_blank_values=True))
|
||||||
|
return urlunsplit(
|
||||||
|
(
|
||||||
|
target.scheme,
|
||||||
|
target.netloc,
|
||||||
|
target.path or "/v1/realtime",
|
||||||
|
urlencode(merged_query, doseq=True),
|
||||||
|
target.fragment,
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""Assemble the client API and retain its existing documentation URLs."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
from s2s_client.api.router import router
|
||||||
|
from s2s_client.core import config, diagnostics # noqa: F401 - retain the startup banner
|
||||||
|
|
||||||
|
app = FastAPI(title="s2s-demo")
|
||||||
|
app.include_router(router)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/project", include_in_schema=False)
|
||||||
|
@app.get("/project/", include_in_schema=False)
|
||||||
|
@app.get("/project/index.html", include_in_schema=False)
|
||||||
|
@app.get("/public/docs.html", include_in_schema=False)
|
||||||
|
async def project_documentation() -> RedirectResponse:
|
||||||
|
return RedirectResponse("/docs/proposals/vocero-architecture.html", status_code=307)
|
||||||
|
|
||||||
|
|
||||||
|
# The existing FastAPI /docs page remains registered ahead of this mount.
|
||||||
|
app.mount("/docs", StaticFiles(directory=os.path.join(config.ROOT_DIR, "docs"), html=True), name="documentation")
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class SearchRequest(BaseModel):
|
||||||
|
query: str
|
||||||
|
# Optional user-supplied key (fallback when the deploy has no server key).
|
||||||
|
# Used for this request only; never stored.
|
||||||
|
key: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ReasoningRequest(BaseModel):
|
||||||
|
transcript: str
|
||||||
|
instructions: str = ""
|
||||||
|
language: str = ""
|
||||||
|
user_context: str = ""
|
||||||
|
# Optional phone number. When S2S_INJECT_PROFILE is true the proxy fetches
|
||||||
|
# the caller profile from VOCERO_USER_PHONE_AUTH_URL and:
|
||||||
|
# 1. prepends the serialized profile to user_context before feeding the
|
||||||
|
# engine (NODE_RED or OPENAI);
|
||||||
|
# 2. appends the profile context to the LLM instructions prompt.
|
||||||
|
phoneNumber: str | None = None
|
||||||
|
# Optional podcast episode ID. When set the proxy fetches the episode from
|
||||||
|
# the Aquachile AzurianLab API and injects it as the PRIMARY context into
|
||||||
|
# both user_context (prepended) and LLM instructions prompt (appended first).
|
||||||
|
episode_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ApipilotExecuteRequest(BaseModel):
|
||||||
|
"""Body for the ApiPilot execute proxy endpoint. Mirrors §2.2 request shape."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
arguments: dict = {}
|
||||||
@@ -0,0 +1,597 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from collections.abc import Callable
|
||||||
|
from time import monotonic
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import HTTPException, Request
|
||||||
|
|
||||||
|
from s2s_client.core import config as settings
|
||||||
|
from s2s_client.core import logging as app_logging
|
||||||
|
|
||||||
|
# In-process cache and asynchronous lock: one caller refreshes while others
|
||||||
|
# read the stale entry. `_APIPILOT_CACHE[auth_fingerprint] = (fetched_at_monotonic, tools_list)`.
|
||||||
|
_APIPILOT_CACHE: dict[str, tuple[float, list[dict]]] = {}
|
||||||
|
_APIPILOT_CACHE_LOCK = asyncio.Lock()
|
||||||
|
|
||||||
|
# Monotonic time helper for duration measurements unaffected by NTP drift.
|
||||||
|
_monotonic_now: Callable[[], float] = monotonic
|
||||||
|
|
||||||
|
|
||||||
|
def _apipilot_auth_fingerprint(auth_header: str | None, demo_session: str | None) -> str:
|
||||||
|
"""Return a short string used as cache key scoped to the caller identity.
|
||||||
|
|
||||||
|
We don't want to share cached tool-lists across users with different auth
|
||||||
|
tokens (requires_auth flags can be token-dependent via /tools?include_unauthorized=true
|
||||||
|
because the back-end may still attach per-role metadata)."""
|
||||||
|
parts: list[str] = []
|
||||||
|
if auth_header:
|
||||||
|
# Don't cache the raw bearer. SHA-256 truncated 12 chars = safe fingerprint.
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
parts.append("h:" + hashlib.sha256(auth_header.encode("utf-8")).hexdigest()[:12])
|
||||||
|
if demo_session:
|
||||||
|
parts.append("d:" + demo_session)
|
||||||
|
return "|".join(parts) if parts else "anon"
|
||||||
|
|
||||||
|
|
||||||
|
def _apipilot_parse_authorization(request: Request | None) -> tuple[str | None, str | None]:
|
||||||
|
"""Extract (Authorization header value, x-demo-session header value) from a FastAPI request.
|
||||||
|
|
||||||
|
Returns a tuple of two optional strings. Both are forwarded to the upstream
|
||||||
|
ApiPilot backend verbatim when non-empty (see docs/client/tool-calling.md).
|
||||||
|
"""
|
||||||
|
if request is None:
|
||||||
|
return None, None
|
||||||
|
auth = None
|
||||||
|
demo = None
|
||||||
|
try:
|
||||||
|
raw_auth = request.headers.get("authorization") or request.headers.get("Authorization")
|
||||||
|
if isinstance(raw_auth, str) and raw_auth.strip():
|
||||||
|
auth = raw_auth.strip()
|
||||||
|
raw_demo = request.headers.get("x-demo-session") or request.headers.get("X-Demo-Session")
|
||||||
|
if isinstance(raw_demo, str) and raw_demo.strip():
|
||||||
|
demo = raw_demo.strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return auth, demo
|
||||||
|
|
||||||
|
|
||||||
|
def _apipilot_resolve_execute_url() -> str:
|
||||||
|
"""Resolve the final POST URL for ApiPilot tool execution.
|
||||||
|
|
||||||
|
Priority (guide §2.2 and explicit environment override):
|
||||||
|
1. When S2S_APIPILOT_TOOLS_EXECUTE_URL is set, append "/execute" if needed.
|
||||||
|
2. Fallback S2S_APIPILOT_TOOLS_URL + "/execute".
|
||||||
|
If neither URL is configured, the caller (usually _apipilot_execute_tool)
|
||||||
|
must return an APIPILOT_NOT_CONFIGURED error envelope before calling this.
|
||||||
|
"""
|
||||||
|
base = settings.S2S_APIPILOT_TOOLS_EXECUTE_URL or settings.S2S_APIPILOT_TOOLS_URL
|
||||||
|
base = base.strip().rstrip("/")
|
||||||
|
if not base:
|
||||||
|
return ""
|
||||||
|
if base.lower().endswith("/execute"):
|
||||||
|
return base
|
||||||
|
return f"{base}/execute"
|
||||||
|
|
||||||
|
|
||||||
|
def _apipilot_strip_tool_for_llm(tool_def: dict) -> dict:
|
||||||
|
"""Return a tool definition that is safe for LLM function-calling providers.
|
||||||
|
|
||||||
|
Guide §4 forbids passing raw /tools output directly to the model because
|
||||||
|
requires_auth is outside the function-calling standard and some providers
|
||||||
|
reject it. Project the native `ToolDefinition` into the OpenAI-compatible
|
||||||
|
`{type: "function", function: {name, description, parameters}}`.
|
||||||
|
"""
|
||||||
|
if not isinstance(tool_def, dict):
|
||||||
|
return {}
|
||||||
|
name = tool_def.get("name") if isinstance(tool_def.get("name"), str) else ""
|
||||||
|
description = tool_def.get("description") if isinstance(tool_def.get("description"), str) else ""
|
||||||
|
parameters = tool_def.get("parameters")
|
||||||
|
if not isinstance(parameters, dict):
|
||||||
|
parameters = {"type": "object", "properties": {}, "required": []}
|
||||||
|
out: dict = {
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": name,
|
||||||
|
"description": description,
|
||||||
|
"parameters": parameters,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def _apipilot_fetch_tools(
|
||||||
|
*,
|
||||||
|
auth_header: str | None,
|
||||||
|
demo_session: str | None,
|
||||||
|
include_unauthorized: bool = True,
|
||||||
|
force_refresh: bool = False,
|
||||||
|
refresh: bool | None = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Fetch ApiPilot tool definitions with a short in-process TTL cache.
|
||||||
|
|
||||||
|
Implements §2.1 (GET /tools?include_unauthorized=true). The cache key is
|
||||||
|
scoped by caller auth fingerprint (see `_apipilot_auth_fingerprint`) so
|
||||||
|
per-token variations in `requires_auth` or list length don't leak between
|
||||||
|
identities.
|
||||||
|
|
||||||
|
Returns a list of tool definitions (parsed JSON, may be empty). Raises
|
||||||
|
`HTTPException` only when `S2S_APIPILOT_TOOLS_URL` is set but the backend
|
||||||
|
replied with a non-2xx / non-JSON response. When env is empty → returns
|
||||||
|
`[]` silently (passthrough).
|
||||||
|
"""
|
||||||
|
# 2026-09-25 compatibility alias: WS connect uses `refresh`; when present,
|
||||||
|
# apply it to force_refresh.
|
||||||
|
if refresh is not None:
|
||||||
|
force_refresh = bool(refresh) or bool(force_refresh)
|
||||||
|
if not settings.S2S_APIPILOT_TOOLS_URL:
|
||||||
|
return []
|
||||||
|
import time as _time_mod
|
||||||
|
|
||||||
|
# 2026-09-25 fingerprint consistency: determine effective auth/demo values
|
||||||
|
# first (SUPER API KEY takes precedence), then calculate the cache fingerprint.
|
||||||
|
# An anonymous fingerprint with APIKEY headers would mismatch the cache and
|
||||||
|
# demo session, causing Aquachile user_type=null and a 403 response.
|
||||||
|
if settings.S2S_APIPILOT_API_KEY:
|
||||||
|
final_auth: str | None = "BYPASS-API-KEY"
|
||||||
|
_demo_used: str | None = settings.S2S_APIPILOT_API_KEY
|
||||||
|
_effective_auth_for_fp = f"Bearer {settings.S2S_APIPILOT_API_KEY}"
|
||||||
|
_effective_demo_for_fp = settings.S2S_APIPILOT_API_KEY
|
||||||
|
else:
|
||||||
|
final_auth = auth_header or (
|
||||||
|
f"Bearer {settings.S2S_APIPILOT_SERVICE_TOKEN}" if settings.S2S_APIPILOT_SERVICE_TOKEN else None
|
||||||
|
)
|
||||||
|
_demo_used = demo_session
|
||||||
|
_effective_auth_for_fp = (
|
||||||
|
final_auth if isinstance(final_auth, str) and final_auth.strip() else (auth_header or "")
|
||||||
|
)
|
||||||
|
_effective_demo_for_fp = _demo_used if isinstance(_demo_used, str) and _demo_used.strip() else ""
|
||||||
|
fp = _apipilot_auth_fingerprint(_effective_auth_for_fp, _effective_demo_for_fp)
|
||||||
|
now = _time_mod.monotonic()
|
||||||
|
cached = _APIPILOT_CACHE.get(fp)
|
||||||
|
if (not force_refresh) and cached and (now - cached[0]) < settings.APIPILOT_CACHE_TTL_SEC:
|
||||||
|
app_logging.logger.debug(
|
||||||
|
"[APIPILOT-DISCOVERY] cache HIT ttl=%.1fs fp=%s tools=%d",
|
||||||
|
settings.APIPILOT_CACHE_TTL_SEC - (now - cached[0]),
|
||||||
|
fp,
|
||||||
|
len(cached[1]),
|
||||||
|
)
|
||||||
|
return cached[1]
|
||||||
|
# Prefer to serve stale (if any) rather than block many callers.
|
||||||
|
stale_tools = cached[1] if cached else None
|
||||||
|
async with _APIPILOT_CACHE_LOCK:
|
||||||
|
# Double-check after acquiring lock (another coroutine may have refreshed).
|
||||||
|
now2 = _time_mod.monotonic()
|
||||||
|
recheck = _APIPILOT_CACHE.get(fp)
|
||||||
|
if (not force_refresh) and recheck and (now2 - recheck[0]) < settings.APIPILOT_CACHE_TTL_SEC:
|
||||||
|
return recheck[1]
|
||||||
|
url = f"{settings.S2S_APIPILOT_TOOLS_URL}/tools"
|
||||||
|
params = {"include_unauthorized": "true" if include_unauthorized else "false"}
|
||||||
|
headers = {"accept": "application/json"}
|
||||||
|
# 2026-09-25 SUPER API KEY overrides other authentication and sends all
|
||||||
|
# three header forms simultaneously for maximum compatibility.
|
||||||
|
if settings.S2S_APIPILOT_API_KEY:
|
||||||
|
headers["X-API-Key"] = settings.S2S_APIPILOT_API_KEY
|
||||||
|
headers["Authorization"] = f"Bearer {settings.S2S_APIPILOT_API_KEY}"
|
||||||
|
headers["x-demo-session"] = settings.S2S_APIPILOT_API_KEY
|
||||||
|
else:
|
||||||
|
if final_auth:
|
||||||
|
headers["Authorization"] = final_auth
|
||||||
|
if _demo_used:
|
||||||
|
headers["x-demo-session"] = _demo_used
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[APIPILOT-DISCOVERY] → GET %s?include_unauthorized=%s fp=%s auth=%s demo=%s",
|
||||||
|
url,
|
||||||
|
params["include_unauthorized"],
|
||||||
|
fp,
|
||||||
|
("APIKEY" if settings.S2S_APIPILOT_API_KEY else ("Y" if final_auth else "-")),
|
||||||
|
("APIKEY" if settings.S2S_APIPILOT_API_KEY else ("Y" if _demo_used else "-")),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=settings.APIPILOT_HTTP_TIMEOUT, follow_redirects=True) as http:
|
||||||
|
resp = await http.get(url, params=params, headers=headers)
|
||||||
|
except Exception as exc:
|
||||||
|
app_logging.logger.error("[APIPILOT-DISCOVERY] ✗ network error: %s", exc)
|
||||||
|
if stale_tools is not None:
|
||||||
|
app_logging.logger.warning(
|
||||||
|
"[APIPILOT-DISCOVERY] using STALE cached fallback tools=%d", len(stale_tools)
|
||||||
|
)
|
||||||
|
return stale_tools
|
||||||
|
raise HTTPException( # noqa: B904
|
||||||
|
status_code=502,
|
||||||
|
detail={
|
||||||
|
"code": "APIPILOT_NETWORK_ERROR",
|
||||||
|
"message": f"Error al contactar ApiPilot tools backend: {exc}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if resp.status_code >= 300:
|
||||||
|
app_logging.logger.error(
|
||||||
|
"[APIPILOT-DISCOVERY] ✗ backend HTTP %s body=%s", resp.status_code, resp.text[:400]
|
||||||
|
)
|
||||||
|
if stale_tools is not None:
|
||||||
|
app_logging.logger.warning(
|
||||||
|
"[APIPILOT-DISCOVERY] using STALE cached fallback tools=%d", len(stale_tools)
|
||||||
|
)
|
||||||
|
return stale_tools
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=502,
|
||||||
|
detail={
|
||||||
|
"code": "APIPILOT_BAD_UPSTREAM",
|
||||||
|
"message": f"Backend ApiPilot tools devolvió HTTP {resp.status_code}",
|
||||||
|
"status_code": resp.status_code,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
payload = resp.json()
|
||||||
|
except Exception:
|
||||||
|
app_logging.logger.error("[APIPILOT-DISCOVERY] ✗ non-JSON response: %s", resp.text[:500])
|
||||||
|
if stale_tools is not None:
|
||||||
|
return stale_tools
|
||||||
|
raise HTTPException( # noqa: B904
|
||||||
|
status_code=502,
|
||||||
|
detail={"code": "APIPILOT_BAD_JSON", "message": "La respuesta de ApiPilot tools no es JSON válido."},
|
||||||
|
)
|
||||||
|
# Backend might return: list OR {"result": [...]} OR {"tools": [...]} OR
|
||||||
|
# {"data": {"tools": [...]}}. Unwrap generic wrapper-style payloads.
|
||||||
|
tools: list[dict] = []
|
||||||
|
if isinstance(payload, list):
|
||||||
|
tools = [t for t in payload if isinstance(t, dict)]
|
||||||
|
elif isinstance(payload, dict):
|
||||||
|
for key in ("tools", "result", "data"):
|
||||||
|
cand = payload.get(key)
|
||||||
|
if isinstance(cand, list):
|
||||||
|
tools = [t for t in cand if isinstance(t, dict)]
|
||||||
|
break
|
||||||
|
if isinstance(cand, dict):
|
||||||
|
sub = cand.get("tools") if isinstance(cand.get("tools"), list) else cand.get("result")
|
||||||
|
if isinstance(sub, list):
|
||||||
|
tools = [t for t in sub if isinstance(t, dict)]
|
||||||
|
break
|
||||||
|
# Validate minimal ToolDefinition contract §2.1: name + description + parameters.
|
||||||
|
sanitized: list[dict] = []
|
||||||
|
for raw in tools:
|
||||||
|
name = raw.get("name")
|
||||||
|
if not isinstance(name, str) or not name.strip():
|
||||||
|
continue
|
||||||
|
description = raw.get("description") if isinstance(raw.get("description"), str) else ""
|
||||||
|
parameters = raw.get("parameters")
|
||||||
|
if not isinstance(parameters, dict):
|
||||||
|
parameters = {"type": "object", "properties": {}, "required": []}
|
||||||
|
requires_auth = raw.get("requires_auth", False)
|
||||||
|
if not isinstance(requires_auth, bool):
|
||||||
|
# Some backends return 0/1; normalize to bool as required.
|
||||||
|
try:
|
||||||
|
requires_auth = bool(int(requires_auth))
|
||||||
|
except Exception:
|
||||||
|
requires_auth = False
|
||||||
|
tool_def = {
|
||||||
|
"name": name.strip(),
|
||||||
|
"description": description,
|
||||||
|
"parameters": parameters,
|
||||||
|
"requires_auth": requires_auth,
|
||||||
|
}
|
||||||
|
sanitized.append(tool_def)
|
||||||
|
_APIPILOT_CACHE[fp] = (now2, sanitized)
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[APIPILOT-DISCOVERY] ← OK tools=%s fp=%s status=%s upstreamFmt=%s cachedTools=%d",
|
||||||
|
len(sanitized),
|
||||||
|
fp,
|
||||||
|
resp.status_code,
|
||||||
|
"list" if isinstance(payload, list) else "object",
|
||||||
|
len(sanitized),
|
||||||
|
)
|
||||||
|
if settings.S2S_DEBUG_PROMPT and sanitized:
|
||||||
|
app_logging.logger.debug(
|
||||||
|
"[APIPILOT-DISCOVERY] raw first tool preview: %s",
|
||||||
|
json.dumps(sanitized[0], ensure_ascii=False)[:400],
|
||||||
|
)
|
||||||
|
return sanitized
|
||||||
|
|
||||||
|
|
||||||
|
def _apipilot_validate_arguments(arguments: dict | None, tool_def: dict) -> tuple[dict, list[str]]:
|
||||||
|
"""Validate / execute arguments strictly against the tool's JSON-Schema parameters.
|
||||||
|
|
||||||
|
Implements §2.2 PRE-CHECK #3:
|
||||||
|
• Remove unknown keys only when parameters.properties is nonempty.
|
||||||
|
• AquaPortal tool.get_user_briefing has parameters.properties={} and
|
||||||
|
required=[]: no explicit schema, so arguments remain flexible. The
|
||||||
|
backend resolves phone from the token; preserve supplied arguments.
|
||||||
|
• Validate required parameters and return errors for missing values.
|
||||||
|
• Never invent parameters or add keys.
|
||||||
|
|
||||||
|
Returns `(filtered_args, list_of_validation_errors_in_spanish)`.
|
||||||
|
If the returned errors list is non-empty, MUST block the execution (see
|
||||||
|
the `_apipilot_execute_tool` caller).
|
||||||
|
"""
|
||||||
|
errors: list[str] = []
|
||||||
|
schema = cast(dict[str, Any], tool_def.get("parameters")) if isinstance(tool_def.get("parameters"), dict) else {}
|
||||||
|
properties = cast(dict[str, Any], schema.get("properties")) if isinstance(schema.get("properties"), dict) else {}
|
||||||
|
required = cast(list[Any], schema.get("required")) if isinstance(schema.get("required"), list) else []
|
||||||
|
required = [x for x in required if isinstance(x, str)]
|
||||||
|
args = arguments if isinstance(arguments, dict) else {}
|
||||||
|
|
||||||
|
# 2026-09-25 empty-schema flexibility: preserve all arguments when no
|
||||||
|
# properties are explicitly declared, as with AquaPortal get_user_briefing.
|
||||||
|
schema_is_open: bool = (not isinstance(properties, dict)) or len(properties) == 0
|
||||||
|
requires_auth_flag = bool(tool_def.get("requires_auth"))
|
||||||
|
# 2026-09-25 auth-only tools: requires_auth=true, empty properties and
|
||||||
|
# required=[], with a valid-session description, resolve user data from
|
||||||
|
# the bearer JWT. Send arguments={} to avoid conflicts between a phone
|
||||||
|
# argument and the token's user, which would produce a 403 response.
|
||||||
|
auth_only_tool = False
|
||||||
|
if schema_is_open and requires_auth_flag and len(required) == 0:
|
||||||
|
_d = tool_def.get("description") if isinstance(tool_def.get("description"), str) else ""
|
||||||
|
if (not _d) or "valid session token" in _d.lower() or "authenticated" in _d.lower():
|
||||||
|
auth_only_tool = True
|
||||||
|
if auth_only_tool:
|
||||||
|
cleaned = {}
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[APIPILOT-VALIDATE] tool=%s requires only an authenticated session → arguments forced to {} (avoid phone-argument/user-token conflicts)",
|
||||||
|
tool_def.get("name") or "?",
|
||||||
|
)
|
||||||
|
elif schema_is_open:
|
||||||
|
cleaned = dict(args)
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[APIPILOT-VALIDATE] tool=%s open schema (empty parameters.properties) → preserve all arguments N=%d keys=%s",
|
||||||
|
tool_def.get("name") or "?",
|
||||||
|
len(cleaned),
|
||||||
|
sorted(list(cleaned.keys())),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cleaned = {}
|
||||||
|
for key in args.keys():
|
||||||
|
if key in properties:
|
||||||
|
cleaned[key] = args[key]
|
||||||
|
else:
|
||||||
|
app_logging.logger.warning(
|
||||||
|
"[APIPILOT-VALIDATE] tool=%s removing unknown arg=%s",
|
||||||
|
tool_def.get("name") or "?",
|
||||||
|
key,
|
||||||
|
)
|
||||||
|
# Check required fields only for a closed schema; open required=[] is vacuous.
|
||||||
|
if not schema_is_open:
|
||||||
|
for req in required:
|
||||||
|
if req not in cleaned:
|
||||||
|
errors.append(f"Falta el parámetro requerido: {req}")
|
||||||
|
return cleaned, errors
|
||||||
|
|
||||||
|
|
||||||
|
async def _apipilot_execute_tool(
|
||||||
|
*,
|
||||||
|
auth_header: str | None,
|
||||||
|
demo_session: str | None,
|
||||||
|
name: str,
|
||||||
|
arguments: dict | None,
|
||||||
|
) -> dict:
|
||||||
|
"""Run a single ApiPilot tool via POST /execute.
|
||||||
|
|
||||||
|
Implements the full PRE-CHECK flow (§2.2) BEFORE sending the HTTP
|
||||||
|
request:
|
||||||
|
1. Fresh discovery (TTL cache; force_refresh=True if a tool is missing).
|
||||||
|
2. requires_auth: return HTTP 401 when authentication is required but absent.
|
||||||
|
3. Strict JSON-Schema validation: filter unknown parameters and check required.
|
||||||
|
|
||||||
|
Always return the standard `{success, data, error}` envelope (guide §2.2).
|
||||||
|
Pre-check failures populate error.{code,message,status_code}, with success=false.
|
||||||
|
"""
|
||||||
|
name_clean = (name or "").strip() if isinstance(name, str) else ""
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[APIPILOT-CALL-NAME] USE=EXECUTE_ENTRYPOINT name=%r argsKeys=%s authHead=%.20s demo=%.20s",
|
||||||
|
name_clean,
|
||||||
|
sorted(list((arguments or {}).keys())) if isinstance(arguments, dict) else [],
|
||||||
|
str(auth_header or "")[:20],
|
||||||
|
str(demo_session or "")[:20],
|
||||||
|
)
|
||||||
|
if not settings.S2S_APIPILOT_TOOLS_URL:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"data": None,
|
||||||
|
"error": {
|
||||||
|
"code": "APIPILOT_NOT_CONFIGURED",
|
||||||
|
"message": "ApiPilot no está configurado en este despliegue (S2S_APIPILOT_TOOLS_URL no establecido).",
|
||||||
|
"status_code": 501,
|
||||||
|
"raw": None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if not name_clean:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"data": None,
|
||||||
|
"error": {
|
||||||
|
"code": "TOOL_NAME_REQUIRED",
|
||||||
|
"message": "Falta el campo obligatorio: name (nombre de la herramienta).",
|
||||||
|
"status_code": 400,
|
||||||
|
"raw": None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
tools = await _apipilot_fetch_tools(auth_header=auth_header, demo_session=demo_session, force_refresh=False)
|
||||||
|
tool_def = next((t for t in tools if t.get("name") == name_clean), None)
|
||||||
|
if tool_def is None:
|
||||||
|
# Unknown tool: refresh discovery and retry once.
|
||||||
|
app_logging.logger.info("[APIPILOT-EXEC] tool=%s not found → forced re-discovery", name_clean)
|
||||||
|
tools = await _apipilot_fetch_tools(auth_header=auth_header, demo_session=demo_session, force_refresh=True)
|
||||||
|
tool_def = next((t for t in tools if t.get("name") == name_clean), None)
|
||||||
|
if tool_def is None:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"data": None,
|
||||||
|
"error": {
|
||||||
|
"code": "TOOL_NOT_FOUND",
|
||||||
|
"message": (
|
||||||
|
f"Herramienta «{name_clean}» no encontrada en el catálogo ApiPilot. "
|
||||||
|
"Actualice la lista de herramientas y vuelva a intentarlo."
|
||||||
|
),
|
||||||
|
"status_code": 404,
|
||||||
|
"raw": None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
# ── PRE-CHECK 2: requires_auth ────────────────────────────────────────────
|
||||||
|
requires_auth = bool(tool_def.get("requires_auth"))
|
||||||
|
has_token = bool(auth_header) or bool(demo_session) or bool(settings.S2S_APIPILOT_SERVICE_TOKEN)
|
||||||
|
if requires_auth and not has_token:
|
||||||
|
app_logging.logger.warning("[APIPILOT-EXEC] tool=%s requires_auth=true blocked (no token)", name_clean)
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"data": None,
|
||||||
|
"error": {
|
||||||
|
"code": "AUTH_REQUIRED",
|
||||||
|
"message": (
|
||||||
|
f"La herramienta «{name_clean}» requiere autenticación. "
|
||||||
|
"Inicie sesión o proporcione un token para continuar."
|
||||||
|
),
|
||||||
|
"status_code": 401,
|
||||||
|
"raw": None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
# Pre-check 3: strict JSON-Schema argument validation.
|
||||||
|
cleaned_args, errors = _apipilot_validate_arguments(arguments, tool_def)
|
||||||
|
if errors:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"data": None,
|
||||||
|
"error": {
|
||||||
|
"code": "INVALID_ARGUMENTS",
|
||||||
|
"message": " / ".join(errors),
|
||||||
|
"status_code": 400,
|
||||||
|
"raw": {
|
||||||
|
"removed": sorted(list(set(arguments) - set(cleaned_args))) if isinstance(arguments, dict) else []
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
# ── HTTP: POST /execute ───────────────────────────────────────────────────
|
||||||
|
url = _apipilot_resolve_execute_url()
|
||||||
|
if not url:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"data": None,
|
||||||
|
"error": {
|
||||||
|
"code": "APIPILOT_NOT_CONFIGURED",
|
||||||
|
"message": "ApiPilot no está configurado en este despliegue (S2S_APIPILOT_TOOLS_URL y S2S_APIPILOT_TOOLS_EXECUTE_URL están vacíos).",
|
||||||
|
"status_code": 501,
|
||||||
|
"raw": None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
headers = {"Content-Type": "application/json", "accept": "application/json,text/plain,*/*"}
|
||||||
|
# 2026-09-25 SUPER API KEY (highest priority): bypass AquaPortal authentication.
|
||||||
|
# Send all three header forms simultaneously for maximum compatibility:
|
||||||
|
# - X-API-Key: <key>
|
||||||
|
# - Authorization: Bearer <key>
|
||||||
|
# - x-demo-session: <key>
|
||||||
|
# Ignore service tokens and login_by_phone when the API KEY is set.
|
||||||
|
if settings.S2S_APIPILOT_API_KEY:
|
||||||
|
headers["X-API-Key"] = settings.S2S_APIPILOT_API_KEY
|
||||||
|
headers["Authorization"] = f"Bearer {settings.S2S_APIPILOT_API_KEY}"
|
||||||
|
headers["x-demo-session"] = settings.S2S_APIPILOT_API_KEY
|
||||||
|
final_auth: str | None = f"Bearer {settings.S2S_APIPILOT_API_KEY}"
|
||||||
|
else:
|
||||||
|
# Authentication priority: service-token override, then caller auth_header
|
||||||
|
# from phone login/discovery/pass-2 fallback, then no authentication.
|
||||||
|
if settings.S2S_APIPILOT_SERVICE_TOKEN:
|
||||||
|
_svc = settings.S2S_APIPILOT_SERVICE_TOKEN.strip()
|
||||||
|
final_auth = (f"Bearer {_svc}" if (not _svc.lower().startswith("bearer ")) else _svc) if _svc else None
|
||||||
|
else:
|
||||||
|
final_auth = auth_header if isinstance(auth_header, str) and auth_header.strip() else None
|
||||||
|
if final_auth:
|
||||||
|
headers["Authorization"] = final_auth
|
||||||
|
if demo_session:
|
||||||
|
headers["x-demo-session"] = demo_session
|
||||||
|
payload = {"name": name_clean, "arguments": cleaned_args}
|
||||||
|
removed = sorted(list(set(arguments) - set(cleaned_args))) if isinstance(arguments, dict) else []
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[APIPILOT-EXEC] → POST %s name=%s argsKeys=%s removedKeys=%s auth=%s demo=%s",
|
||||||
|
url,
|
||||||
|
name_clean,
|
||||||
|
sorted(cleaned_args.keys()),
|
||||||
|
removed,
|
||||||
|
("APIKEY" if settings.S2S_APIPILOT_API_KEY else ("Y" if final_auth else "-")),
|
||||||
|
("APIKEY" if settings.S2S_APIPILOT_API_KEY else ("Y" if demo_session else "-")),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=settings.APIPILOT_HTTP_TIMEOUT, follow_redirects=True) as http:
|
||||||
|
resp = await http.post(url, json=payload, headers=headers)
|
||||||
|
except Exception as exc:
|
||||||
|
app_logging.logger.error("[APIPILOT-EXEC] ✗ network error: %s", exc)
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"data": None,
|
||||||
|
"error": {
|
||||||
|
"code": "EXECUTION_ERROR",
|
||||||
|
"message": f"Error de red al ejecutar «{name_clean}»: {exc}",
|
||||||
|
"status_code": 502,
|
||||||
|
"raw": None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
# Parse a standard envelope; construct a matching error for non-JSON/5xx responses.
|
||||||
|
raw_text = resp.text
|
||||||
|
# Detailed diagnostics are always enabled for HTTP >= 400: request payload,
|
||||||
|
# unmasked demo session, response status and bounded raw response body.
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
try:
|
||||||
|
payload_dump = __import__("json").dumps(payload, ensure_ascii=False)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
payload_dump = str(payload)
|
||||||
|
app_logging.logger.warning(
|
||||||
|
"[APIPILOT-EXEC][DIAGNOSTIC 4xx] HTTP=%d url=%s name=%s demoSession=%.60s AUTH=%s requestPAYLOAD=%s |RAW RESPONSE BODY (max 1800 chars)=|%s|",
|
||||||
|
resp.status_code,
|
||||||
|
url,
|
||||||
|
name_clean,
|
||||||
|
(demo_session if isinstance(demo_session, str) else "?"),
|
||||||
|
("YES len=" + str(len(final_auth)) if isinstance(final_auth, str) and final_auth.strip() else "-"),
|
||||||
|
payload_dump,
|
||||||
|
(raw_text or "")[:1800],
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
parsed = resp.json()
|
||||||
|
except Exception:
|
||||||
|
app_logging.logger.error("[APIPILOT-EXEC] ✗ non-JSON body HTTP %s: %s", resp.status_code, raw_text[:500])
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"data": None,
|
||||||
|
"error": {
|
||||||
|
"code": "EXECUTION_ERROR",
|
||||||
|
"message": f"La respuesta de ejecución «{name_clean}» no es JSON válido (HTTP {resp.status_code}).",
|
||||||
|
"status_code": resp.status_code,
|
||||||
|
"raw": raw_text[:2000] if raw_text else None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
# Return standard {success,data,error} envelopes unchanged.
|
||||||
|
if isinstance(parsed, dict) and "success" in parsed and isinstance(parsed.get("success"), bool):
|
||||||
|
envelope_ok = parsed["success"] and parsed.get("error") in (None, {})
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[APIPILOT-EXEC] ← %s name=%s success=%s http=%s dataKeys=%s",
|
||||||
|
"OK" if envelope_ok else "FAIL",
|
||||||
|
name_clean,
|
||||||
|
bool(parsed.get("success")),
|
||||||
|
resp.status_code,
|
||||||
|
sorted(list(cast(dict, parsed.get("data")).keys())) if isinstance(parsed.get("data"), dict) else "-",
|
||||||
|
)
|
||||||
|
if (not envelope_ok) and isinstance(parsed.get("error"), dict):
|
||||||
|
err = parsed["error"]
|
||||||
|
app_logging.logger.warning(
|
||||||
|
"[APIPILOT-EXEC] ERROR: code=%s status=%s msg=%s",
|
||||||
|
err.get("code"),
|
||||||
|
err.get("status_code"),
|
||||||
|
(str(err.get("message"))[:200] if err.get("message") is not None else "-"),
|
||||||
|
)
|
||||||
|
return parsed
|
||||||
|
# Wrap raw backend data in the required envelope (guide §2.2) for stable
|
||||||
|
# downstream behavior when an upstream response omits the wrapper.
|
||||||
|
app_logging.logger.warning(
|
||||||
|
"[APIPILOT-EXEC] backend omitted standard envelope → wrapping response http=%s", resp.status_code
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"success": 200 <= resp.status_code < 300,
|
||||||
|
"data": parsed if (200 <= resp.status_code < 300) else None,
|
||||||
|
"error": None
|
||||||
|
if (200 <= resp.status_code < 300)
|
||||||
|
else {
|
||||||
|
"code": "EXECUTION_ERROR",
|
||||||
|
"message": (f"Ejecución «{name_clean}» devolvió HTTP {resp.status_code} sin envelope estándar."),
|
||||||
|
"status_code": resp.status_code,
|
||||||
|
"raw": (raw_text[:2000] if raw_text else None),
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,558 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from s2s_client.core import config as settings
|
||||||
|
from s2s_client.core import logging as app_logging
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_reports_list(phone_number: str | None = None, auth_token: str | None = None) -> list | dict | None:
|
||||||
|
"""Fetch scheduled reports list from S2S_REPORT_LIST.
|
||||||
|
|
||||||
|
Completely generic / deploy-agnostic: performs GET ${S2S_REPORT_LIST} and appends
|
||||||
|
?phoneNumber=<x> if a phone is provided. Accepts JSON (optional {"result": [...]}
|
||||||
|
wrapper) and returns the list or unwrapped list. On any error logs and returns None
|
||||||
|
(never fails the proxy / reasoning tunnel).
|
||||||
|
|
||||||
|
USER FIX 2026-09-11: remove mass injection of scheduled reports.
|
||||||
|
• If S2S_INJECT_REPORTS is FALSE (DEFAULT) we short-circuit to None at the
|
||||||
|
very top — NO HTTP call is made to S2S_REPORT_LIST at all. The upstream
|
||||||
|
endpoint is never hit, not even for a "cheap empty list"; combined with
|
||||||
|
_resolve_reports_context → "" above, this guarantees ZERO scheduled-report
|
||||||
|
contents reach any LLM prompt anywhere (WS session instructions patch,
|
||||||
|
/api/reasoning user_context, NODE_RED instructions augment, quick reports
|
||||||
|
lookup response).
|
||||||
|
• S2S_INJECT_REPORTS=true restores prior behaviour (both the HTTP fetch AND
|
||||||
|
the full injection into every prompt path).
|
||||||
|
"""
|
||||||
|
# ── MASS-INJECTION KILL SWITCH (user request) ─────────────────────────
|
||||||
|
if not settings.S2S_INJECT_REPORTS:
|
||||||
|
return None
|
||||||
|
# ───────────────────────────────────────────────────────────────────────
|
||||||
|
if not settings.S2S_REPORT_LIST:
|
||||||
|
return None
|
||||||
|
if phone_number:
|
||||||
|
sep = "&" if "?" in settings.S2S_REPORT_LIST else "?"
|
||||||
|
target_url = f"{settings.S2S_REPORT_LIST}{sep}{urlencode({'phoneNumber': phone_number})}"
|
||||||
|
else:
|
||||||
|
target_url = settings.S2S_REPORT_LIST
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[INJECT-REPORTS] fetch upstream URL=%s phoneLen=%s bearer=%s",
|
||||||
|
settings.S2S_REPORT_LIST,
|
||||||
|
len(phone_number) if phone_number else "none",
|
||||||
|
"yes" if auth_token else "no",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
headers = {}
|
||||||
|
if auth_token:
|
||||||
|
headers["Authorization"] = f"Bearer {auth_token}"
|
||||||
|
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as http:
|
||||||
|
resp = await http.get(target_url, headers=headers or None)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
app_logging.logger.warning(
|
||||||
|
"[INJECT-REPORTS] ⛔ upstream HTTP %s response=%.250s",
|
||||||
|
resp.status_code,
|
||||||
|
resp.text,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
text = (resp.text or "").strip()
|
||||||
|
if not text:
|
||||||
|
app_logging.logger.info("[INJECT-REPORTS] ⏭️ upstream returned empty body.")
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
payload = resp.json()
|
||||||
|
except Exception:
|
||||||
|
app_logging.logger.warning(
|
||||||
|
"[INJECT-REPORTS] ⛔ upstream returned non-JSON body len=%d; cannot parse list. response=%.250s",
|
||||||
|
len(text),
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
except Exception as exc:
|
||||||
|
app_logging.logger.warning(
|
||||||
|
"[INJECT-REPORTS] ⛔ fetch unreachable error=%r",
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
# Unwrap {"result": [...]} or other common list-wrapping dict keys
|
||||||
|
# (e.g. {"data": [...], "reports": [...], "items": [...], "content": [...], "value": [...]}).
|
||||||
|
unwrapped: object = payload
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
# Log full structure for debugging if enabled
|
||||||
|
if settings.S2S_DEBUG_PROMPT:
|
||||||
|
try:
|
||||||
|
keys_preview = ",".join(sorted(str(k) for k in payload.keys()))
|
||||||
|
preview = json.dumps(payload, ensure_ascii=False)
|
||||||
|
preview = preview[:4000]
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[DEBUG-REPORTS][fetch] upstream response dict keys=[%s] full PREVIEW (len=%d):\n%s\n",
|
||||||
|
keys_preview,
|
||||||
|
len(preview),
|
||||||
|
preview,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Preferred explicit "result" key first
|
||||||
|
if "result" in payload and isinstance(payload["result"], list):
|
||||||
|
unwrapped = payload["result"]
|
||||||
|
else:
|
||||||
|
# Scan all common list-wrapper keys (case-insensitive match on the key)
|
||||||
|
wrapper_keys = {
|
||||||
|
k.lower()
|
||||||
|
for k in (
|
||||||
|
"data",
|
||||||
|
"reports",
|
||||||
|
"reportList",
|
||||||
|
"report_list",
|
||||||
|
"report",
|
||||||
|
"items",
|
||||||
|
"itemList",
|
||||||
|
"item_list",
|
||||||
|
"list",
|
||||||
|
"content",
|
||||||
|
"scheduled",
|
||||||
|
"scheduledReports",
|
||||||
|
"scheduled_reports",
|
||||||
|
"podcastList",
|
||||||
|
"podcast_list",
|
||||||
|
"podcasts",
|
||||||
|
"entries",
|
||||||
|
"records",
|
||||||
|
"rows",
|
||||||
|
"pageContent",
|
||||||
|
"page_content",
|
||||||
|
"value",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
for k, v in payload.items():
|
||||||
|
if isinstance(k, str) and isinstance(v, list) and k.lower() in wrapper_keys:
|
||||||
|
unwrapped = v
|
||||||
|
break
|
||||||
|
# Fallback: if it's still a dict with only ONE list-valued key,
|
||||||
|
# assume that's the list (common pattern like {"data": [...]} with
|
||||||
|
# unusual key name we don't hardcode).
|
||||||
|
if not isinstance(unwrapped, list):
|
||||||
|
list_valued_keys = [k for k, v in payload.items() if isinstance(v, list) and len(v) > 0]
|
||||||
|
if len(list_valued_keys) == 1:
|
||||||
|
unwrapped = payload[list_valued_keys[0]]
|
||||||
|
# Validate array shape
|
||||||
|
if isinstance(unwrapped, list):
|
||||||
|
n_items = len(unwrapped)
|
||||||
|
n_dict = sum(1 for x in unwrapped if isinstance(x, dict))
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[INJECT-REPORTS] ✅ reports list loaded OK (list len=%d dictItems=%d)",
|
||||||
|
n_items,
|
||||||
|
n_dict,
|
||||||
|
)
|
||||||
|
return unwrapped
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[INJECT-REPORTS] ⏭️ upstream returned type=%s (not list); nothing to inject.",
|
||||||
|
type(unwrapped).__name__,
|
||||||
|
)
|
||||||
|
if settings.S2S_DEBUG_PROMPT and isinstance(payload, dict):
|
||||||
|
# Log final top-level keys to help diagnose wrapper shape
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[DEBUG-REPORTS][fetch] wrapper not detected — top-level keys of dict response: [%s] "
|
||||||
|
"(list values under keys: %s)",
|
||||||
|
",".join(sorted(str(k) for k in payload.keys())),
|
||||||
|
",".join(sorted(str(k) + "=list[" + str(len(v)) + "]" for k, v in payload.items() if isinstance(v, list)))
|
||||||
|
or "none",
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_login_token(login_payload: dict | None) -> str | None:
|
||||||
|
"""Given the parsed response from the S2S_LOGIN_BY_PHONE endpoint (already
|
||||||
|
{"result":…}-unwrapped), try to extract an auth / session token.
|
||||||
|
Looks under common names (case-insensitive). Returns None if nothing found.
|
||||||
|
"""
|
||||||
|
if not login_payload or not isinstance(login_payload, dict):
|
||||||
|
return None
|
||||||
|
# Unwrap one level of nesting if result is still an dict: handled by caller already.
|
||||||
|
candidates = {
|
||||||
|
c.lower()
|
||||||
|
for c in (
|
||||||
|
"token",
|
||||||
|
"jwt",
|
||||||
|
"access_token",
|
||||||
|
"accessToken",
|
||||||
|
"auth_token",
|
||||||
|
"authToken",
|
||||||
|
"session_token",
|
||||||
|
"sessionToken",
|
||||||
|
"id_token",
|
||||||
|
"idToken",
|
||||||
|
"bearer",
|
||||||
|
"bearer_token",
|
||||||
|
"bearerToken",
|
||||||
|
"authentication_token",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
for k, v in login_payload.items():
|
||||||
|
if isinstance(k, str) and k.lower() in candidates and isinstance(v, str) and v.strip():
|
||||||
|
return v.strip()
|
||||||
|
# Second pass: nested dict {"data": {"token": …}} or {"auth": {"jwt": …}}
|
||||||
|
for _k, v in login_payload.items():
|
||||||
|
if isinstance(v, dict):
|
||||||
|
nested = _extract_login_token(v)
|
||||||
|
if nested:
|
||||||
|
return nested
|
||||||
|
# Third pass: list like [{"token": ...}] (uncommon but safe)
|
||||||
|
for _k, v in login_payload.items():
|
||||||
|
if isinstance(v, list) and v:
|
||||||
|
for elem in v:
|
||||||
|
if isinstance(elem, dict):
|
||||||
|
nested = _extract_login_token(elem)
|
||||||
|
if nested:
|
||||||
|
return nested
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _login_by_phone(phone_number: str) -> tuple[str | None, dict | None]:
|
||||||
|
"""Perform login-by-phone: POST S2S_LOGIN_BY_PHONE with JSON
|
||||||
|
{"phoneNumber": phone_number}. Returns (bearer_token_or_None, response_obj_or_None).
|
||||||
|
|
||||||
|
Never fails: on any HTTP/parse error logs a warning and returns (None, None)
|
||||||
|
so subsequent profile/summary calls simply proceed without a Bearer token.
|
||||||
|
"""
|
||||||
|
if not settings.S2S_LOGIN_BY_PHONE or not phone_number:
|
||||||
|
return None, None
|
||||||
|
body = {"phoneNumber": phone_number}
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[INJECT-LOGIN] POST upstream URL=%s phoneLen=%d",
|
||||||
|
settings.S2S_LOGIN_BY_PHONE,
|
||||||
|
len(phone_number),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as http:
|
||||||
|
resp = await http.post(
|
||||||
|
settings.S2S_LOGIN_BY_PHONE,
|
||||||
|
json=body,
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
app_logging.logger.warning(
|
||||||
|
"[INJECT-LOGIN] ⛔ upstream HTTP %s phoneLen=%d response=%.250s",
|
||||||
|
resp.status_code,
|
||||||
|
len(phone_number),
|
||||||
|
resp.text,
|
||||||
|
)
|
||||||
|
return None, None
|
||||||
|
text = (resp.text or "").strip()
|
||||||
|
if not text:
|
||||||
|
app_logging.logger.info("[INJECT-LOGIN] ⏭️ upstream returned empty body; no login token available.")
|
||||||
|
return None, None
|
||||||
|
try:
|
||||||
|
payload = resp.json()
|
||||||
|
except Exception:
|
||||||
|
app_logging.logger.warning(
|
||||||
|
"[INJECT-LOGIN] ⛔ upstream returned non-JSON body len=%d; cannot extract token. response=%.250s",
|
||||||
|
len(text),
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
return None, None
|
||||||
|
except Exception as exc:
|
||||||
|
app_logging.logger.warning(
|
||||||
|
"[INJECT-LOGIN] ⛔ fetch unreachable phoneLen=%d error=%r",
|
||||||
|
len(phone_number),
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
# Unwrap {"result": …} OR {"data": …} if present (common wrapper conventions).
|
||||||
|
unwrapped: dict | None = None
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
for _wrap_key in ("result", "data", "payload"):
|
||||||
|
if isinstance(payload.get(_wrap_key), dict):
|
||||||
|
unwrapped = payload[_wrap_key]
|
||||||
|
break
|
||||||
|
if unwrapped is None:
|
||||||
|
unwrapped = payload
|
||||||
|
token = (
|
||||||
|
_extract_login_token(unwrapped)
|
||||||
|
if isinstance(unwrapped, dict)
|
||||||
|
else _extract_login_token(payload if isinstance(payload, dict) else None)
|
||||||
|
)
|
||||||
|
# Always log login-token diagnostics, even without S2S_DEBUG_PROMPT:
|
||||||
|
# token length, first eight characters, response keys and wrapper layer.
|
||||||
|
_all_keys: list[str] = []
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
_all_keys.extend(str(k) for k in payload.keys())
|
||||||
|
_unwrap_keys: list[str] = []
|
||||||
|
if isinstance(unwrapped, dict):
|
||||||
|
_unwrap_keys.extend(str(k) for k in unwrapped.keys())
|
||||||
|
if isinstance(unwrapped, dict):
|
||||||
|
_t_preview = ""
|
||||||
|
_t_len = 0
|
||||||
|
if isinstance(token, str) and token.strip():
|
||||||
|
_t_len = len(token.strip())
|
||||||
|
if _t_len > 8:
|
||||||
|
_t_preview = token.strip()[:8] + "..."
|
||||||
|
else:
|
||||||
|
_t_preview = token.strip()
|
||||||
|
_fmt_note = ""
|
||||||
|
if _t_len >= 120:
|
||||||
|
_fmt_note = " [JWT-like]"
|
||||||
|
elif _t_len > 0:
|
||||||
|
_fmt_note = " [SHORT]"
|
||||||
|
_wrap_layer = "ROOT"
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
for _wk in ("result", "data", "payload"):
|
||||||
|
if isinstance(payload.get(_wk), dict):
|
||||||
|
_wrap_layer = "result/data/payload"
|
||||||
|
break
|
||||||
|
_all_keys_s = ",".join(sorted(_all_keys)) if _all_keys else "-"
|
||||||
|
_unwrap_keys_s = ",".join(sorted(_unwrap_keys)) if _unwrap_keys else "-"
|
||||||
|
_t_info = "MISSING"
|
||||||
|
if _t_len:
|
||||||
|
_t_info = "YES len=" + str(_t_len) + " head=" + _t_preview
|
||||||
|
msg = f"[INJECT-LOGIN] ✅ login OK: wrapLayer={_wrap_layer} topKeys={_all_keys_s} unwrappedKeys={_unwrap_keys_s} token={_t_info}{_fmt_note}"
|
||||||
|
app_logging.logger.info("%s", msg)
|
||||||
|
return token, unwrapped
|
||||||
|
# Fallback: non-dict payload (string/list/number) — still log it, but no token expected.
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[INJECT-LOGIN] ⏭️ login response parsed but type=%s (not dict); no token extracted.",
|
||||||
|
type(payload).__name__,
|
||||||
|
)
|
||||||
|
if settings.S2S_DEBUG_PROMPT:
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[DEBUG-LOGIN][response] FULL login payload (len=%d):\n%s\n",
|
||||||
|
len(str(payload)),
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_latest_summary_for_phone(phone_number: str, auth_token: str | None = None) -> Any:
|
||||||
|
"""Fetch the latest caller summary via S2S_LATEST_SUMMARY URL.
|
||||||
|
|
||||||
|
Generic / completely deploy-agnostic: plain GET
|
||||||
|
S2S_LATEST_SUMMARY?phoneNumber=<phone>; accepts JSON (optional {"result": …}
|
||||||
|
wrapper) or plain text. Never fails the proxy / reasoning; returns None on
|
||||||
|
any error and logs it. If `auth_token` is provided it is sent as
|
||||||
|
Authorization: Bearer <auth_token>.
|
||||||
|
|
||||||
|
OVERRIDE (2026-09-23): when S2S_LATEST_SUMMARY_TEXT is set (non-empty) it
|
||||||
|
takes absolute priority over S2S_LATEST_SUMMARY. No HTTP call is made at all;
|
||||||
|
the raw plain-text value is returned directly. Designed for operators who want
|
||||||
|
a clean, pre-curated, essential summary snippet without any upstream API
|
||||||
|
dependency. Works even without a phone_number (static snippet).
|
||||||
|
"""
|
||||||
|
if settings.S2S_LATEST_SUMMARY_TEXT:
|
||||||
|
override_len = len(settings.S2S_LATEST_SUMMARY_TEXT)
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[INJECT-SUMMARY] S2S_LATEST_SUMMARY_TEXT OVERRIDE active (len=%d chars). Skipping HTTP fetch; injecting raw static plain-text summary directly.",
|
||||||
|
override_len,
|
||||||
|
)
|
||||||
|
if settings.S2S_DEBUG_PROMPT:
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[INJECT-SUMMARY] [DEBUG-PROMPT] S2S_LATEST_SUMMARY_TEXT raw value (first 800 chars):\n%s",
|
||||||
|
settings.S2S_LATEST_SUMMARY_TEXT[:800],
|
||||||
|
)
|
||||||
|
return settings.S2S_LATEST_SUMMARY_TEXT
|
||||||
|
if not settings.S2S_LATEST_SUMMARY or not phone_number:
|
||||||
|
return None
|
||||||
|
target_url = f"{settings.S2S_LATEST_SUMMARY}?{urlencode({'phoneNumber': phone_number})}"
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[INJECT-SUMMARY] fetch upstream URL=%s phoneLen=%d bearer=%s",
|
||||||
|
settings.S2S_LATEST_SUMMARY,
|
||||||
|
len(phone_number),
|
||||||
|
"yes" if auth_token else "no",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
headers = {}
|
||||||
|
if auth_token:
|
||||||
|
headers["Authorization"] = f"Bearer {auth_token}"
|
||||||
|
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as http:
|
||||||
|
resp = await http.get(target_url, headers=headers or None)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
app_logging.logger.warning(
|
||||||
|
"[INJECT-SUMMARY] ⛔ upstream HTTP %s phoneLen=%d response=%.200s",
|
||||||
|
resp.status_code,
|
||||||
|
len(phone_number),
|
||||||
|
resp.text,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
# Accept JSON first, then fall back to plain text (some summary APIs
|
||||||
|
# return text/plain).
|
||||||
|
text = (resp.text or "").strip()
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
payload = resp.json()
|
||||||
|
except Exception:
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[INJECT-SUMMARY] upstream returned non-JSON response (len=%d); treating as plain string summary.",
|
||||||
|
len(text),
|
||||||
|
)
|
||||||
|
return text
|
||||||
|
except Exception as exc:
|
||||||
|
app_logging.logger.warning(
|
||||||
|
"[INJECT-SUMMARY] ⛔ fetch unreachable phoneLen=%d error=%r",
|
||||||
|
len(phone_number),
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
# Unwrap {"result": …} just like profile fetch (common wrapper pattern).
|
||||||
|
if isinstance(payload, dict) and "result" in payload:
|
||||||
|
payload = payload["result"]
|
||||||
|
# Filter out empty dict/list / empty string after unwrap.
|
||||||
|
empty = (
|
||||||
|
payload is None
|
||||||
|
or (isinstance(payload, str) and not payload.strip())
|
||||||
|
or (isinstance(payload, dict) and len(payload) == 0)
|
||||||
|
or (isinstance(payload, list) and len(payload) == 0)
|
||||||
|
)
|
||||||
|
if empty:
|
||||||
|
app_logging.logger.info("[INJECT-SUMMARY] ⏭️ upstream returned empty summary; nothing to inject.")
|
||||||
|
return None
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[INJECT-SUMMARY] ✅ summary loaded OK (object) keys=%s",
|
||||||
|
",".join(sorted(str(k) for k in payload.keys())),
|
||||||
|
)
|
||||||
|
elif isinstance(payload, list):
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[INJECT-SUMMARY] ✅ summary loaded OK (list) len=%d",
|
||||||
|
len(payload),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[INJECT-SUMMARY] ✅ summary loaded OK (string) len=%d",
|
||||||
|
len(str(payload)),
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_podcast_episode(episode_id: str) -> Any:
|
||||||
|
"""Fetch a single podcast episode by ID from the Aquachile AzurianLab API.
|
||||||
|
|
||||||
|
Completely generic / deploy-agnostic: plain GET
|
||||||
|
https://aquachile.azurianlab.com/api/v1/podcast/episodes/<episode_id>
|
||||||
|
Accepts JSON (optional {"result": …} wrapper) or plain text. Never fails
|
||||||
|
the proxy / reasoning; returns None on any error and logs it.
|
||||||
|
"""
|
||||||
|
if not episode_id:
|
||||||
|
return None
|
||||||
|
target_url = f"https://aquachile.azurianlab.com/api/v1/podcast/episodes/{episode_id}"
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[INJECT-EPISODE] fetch upstream URL=%s episodeId=%.80s",
|
||||||
|
target_url,
|
||||||
|
episode_id,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as http:
|
||||||
|
resp = await http.get(target_url)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
app_logging.logger.warning(
|
||||||
|
"[INJECT-EPISODE] ⛔ upstream HTTP %s episodeId=%.80s response=%.200s",
|
||||||
|
resp.status_code,
|
||||||
|
episode_id,
|
||||||
|
resp.text,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
text = (resp.text or "").strip()
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
payload = resp.json()
|
||||||
|
except Exception:
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[INJECT-EPISODE] upstream returned non-JSON response (len=%d); treating as plain string.",
|
||||||
|
len(text),
|
||||||
|
)
|
||||||
|
return text
|
||||||
|
except Exception as exc:
|
||||||
|
app_logging.logger.warning(
|
||||||
|
"[INJECT-EPISODE] ⛔ fetch unreachable episodeId=%.80s error=%r",
|
||||||
|
episode_id,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
if isinstance(payload, dict) and "result" in payload:
|
||||||
|
payload = payload["result"]
|
||||||
|
empty = (
|
||||||
|
payload is None
|
||||||
|
or (isinstance(payload, str) and not payload.strip())
|
||||||
|
or (isinstance(payload, dict) and len(payload) == 0)
|
||||||
|
or (isinstance(payload, list) and len(payload) == 0)
|
||||||
|
)
|
||||||
|
if empty:
|
||||||
|
app_logging.logger.info("[INJECT-EPISODE] ⏭️ upstream returned empty episode; nothing to inject.")
|
||||||
|
return None
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[INJECT-EPISODE] ✅ episode loaded OK (object) keys=%s",
|
||||||
|
",".join(sorted(str(k) for k in payload.keys())),
|
||||||
|
)
|
||||||
|
elif isinstance(payload, list):
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[INJECT-EPISODE] ✅ episode loaded OK (list) len=%d",
|
||||||
|
len(payload),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[INJECT-EPISODE] ✅ episode loaded OK (string) len=%d",
|
||||||
|
len(str(payload)),
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_user_profile_for_phone(phone_number: str, auth_token: str | None = None) -> dict | None:
|
||||||
|
"""Fetch the profile JSON from VOCERO_USER_PHONE_AUTH_URL for a phone.
|
||||||
|
Returns the raw {"result": ...}-unwrapped object or None. Errors are logged
|
||||||
|
then suppressed (never fail the proxy / reasoning because of this).
|
||||||
|
|
||||||
|
When `auth_token` is provided it is forwarded as Authorization: Bearer <auth_token>
|
||||||
|
to the upstream profile endpoint.
|
||||||
|
"""
|
||||||
|
if not settings.VOCERO_USER_PHONE_AUTH_URL or not phone_number:
|
||||||
|
return None
|
||||||
|
target_url = f"{settings.VOCERO_USER_PHONE_AUTH_URL}?{urlencode({'phoneNumber': phone_number})}"
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[INJECT-PROFILE] fetch upstream URL=%s phoneLen=%d bearer=%s",
|
||||||
|
settings.VOCERO_USER_PHONE_AUTH_URL,
|
||||||
|
len(phone_number),
|
||||||
|
"yes" if auth_token else "no",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
headers = {}
|
||||||
|
if auth_token:
|
||||||
|
headers["Authorization"] = f"Bearer {auth_token}"
|
||||||
|
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as http:
|
||||||
|
resp = await http.get(target_url, headers=headers or None)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[INJECT-PROFILE] ⛔ upstream HTTP %s phoneLen=%d response=%.200s",
|
||||||
|
resp.status_code,
|
||||||
|
len(phone_number),
|
||||||
|
resp.text,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
payload = resp.json()
|
||||||
|
except Exception as exc:
|
||||||
|
app_logging.logger.warning(
|
||||||
|
"[INJECT-PROFILE] ⛔ fetch unreachable phoneLen=%d error=%r",
|
||||||
|
len(phone_number),
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
if isinstance(payload, dict) and "result" in payload and isinstance(payload["result"], dict):
|
||||||
|
profile_obj = payload["result"]
|
||||||
|
elif isinstance(payload, dict) and not isinstance(payload, list):
|
||||||
|
profile_obj = payload
|
||||||
|
else:
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[INJECT-PROFILE] ⛔ upstream returned non-object JSON type=%s keys=?",
|
||||||
|
type(payload).__name__,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[INJECT-PROFILE] ✅ profile loaded OK keys=%s",
|
||||||
|
",".join(sorted(profile_obj.keys())),
|
||||||
|
)
|
||||||
|
return profile_obj
|
||||||
@@ -0,0 +1,461 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
from s2s_client.core import logging as app_logging
|
||||||
|
from s2s_client.services import context as services_context
|
||||||
|
from s2s_client.services import tool_context as services_tool_context
|
||||||
|
|
||||||
|
|
||||||
|
async def _report_server_debug(hypothesis_id: str, location: str, msg: str, data: dict | None = None) -> None:
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def _realtime_event_summary(raw: str) -> dict | None:
|
||||||
|
try:
|
||||||
|
payload = json.loads(raw)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return None
|
||||||
|
event_type = payload.get("type")
|
||||||
|
if not isinstance(event_type, str):
|
||||||
|
return None
|
||||||
|
summary: dict[str, Any] = {"type": event_type}
|
||||||
|
if event_type == "session.update":
|
||||||
|
session = cast(dict[str, Any], payload.get("session")) if isinstance(payload.get("session"), dict) else {}
|
||||||
|
tools = cast(list[Any], session.get("tools")) if isinstance(session.get("tools"), list) else []
|
||||||
|
summary["toolCount"] = len(tools)
|
||||||
|
summary["toolNames"] = [tool.get("name") for tool in tools if isinstance(tool, dict)]
|
||||||
|
summary["toolChoice"] = session.get("tool_choice")
|
||||||
|
summary["instructionLength"] = len(session.get("instructions") or "")
|
||||||
|
elif event_type in {"response.function_call_arguments.done", "error", "session.updated"}:
|
||||||
|
for key in ("name", "call_id", "code", "message", "response_id"):
|
||||||
|
if key in payload:
|
||||||
|
summary[key] = payload.get(key)
|
||||||
|
# --- INPUT (user speech → STT transcript) ---
|
||||||
|
# Can arrive from upstream (server-side STT) — match either direction.
|
||||||
|
elif event_type == "conversation.item.input_audio_transcription.completed":
|
||||||
|
summary["transcript"] = payload.get("transcript", "")
|
||||||
|
summary["item_id"] = payload.get("item_id")
|
||||||
|
elif event_type == "conversation.item.created":
|
||||||
|
item = payload.get("item") if isinstance(payload.get("item"), dict) else None
|
||||||
|
if isinstance(item, dict):
|
||||||
|
summary["item_id"] = item.get("id")
|
||||||
|
summary["role"] = item.get("role")
|
||||||
|
summary["item_type"] = item.get("type")
|
||||||
|
summary["status"] = item.get("status")
|
||||||
|
content_parts = []
|
||||||
|
for c in item.get("content") or []:
|
||||||
|
if isinstance(c, dict):
|
||||||
|
if c.get("type") in {"input_text", "text"} and isinstance(c.get("text"), str):
|
||||||
|
content_parts.append(c["text"])
|
||||||
|
elif c.get("type") == "transcript" and isinstance(c.get("transcript"), str):
|
||||||
|
content_parts.append(c["transcript"])
|
||||||
|
if content_parts:
|
||||||
|
summary["text"] = " ".join(content_parts)
|
||||||
|
# --- OUTPUT (agent text/speech generation) ---
|
||||||
|
elif event_type == "response.output_item.done":
|
||||||
|
item = payload.get("item") if isinstance(payload.get("item"), dict) else None
|
||||||
|
if isinstance(item, dict):
|
||||||
|
summary["item_id"] = item.get("id")
|
||||||
|
summary["role"] = item.get("role")
|
||||||
|
summary["status"] = item.get("status")
|
||||||
|
content_parts = []
|
||||||
|
for c in item.get("content") or []:
|
||||||
|
if isinstance(c, dict):
|
||||||
|
if c.get("type") == "text" and isinstance(c.get("text"), str) and c["text"]:
|
||||||
|
content_parts.append(c["text"])
|
||||||
|
elif c.get("type") == "audio" and isinstance(c.get("transcript"), str) and c["transcript"]:
|
||||||
|
content_parts.append(c["transcript"])
|
||||||
|
if content_parts:
|
||||||
|
summary["text"] = " ".join(content_parts)
|
||||||
|
elif event_type == "response.content_part.done":
|
||||||
|
part = payload.get("part") if isinstance(payload.get("part"), dict) else None
|
||||||
|
if isinstance(part, dict):
|
||||||
|
summary["response_id"] = payload.get("response_id")
|
||||||
|
summary["item_id"] = payload.get("item_id")
|
||||||
|
summary["output_index"] = payload.get("output_index")
|
||||||
|
summary["content_index"] = payload.get("content_index")
|
||||||
|
if part.get("type") == "text":
|
||||||
|
summary["text"] = part.get("text", "")
|
||||||
|
elif part.get("type") == "audio":
|
||||||
|
if isinstance(part.get("transcript"), str) and part["transcript"]:
|
||||||
|
summary["text"] = part["transcript"]
|
||||||
|
# Standard OpenAI variant:
|
||||||
|
elif event_type == "response.audio_transcript.done":
|
||||||
|
summary["response_id"] = payload.get("response_id")
|
||||||
|
summary["item_id"] = payload.get("item_id")
|
||||||
|
summary["text"] = payload.get("transcript", "")
|
||||||
|
# Custom upstream variant observed in user logs:
|
||||||
|
elif event_type == "response.output_audio_transcript.done":
|
||||||
|
summary["response_id"] = payload.get("response_id")
|
||||||
|
summary["item_id"] = payload.get("item_id")
|
||||||
|
summary["output_index"] = payload.get("output_index")
|
||||||
|
# Try field names in order of likelihood
|
||||||
|
text = ""
|
||||||
|
for key in (
|
||||||
|
"transcript",
|
||||||
|
"text",
|
||||||
|
"output_text",
|
||||||
|
"audio_text",
|
||||||
|
"output_transcript",
|
||||||
|
"final_transcript",
|
||||||
|
"output_audio_text",
|
||||||
|
"content",
|
||||||
|
"message",
|
||||||
|
"utterance",
|
||||||
|
):
|
||||||
|
v = payload.get(key)
|
||||||
|
if isinstance(v, str) and v.strip():
|
||||||
|
text = v.strip()
|
||||||
|
break
|
||||||
|
# If text is still empty, search known nested objects.
|
||||||
|
if not text:
|
||||||
|
for container in ("item", "part", "content", "output", "delta", "audio"):
|
||||||
|
sub = payload.get(container)
|
||||||
|
if isinstance(sub, dict):
|
||||||
|
for key in ("transcript", "text", "output_text"):
|
||||||
|
v = sub.get(key)
|
||||||
|
if isinstance(v, str) and v.strip():
|
||||||
|
text = v.strip()
|
||||||
|
break
|
||||||
|
if text:
|
||||||
|
break
|
||||||
|
if not text:
|
||||||
|
# Last resort: the content[] array.
|
||||||
|
arr = payload.get("content")
|
||||||
|
if isinstance(arr, list):
|
||||||
|
parts = []
|
||||||
|
for c in arr:
|
||||||
|
if isinstance(c, dict):
|
||||||
|
for key in ("transcript", "text"):
|
||||||
|
v = c.get(key)
|
||||||
|
if isinstance(v, str) and v.strip():
|
||||||
|
parts.append(v.strip())
|
||||||
|
if parts:
|
||||||
|
text = " ".join(parts)
|
||||||
|
summary["text"] = text
|
||||||
|
# If no text was found, retain a raw preview to inspect the custom format.
|
||||||
|
if not text:
|
||||||
|
preview = raw[:800]
|
||||||
|
try:
|
||||||
|
pretty = json.dumps(payload, ensure_ascii=True)[:800]
|
||||||
|
except Exception:
|
||||||
|
pretty = preview
|
||||||
|
summary["_raw_preview"] = pretty
|
||||||
|
elif event_type == "response.function_call_arguments.done":
|
||||||
|
summary["arguments"] = payload.get("arguments")
|
||||||
|
# This event confirms strategy B: ai-server accepted native tools[].
|
||||||
|
_tool_name = payload.get("name") if isinstance(payload.get("name"), str) else ""
|
||||||
|
_call_id = payload.get("call_id") if isinstance(payload.get("call_id"), str) else ""
|
||||||
|
_args_json = cast(str, payload.get("arguments")) if isinstance(payload.get("arguments"), str) else "{}"
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[TOOLCALL] ✓ UPSTREAM emitted response.function_call_arguments.done! "
|
||||||
|
"tool=%s callId=%s argsLen=%d argsPreview=%s",
|
||||||
|
_tool_name or "(empty)",
|
||||||
|
_call_id or "-",
|
||||||
|
len(_args_json),
|
||||||
|
_args_json[:200],
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
_args_parsed = json.loads(_args_json or "{}")
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[TOOLCALL] ✓ Tool call parsed argsKeys=%s",
|
||||||
|
sorted(_args_parsed.keys()) if isinstance(_args_parsed, dict) else "N/A",
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return summary
|
||||||
|
|
||||||
|
|
||||||
|
# Suppress noisy high-frequency events in detailed logs. Internal debug
|
||||||
|
# reporting still tracks them when enabled.
|
||||||
|
_NOISY_REALTIME_EVENT_TYPES = frozenset(
|
||||||
|
{
|
||||||
|
"response.output_audio.delta",
|
||||||
|
"response.audio.delta",
|
||||||
|
"conversation.item.input_audio_transcription.delta",
|
||||||
|
"response.text.delta",
|
||||||
|
"response.output_text.delta",
|
||||||
|
"rate_limits.updated",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Suppress client-side high-frequency events such as input_audio_buffer.append.
|
||||||
|
_NOISY_CLIENT_EVENT_TYPES = frozenset(
|
||||||
|
{
|
||||||
|
"input_audio_buffer.append",
|
||||||
|
"input_audio_buffer.commit",
|
||||||
|
"input_audio_buffer.clear",
|
||||||
|
"input_audio_buffer.speech_started",
|
||||||
|
"input_audio_buffer.speech_stopped",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_profile_for_session(session_ctx: dict) -> dict:
|
||||||
|
"""Return the profile dict stored in session_ctx (phone auth result)."""
|
||||||
|
p = session_ctx.get("profile_obj") or session_ctx.get("user_profile") or {}
|
||||||
|
if isinstance(p, dict) and isinstance(p.get("user"), dict):
|
||||||
|
return cast(dict[str, Any], p["user"])
|
||||||
|
if isinstance(p, dict):
|
||||||
|
return p
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_agent_output(text: Any, session_ctx: dict[str, Any] | None = None) -> str:
|
||||||
|
"""Server-side FINAL filter applied to ALL assistant output text.
|
||||||
|
|
||||||
|
Guarantees (belt-and-suspenders over the 6 prompt layers):
|
||||||
|
1. No [...][xxx] bracket markers of any kind.
|
||||||
|
2. No inline { ... } JSON / dict literals.
|
||||||
|
3. No [-]: <internal action name> pseudo-markers.
|
||||||
|
4. No name anglicization: Alessandro → restored to Alessandro.
|
||||||
|
5. Trim + collapse multiple spaces.
|
||||||
|
"""
|
||||||
|
s = str(text or "")
|
||||||
|
if not s:
|
||||||
|
return s
|
||||||
|
# (0) Remove "action line" pseudo-markers FIRST, BEFORE stripping all
|
||||||
|
# other brackets, because the pattern uses the literal "[-]" prefix
|
||||||
|
# which would get silently swallowed by step (1) otherwise. Match:
|
||||||
|
# [-]: checking_podcast_report (bracket literal then colon)
|
||||||
|
# []: action_name (empty bracket variant)
|
||||||
|
# [ - ] : do_stuff (spaces inside & around)
|
||||||
|
# Pattern is intentionally TIGHT: after the colon we only take up to 6
|
||||||
|
# underscore/hyphen words so we cannot accidentally eat the following
|
||||||
|
# real user speech (bug: greedy class with ,. and 200-char upper bound ate
|
||||||
|
# the entire "Claro, revisaré el reporte." clause on 2026-09-11).
|
||||||
|
s = re.sub(
|
||||||
|
r"\[[\s\-]{0,6}\]\s*:\s*[a-z0-9_\-]+(?:[ \t]+[a-z0-9_\-]+){0,5}",
|
||||||
|
" ",
|
||||||
|
s,
|
||||||
|
)
|
||||||
|
# Also catch dangling residual patterns if brackets got normalized to a
|
||||||
|
# single char already — handles cases where upstream writes the "colon"
|
||||||
|
# action marker without explicit brackets.
|
||||||
|
s = re.sub(r"(?<!\w):\s*checking_[A-Za-z0-9_\-]{1,120}", " ", s)
|
||||||
|
# (1) Remove ALL bracket-markers — [rightspeak], [endspeak], [thinking],
|
||||||
|
# [anything_else01]. We also remove stray [ / ] that don't form a
|
||||||
|
# complete pair (e.g. leftover "hello] there").
|
||||||
|
s = re.sub(r"\[[^\]\n]{0,80}\]", " ", s)
|
||||||
|
# (2) Remove inline dict / JSON literals. Pattern: { ... }, possibly
|
||||||
|
# split across a few \n (not greedy). Conservative: matches balanced
|
||||||
|
# pairs up to a reasonable length, otherwise fallback.
|
||||||
|
_max_iter = 5
|
||||||
|
for _ in range(_max_iter):
|
||||||
|
new_s = re.sub(r"\{[^{}]{0,1600}\}", " ", s)
|
||||||
|
if new_s == s:
|
||||||
|
break
|
||||||
|
s = new_s
|
||||||
|
# (3) Name anglicization. Iterate profile name's known variants →
|
||||||
|
# replace with original (case-insensitive match).
|
||||||
|
if session_ctx is not None and isinstance(session_ctx, dict):
|
||||||
|
profile = _extract_profile_for_session(session_ctx)
|
||||||
|
if profile:
|
||||||
|
full_name = str(profile.get("name") or "").strip()
|
||||||
|
if full_name:
|
||||||
|
# Split into parts and check each
|
||||||
|
parts = [p for p in re.split(r"[\s\-_]+", full_name) if p]
|
||||||
|
for part in parts:
|
||||||
|
variants = services_context._common_name_variants(part)
|
||||||
|
orig_cap = part[0].upper() + part[1:] if part else part
|
||||||
|
for variant in variants:
|
||||||
|
# Case-insensitive global replace → original case
|
||||||
|
s = re.sub(
|
||||||
|
r"\b" + re.escape(variant) + r"\b",
|
||||||
|
orig_cap,
|
||||||
|
s,
|
||||||
|
flags=re.IGNORECASE,
|
||||||
|
)
|
||||||
|
# Also try full-name variants (combined)
|
||||||
|
combined = full_name.replace(" ", "") # noqa: F841
|
||||||
|
# (5) Collapse whitespace. Re-punctuation cleanup: avoid normalizing
|
||||||
|
# legitimate terminal "?" / "!" into nothing; only collapse doubled
|
||||||
|
# or space-separated punctuation that resulted from earlier deletes.
|
||||||
|
s = re.sub(r"[ \t\u00a0]+", " ", s)
|
||||||
|
s = re.sub(r"\n{3,}", "\n\n", s)
|
||||||
|
# Interior punctuation: normalize spaces, but keep the char itself.
|
||||||
|
# Example: "hello , world" → "hello, world"
|
||||||
|
s = re.sub(r"\s+([,.;:])\s+", r"\1 ", s)
|
||||||
|
# Terminal / exclamation / interrogation: only fix spacing, never strip.
|
||||||
|
s = re.sub(r"\s+([!?])", r"\1", s)
|
||||||
|
s = re.sub(r"([!?])\s{2,}", r"\1 ", s)
|
||||||
|
# Strip leftover whitespace. Do NOT strip the legitimate terminal
|
||||||
|
# punctuation chars ?, !, ., because they are part of the real speech.
|
||||||
|
# Only strip repeated trailing commas / semicolons that appear because
|
||||||
|
# we deleted bracket/JSON fragments that separated clauses.
|
||||||
|
s = s.strip()
|
||||||
|
# Strip 2+ identical terminal punctuation chars (e.g. "hello.." → "hello.")
|
||||||
|
s = re.sub(r"([,.;:!?])\1{1,}$", r"\1", s)
|
||||||
|
# Strip terminal comma / semicolon / colon that now dangle (clause joins)
|
||||||
|
# but preserve ., ?, !.
|
||||||
|
s = re.sub(r"[,;:]$", "", s).strip()
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_realtime_event_raw(raw_message: str, session_ctx: dict[str, Any] | None = None) -> tuple[str, bool]:
|
||||||
|
"""If the raw upstream event contains assistant OUTPUT text, sanitize it
|
||||||
|
in-place and return (new_json_str, True) — otherwise return (raw, False).
|
||||||
|
|
||||||
|
Mirrors exactly the same traversal used by _realtime_event_summary so we
|
||||||
|
touch every field that would become summary["text"].
|
||||||
|
"""
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
if not isinstance(raw_message, str) or not raw_message.strip():
|
||||||
|
return raw_message, False
|
||||||
|
try:
|
||||||
|
payload = _json.loads(raw_message)
|
||||||
|
except Exception:
|
||||||
|
return raw_message, False
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return raw_message, False
|
||||||
|
event_type = payload.get("type")
|
||||||
|
if not isinstance(event_type, str):
|
||||||
|
return raw_message, False
|
||||||
|
|
||||||
|
changed = False
|
||||||
|
|
||||||
|
def _sanitize_value(v: Any) -> Any:
|
||||||
|
nonlocal changed
|
||||||
|
if isinstance(v, str) and v.strip():
|
||||||
|
cleaned = _sanitize_agent_output(v, session_ctx)
|
||||||
|
# Layer 8 removes internal ApiPilot CALL markers before UI/TTS.
|
||||||
|
# The pass-2 accumulator retains the original transcript so
|
||||||
|
# execution can still be triggered by its marker.
|
||||||
|
cleaned = services_tool_context._sanitize_text_strip_apipilot_markers(cleaned)
|
||||||
|
if cleaned != v:
|
||||||
|
changed = True
|
||||||
|
return cleaned
|
||||||
|
return v
|
||||||
|
|
||||||
|
# --- Event dispatch (same shape as _realtime_event_summary) -------------
|
||||||
|
# Sanitize streaming deltas as well as .done events. Qwen3-TTS reads deltas
|
||||||
|
# before generating binary audio and could otherwise pronounce the marker.
|
||||||
|
if event_type in {"response.audio_transcript.done", "response.audio_transcript.delta"}:
|
||||||
|
if "transcript" in payload:
|
||||||
|
payload["transcript"] = _sanitize_value(payload["transcript"])
|
||||||
|
if "delta" in payload and isinstance(payload.get("delta"), str):
|
||||||
|
payload["delta"] = _sanitize_value(payload["delta"])
|
||||||
|
for container in ("item", "part", "content", "output", "audio"):
|
||||||
|
sub = payload.get(container)
|
||||||
|
if isinstance(sub, dict):
|
||||||
|
for key in ("transcript", "text", "delta"):
|
||||||
|
if key in sub and isinstance(sub.get(key), str):
|
||||||
|
sub[key] = _sanitize_value(sub[key])
|
||||||
|
elif event_type in {"response.output_audio_transcript.done", "response.output_audio_transcript.delta"}:
|
||||||
|
# Top-level keys
|
||||||
|
for key in (
|
||||||
|
"transcript",
|
||||||
|
"text",
|
||||||
|
"output_text",
|
||||||
|
"audio_text",
|
||||||
|
"output_transcript",
|
||||||
|
"final_transcript",
|
||||||
|
"output_audio_text",
|
||||||
|
"content",
|
||||||
|
"message",
|
||||||
|
"utterance",
|
||||||
|
"delta",
|
||||||
|
):
|
||||||
|
if key in payload and isinstance(payload.get(key), str):
|
||||||
|
payload[key] = _sanitize_value(payload[key])
|
||||||
|
# Sub-containers
|
||||||
|
for container in ("item", "part", "content", "output", "delta", "audio"):
|
||||||
|
sub = payload.get(container)
|
||||||
|
if isinstance(sub, dict):
|
||||||
|
for key in ("transcript", "text", "output_text", "delta"):
|
||||||
|
if key in sub and isinstance(sub.get(key), str):
|
||||||
|
sub[key] = _sanitize_value(sub[key])
|
||||||
|
# content[] array
|
||||||
|
if isinstance(sub.get("content"), list):
|
||||||
|
for c in sub["content"]:
|
||||||
|
if isinstance(c, dict):
|
||||||
|
for k2 in ("transcript", "text", "delta"):
|
||||||
|
if k2 in c and isinstance(c.get(k2), str):
|
||||||
|
c[k2] = _sanitize_value(c[k2])
|
||||||
|
if isinstance(payload.get("content"), list):
|
||||||
|
for c in payload["content"]:
|
||||||
|
if isinstance(c, dict):
|
||||||
|
for k2 in ("transcript", "text", "delta"):
|
||||||
|
if k2 in c and isinstance(c.get(k2), str):
|
||||||
|
c[k2] = _sanitize_value(c[k2])
|
||||||
|
elif event_type in {
|
||||||
|
"response.text.delta",
|
||||||
|
"response.text.done",
|
||||||
|
"response.output_text.delta",
|
||||||
|
"response.output_text.done",
|
||||||
|
}:
|
||||||
|
for key in ("text", "output_text", "delta", "final_text", "content"):
|
||||||
|
if key in payload and isinstance(payload.get(key), str):
|
||||||
|
payload[key] = _sanitize_value(payload[key])
|
||||||
|
for container in ("item", "part", "output", "content", "text"):
|
||||||
|
sub = payload.get(container)
|
||||||
|
if isinstance(sub, dict):
|
||||||
|
for key in ("text", "output_text", "delta"):
|
||||||
|
if key in sub and isinstance(sub.get(key), str):
|
||||||
|
sub[key] = _sanitize_value(sub[key])
|
||||||
|
if isinstance(sub.get("content"), list):
|
||||||
|
for c in sub["content"]:
|
||||||
|
if isinstance(c, dict):
|
||||||
|
for k2 in ("text", "transcript", "delta"):
|
||||||
|
if k2 in c and isinstance(c.get(k2), str):
|
||||||
|
c[k2] = _sanitize_value(c[k2])
|
||||||
|
if isinstance(payload.get("content"), list):
|
||||||
|
for c in payload["content"]:
|
||||||
|
if isinstance(c, dict):
|
||||||
|
for k2 in ("text", "transcript", "delta"):
|
||||||
|
if k2 in c and isinstance(c.get(k2), str):
|
||||||
|
c[k2] = _sanitize_value(c[k2])
|
||||||
|
elif event_type == "response.content_part.done":
|
||||||
|
part = payload.get("part")
|
||||||
|
if isinstance(part, dict):
|
||||||
|
if "text" in part and isinstance(part.get("text"), str):
|
||||||
|
part["text"] = _sanitize_value(part["text"])
|
||||||
|
if "transcript" in part and isinstance(part.get("transcript"), str):
|
||||||
|
part["transcript"] = _sanitize_value(part["transcript"])
|
||||||
|
elif event_type == "response.content_part.added":
|
||||||
|
part = payload.get("part")
|
||||||
|
if isinstance(part, dict):
|
||||||
|
if "text" in part and isinstance(part.get("text"), str):
|
||||||
|
part["text"] = _sanitize_value(part["text"])
|
||||||
|
if "transcript" in part and isinstance(part.get("transcript"), str):
|
||||||
|
part["transcript"] = _sanitize_value(part["transcript"])
|
||||||
|
elif event_type in {
|
||||||
|
"response.content_part.text.delta",
|
||||||
|
"response.output_content.text.delta",
|
||||||
|
"response.content.text.delta",
|
||||||
|
}:
|
||||||
|
for key in ("text", "delta"):
|
||||||
|
if key in payload and isinstance(payload.get(key), str):
|
||||||
|
payload[key] = _sanitize_value(payload[key])
|
||||||
|
elif event_type in {"response.output_item.done", "response.output_item.added"}:
|
||||||
|
item = payload.get("item")
|
||||||
|
if isinstance(item, dict):
|
||||||
|
for c in item.get("content") or []:
|
||||||
|
if isinstance(c, dict):
|
||||||
|
if "text" in c and isinstance(c["text"], str):
|
||||||
|
c["text"] = _sanitize_value(c["text"])
|
||||||
|
if "transcript" in c and isinstance(c["transcript"], str):
|
||||||
|
c["transcript"] = _sanitize_value(c["transcript"])
|
||||||
|
elif event_type == "conversation.item.created":
|
||||||
|
item = payload.get("item")
|
||||||
|
if isinstance(item, dict) and item.get("role") == "assistant":
|
||||||
|
for c in item.get("content") or []:
|
||||||
|
if isinstance(c, dict):
|
||||||
|
if c.get("type") in {"input_text", "text"} and isinstance(c.get("text"), str):
|
||||||
|
c["text"] = _sanitize_value(c["text"])
|
||||||
|
if c.get("type") == "transcript" and isinstance(c.get("transcript"), str):
|
||||||
|
c["transcript"] = _sanitize_value(c["transcript"])
|
||||||
|
|
||||||
|
if not changed:
|
||||||
|
return raw_message, False
|
||||||
|
try:
|
||||||
|
new_raw = _json.dumps(payload, ensure_ascii=False)
|
||||||
|
except Exception:
|
||||||
|
return raw_message, False
|
||||||
|
return new_raw, True
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,960 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from s2s_client.core import config as settings
|
||||||
|
from s2s_client.core import logging as app_logging
|
||||||
|
from s2s_client.models import requests as models_requests
|
||||||
|
from s2s_client.providers import apipilot as providers_apipilot
|
||||||
|
from s2s_client.providers import caller as providers_caller
|
||||||
|
from s2s_client.services import context as services_context
|
||||||
|
from s2s_client.services import tool_context as services_tool_context
|
||||||
|
|
||||||
|
|
||||||
|
def _openai_headers() -> dict[str, str]:
|
||||||
|
if not settings.OPENAI_API_KEY:
|
||||||
|
raise RuntimeError("OPENAI_API_KEY is not configured.")
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {settings.OPENAI_API_KEY}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
if settings.OPENAI_ORGANIZATION:
|
||||||
|
headers["OpenAI-Organization"] = settings.OPENAI_ORGANIZATION
|
||||||
|
if settings.OPENAI_PROJECT:
|
||||||
|
headers["OpenAI-Project"] = settings.OPENAI_PROJECT
|
||||||
|
return headers
|
||||||
|
|
||||||
|
|
||||||
|
async def reasoning(req: models_requests.ReasoningRequest) -> JSONResponse:
|
||||||
|
if settings.S2S_REASONING_TYPE == "PROXY":
|
||||||
|
raise HTTPException(status_code=409, detail="El servicio de razonamiento está deshabilitado en el modo PROXY.")
|
||||||
|
transcript = (req.transcript or "").strip()
|
||||||
|
if not transcript:
|
||||||
|
raise HTTPException(status_code=400, detail="Se requiere el campo transcript.")
|
||||||
|
|
||||||
|
# ── S2S_INJECT_PROFILE: resolve podcast episode + caller profile + reports list + latest summary ──
|
||||||
|
# (When S2S_INJECT_PROFILE=false this entire block is a no-op; user_context
|
||||||
|
# and instructions are taken verbatim from the request body.)
|
||||||
|
inject_episode_text = ""
|
||||||
|
inject_profile_text = ""
|
||||||
|
inject_reports_text = ""
|
||||||
|
inject_summary_text = ""
|
||||||
|
phone = (req.phoneNumber or "").strip()
|
||||||
|
episode_id = (req.episode_id or "").strip()
|
||||||
|
if not settings.S2S_INJECT_PROFILE:
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[INJECT-PROFILE] ⏭️ REASONING: skipped (S2S_INJECT_PROFILE=false). "
|
||||||
|
"Using verbatim request body; no fetch, no augmentation."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
needs_phone_authed = bool(settings.VOCERO_USER_PHONE_AUTH_URL.strip())
|
||||||
|
# 2026-09-11 user fix: remove mass injection of scheduled reports.
|
||||||
|
# treats S2S_REPORT_LIST as "need to fetch reports" ONLY when BOTH the URL is
|
||||||
|
# configured AND the new opt-in S2S_INJECT_REPORTS flag is true. When
|
||||||
|
# S2S_INJECT_REPORTS=false (default) needs_reports stays False here → we skip
|
||||||
|
# the branch entirely, no HTTP call, no context string, no prompt bloat.
|
||||||
|
needs_reports = bool(settings.S2S_REPORT_LIST.strip()) and settings.S2S_INJECT_REPORTS
|
||||||
|
needs_summary = bool(settings.S2S_LATEST_SUMMARY.strip()) or bool(settings.S2S_LATEST_SUMMARY_TEXT)
|
||||||
|
needs_login = bool(settings.S2S_LOGIN_BY_PHONE.strip())
|
||||||
|
can_inject = needs_phone_authed or needs_reports or needs_summary or needs_login or bool(episode_id)
|
||||||
|
if (
|
||||||
|
(not needs_phone_authed)
|
||||||
|
and (not needs_reports)
|
||||||
|
and (not needs_summary)
|
||||||
|
and (not needs_login)
|
||||||
|
and (not episode_id)
|
||||||
|
):
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[INJECT-PROFILE] ⏭️ REASONING: skipped (VOCERO_USER_PHONE_AUTH_URL, S2S_REPORT_LIST, S2S_LATEST_SUMMARY/S2S_LATEST_SUMMARY_TEXT and S2S_LOGIN_BY_PHONE all empty, AND no episode_id in request; OR S2S_INJECT_REPORTS is false so reports injection is globally disabled). "
|
||||||
|
"Set at least one AND set S2S_INJECT_REPORTS=true (if you want scheduled reports) to enable login / fetch episode/profile/reports/summary for REASONING endpoint."
|
||||||
|
)
|
||||||
|
elif (not phone) and (not needs_reports) and (not episode_id) and (not settings.S2S_LATEST_SUMMARY_TEXT):
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[INJECT-PROFILE] ⏭️ REASONING: skipped (phoneNumber missing from request body AND no episode_id). "
|
||||||
|
"Browser-side issue: check VoiceController passes phoneNumber and/or episode_id in the JSON body. (Note: S2S_REPORT_LIST fetch works even without phone.)"
|
||||||
|
)
|
||||||
|
elif not can_inject:
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[INJECT-PROFILE] ⏭️ REASONING: skipped (env for phone empty and no reports/summary/login API and no episode_id)."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# ###################################################################
|
||||||
|
# 0) Login-by-phone (optional — only if S2S_LOGIN_BY_PHONE env set)
|
||||||
|
# Obtains a bearer token that will be forwarded for profile/reports/summary fetches.
|
||||||
|
# ###################################################################
|
||||||
|
login_token = None
|
||||||
|
if settings.S2S_LOGIN_BY_PHONE and phone:
|
||||||
|
login_token, _login_resp = await providers_caller._login_by_phone(phone)
|
||||||
|
# ###################################################################
|
||||||
|
# 0.5) Podcast episode fetch (independent of phone — uses req.episode_id)
|
||||||
|
# ###################################################################
|
||||||
|
if episode_id:
|
||||||
|
if settings.S2S_DEBUG_PROMPT:
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[INJECT-EPISODE] starting REASONING fetch podcast episode episodeId=%.80s...",
|
||||||
|
episode_id,
|
||||||
|
)
|
||||||
|
episode_raw = await providers_caller._fetch_podcast_episode(episode_id)
|
||||||
|
inject_episode_text = services_context._resolve_episode_context(episode_raw)
|
||||||
|
if inject_episode_text.strip():
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[INJECT-EPISODE] ✅ REASONING: PODCAST EPISODE READY episodeId=%.80s wrappedLen=%d -> augmenting instructions + user_context before sending upstream.",
|
||||||
|
episode_id,
|
||||||
|
len(inject_episode_text),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[INJECT-EPISODE] ⏭️ REASONING: episode skipped (context empty / upstream returned empty / fetch failed)."
|
||||||
|
)
|
||||||
|
# ###################################################################
|
||||||
|
# 1) Profile fetch (optional — only if env is configured)
|
||||||
|
# ###################################################################
|
||||||
|
if needs_phone_authed and phone:
|
||||||
|
profile_obj = await providers_caller._fetch_user_profile_for_phone(phone, auth_token=login_token)
|
||||||
|
inject_profile_text = services_context._resolve_user_context_text(phone, profile_obj, loaded=True)
|
||||||
|
if inject_profile_text.strip():
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[INJECT-PROFILE] ✅ REASONING: profile READY phoneLen=%d contextLen=%d -> augmenting instructions + user_context before sending upstream.",
|
||||||
|
len(phone),
|
||||||
|
len(inject_profile_text),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[INJECT-PROFILE] ⏭️ REASONING: profile fetch OK but resolved context empty."
|
||||||
|
)
|
||||||
|
# ###################################################################
|
||||||
|
# 1.5) Scheduled reports / podcast scripts list (optional — S2S_REPORT_LIST)
|
||||||
|
# ###################################################################
|
||||||
|
if needs_reports:
|
||||||
|
if settings.S2S_DEBUG_PROMPT:
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[INJECT-REPORTS] starting REASONING fetch S2S_REPORT_LIST (phoneLen=%s, bearer=%s)...",
|
||||||
|
len(phone) if phone else "none",
|
||||||
|
"yes" if login_token else "no",
|
||||||
|
)
|
||||||
|
reports_raw = await providers_caller._fetch_reports_list(
|
||||||
|
phone_number=phone if phone else None, auth_token=login_token
|
||||||
|
)
|
||||||
|
inject_reports_text = services_context._resolve_reports_context(reports_raw)
|
||||||
|
if inject_reports_text.strip():
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[INJECT-REPORTS] ✅ REASONING: reports list READY wrappedLen=%d -> augmenting instructions + user_context before sending upstream.",
|
||||||
|
len(inject_reports_text),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[INJECT-REPORTS] ⏭️ REASONING: reports list skipped (context empty / upstream returned empty list / fetch failed)."
|
||||||
|
)
|
||||||
|
# ###################################################################
|
||||||
|
# 2) Latest summary fetch (optional — only if env is configured)
|
||||||
|
# ###################################################################
|
||||||
|
if needs_summary and (phone or settings.S2S_LATEST_SUMMARY_TEXT):
|
||||||
|
if settings.S2S_DEBUG_PROMPT:
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[INJECT-SUMMARY] starting REASONING fetch (reasoning summary... S2S_LATEST_SUMMARY=%s S2S_LATEST_SUMMARY_TEXT=%s phoneLen=%s.",
|
||||||
|
bool(settings.S2S_LATEST_SUMMARY),
|
||||||
|
bool(settings.S2S_LATEST_SUMMARY_TEXT),
|
||||||
|
len(phone) if phone else "none",
|
||||||
|
)
|
||||||
|
latest_summary_raw = await providers_caller._fetch_latest_summary_for_phone(
|
||||||
|
phone, auth_token=login_token
|
||||||
|
)
|
||||||
|
inject_summary_text = services_context._resolve_latest_summary_context(latest_summary_raw)
|
||||||
|
if inject_summary_text.strip():
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[INJECT-SUMMARY] ✅ REASONING: latest summary READY phoneLen=%s summaryTextOverride=%s wrappedLen=%d -> augmenting instructions + user_context before sending upstream.",
|
||||||
|
len(phone) if phone else "none",
|
||||||
|
bool(settings.S2S_LATEST_SUMMARY_TEXT),
|
||||||
|
len(inject_summary_text),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[INJECT-SUMMARY] ⏭️ REASONING: latest summary skipped (context empty / upstream returned empty summary / fetch failed.)"
|
||||||
|
)
|
||||||
|
# Load dynamic ApiPilot tools before building context, independently of
|
||||||
|
# S2S_INJECT_PROFILE.
|
||||||
|
needs_apipilot = bool(settings.S2S_APIPILOT_TOOLS_URL) or bool(settings.S2S_APIPILOT_TOOLS_EXECUTE_URL)
|
||||||
|
apipilot_tools_pass1: list[dict] = []
|
||||||
|
apipilot_auth_header: str | None = None
|
||||||
|
if isinstance(login_token, str) and login_token.strip():
|
||||||
|
tk = login_token.strip()
|
||||||
|
if tk.lower().startswith("bearer "):
|
||||||
|
apipilot_auth_header = tk
|
||||||
|
else:
|
||||||
|
apipilot_auth_header = f"Bearer {tk}"
|
||||||
|
# S2S_LOGIN_BY_PHONE resolves an AquaPortal JWT, used for requires_auth=true
|
||||||
|
# execution. S2S_APIPILOT_SERVICE_TOKEN overrides it when configured.
|
||||||
|
if settings.S2S_APIPILOT_SERVICE_TOKEN:
|
||||||
|
_svc = settings.S2S_APIPILOT_SERVICE_TOKEN.strip()
|
||||||
|
if _svc:
|
||||||
|
apipilot_auth_header = f"Bearer {_svc}" if not _svc.lower().startswith("bearer ") else _svc
|
||||||
|
if needs_apipilot:
|
||||||
|
try:
|
||||||
|
apipilot_t0 = providers_apipilot._monotonic_now()
|
||||||
|
apipilot_tools_pass1 = await providers_apipilot._apipilot_fetch_tools(
|
||||||
|
auth_header=apipilot_auth_header,
|
||||||
|
demo_session=None,
|
||||||
|
force_refresh=False,
|
||||||
|
)
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[APIPILOT] REASONING PASS-1: tool list ready N=%d t=%dms auth=%s",
|
||||||
|
len(apipilot_tools_pass1),
|
||||||
|
int((providers_apipilot._monotonic_now() - apipilot_t0) * 1000),
|
||||||
|
"Y" if apipilot_auth_header else "-",
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
app_logging.logger.warning("[APIPILOT] REASONING: tool-list fetch failed, ignoring: %s", exc)
|
||||||
|
apipilot_tools_pass1 = []
|
||||||
|
inject_apipilot_text_pass1 = services_tool_context._resolve_apipilot_reasoning_context_block(
|
||||||
|
apipilot_tools_pass1,
|
||||||
|
call_result=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
user_context_parts: list[str] = []
|
||||||
|
# ─── USER FIX BUG 3: TODAY fixed context block → PREPEND to user_context too
|
||||||
|
# (the same anti-hallucination block gets prefixed in the SYSTEM instructions
|
||||||
|
# path via _augment_instructions_with_profile; we also prepend here so the
|
||||||
|
# OPENAI/ANYSCALE messages[] array (where caller context is user_message[0])
|
||||||
|
# ALSO gets the same guard.)
|
||||||
|
# ─── USER FIX 2026-09-11 HALLUCINATION: voice_lock_block FIRST position
|
||||||
|
voice_lock_block = services_context._build_voice_rules_head_lock_block(profile=profile_obj)
|
||||||
|
if voice_lock_block.strip():
|
||||||
|
user_context_parts.append(voice_lock_block)
|
||||||
|
today_block = services_context._build_today_fixed_context_block()
|
||||||
|
if today_block.strip():
|
||||||
|
user_context_parts.append(today_block)
|
||||||
|
if inject_episode_text:
|
||||||
|
user_context_parts.append(inject_episode_text)
|
||||||
|
if inject_profile_text:
|
||||||
|
user_context_parts.append(inject_profile_text)
|
||||||
|
if inject_reports_text:
|
||||||
|
user_context_parts.append(inject_reports_text)
|
||||||
|
if inject_summary_text:
|
||||||
|
user_context_parts.append(inject_summary_text)
|
||||||
|
if inject_apipilot_text_pass1:
|
||||||
|
user_context_parts.append(inject_apipilot_text_pass1)
|
||||||
|
original_user_context = (req.user_context or "").strip()
|
||||||
|
if original_user_context:
|
||||||
|
user_context_parts.append(original_user_context)
|
||||||
|
user_context_parts = services_context._dedup_context_blocks_by_type(user_context_parts)
|
||||||
|
if original_user_context:
|
||||||
|
server_injected_types_in_client_ctx: set = set()
|
||||||
|
for part in user_context_parts:
|
||||||
|
if original_user_context and (part or "").strip() == original_user_context:
|
||||||
|
continue
|
||||||
|
tid = services_context._classify_context_block(part)
|
||||||
|
if tid is None:
|
||||||
|
continue
|
||||||
|
for marker_tid, marker in services_context._CONTEXT_DEDUPE_MARKERS:
|
||||||
|
if marker_tid == tid and marker and marker in original_user_context:
|
||||||
|
server_injected_types_in_client_ctx.add(tid)
|
||||||
|
break
|
||||||
|
if server_injected_types_in_client_ctx:
|
||||||
|
new_parts: list[str] = []
|
||||||
|
for part in user_context_parts:
|
||||||
|
if (part or "").strip() == original_user_context:
|
||||||
|
new_parts.append(part)
|
||||||
|
continue
|
||||||
|
tid = services_context._classify_context_block(part)
|
||||||
|
if tid in server_injected_types_in_client_ctx:
|
||||||
|
continue
|
||||||
|
new_parts.append(part)
|
||||||
|
if settings.S2S_DEBUG_PROMPT and new_parts != user_context_parts:
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[INJECT-DEDUPE] dropped server-injected profile/episode/reports/summary because the same block type was already present in req.user_context client-side context=%s kept=%d",
|
||||||
|
", ".join(sorted(server_injected_types_in_client_ctx)),
|
||||||
|
len(new_parts),
|
||||||
|
)
|
||||||
|
user_context_parts = new_parts
|
||||||
|
# ─── 2026-09-11 MARKER LEAK GUARD: strip invisible internal sentinels from EACH part.
|
||||||
|
stripped_parts: list[str] = []
|
||||||
|
for p in user_context_parts:
|
||||||
|
sp = services_context._strip_internal_markers(p)
|
||||||
|
if sp:
|
||||||
|
stripped_parts.append(sp)
|
||||||
|
user_context_parts = stripped_parts
|
||||||
|
effective_user_context = "\n\n".join(user_context_parts)
|
||||||
|
if settings.S2S_INJECT_PROFILE:
|
||||||
|
any_injected = bool(inject_episode_text or inject_profile_text or inject_reports_text or inject_summary_text)
|
||||||
|
if any_injected:
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[INJECT-PROFILE] REASONING user_context final parts=%d serverEpisodeLen=%d serverProfileLen=%d serverReportsLen=%d serverSummaryLen=%d browserUserCtxLen=%d joinedLen=%d",
|
||||||
|
len(user_context_parts),
|
||||||
|
len(inject_episode_text),
|
||||||
|
len(inject_profile_text),
|
||||||
|
len(inject_reports_text),
|
||||||
|
len(inject_summary_text),
|
||||||
|
len(original_user_context),
|
||||||
|
len(effective_user_context),
|
||||||
|
)
|
||||||
|
if settings.S2S_DEBUG_PROMPT and voice_lock_block.strip():
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[DEBUG-VOICELOCK][REASONING augment] FULL voice lock block (len=%d):\n%s\n",
|
||||||
|
len(voice_lock_block),
|
||||||
|
services_context._strip_internal_markers(voice_lock_block),
|
||||||
|
)
|
||||||
|
if settings.S2S_DEBUG_PROMPT and inject_episode_text.strip():
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[DEBUG-EPISODE][REASONING augment] FULL podcast episode block (len=%d):\n%s\n",
|
||||||
|
len(inject_episode_text),
|
||||||
|
services_context._strip_internal_markers(inject_episode_text),
|
||||||
|
)
|
||||||
|
if settings.S2S_DEBUG_PROMPT and today_block.strip():
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[DEBUG-TODAY][REASONING augment] FULL today block (len=%d):\n%s\n",
|
||||||
|
len(today_block),
|
||||||
|
services_context._strip_internal_markers(today_block),
|
||||||
|
)
|
||||||
|
if settings.S2S_DEBUG_PROMPT and inject_reports_text.strip():
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[DEBUG-REPORTS][REASONING augment] FULL reports list block (len=%d):\n%s\n",
|
||||||
|
len(inject_reports_text),
|
||||||
|
services_context._strip_internal_markers(inject_reports_text),
|
||||||
|
)
|
||||||
|
if settings.S2S_DEBUG_PROMPT and inject_summary_text.strip():
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[DEBUG-SUMMARY][REASONING augment] FULL latest summary block (len=%d):\n%s\n",
|
||||||
|
len(inject_summary_text),
|
||||||
|
services_context._strip_internal_markers(inject_summary_text),
|
||||||
|
)
|
||||||
|
if settings.S2S_DEBUG_PROMPT and inject_profile_text.strip():
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[DEBUG-PROFILE][REASONING augment] FULL profile block (len=%d):\n%s\n",
|
||||||
|
len(inject_profile_text),
|
||||||
|
services_context._strip_internal_markers(inject_profile_text),
|
||||||
|
)
|
||||||
|
if settings.S2S_DEBUG_PROMPT and inject_apipilot_text_pass1.strip():
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[DEBUG-APIPILOT][REASONING augment] FULL ApiPilot tools list block (len=%d):\n%s\n",
|
||||||
|
len(inject_apipilot_text_pass1),
|
||||||
|
services_context._strip_internal_markers(inject_apipilot_text_pass1),
|
||||||
|
)
|
||||||
|
if needs_apipilot and inject_apipilot_text_pass1.strip():
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[INJECT-APIPILOT] REASONING PASS-1: inject tool list (len=%d, Ntools=%d) into instructions and user_context.",
|
||||||
|
len(inject_apipilot_text_pass1),
|
||||||
|
len(apipilot_tools_pass1),
|
||||||
|
)
|
||||||
|
effective_instructions = services_context._augment_instructions_with_profile(
|
||||||
|
req.instructions or "",
|
||||||
|
inject_profile_text,
|
||||||
|
inject_reports_text,
|
||||||
|
inject_summary_text,
|
||||||
|
inject_episode_text,
|
||||||
|
profile_obj=profile_obj,
|
||||||
|
)
|
||||||
|
if inject_apipilot_text_pass1.strip():
|
||||||
|
effective_instructions = (
|
||||||
|
effective_instructions + "\n\n" if effective_instructions else ""
|
||||||
|
) + inject_apipilot_text_pass1
|
||||||
|
else:
|
||||||
|
effective_instructions = (req.instructions or "").strip()
|
||||||
|
if inject_apipilot_text_pass1.strip():
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[INJECT-APIPILOT] REASONING PASS-1: inject tool list (len=%d, Ntools=%d) into instructions (inject_profile=false).",
|
||||||
|
len(inject_apipilot_text_pass1),
|
||||||
|
len(apipilot_tools_pass1),
|
||||||
|
)
|
||||||
|
effective_instructions = (
|
||||||
|
effective_instructions + "\n\n" if effective_instructions else ""
|
||||||
|
) + inject_apipilot_text_pass1
|
||||||
|
if settings.S2S_DEBUG_PROMPT and inject_apipilot_text_pass1.strip():
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[DEBUG-APIPILOT][REASONING augment] FULL ApiPilot tools list block (len=%d):\n%s\n",
|
||||||
|
len(inject_apipilot_text_pass1),
|
||||||
|
services_context._strip_internal_markers(inject_apipilot_text_pass1),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────────────
|
||||||
|
# Mode A: NODE_RED forwards the complete payload; the flow decides behavior.
|
||||||
|
# Stage 1 (log-only) returns {answer: "", log_only: true}, allowing the browser
|
||||||
|
# to leave speech generation to the S2S backend. Later stages can return
|
||||||
|
# {answer: "...", log_only: false}. Expected response fields include answer,
|
||||||
|
# log_only, usage, debug and latency_ms.
|
||||||
|
# ────────────────────────────────────────────────────────────────────
|
||||||
|
if settings.S2S_REASONING_TYPE == "NODE_RED":
|
||||||
|
if not settings.NODE_RED_REASONING_URL:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="S2S_REASONING_NODE_RED_URL no está configurado. Configure esta variable de entorno con la dirección de Node-RED (por ejemplo, http://localhost:1881).",
|
||||||
|
)
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[INJECT-PROFILE] REASONING->NODE_RED body fields. instructionsLen=%d userCtxLen=%d phoneLen=%d episodeIdLen=%d",
|
||||||
|
len(effective_instructions),
|
||||||
|
len(effective_user_context),
|
||||||
|
len(phone),
|
||||||
|
len(episode_id),
|
||||||
|
)
|
||||||
|
upstream_body = {
|
||||||
|
"transcript": transcript,
|
||||||
|
"instructions": effective_instructions,
|
||||||
|
"language": (req.language or "").strip(),
|
||||||
|
"user_context": effective_user_context,
|
||||||
|
}
|
||||||
|
if settings.S2S_DEBUG_PROMPT:
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[DEBUG-PROMPT][REASONING → NODE_RED] FULL upstream_body JSON:\n%s\n",
|
||||||
|
json.dumps(upstream_body, ensure_ascii=False, indent=2),
|
||||||
|
)
|
||||||
|
app_logging.logger.debug(
|
||||||
|
"[EXT][NODE_RED] reasoning POST %s/api/reasoning lang=%s transcriptLen=%d",
|
||||||
|
settings.NODE_RED_REASONING_URL,
|
||||||
|
(req.language or "")[:20] or "auto",
|
||||||
|
len(transcript),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=120.0) as http:
|
||||||
|
resp = await http.post(
|
||||||
|
f"{settings.NODE_RED_REASONING_URL}/api/reasoning",
|
||||||
|
json=upstream_body,
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-S2S-Caller": "demo-server",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except httpx.RequestError as exc:
|
||||||
|
app_logging.logger.warning("[EXT][NODE_RED] unreachable: %r", exc)
|
||||||
|
raise HTTPException( # noqa: B904
|
||||||
|
status_code=502, detail="No se puede conectar con el motor de razonamiento de Node-RED."
|
||||||
|
)
|
||||||
|
app_logging.logger.debug(
|
||||||
|
"[EXT][NODE_RED] reasoning RESP status=%s len=%dB",
|
||||||
|
resp.status_code,
|
||||||
|
len(resp.content or b""),
|
||||||
|
)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
app_logging.logger.warning("[EXT][NODE_RED] failed %s: %s", resp.status_code, resp.text[:500])
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=502,
|
||||||
|
detail=f"El razonamiento de Node-RED falló (HTTP {resp.status_code}).",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
payload = resp.json()
|
||||||
|
except Exception:
|
||||||
|
app_logging.logger.warning("[EXT][NODE_RED] invalid JSON: %s", resp.text[:500])
|
||||||
|
raise HTTPException(status_code=502, detail="Node-RED devolvió un JSON no válido.") # noqa: B904
|
||||||
|
answer = payload.get("answer") if isinstance(payload, dict) else None
|
||||||
|
log_only = bool(isinstance(payload, dict) and payload.get("log_only") is True)
|
||||||
|
if not isinstance(answer, str):
|
||||||
|
app_logging.logger.warning(
|
||||||
|
"[EXT][NODE_RED] invalid answer type: %s", json.dumps(payload, ensure_ascii=True)[:800]
|
||||||
|
)
|
||||||
|
raise HTTPException(status_code=502, detail="Node-RED devolvió un campo answer no válido.")
|
||||||
|
answer_stripped = answer.strip()
|
||||||
|
# An empty answer is intentional in log-only mode; forward the flag.
|
||||||
|
if not answer_stripped and not log_only:
|
||||||
|
app_logging.logger.warning(
|
||||||
|
"[EXT][NODE_RED] empty answer and !log_only: %s", json.dumps(payload, ensure_ascii=True)[:800]
|
||||||
|
)
|
||||||
|
raise HTTPException(status_code=502, detail="Node-RED no devolvió una respuesta ni la marca log_only.")
|
||||||
|
usage = payload.get("usage") if isinstance(payload, dict) else None
|
||||||
|
if isinstance(usage, dict):
|
||||||
|
app_logging.logger.debug(
|
||||||
|
"[EXT][NODE_RED] reasoning USAGE prompt=%d completion=%d total=%d",
|
||||||
|
usage.get("prompt_tokens", 0),
|
||||||
|
usage.get("completion_tokens", 0),
|
||||||
|
usage.get("total_tokens", 0),
|
||||||
|
)
|
||||||
|
extra = {}
|
||||||
|
for key in ("latency_ms", "intent", "caller_profile_keys", "debug"):
|
||||||
|
if isinstance(payload, dict) and key in payload:
|
||||||
|
extra[key] = payload[key]
|
||||||
|
if log_only:
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[Vocero REASONING] engine=NODE_RED mode=LOG_ONLY transcript=%s extra=%s",
|
||||||
|
transcript[:300],
|
||||||
|
json.dumps({k: v for k, v in extra.items() if k != "debug"}, ensure_ascii=True)[:400],
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[Vocero REASONING] engine=NODE_RED transcript=%s answer=%s extra=%s",
|
||||||
|
transcript[:300],
|
||||||
|
answer_stripped[:500],
|
||||||
|
json.dumps({k: v for k, v in extra.items() if k != "debug"}, ensure_ascii=True)[:400],
|
||||||
|
)
|
||||||
|
out: dict[str, Any] = {"answer": answer_stripped}
|
||||||
|
if log_only:
|
||||||
|
out["log_only"] = True
|
||||||
|
if usage:
|
||||||
|
out["usage"] = usage
|
||||||
|
out.update({k: v for k, v in extra.items() if k != "debug" or app_logging.logger.level <= logging.DEBUG})
|
||||||
|
# Capture the ApiPilot marker, execute its tool and run reasoning pass 2.
|
||||||
|
tool_name_p1, tool_args_p1 = services_tool_context._apipilot_parse_call_marker(answer_stripped)
|
||||||
|
if tool_name_p1:
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[APIPILOT-CALL-NAME] USE=NODE_RED_PASS2_TRIGGER name=%r argsKeys=%s",
|
||||||
|
tool_name_p1,
|
||||||
|
sorted(list((tool_args_p1 or {}).keys())),
|
||||||
|
)
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[APIPILOT] REASONING PASS-2 trigger NODE_RED marker name=%s args=%s",
|
||||||
|
tool_name_p1,
|
||||||
|
json.dumps(tool_args_p1 or {}, ensure_ascii=True)[:300],
|
||||||
|
)
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[APIPILOT-CALL-NAME] USE=NODE_RED_PASS2_EXEC_HELPER name=%r → forward to _apipilot_execute_tool",
|
||||||
|
tool_name_p1,
|
||||||
|
)
|
||||||
|
tool_env_p1 = await providers_apipilot._apipilot_execute_tool(
|
||||||
|
auth_header=apipilot_auth_header,
|
||||||
|
demo_session=None,
|
||||||
|
name=tool_name_p1,
|
||||||
|
arguments=tool_args_p1 or {},
|
||||||
|
)
|
||||||
|
is_tool_err = not bool(tool_env_p1.get("success"))
|
||||||
|
tool_data = tool_env_p1.get("data")
|
||||||
|
if isinstance(tool_data, str):
|
||||||
|
tool_text_p1 = tool_data
|
||||||
|
elif tool_data is None:
|
||||||
|
tool_text_p1 = ""
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
tool_text_p1 = json.dumps(tool_data, ensure_ascii=False, indent=2)
|
||||||
|
except Exception:
|
||||||
|
tool_text_p1 = str(tool_data)
|
||||||
|
if is_tool_err and isinstance(tool_env_p1.get("error"), dict):
|
||||||
|
err = tool_env_p1["error"]
|
||||||
|
err_head = f"[{err.get('code')}] {err.get('message')}"
|
||||||
|
tool_text_p1 = (err_head + ("\n" + tool_text_p1 if tool_text_p1.strip() else "")).strip()
|
||||||
|
inject_apipilot_text_pass2 = services_tool_context._resolve_apipilot_reasoning_context_block(
|
||||||
|
[],
|
||||||
|
call_result={
|
||||||
|
"name": tool_name_p1,
|
||||||
|
"text": tool_text_p1,
|
||||||
|
"error": is_tool_err,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[APIPILOT] REASONING PASS-2 execute name=%s success=%s httpResultLen=%d trigger second Node-RED call",
|
||||||
|
tool_name_p1,
|
||||||
|
str(not is_tool_err).lower(),
|
||||||
|
len(tool_text_p1),
|
||||||
|
)
|
||||||
|
# Pass-2 context replaces the tool list with the execution result,
|
||||||
|
# preserving voice/date/profile context.
|
||||||
|
user_ctx_parts_2: list[str] = []
|
||||||
|
if voice_lock_block.strip():
|
||||||
|
user_ctx_parts_2.append(voice_lock_block)
|
||||||
|
if today_block.strip():
|
||||||
|
user_ctx_parts_2.append(today_block)
|
||||||
|
if inject_episode_text:
|
||||||
|
user_ctx_parts_2.append(inject_episode_text)
|
||||||
|
if inject_profile_text:
|
||||||
|
user_ctx_parts_2.append(inject_profile_text)
|
||||||
|
if inject_reports_text:
|
||||||
|
user_ctx_parts_2.append(inject_reports_text)
|
||||||
|
if inject_summary_text:
|
||||||
|
user_ctx_parts_2.append(inject_summary_text)
|
||||||
|
if inject_apipilot_text_pass2:
|
||||||
|
user_ctx_parts_2.append(inject_apipilot_text_pass2)
|
||||||
|
if original_user_context:
|
||||||
|
user_ctx_parts_2.append(original_user_context)
|
||||||
|
user_ctx_parts_2 = services_context._dedup_context_blocks_by_type(user_ctx_parts_2)
|
||||||
|
stripped_2: list[str] = []
|
||||||
|
for p in user_ctx_parts_2:
|
||||||
|
sp = services_context._strip_internal_markers(p)
|
||||||
|
if sp:
|
||||||
|
stripped_2.append(sp)
|
||||||
|
user_ctx_parts_2 = stripped_2
|
||||||
|
effective_user_ctx_2 = "\n\n".join(user_ctx_parts_2)
|
||||||
|
# Preserve augmentation without CALL-marker instructions; the result
|
||||||
|
# block supplies the pass-2 response instructions.
|
||||||
|
if settings.S2S_INJECT_PROFILE:
|
||||||
|
eff_inst_2 = services_context._augment_instructions_with_profile(
|
||||||
|
req.instructions or "",
|
||||||
|
inject_profile_text,
|
||||||
|
inject_reports_text,
|
||||||
|
inject_summary_text,
|
||||||
|
inject_episode_text,
|
||||||
|
profile_obj=profile_obj,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
eff_inst_2 = (req.instructions or "").strip()
|
||||||
|
if inject_apipilot_text_pass2.strip():
|
||||||
|
eff_inst_2 = (eff_inst_2 + "\n\n" if eff_inst_2 else "") + inject_apipilot_text_pass2
|
||||||
|
upstream_body_2 = {
|
||||||
|
"transcript": transcript,
|
||||||
|
"instructions": eff_inst_2,
|
||||||
|
"language": (req.language or "").strip(),
|
||||||
|
"user_context": effective_user_ctx_2,
|
||||||
|
}
|
||||||
|
if settings.S2S_DEBUG_PROMPT:
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[DEBUG-PROMPT][REASONING → NODE_RED PASS-2] FULL upstream_body_2 JSON:\n%s\n",
|
||||||
|
json.dumps(upstream_body_2, ensure_ascii=False, indent=2),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=120.0) as http2:
|
||||||
|
resp2 = await http2.post(
|
||||||
|
f"{settings.NODE_RED_REASONING_URL}/api/reasoning",
|
||||||
|
json=upstream_body_2,
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-S2S-Caller": "demo-server-pass2",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except httpx.RequestError as exc:
|
||||||
|
app_logging.logger.warning("[EXT][NODE_RED PASS-2] unreachable: %r", exc)
|
||||||
|
raise HTTPException( # noqa: B904
|
||||||
|
status_code=502,
|
||||||
|
detail="No se puede conectar con el motor de razonamiento de Node-RED (segunda pasada).",
|
||||||
|
)
|
||||||
|
if resp2.status_code != 200:
|
||||||
|
app_logging.logger.warning("[EXT][NODE_RED PASS-2] failed %s: %s", resp2.status_code, resp2.text[:500])
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=502,
|
||||||
|
detail=f"La segunda pasada del razonamiento de Node-RED falló (HTTP {resp2.status_code}).",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
payload2 = resp2.json()
|
||||||
|
except Exception:
|
||||||
|
app_logging.logger.warning("[EXT][NODE_RED PASS-2] invalid JSON: %s", resp2.text[:500])
|
||||||
|
raise HTTPException(status_code=502, detail="Node-RED devolvió un JSON no válido (segunda pasada).") # noqa: B904
|
||||||
|
final_ans = payload2.get("answer") if isinstance(payload2, dict) else None
|
||||||
|
if not isinstance(final_ans, str):
|
||||||
|
# Fallback: the first message and raw result.
|
||||||
|
app_logging.logger.warning("[EXT][NODE_RED PASS-2] invalid answer, falling back to raw result text")
|
||||||
|
final_ans = tool_text_p1 or answer_stripped
|
||||||
|
final_stripped = final_ans.strip()
|
||||||
|
# Pass 2 always sets log_only=False: its final answer is ready for speech.
|
||||||
|
out2: dict = {"answer": final_stripped}
|
||||||
|
usage2 = payload2.get("usage") if isinstance(payload2, dict) else None
|
||||||
|
if usage2:
|
||||||
|
out2["usage_pass2"] = usage2
|
||||||
|
out2["apipilot_invoked"] = {
|
||||||
|
"name": tool_name_p1,
|
||||||
|
"success": not is_tool_err,
|
||||||
|
"result_length": len(tool_text_p1),
|
||||||
|
}
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[Vocero REASONING] engine=NODE_RED PASS-2 transcript=%s finalAnswer=%s apipilot=%s",
|
||||||
|
transcript[:300],
|
||||||
|
final_stripped[:500],
|
||||||
|
json.dumps(out2["apipilot_invoked"], ensure_ascii=True),
|
||||||
|
)
|
||||||
|
return JSONResponse(out2)
|
||||||
|
# Without a marker, return the original pass-1 answer.
|
||||||
|
return JSONResponse(out)
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────────────
|
||||||
|
# Mode B: OPENAI calls OpenAI Chat Completions directly.
|
||||||
|
# ────────────────────────────────────────────────────────────────────
|
||||||
|
if not settings.OPENAI_API_KEY:
|
||||||
|
raise HTTPException(status_code=503, detail="OPENAI_API_KEY no está configurado.")
|
||||||
|
system_parts = []
|
||||||
|
instructions = effective_instructions or ((req.instructions or "").strip())
|
||||||
|
if instructions:
|
||||||
|
system_parts.append(instructions)
|
||||||
|
if effective_user_context.strip():
|
||||||
|
system_parts.append(f"User context:\n{effective_user_context.strip()}")
|
||||||
|
if inject_apipilot_text_pass1:
|
||||||
|
system_parts.append(inject_apipilot_text_pass1)
|
||||||
|
if req.language.strip():
|
||||||
|
system_parts.append(
|
||||||
|
"Reply only with the final assistant answer text. "
|
||||||
|
f"Keep the answer natural and concise in this language: {req.language.strip()}."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
system_parts.append("Reply only with the final assistant answer text. Keep the answer natural and concise.")
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[INJECT-PROFILE] REASONING->OPENAI system message parts=%d instructionsLen=%d userCtxLen=%d phoneLen=%d episodeIdLen=%d",
|
||||||
|
len(system_parts),
|
||||||
|
len(effective_instructions),
|
||||||
|
len(effective_user_context),
|
||||||
|
len(phone),
|
||||||
|
len(episode_id),
|
||||||
|
)
|
||||||
|
system_msg = "\n\n".join(system_parts)
|
||||||
|
body = {
|
||||||
|
"model": settings.OPENAI_REASONING_MODEL,
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": system_msg},
|
||||||
|
{"role": "user", "content": transcript},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
if settings.S2S_DEBUG_PROMPT:
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[DEBUG-PROMPT][REASONING → OPENAI] FULL SYSTEM message (len=%d):\n%s\n",
|
||||||
|
len(system_msg),
|
||||||
|
system_msg,
|
||||||
|
)
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[DEBUG-PROMPT][REASONING → OPENAI] USER transcript (len=%d):\n%s\n",
|
||||||
|
len(transcript),
|
||||||
|
transcript,
|
||||||
|
)
|
||||||
|
app_logging.logger.debug(
|
||||||
|
"[EXT][OPENAI] reasoning POST %s/chat/completions model=%s systemLen=%d transcriptLen=%d language=%s",
|
||||||
|
settings.OPENAI_BASE_URL,
|
||||||
|
settings.OPENAI_REASONING_MODEL,
|
||||||
|
len(system_parts[0]) if system_parts else 0,
|
||||||
|
len(transcript),
|
||||||
|
(req.language or "")[:20] or "auto",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as http:
|
||||||
|
resp = await http.post(
|
||||||
|
f"{settings.OPENAI_BASE_URL}/chat/completions",
|
||||||
|
headers=_openai_headers(),
|
||||||
|
json=body,
|
||||||
|
)
|
||||||
|
except httpx.RequestError as exc:
|
||||||
|
app_logging.logger.warning("OpenAI reasoning unreachable: %r", exc)
|
||||||
|
raise HTTPException(status_code=502, detail="No se puede conectar con el servicio de razonamiento de OpenAI.") # noqa: B904
|
||||||
|
app_logging.logger.debug(
|
||||||
|
"[EXT][OPENAI] reasoning RESP status=%s len=%dB",
|
||||||
|
resp.status_code,
|
||||||
|
len(resp.content or b""),
|
||||||
|
)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
app_logging.logger.warning("OpenAI reasoning failed %s: %s", resp.status_code, resp.text[:400])
|
||||||
|
raise HTTPException(status_code=502, detail=f"El razonamiento de OpenAI falló ({resp.status_code}).")
|
||||||
|
try:
|
||||||
|
payload = resp.json()
|
||||||
|
except Exception:
|
||||||
|
app_logging.logger.warning("OpenAI reasoning returned invalid JSON: %s", resp.text[:400])
|
||||||
|
raise HTTPException(status_code=502, detail="El servicio de razonamiento de OpenAI devolvió un JSON no válido.") # noqa: B904
|
||||||
|
answer = ""
|
||||||
|
choices = payload.get("choices")
|
||||||
|
if isinstance(choices, list) and choices:
|
||||||
|
message = choices[0].get("message") if isinstance(choices[0], dict) else None
|
||||||
|
if isinstance(message, dict):
|
||||||
|
content = message.get("content")
|
||||||
|
if isinstance(content, str):
|
||||||
|
answer = content.strip()
|
||||||
|
usage = payload.get("usage")
|
||||||
|
if isinstance(usage, dict):
|
||||||
|
app_logging.logger.debug(
|
||||||
|
"[EXT][OPENAI] reasoning USAGE prompt=%d completion=%d total=%d",
|
||||||
|
usage.get("prompt_tokens", 0),
|
||||||
|
usage.get("completion_tokens", 0),
|
||||||
|
usage.get("total_tokens", 0),
|
||||||
|
)
|
||||||
|
if not answer:
|
||||||
|
app_logging.logger.warning(
|
||||||
|
"OpenAI reasoning returned empty answer: %s", json.dumps(payload, ensure_ascii=True)[:800]
|
||||||
|
)
|
||||||
|
raise HTTPException(status_code=502, detail="El servicio de razonamiento de OpenAI no devolvió una respuesta.")
|
||||||
|
app_logging.logger.info("[Vocero REASONING] engine=OPENAI transcript=%s answer=%s", transcript[:300], answer[:500])
|
||||||
|
# Capture the ApiPilot marker, execute its tool and run OpenAI pass 2.
|
||||||
|
tool_name_p1_oai, tool_args_p1_oai = services_tool_context._apipilot_parse_call_marker(answer)
|
||||||
|
if tool_name_p1_oai:
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[APIPILOT-CALL-NAME] USE=OPENAI_PASS2_TRIGGER name=%r argsKeys=%s",
|
||||||
|
tool_name_p1_oai,
|
||||||
|
sorted(list((tool_args_p1_oai or {}).keys())),
|
||||||
|
)
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[APIPILOT] REASONING PASS-2 trigger OPENAI marker name=%s args=%s",
|
||||||
|
tool_name_p1_oai,
|
||||||
|
json.dumps(tool_args_p1_oai or {}, ensure_ascii=True)[:300],
|
||||||
|
)
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[APIPILOT-CALL-NAME] USE=OPENAI_PASS2_EXEC_HELPER name=%r → forward to _apipilot_execute_tool",
|
||||||
|
tool_name_p1_oai,
|
||||||
|
)
|
||||||
|
tool_env_oai_p1 = await providers_apipilot._apipilot_execute_tool(
|
||||||
|
auth_header=apipilot_auth_header,
|
||||||
|
demo_session=None,
|
||||||
|
name=tool_name_p1_oai,
|
||||||
|
arguments=tool_args_p1_oai or {},
|
||||||
|
)
|
||||||
|
is_tool_err_oai = not bool(tool_env_oai_p1.get("success"))
|
||||||
|
tool_data_oai = tool_env_oai_p1.get("data")
|
||||||
|
if isinstance(tool_data_oai, str):
|
||||||
|
tool_text_oai = tool_data_oai
|
||||||
|
elif tool_data_oai is None:
|
||||||
|
tool_text_oai = ""
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
tool_text_oai = json.dumps(tool_data_oai, ensure_ascii=False, indent=2)
|
||||||
|
except Exception:
|
||||||
|
tool_text_oai = str(tool_data_oai)
|
||||||
|
if is_tool_err_oai and isinstance(tool_env_oai_p1.get("error"), dict):
|
||||||
|
err_oai = tool_env_oai_p1["error"]
|
||||||
|
err_oai_head = f"[{err_oai.get('code')}] {err_oai.get('message')}"
|
||||||
|
tool_text_oai = (err_oai_head + ("\n" + tool_text_oai if tool_text_oai.strip() else "")).strip()
|
||||||
|
inject_apipilot_pass2_oai = services_tool_context._resolve_apipilot_reasoning_context_block(
|
||||||
|
[],
|
||||||
|
call_result={
|
||||||
|
"name": tool_name_p1_oai,
|
||||||
|
"text": tool_text_oai,
|
||||||
|
"error": is_tool_err_oai,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[APIPILOT] REASONING PASS-2 OPENAI execute name=%s success=%s resultLen=%d → second chat/completions call",
|
||||||
|
tool_name_p1_oai,
|
||||||
|
str(not is_tool_err_oai).lower(),
|
||||||
|
len(tool_text_oai),
|
||||||
|
)
|
||||||
|
# Build pass 2 with the same instructions/context structure, replacing
|
||||||
|
# inject_apipilot_text_pass1 with the result block inject_apipilot_pass2_oai.
|
||||||
|
user_ctx_oai_2: list[str] = []
|
||||||
|
if voice_lock_block.strip():
|
||||||
|
user_ctx_oai_2.append(voice_lock_block)
|
||||||
|
if today_block.strip():
|
||||||
|
user_ctx_oai_2.append(today_block)
|
||||||
|
if inject_episode_text:
|
||||||
|
user_ctx_oai_2.append(inject_episode_text)
|
||||||
|
if inject_profile_text:
|
||||||
|
user_ctx_oai_2.append(inject_profile_text)
|
||||||
|
if inject_reports_text:
|
||||||
|
user_ctx_oai_2.append(inject_reports_text)
|
||||||
|
if inject_summary_text:
|
||||||
|
user_ctx_oai_2.append(inject_summary_text)
|
||||||
|
if inject_apipilot_pass2_oai:
|
||||||
|
user_ctx_oai_2.append(inject_apipilot_pass2_oai)
|
||||||
|
if original_user_context:
|
||||||
|
user_ctx_oai_2.append(original_user_context)
|
||||||
|
user_ctx_oai_2 = services_context._dedup_context_blocks_by_type(user_ctx_oai_2)
|
||||||
|
stripped_oai_2: list[str] = []
|
||||||
|
for p in user_ctx_oai_2:
|
||||||
|
sp = services_context._strip_internal_markers(p)
|
||||||
|
if sp:
|
||||||
|
stripped_oai_2.append(sp)
|
||||||
|
user_ctx_oai_2 = stripped_oai_2
|
||||||
|
eff_user_ctx_oai_2 = "\n\n".join(user_ctx_oai_2)
|
||||||
|
if settings.S2S_INJECT_PROFILE:
|
||||||
|
eff_inst_oai_2 = services_context._augment_instructions_with_profile(
|
||||||
|
req.instructions or "",
|
||||||
|
inject_profile_text,
|
||||||
|
inject_reports_text,
|
||||||
|
inject_summary_text,
|
||||||
|
inject_episode_text,
|
||||||
|
profile_obj=profile_obj,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
eff_inst_oai_2 = (req.instructions or "").strip()
|
||||||
|
sys_parts_oai_2: list[str] = []
|
||||||
|
instr_oai_2 = eff_inst_oai_2 or ((req.instructions or "").strip())
|
||||||
|
if instr_oai_2:
|
||||||
|
sys_parts_oai_2.append(instr_oai_2)
|
||||||
|
if eff_user_ctx_oai_2.strip():
|
||||||
|
sys_parts_oai_2.append(f"User context:\n{eff_user_ctx_oai_2.strip()}")
|
||||||
|
if inject_apipilot_pass2_oai:
|
||||||
|
sys_parts_oai_2.append(inject_apipilot_pass2_oai)
|
||||||
|
if req.language.strip():
|
||||||
|
sys_parts_oai_2.append(
|
||||||
|
"Reply only with the final assistant answer text. "
|
||||||
|
f"Keep the answer natural and concise in this language: {req.language.strip()}."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
sys_parts_oai_2.append(
|
||||||
|
"Reply only with the final assistant answer text. Keep the answer natural and concise."
|
||||||
|
)
|
||||||
|
system_msg_oai_2 = "\n\n".join(sys_parts_oai_2)
|
||||||
|
body_oai_2 = {
|
||||||
|
"model": settings.OPENAI_REASONING_MODEL,
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": system_msg_oai_2},
|
||||||
|
{"role": "user", "content": transcript},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
if settings.S2S_DEBUG_PROMPT:
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[DEBUG-PROMPT][REASONING → OPENAI PASS-2] FULL SYSTEM message (len=%d):\n%s\n",
|
||||||
|
len(system_msg_oai_2),
|
||||||
|
system_msg_oai_2,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as http_oai2:
|
||||||
|
resp_oai_2 = await http_oai2.post(
|
||||||
|
f"{settings.OPENAI_BASE_URL}/chat/completions",
|
||||||
|
headers=_openai_headers(),
|
||||||
|
json=body_oai_2,
|
||||||
|
)
|
||||||
|
except httpx.RequestError as exc:
|
||||||
|
app_logging.logger.warning("[EXT][OPENAI PASS-2] unreachable: %r", exc)
|
||||||
|
raise HTTPException( # noqa: B904
|
||||||
|
status_code=502,
|
||||||
|
detail="No se puede conectar con el servicio de razonamiento de OpenAI (segunda pasada).",
|
||||||
|
)
|
||||||
|
if resp_oai_2.status_code != 200:
|
||||||
|
app_logging.logger.warning(
|
||||||
|
"[EXT][OPENAI PASS-2] failed %s: %s", resp_oai_2.status_code, resp_oai_2.text[:400]
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=502,
|
||||||
|
detail=f"La segunda pasada del razonamiento de OpenAI falló ({resp_oai_2.status_code}).",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
payload_oai_2 = resp_oai_2.json()
|
||||||
|
except Exception:
|
||||||
|
app_logging.logger.warning("[EXT][OPENAI PASS-2] invalid JSON: %s", resp_oai_2.text[:400])
|
||||||
|
raise HTTPException( # noqa: B904
|
||||||
|
status_code=502,
|
||||||
|
detail="El servicio de razonamiento de OpenAI devolvió un JSON no válido (segunda pasada).",
|
||||||
|
)
|
||||||
|
final_oai_ans = ""
|
||||||
|
choices_oai_2 = payload_oai_2.get("choices")
|
||||||
|
if isinstance(choices_oai_2, list) and choices_oai_2:
|
||||||
|
msg_oai_2 = choices_oai_2[0].get("message") if isinstance(choices_oai_2[0], dict) else None
|
||||||
|
if isinstance(msg_oai_2, dict):
|
||||||
|
content_oai_2 = msg_oai_2.get("content")
|
||||||
|
if isinstance(content_oai_2, str):
|
||||||
|
final_oai_ans = content_oai_2.strip()
|
||||||
|
usage_oai_2 = payload_oai_2.get("usage")
|
||||||
|
if not final_oai_ans:
|
||||||
|
# Last-resort fallback: raw tool-result text.
|
||||||
|
app_logging.logger.warning("[EXT][OPENAI PASS-2] empty answer, falling back to raw tool-result text")
|
||||||
|
final_oai_ans = (tool_text_oai or answer).strip()
|
||||||
|
final_out_oai: dict = {"answer": final_oai_ans}
|
||||||
|
if usage_oai_2:
|
||||||
|
final_out_oai["usage_pass2"] = usage_oai_2
|
||||||
|
final_out_oai["apipilot_invoked"] = {
|
||||||
|
"name": tool_name_p1_oai,
|
||||||
|
"success": not is_tool_err_oai,
|
||||||
|
"result_length": len(tool_text_oai),
|
||||||
|
}
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[Vocero REASONING] engine=OPENAI PASS-2 transcript=%s finalAnswer=%s apipilot=%s",
|
||||||
|
transcript[:300],
|
||||||
|
final_oai_ans[:500],
|
||||||
|
json.dumps(final_out_oai["apipilot_invoked"], ensure_ascii=True),
|
||||||
|
)
|
||||||
|
return JSONResponse(final_out_oai)
|
||||||
|
# Without a marker, return the original pass-1 answer.
|
||||||
|
return JSONResponse({"answer": answer})
|
||||||
|
|
||||||
|
|
||||||
|
async def _forward_agent_text_to_nodered(payload: dict) -> None:
|
||||||
|
"""Forward native-backend TTS responses to Node-RED's stage-1 logging endpoint.
|
||||||
|
|
||||||
|
Ignore unavailable or unconfigured Node-RED so the realtime loop continues.
|
||||||
|
"""
|
||||||
|
if settings.S2S_REASONING_TYPE != "NODE_RED" or not settings.NODE_RED_REASONING_URL:
|
||||||
|
return
|
||||||
|
url = f"{settings.NODE_RED_REASONING_URL}/api/log/system-response"
|
||||||
|
app_logging.logger.debug(
|
||||||
|
"[EXT][NODE_RED] forward system-response POST %s len=%d source=%s",
|
||||||
|
settings.NODE_RED_REASONING_URL,
|
||||||
|
len(str(payload.get("tts", "")) or 0), # type: ignore[arg-type] # Preserve the legacy empty-TTS failure.
|
||||||
|
payload.get("source_event") or "-",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=5.0) as http:
|
||||||
|
resp = await http.post(
|
||||||
|
url,
|
||||||
|
json=payload,
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-S2S-Caller": "demo-server",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
app_logging.logger.debug(
|
||||||
|
"[EXT][NODE_RED] forward system-response non-200 status=%s body=%s",
|
||||||
|
resp.status_code,
|
||||||
|
(resp.text or "")[:300],
|
||||||
|
)
|
||||||
|
except httpx.RequestError as exc:
|
||||||
|
app_logging.logger.debug("[EXT][NODE_RED] forward system-response unreachable: %r", exc)
|
||||||
|
except Exception as exc: # noqa: BLE001 — never interrupt the realtime loop
|
||||||
|
app_logging.logger.debug("[EXT][NODE_RED] forward system-response unexpected: %r", exc)
|
||||||
@@ -0,0 +1,629 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
from s2s_client.core import logging as app_logging
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# APIPILOT — Reasoning engine integration (two passes)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Deduplication/start/end markers used by _classify_context_block to avoid
|
||||||
|
# injecting a block already supplied by the client or found in base_instructions.
|
||||||
|
APIPILOT_PASS1_LIST_MARKER = "<<<APIPILOT_TOOLS_LIST_BEGIN>>>"
|
||||||
|
APIPILOT_PASS2_RESULT_MARKER = "<<<APIPILOT_RESULT:"
|
||||||
|
|
||||||
|
# Flow:
|
||||||
|
# PASS 1: inject {name,description,required,parameters} and marker rules.
|
||||||
|
# The model matches descriptions and emits the CALL marker.
|
||||||
|
# PASS 2: capture the marker, execute through S2S_APIPILOT_TOOLS_EXECUTE_URL,
|
||||||
|
# inject the result text, and request a natural spoken response.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_APIPILOT_CALL_MARKER_RE = re.compile(
|
||||||
|
r"[<]{1,3}\s*APIPILOT_CALL:NAME=([A-Za-z0-9_.-]+),ARGUMENTS=(.*?)[>]{1,3}",
|
||||||
|
re.IGNORECASE | re.DOTALL,
|
||||||
|
)
|
||||||
|
# 2026-09-25: observed inverted marker (incorrect LLM syntax):
|
||||||
|
# >>>APIPILOT_CALL:NAME=tool,ARGUMENTS=json<<<
|
||||||
|
_APIPILOT_CALL_MARKER_INVERTED_RE = re.compile(
|
||||||
|
r"[>]{1,3}\s*APIPILOT_CALL:NAME=([A-Za-z0-9_.-]+),ARGUMENTS=(.*?)[<]{1,3}",
|
||||||
|
re.IGNORECASE | re.DOTALL,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _apipilot_parse_call_marker(answer_text: str, *, defaults: dict | None = None) -> tuple[str | None, dict | None]:
|
||||||
|
"""Parse the mandatory CALL marker emitted by the LLM in pass 1.
|
||||||
|
|
||||||
|
Returns (tool_name, arguments_dict), or (None, None) when no marker exists.
|
||||||
|
|
||||||
|
Supports:
|
||||||
|
(1) ARGUMENTS={"key":"val"}, valid JSON.
|
||||||
|
(2) ARGUMENTS={}, a valid empty object.
|
||||||
|
(3) ARGUMENTS= without braces, observed on realtime WebSocket sessions.
|
||||||
|
(4) key=val,key2=val2 strings through a manual parsing fallback.
|
||||||
|
(5) After parsing, fill missing required arguments from defaults such as
|
||||||
|
phone and episode_id.
|
||||||
|
"""
|
||||||
|
if not isinstance(answer_text, str) or not answer_text.strip():
|
||||||
|
app_logging.logger.info("[APIPILOT-CALL-NAME] USE=PARSE_EMPTY_INPUT name=None markerFound=N")
|
||||||
|
return None, None
|
||||||
|
m = _APIPILOT_CALL_MARKER_RE.search(answer_text.strip())
|
||||||
|
inverted = False
|
||||||
|
if not m:
|
||||||
|
m = _APIPILOT_CALL_MARKER_INVERTED_RE.search(answer_text.strip())
|
||||||
|
inverted = bool(m)
|
||||||
|
if not m:
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[APIPILOT-CALL-NAME] USE=PARSE_NO_MATCH name=None markerFound=N textHead=%r", answer_text.strip()[:80]
|
||||||
|
)
|
||||||
|
return None, None
|
||||||
|
name_raw = m.group(1).strip()
|
||||||
|
args_raw = (m.group(2) if m.lastindex and m.lastindex >= 2 and isinstance(m.group(2), str) else "").strip()
|
||||||
|
if not name_raw:
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[APIPILOT-CALL-NAME] USE=PARSE_EMPTY_NAME name=None markerFound=Y argsRawHead=%r inverted=%s",
|
||||||
|
args_raw[:60],
|
||||||
|
("Y" if inverted else "N"),
|
||||||
|
)
|
||||||
|
return None, None
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[APIPILOT-CALL-NAME] USE=PARSE_MARKER_OK name=%r argsRawLen=%d markerFound=Y inverted=%s",
|
||||||
|
name_raw,
|
||||||
|
len(args_raw),
|
||||||
|
("Y" if inverted else "N"),
|
||||||
|
)
|
||||||
|
args: dict = {}
|
||||||
|
# Parse args_raw into a dict, tolerating common formatting variations.
|
||||||
|
if args_raw == "" or args_raw in {"{}", "null", "None"}:
|
||||||
|
args = {}
|
||||||
|
else:
|
||||||
|
# Attempt 1: standard JSON, with or without braces.
|
||||||
|
_candidate = args_raw
|
||||||
|
try:
|
||||||
|
args = json.loads(_candidate)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
# Attempt 2: supply missing outer braces.
|
||||||
|
args = json.loads("{" + _candidate + "}")
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
# Attempt 3: replace single quotes with double quotes.
|
||||||
|
args = json.loads(_candidate.replace("'", '"'))
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
# Attempt 4: supply braces and replace single quotes.
|
||||||
|
args = json.loads("{" + _candidate.replace("'", '"') + "}")
|
||||||
|
except Exception:
|
||||||
|
# Manual fallback 5: equals signs and commas.
|
||||||
|
# Example: args_raw="phone=+56,name=Mario" → dict.
|
||||||
|
args = {}
|
||||||
|
for piece in args_raw.split(","):
|
||||||
|
kv = piece.strip()
|
||||||
|
if not kv or "=" not in kv:
|
||||||
|
continue
|
||||||
|
k, v = kv.split("=", 1)
|
||||||
|
k = k.strip().strip('"').strip("'")
|
||||||
|
v = v.strip().strip('"').strip("'")
|
||||||
|
if k:
|
||||||
|
args[k] = v
|
||||||
|
if not isinstance(args, dict):
|
||||||
|
args = {}
|
||||||
|
# Stage 2: fill missing arguments from defaults (phone, etc.).
|
||||||
|
if isinstance(defaults, dict) and defaults:
|
||||||
|
for dk, dv in defaults.items():
|
||||||
|
if dk not in args or args[dk] in (None, "", []):
|
||||||
|
if dv not in (None, "", []):
|
||||||
|
args[dk] = dv
|
||||||
|
app_logging.logger.info(
|
||||||
|
"[APIPILOT-CALL-NAME] USE=PARSE_RETURN_FINAL name=%r finalArgsKeys=%s defaultsApplied=%s",
|
||||||
|
name_raw,
|
||||||
|
sorted(list(args.keys())),
|
||||||
|
("Y" if isinstance(defaults, dict) and defaults else "N"),
|
||||||
|
)
|
||||||
|
return name_raw, args
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_apipilot_reasoning_context_block(
|
||||||
|
tools_list: list[dict] | None,
|
||||||
|
*,
|
||||||
|
call_result: dict | None = None,
|
||||||
|
context_defaults: dict | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Build the ApiPilot context block for the reasoning prompt.
|
||||||
|
|
||||||
|
With call_result=None, pass 1 supplies the tool list and marker syntax
|
||||||
|
the model must emit when a description matches.
|
||||||
|
|
||||||
|
With call_result={'name':str, 'text':str, 'error':bool}, pass 2 injects the
|
||||||
|
execution result and requests a final spoken/text response for the user.
|
||||||
|
|
||||||
|
The optional context_defaults dict adds known default values to pass 1.
|
||||||
|
The model must use these exact values for required arguments rather than
|
||||||
|
inventing them. Usually contains {"phone": "+56..."} from inject_phone.
|
||||||
|
"""
|
||||||
|
# Pass 2: the tool result is already available.
|
||||||
|
if isinstance(call_result, dict) and call_result.get("name"):
|
||||||
|
name = call_result.get("name") or "?"
|
||||||
|
text = (call_result.get("text") if isinstance(call_result.get("text"), str) else "") or ""
|
||||||
|
is_err = bool(call_result.get("error"))
|
||||||
|
status_label = "ERROR" if is_err else "OK"
|
||||||
|
title = f"<<<APIPILOT_RESULT:STATUS={status_label},NAME={name}>>>"
|
||||||
|
trailer = (
|
||||||
|
"Now reinterpret THE RESULT TEXT (above) in clear, concise natural language, "
|
||||||
|
"exclusively in the user's language (language field; Spanish by default). "
|
||||||
|
"Do not mention the internal tool or technical details. "
|
||||||
|
"Respond ONLY with the final content intended for the user."
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
f"{APIPILOT_PASS2_RESULT_MARKER}PASS2>>>\n"
|
||||||
|
"### Invoked ApiPilot tool result\n"
|
||||||
|
f"{title}\n"
|
||||||
|
f"\n```tool-output\n"
|
||||||
|
f"{text.rstrip() if text.strip() else '(empty output)'}\n"
|
||||||
|
f"```\n\n"
|
||||||
|
f"{trailer}\n"
|
||||||
|
)
|
||||||
|
# Pass 1: available tools and marker rules.
|
||||||
|
tools = tools_list if isinstance(tools_list, list) else []
|
||||||
|
# Limit to 100 tools, although the guide specifies at most 50 for the demo.
|
||||||
|
tools_cap = tools[:100]
|
||||||
|
if not tools_cap:
|
||||||
|
return ""
|
||||||
|
entries: list[str] = []
|
||||||
|
for t in tools_cap:
|
||||||
|
if not isinstance(t, dict):
|
||||||
|
continue
|
||||||
|
name = t.get("name") if isinstance(t.get("name"), str) else ""
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
desc = cast(str, t.get("description")) if isinstance(t.get("description"), str) else ""
|
||||||
|
params = t.get("parameters") if isinstance(t.get("parameters"), dict) else None
|
||||||
|
requires_auth = bool(t.get("requires_auth"))
|
||||||
|
required_fields: list[str] = []
|
||||||
|
if isinstance(params, dict) and isinstance(params.get("required"), list):
|
||||||
|
required_fields = [str(x) for x in cast(list, params.get("required")) if isinstance(x, str)]
|
||||||
|
# Limit descriptions to about 500 characters to control prompt size.
|
||||||
|
desc_cut = desc[:500].rstrip()
|
||||||
|
if len(desc) > 500:
|
||||||
|
desc_cut += "…"
|
||||||
|
snippet = f"- name: {name}\n requires_auth: {('true' if requires_auth else 'false')}"
|
||||||
|
if required_fields:
|
||||||
|
snippet += f"\n required_parameters: [{', '.join(required_fields[:40])}]"
|
||||||
|
if desc_cut:
|
||||||
|
snippet += f"\n description: {desc_cut}"
|
||||||
|
entries.append(snippet)
|
||||||
|
if not entries:
|
||||||
|
return ""
|
||||||
|
rule = (
|
||||||
|
f"{APIPILOT_PASS1_LIST_MARKER}\n"
|
||||||
|
"### Available dynamic tools (ApiPilot AquaPortal)\n"
|
||||||
|
"\n"
|
||||||
|
"Each tool has a `name` field (a unique identifier: do NOT modify it, "
|
||||||
|
"use camelCase, or translate it) and a `description` describing its use case.\n"
|
||||||
|
"\n"
|
||||||
|
"MANDATORY EXECUTION RULES:\n"
|
||||||
|
"1. If the USER REQUEST (transcript) clearly matches the `description` of "
|
||||||
|
"EXACTLY one tool below, emit ONLY one line as the FIRST line of YOUR RESPONSE, "
|
||||||
|
"with this exact syntax:\n"
|
||||||
|
' <<<APIPILOT_CALL:NAME=tool_name,ARGUMENTS={"parameter1":"value1","parameter2":"value2"}>>>\n'
|
||||||
|
"2. ARGUMENTS={…} MUST be valid JSON (double quotes, commas, no comments). "
|
||||||
|
"Include ONLY fields listed in `required_parameters:`, if present.\n"
|
||||||
|
"3. Add NO text after the marker line: no explanations or previews. The engine "
|
||||||
|
"will execute the tool and supply its result in a second call.\n"
|
||||||
|
"4. If NO description matches, emit NO marker and respond normally in Spanish.\n"
|
||||||
|
"5. If multiple descriptions match, choose the most specific one and use the marker.\n"
|
||||||
|
"6. 🔴 EXCLUSIVE RESPONSE MODE (HIGHEST priority, mandatory): when using a CALL "
|
||||||
|
"marker, YOUR ENTIRE RESPONSE MUST CONTAIN ONLY THE MARKER LINE. "
|
||||||
|
"Write NOTHING BEFORE it: no 'Claro que sí', 'Voy a revisar', "
|
||||||
|
"'Voy a generar tu briefing', greeting, or transition. "
|
||||||
|
"Write NOTHING AFTER it: no comments, previews, or explanations. "
|
||||||
|
"Exactly one line containing the marker. Additional text prevents the engine "
|
||||||
|
"from recognizing the marker and executing the tool.\n"
|
||||||
|
"8. INCORRECT examples (NEVER do this):\n"
|
||||||
|
" 'Claro que sí, ahora ejecuto'\n"
|
||||||
|
" 'Voy a generar tu briefing ejecutivo personal.'\n"
|
||||||
|
" 'Claro que sí.\\n<<<APIPILOT_CALL:...>>>'\n"
|
||||||
|
" '<<<APIPILOT_CALL:...>>>\\nAhora proceso el resultado.'\n"
|
||||||
|
"9. 🔴 TTS ANTI-LEAK RULES (mandatory, HIGHEST audio priority):\n"
|
||||||
|
" <<<APIPILOT_CALL:...>>> IS AN INTERNAL SYSTEM SIGNAL and MUST NEVER be spoken,\n"
|
||||||
|
" read aloud, or synthesized by TTS. When emitting the marker:\n"
|
||||||
|
" - INTERNAL REASONING (text/transcript) MUST contain the exact marker line.\n"
|
||||||
|
" - FINAL TTS AUDIO heard by the user MUST contain ONLY this placeholder:\n"
|
||||||
|
' "Voy a consultar tus datos, un momento por favor."\n'
|
||||||
|
" - NEVER speak any of these technical terms in final TTS audio:\n"
|
||||||
|
" APIPILOT, CALL, ARGUMENTS, tool, herramienta, ejecutar, function, function call,\n"
|
||||||
|
" marcador, marker, even to explain or describe the call.\n"
|
||||||
|
"\n"
|
||||||
|
)
|
||||||
|
# 2026-09-25 known defaults: inject values already resolved from the URL or
|
||||||
|
# profile, such as phoneNumber, so tool arguments use those exact values.
|
||||||
|
if isinstance(context_defaults, dict) and context_defaults:
|
||||||
|
defaults_lines = []
|
||||||
|
for dk, dv in context_defaults.items():
|
||||||
|
if dv in (None, "", []):
|
||||||
|
continue
|
||||||
|
if isinstance(dv, (dict, list)):
|
||||||
|
try:
|
||||||
|
dv_repr = json.dumps(dv, ensure_ascii=False)
|
||||||
|
except Exception:
|
||||||
|
dv_repr = str(dv)
|
||||||
|
else:
|
||||||
|
dv_repr = str(dv)
|
||||||
|
defaults_lines.append(f'- {dk} = "{dv_repr}"')
|
||||||
|
if defaults_lines:
|
||||||
|
rule += (
|
||||||
|
"KNOWN DEFAULT VALUES (use these exact values in ARGUMENTS={…} when "
|
||||||
|
"the corresponding tool lists these names in required_parameters; "
|
||||||
|
"do not invent, translate, or normalize them):\n"
|
||||||
|
f"{chr(10).join(defaults_lines)}\n"
|
||||||
|
"\n"
|
||||||
|
)
|
||||||
|
rule += f"Tool list (name / description):\n{chr(10).join(entries)}\n<<<APIPILOT_TOOLS_LIST_END>>>"
|
||||||
|
return rule
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_pass2_internal_context(text: str) -> str:
|
||||||
|
"""Lightly sanitize the pass-2 block injected into the upstream model.
|
||||||
|
|
||||||
|
Unlike _sanitize_text_strip_apipilot_markers, preserve the pass-2 wrapper:
|
||||||
|
the model needs it to recognize tool data. Limit sanitation to filesystem
|
||||||
|
path leaks, fragmented marker tokens, and contextual phone leaks.
|
||||||
|
"""
|
||||||
|
if not isinstance(text, str) or not text.strip():
|
||||||
|
return text
|
||||||
|
# Block 3: Unix/Windows/TRAE path leaks, matching the user-output sanitizer.
|
||||||
|
import re as _re_pass2
|
||||||
|
|
||||||
|
text = _re_pass2.sub(
|
||||||
|
r"(?i)(?:/[A-Za-z0-9_.\-@~]+){2,}/(?:\.trae[^\s\"']*|workspaceStorage[^\s\"']*|User[^\s\"']*|home[^\s\"']*)",
|
||||||
|
" ",
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
text = _re_pass2.sub(r"(?i)(?:[A-Z]:)?[\\/](?:Users|home|root|tmp|var|mnt|opt|srv)[\\/][^\s\"']{5,}", " ", text)
|
||||||
|
text = _re_pass2.sub(r"(?i)(?:\.trae-server|\.trae[^\s\"']{0,40}|workspaceStorage[0-9a-f]+)", " ", text)
|
||||||
|
text = _re_pass2.sub(r"\s{2,}", " ", text)
|
||||||
|
# Block 4: fragmented marker leaks, tier 4.
|
||||||
|
tier4_out_parts: list[str] = []
|
||||||
|
for _line in text.splitlines(keepends=True):
|
||||||
|
_orig = _line
|
||||||
|
_low = _line.lower()
|
||||||
|
if "apipilot" in _low and (
|
||||||
|
("call" in _low and "name=" in _low) or ("arguments=" in _low) or ("result" in _low and "pass" in _low)
|
||||||
|
):
|
||||||
|
if "<<<" in _line or ">>>" in _line:
|
||||||
|
_line = _re_pass2.sub(
|
||||||
|
r"[<]{1,3}\s*APIPILOT_(?:CALL|RESULT)[^<>\n]{0,500}[>]{1,3}",
|
||||||
|
" ",
|
||||||
|
_line,
|
||||||
|
flags=_re_pass2.IGNORECASE,
|
||||||
|
)
|
||||||
|
if "apipilot" in _line.lower():
|
||||||
|
_line = _re_pass2.sub(
|
||||||
|
r"(?i)\bAPIPILOT(?:_CALL|_RESULT)?\b",
|
||||||
|
" ",
|
||||||
|
_line,
|
||||||
|
)
|
||||||
|
_line = _re_pass2.sub(
|
||||||
|
r"(?i)\b(?:NAME|ARGUMENTS|STATUS|REV|PASS2)\s*[:=]",
|
||||||
|
" ",
|
||||||
|
_line,
|
||||||
|
)
|
||||||
|
tier4_out_parts.append(_line if _line.strip() else _orig)
|
||||||
|
text = "".join(tier4_out_parts)
|
||||||
|
text = _re_pass2.sub(r"\s{2,}", " ", text)
|
||||||
|
return text.strip() if isinstance(text, str) else text
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_text_strip_apipilot_markers(
|
||||||
|
text: str,
|
||||||
|
*,
|
||||||
|
placeholder: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Remove all internal ApiPilot markers from user-visible chat and TTS text.
|
||||||
|
|
||||||
|
Replace pass-1 CALL markers with a Spanish placeholder. Silently remove
|
||||||
|
pass-2 RESULT markers, their heading, tool-output block and instruction
|
||||||
|
trailer. These server-only signals must never reach the user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Text to sanitize.
|
||||||
|
placeholder: Optional replacement; defaults to the Spanish UI/voice message.
|
||||||
|
"""
|
||||||
|
if not isinstance(text, str) or not text:
|
||||||
|
return text
|
||||||
|
if placeholder is None:
|
||||||
|
placeholder = "Voy a consultar tus datos, un momento por favor."
|
||||||
|
|
||||||
|
# ================================================================
|
||||||
|
# Block 1: silently remove pass-2 RESULT markers and wrappers from TTS/UI.
|
||||||
|
# ================================================================
|
||||||
|
# (1a) Full pass-2 wrapper first: marker, heading, status, output, trailer.
|
||||||
|
pass2_full_block_pattern = re.compile(
|
||||||
|
r"[ \t]*(?:\r?\n|\r)?[ \t]*"
|
||||||
|
r"<<<APIPILOT_RESULT:PASS2>>>[ \t]*(?:\r?\n|\r)+"
|
||||||
|
r"###[ \t]+(?:Resultado de la herramienta ApiPilot invocada|Invoked ApiPilot tool result)[ \t]*(?:\r?\n|\r)+"
|
||||||
|
r"<<<APIPILOT_RESULT:STATUS=[A-Za-z_]+,NAME=[A-Za-z0-9_.-]+>>>[ \t]*(?:\r?\n|\r)*"
|
||||||
|
r"(?:\r?\n|\r)?"
|
||||||
|
r"```tool-output[ \t]*(?:\r?\n|\r)?"
|
||||||
|
r"(?P<tool_body>.*?)"
|
||||||
|
r"(?:\r?\n|\r)?```[ \t]*(?:\r?\n|\r){0,3}"
|
||||||
|
r"(?:"
|
||||||
|
r"(?:Ahora debes reinterpretar EL TEXTO RESULTADO|Now reinterpret THE RESULT TEXT)[\s\S]{0,1200}?"
|
||||||
|
r"(?:\r?\n|\r){0,2}"
|
||||||
|
r")?"
|
||||||
|
r"[ \t]*",
|
||||||
|
re.IGNORECASE | re.DOTALL,
|
||||||
|
)
|
||||||
|
text = pass2_full_block_pattern.sub("", text)
|
||||||
|
|
||||||
|
# (1b) Standalone PASS2 markers when the wrapper is incomplete or fragmented.
|
||||||
|
pass2_marker_pattern = re.compile(
|
||||||
|
r"[ \t]*(?:\r?\n|\r)?[ \t]*<<<APIPILOT_RESULT(?::PASS2|:STATUS=[A-Za-z_]+,NAME=[A-Za-z0-9_.-]+)?>>>[ \t]*(?:\r?\n|\r)?[ \t]*",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
text = pass2_marker_pattern.sub("", text)
|
||||||
|
|
||||||
|
# (1c) Residual Markdown heading after the marker has been removed.
|
||||||
|
pass2_title_pattern = re.compile(
|
||||||
|
r"[ \t]*(?:\r?\n|\r)?[ \t]*###[ \t]+(?:Resultado de la herramienta ApiPilot invocada|Invoked ApiPilot tool result)[ \t]*(?:\r?\n|\r)?[ \t]*",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
text = pass2_title_pattern.sub("", text)
|
||||||
|
|
||||||
|
# (1d) Standalone tool-output blocks without a PASS2 wrapper.
|
||||||
|
pass2_codeblock_pattern = re.compile(
|
||||||
|
r"```tool-output[ \t]*(?:\r?\n|\r)?.*?(?:\r?\n|\r)?```",
|
||||||
|
re.IGNORECASE | re.DOTALL,
|
||||||
|
)
|
||||||
|
text = pass2_codeblock_pattern.sub("", text)
|
||||||
|
|
||||||
|
# (1e) Residual instruction trailers missed by the full-wrapper pattern.
|
||||||
|
pass2_trailer_pattern = re.compile(
|
||||||
|
r"(?:Ahora debes reinterpretar EL TEXTO RESULTADO[\s\S]{0,1200}?contenido final que le quieres transmitir al usuario|Now reinterpret THE RESULT TEXT[\s\S]{0,1200}?final content intended for the user)\.",
|
||||||
|
re.IGNORECASE | re.DOTALL,
|
||||||
|
)
|
||||||
|
text = pass2_trailer_pattern.sub("", text)
|
||||||
|
# Broader fallback also handles trailers truncated before their closing text.
|
||||||
|
pass2_trailer_fallback = re.compile(
|
||||||
|
r"(?:Ahora debes reinterpretar[\s\S]{0,1500}?(?:al usuario\.|$)|Now reinterpret THE RESULT TEXT[\s\S]{0,1500}?(?:for the user\.|$))",
|
||||||
|
re.IGNORECASE | re.DOTALL,
|
||||||
|
)
|
||||||
|
text = pass2_trailer_fallback.sub("", text)
|
||||||
|
# Handle residual middle fragments after the trailer's opening was removed.
|
||||||
|
pass2_trailer_broad = re.compile(
|
||||||
|
r"(?:en lenguaje natural[\s\S]{0,800}?contenido final que le quieres transmitir al usuario|in clear, concise natural language[\s\S]{0,800}?final content intended for the user)\.?",
|
||||||
|
re.IGNORECASE | re.DOTALL,
|
||||||
|
)
|
||||||
|
text = pass2_trailer_broad.sub("", text)
|
||||||
|
|
||||||
|
# ================================================================
|
||||||
|
# Block 2: pass-1 CALL marker replacement with a Spanish placeholder.
|
||||||
|
# ================================================================
|
||||||
|
marker_full_pattern = re.compile(
|
||||||
|
r"<<<APIPILOT_CALL:NAME=[A-Za-z0-9_.-]+,ARGUMENTS=.*?>>>",
|
||||||
|
re.IGNORECASE | re.DOTALL,
|
||||||
|
)
|
||||||
|
line_wrapped_pattern = re.compile(
|
||||||
|
r"[ \t]*(?:\r?\n|\r)?[ \t]*<<<APIPILOT_CALL:NAME=[A-Za-z0-9_.-]+,ARGUMENTS=.*?>>>[ \t]*(?:\r?\n|\r)?[ \t]*",
|
||||||
|
re.IGNORECASE | re.DOTALL,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _replacement_full(_m: re.Match) -> str:
|
||||||
|
"""Replace standalone or inline markers with the spoken placeholder."""
|
||||||
|
return placeholder
|
||||||
|
|
||||||
|
text = line_wrapped_pattern.sub(_replacement_full, text)
|
||||||
|
text = marker_full_pattern.sub(_replacement_full, text)
|
||||||
|
|
||||||
|
# Remove truncated CALL markers such as <<<APIPILOT_CALL:NAME=tool,ARGS….
|
||||||
|
trunc_call_pattern = re.compile(
|
||||||
|
r"<<<APIPILOT_CALL(?::NAME=[A-Za-z0-9_.-]+,?[^>\n]{0,500}?)?>>*",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
text = trunc_call_pattern.sub(_replacement_full, text)
|
||||||
|
|
||||||
|
# Make closing brackets optional to remove RESULT markers truncated by TTS,
|
||||||
|
# such as <<<APIPILOT_RESULT:STATUS=OK,NAM without closing >>>.
|
||||||
|
trunc_result_pattern = re.compile(
|
||||||
|
r"<<<APIPILOT_RESULT(?::[A-Za-z0-9_=,:.\- ]{0,800}?)?>?>?>?",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
text = trunc_result_pattern.sub("", text)
|
||||||
|
# Remove remaining APIPILOT_RESULT fragments after partial sanitation.
|
||||||
|
trunc_result_residual = re.compile(
|
||||||
|
r"<{0,3}APIPILOT_RESULT(?::[^\s<]{0,800})?<{0,3}",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
text = trunc_result_residual.sub("", text)
|
||||||
|
|
||||||
|
# Collapse whitespace left by pass-2 block removal.
|
||||||
|
text = re.sub(r"[ \t]{2,}", " ", text)
|
||||||
|
text = re.sub(r"\n{3,}", "\n\n", text)
|
||||||
|
# Remove residual trailer fragments and empty parentheses before TTS.
|
||||||
|
text = re.sub(r"\s*\((?:arriba|above)\)\s*", " ", text, flags=re.IGNORECASE)
|
||||||
|
text = re.sub(r"\(\s*\)", "", text)
|
||||||
|
# ================================================================
|
||||||
|
# Block 3: remove filesystem paths and host information leaked into TTS.
|
||||||
|
# Match long absolute Unix/Windows/mixed paths and known workspace tokens.
|
||||||
|
# ================================================================
|
||||||
|
# (3a) Absolute Unix paths and nested paths with multiple separators.
|
||||||
|
unix_path_pattern = re.compile(
|
||||||
|
r"\b(?:/(?:home|tmp|var|root|opt|mnt|media|srv|proc|sys|etc|usr|Users|trae)[/\w@.+\-:#$%^&()[\]{}!~=,]*){2,}[A-Za-z0-9_./\-+@#$%^&()[\]{}!~=,:]*\.(?:txt|md|log|json|csv|pdf|doc|docx|py|js|ts|html|css|sh|yaml|yml|env|conf|cfg|ini|mp3|wav|ogg|png|jpg|jpeg|gif)\b"
|
||||||
|
r"|"
|
||||||
|
# Broad fallback for long absolute Unix paths under known directories,
|
||||||
|
# including paths without extensions; avoid short URLs such as /api/v1.
|
||||||
|
r"(?:^|\s+|\(|[,.;:!?])(/(?:home|tmp|var|root|opt|mnt|media|srv|proc|sys|etc|usr|Users|trae)[/A-Za-z0-9_\-+@#$%^&()[\]{}!~=,.]{10,})(?=$|\s|\)|[,.;:!?])",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
text = unix_path_pattern.sub("", text)
|
||||||
|
# (3b) Windows paths with single/double backslashes and mixed separators.
|
||||||
|
win_path_pattern = re.compile(
|
||||||
|
r"(?:^|\s|\(|[,.;:])"
|
||||||
|
r"([A-Za-z]:[\\/]"
|
||||||
|
r"|(?:[\\/]{1,2}(?:home|Users|Windows|Program|Program\sFiles|ProgramData|Users|trae|AppData)[\\/])"
|
||||||
|
r"|(?:[\\/]{1,2}\.(?:trae|trae-server|vscode|config|cache)[\\/])"
|
||||||
|
r")"
|
||||||
|
r"[A-Za-z0-9_@.+\-:#$%^&()[\]{}!~=,\\/\s]{10,}"
|
||||||
|
r"(?:\.(?:txt|md|log|json|csv|pdf|doc|docx|py|js|ts|html|css|sh|yaml|yml|env|conf|cfg|ini|mp3|wav|ogg|png|jpg|jpeg|gif))?"
|
||||||
|
r"(?=$|\s|\)|[,.;:!?])",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
text = win_path_pattern.sub("", text)
|
||||||
|
# (3c) TRAE workspace tokens, including fragments without complete paths.
|
||||||
|
trae_leak_tokens = re.compile(
|
||||||
|
r"\b(?:workspaceStorage|long-text|\.trae-server|\.trae|ckg_server|ai-agent)\b[^\s()'\"`,.]{0,80}",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
text = trae_leak_tokens.sub("", text)
|
||||||
|
# (3d) Remove long tokens containing mixed separators or many path segments.
|
||||||
|
mixed_path_pattern = re.compile(
|
||||||
|
r"(?=\S{0,200})"
|
||||||
|
r"(?=\S*(?:[/\\].*){5,})"
|
||||||
|
r"(?:\S{10,})",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _mixed_safe(m: re.Match[str]) -> str:
|
||||||
|
s = m.group(0)
|
||||||
|
has_slash = "/" in s
|
||||||
|
has_back = "\\" in s
|
||||||
|
if (has_slash and has_back) or s.count("/") + s.count("\\") >= 6:
|
||||||
|
return ""
|
||||||
|
return s
|
||||||
|
|
||||||
|
text = mixed_path_pattern.sub(_mixed_safe, text)
|
||||||
|
# ================================================================
|
||||||
|
# Block 4: remove fragmented APIPILOT CALL text from TTS. The upstream
|
||||||
|
# pipeline (start_pipeline_rag / Qwen3-TTS) can remove <<< >>> and read:
|
||||||
|
# "APIPILOT CALL name get_user_briefing arguments phone +56912345678"
|
||||||
|
# The bracketed patterns cannot catch this, so use contextual fallback tiers.
|
||||||
|
# ================================================================
|
||||||
|
# Tier 1: full normalized form without braces, through the sentence ending.
|
||||||
|
call_normalized_full = re.compile(
|
||||||
|
r"(?:^|\s+|\(|[,.;:!?\"\'])\s*"
|
||||||
|
r"(?:"
|
||||||
|
r"APIPILOT[\s_\-]+CALL\s+(?:marker\s+)?(?:de\s+)?(?:señal\s+)?(?:herramienta\s+)?(?:funci[oó]n\s+)?"
|
||||||
|
r"|marcador\s+APIPILOT(?:\s+CALL)?\s*"
|
||||||
|
r"|señal\s+de\s+llamada\s+APIPILOT\s*"
|
||||||
|
r"|llamada\s+(?:a\s+)?(?:la\s+)?(?:herramienta|funci[oó]n)\s+APIPILOT\s*"
|
||||||
|
r")"
|
||||||
|
r"(?:\s*(?:con\s+)?(?:identificador|ID|name|nombre)\s*[:=]?\s*[A-Za-z0-9_.-]+)?"
|
||||||
|
r"(?:\s*(?:y\s+)?(?:con\s+)?(?:argumentos|ARGUMENTS|params|par[áa]metros)\s*[:=]?\s*(?:\{[^{}]{0,800}\}|[\"'][^\"']{0,200}[\"']|[^\s.!,?;]{3,100}(?:\s*[,:]\s*[^\s.!,?;]{3,100})*\s*)?)?"
|
||||||
|
r"(?:\s*(?:phone|tel[ée]fono|correo|email)\s*[:=]?\s*[+0-9A-Za-z._@\-]{6,50})?"
|
||||||
|
r"(?=$|\s+|\)|[,.;:!?\"\'])",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
text = call_normalized_full.sub(placeholder, text)
|
||||||
|
# Tier 2: shortened forms with APIPILOT_CALL or adjacent APIPILOT and CALL.
|
||||||
|
apipilot_call_token = re.compile(
|
||||||
|
r"(?:^|\s|\(|[,.;:!?\"\'])\s*"
|
||||||
|
r"APIPILOT(?:[\s_\-]+CALL|_CALL|\s+llamada|\s+marker)"
|
||||||
|
r"(?:[^.!,?;\n]{0,600})?"
|
||||||
|
r"(?=$|\s|\)|[,.;:!?\"\'])",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
text = apipilot_call_token.sub(placeholder, text)
|
||||||
|
# Tier 3: standalone service tokens in call/name/argument contexts.
|
||||||
|
service_token_context = re.compile(
|
||||||
|
r"(?:"
|
||||||
|
r"ARGUMENTS\s*[:=]\s*(?:\{[^{}]{0,800}\}|[^\s,.;!?]{3,100})"
|
||||||
|
r"|\barguments\s+(?:de\s+)?(?:la\s+)?(?:llamada|herramienta|funci[oó]n)\b[^\n.]{0,200}"
|
||||||
|
r")",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
text = service_token_context.sub("", text)
|
||||||
|
|
||||||
|
# Tier 4: remove sentences containing APIPILOT with technical call context.
|
||||||
|
def _apipilot_sentence(m: re.Match[str]) -> str:
|
||||||
|
sentence = m.group(0)
|
||||||
|
lowered = sentence.lower()
|
||||||
|
# Preserve a sentence already containing the Spanish CALL placeholder,
|
||||||
|
# including any natural text surrounding it.
|
||||||
|
if "voy a consultar tus datos" in lowered:
|
||||||
|
return sentence
|
||||||
|
ctx_kw = (
|
||||||
|
"name",
|
||||||
|
"argument",
|
||||||
|
"arg",
|
||||||
|
"herramienta",
|
||||||
|
"tool",
|
||||||
|
"funci",
|
||||||
|
"phone",
|
||||||
|
"tel",
|
||||||
|
"+56",
|
||||||
|
"+44",
|
||||||
|
"marker",
|
||||||
|
"call",
|
||||||
|
"llamada",
|
||||||
|
"señal",
|
||||||
|
"marcador",
|
||||||
|
)
|
||||||
|
if any(k in lowered for k in ctx_kw) and "apipilot" in lowered:
|
||||||
|
return ""
|
||||||
|
return sentence
|
||||||
|
|
||||||
|
catchall_sentence = re.compile(
|
||||||
|
r"[^.?!;\n]{0,600}APIPILOT[^.?!;\n]{0,600}[.?!;\n]{0,1}",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
text = catchall_sentence.sub(_apipilot_sentence, text)
|
||||||
|
# Tier 5: replace any remaining APIPILOT CALL tokens, even without context.
|
||||||
|
final_apipilot_leak = re.compile(
|
||||||
|
r"\bAPIPILOT[\s_\-]+CALL\b|\bAPIPILOT_CALL\b",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
text = final_apipilot_leak.sub(placeholder, text)
|
||||||
|
# Tier 6: remove residual fake/real phone numbers from internal call text.
|
||||||
|
# Match compact and spaced forms to tolerate TTS normalization.
|
||||||
|
fake_phone_pattern = re.compile(
|
||||||
|
r"("
|
||||||
|
# Compact forms without spaces:
|
||||||
|
r"\+56912345678"
|
||||||
|
r"|\+1234567890"
|
||||||
|
r"|\+447432477433"
|
||||||
|
r"|56912345678"
|
||||||
|
r"|1234567890"
|
||||||
|
r"|447432477433"
|
||||||
|
# Spaced forms with punctuation:
|
||||||
|
r"|\+?\s*56\s*[\s\-()]?9\s*[\s\-()]?1\s*[\s\-()]?2\s*[\s\-()]?3\s*[\s\-()]?4\s*[\s\-()]?5\s*[\s\-()]?6\s*[\s\-()]?7\s*[\s\-()]?8"
|
||||||
|
r"|\+?\s*1\s*[\s\-()]?2\s*[\s\-()]?3\s*[\s\-()]?4\s*[\s\-()]?5\s*[\s\-()]?6\s*[\s\-()]?7\s*[\s\-()]?8\s*[\s\-()]?9\s*[\s\-()]?0"
|
||||||
|
r"|\+?\s*44\s*[\s\-()]?7\s*[\s\-()]?4\s*[\s\-()]?3\s*[\s\-()]?2\s*[\s\-()]?4\s*[\s\-()]?7\s*[\s\-()]?7\s*[\s\-()]?4\s*[\s\-()]?3\s*[\s\-()]?3"
|
||||||
|
# Generic labeled forms (English or Spanish phone labels):
|
||||||
|
r"|phone\s*[:=]\s*[+()\-\s]*[0-9][+()\-\s0-9]{6,}"
|
||||||
|
r"|tel(?:éfono|efono)?\s*[:=]\s*[+()\-\s]*[0-9][+()\-\s0-9]{6,}"
|
||||||
|
r")",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
# Strip numbers only from lines with technical call context or the Spanish
|
||||||
|
# placeholder, indicating that the number belongs to a replaced CALL marker.
|
||||||
|
lines = text.splitlines(keepends=True)
|
||||||
|
_phone_ctx = (
|
||||||
|
"apipilot",
|
||||||
|
"call",
|
||||||
|
"arguments",
|
||||||
|
"marker",
|
||||||
|
"herramienta",
|
||||||
|
"funcion",
|
||||||
|
"función",
|
||||||
|
"ejecutar",
|
||||||
|
"llamada",
|
||||||
|
"marcador",
|
||||||
|
"señal",
|
||||||
|
"voy a consultar tus datos",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _phone_line_safe(line: str) -> str:
|
||||||
|
lowered = line.lower()
|
||||||
|
has_ctx = any(c in lowered for c in _phone_ctx)
|
||||||
|
if has_ctx:
|
||||||
|
# Remove phone numbers in this line (strip also surrounding space)
|
||||||
|
return fake_phone_pattern.sub(" ", line)
|
||||||
|
return line
|
||||||
|
|
||||||
|
text = "".join(_phone_line_safe(line) for line in lines)
|
||||||
|
# Collapse whitespace left by marker and path removal.
|
||||||
|
text = re.sub(r"[ \t]{2,}", " ", text)
|
||||||
|
text = re.sub(r"\n{3,}", "\n\n", text)
|
||||||
|
return text.strip()
|
||||||
@@ -0,0 +1,559 @@
|
|||||||
|
"""Golden wire behavior captured before the tooling/structure migration.
|
||||||
|
|
||||||
|
Fixtures include permissive payloads and existing failures deliberately. Changing
|
||||||
|
them requires a separate, explicitly approved API behavior change.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {}, clear=True), patch("dotenv.load_dotenv", return_value=False):
|
||||||
|
from s2s_client import main as server
|
||||||
|
from s2s_client.core import config as settings
|
||||||
|
from s2s_client.providers import apipilot
|
||||||
|
from s2s_client.realtime import proxy
|
||||||
|
from s2s_client.services import context, reasoning
|
||||||
|
|
||||||
|
ASYNC_CLIENT = httpx.AsyncClient
|
||||||
|
FIXTURE = Path(__file__).parent / "fixtures" / "legacy-contracts.json"
|
||||||
|
BASE_CONFIG = {
|
||||||
|
"LOAD_BALANCER_URL": "",
|
||||||
|
"SPEECH_TO_SPEECH_URL": "",
|
||||||
|
"S2S_PROXY_TARGET": "",
|
||||||
|
"S2S_PRIMARY_PROMPT": "",
|
||||||
|
"SERPER_KEY": "",
|
||||||
|
"S2S_REASONING_TYPE": "PROXY",
|
||||||
|
"S2S_INJECT_PROFILE": False,
|
||||||
|
"S2S_INJECT_REPORTS": False,
|
||||||
|
"S2S_DEBUG_PROMPT": False,
|
||||||
|
"S2S_START_SESSION_MODE": "",
|
||||||
|
"S2S_THEME": "",
|
||||||
|
"VOCERO_USER_PHONE_AUTH_URL": "",
|
||||||
|
"S2S_REPORT_LIST": "",
|
||||||
|
"S2S_LOGIN_BY_PHONE": "",
|
||||||
|
"S2S_LATEST_SUMMARY": "",
|
||||||
|
"S2S_LATEST_SUMMARY_TEXT": "",
|
||||||
|
"S2S_APIPILOT_TOOLS_URL": "",
|
||||||
|
"S2S_APIPILOT_TOOLS_EXECUTE_URL": "",
|
||||||
|
"S2S_APIPILOT_SERVICE_TOKEN": "",
|
||||||
|
"S2S_APIPILOT_API_KEY": "",
|
||||||
|
"OPENAI_API_KEY": "",
|
||||||
|
"NODE_RED_REASONING_URL": "",
|
||||||
|
"S2S_REPORTS_QUICK_HINT_MAX": 5,
|
||||||
|
"S2S_REPORTS_INJECT_MODE": "minimal",
|
||||||
|
}
|
||||||
|
TOOL = {
|
||||||
|
"name": "fixture_tool",
|
||||||
|
"description": "A fixture tool.",
|
||||||
|
"parameters": {"type": "object", "properties": {"count": {"type": "integer"}}, "required": ["count"]},
|
||||||
|
"requires_auth": False,
|
||||||
|
"future_definition_field": {"nested": True},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def cases():
|
||||||
|
"""Each case controls all upstream IO; no deployment services are contacted."""
|
||||||
|
yield "config-default", "GET", "/api/config", {}, {}, []
|
||||||
|
yield (
|
||||||
|
"config-proxy",
|
||||||
|
"GET",
|
||||||
|
"/api/config",
|
||||||
|
{
|
||||||
|
"headers": {"x-forwarded-proto": "https", "x-forwarded-host": "voice.test, internal.test"},
|
||||||
|
},
|
||||||
|
{"S2S_PROXY_TARGET": "ws://private.test/voice"},
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
yield (
|
||||||
|
"config-direct",
|
||||||
|
"GET",
|
||||||
|
"/api/config",
|
||||||
|
{},
|
||||||
|
{"SPEECH_TO_SPEECH_URL": "wss://voice.test/realtime?token=fixture"},
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
yield "config-lb", "GET", "/api/config", {}, {"LOAD_BALANCER_URL": "http://lb.test"}, []
|
||||||
|
for path in ("/api/me", "/api/session/heartbeat", "/api/session/end", "/", "/server.py"):
|
||||||
|
yield f"removed-{path}", "GET", path, {}, {}, []
|
||||||
|
for path in ("/project", "/project/", "/project/index.html", "/public/docs.html"):
|
||||||
|
yield f"redirect-{path}", "GET", path, {}, {}, []
|
||||||
|
for path in ("/api/session", "/api/queue/end"):
|
||||||
|
yield f"disabled-{path}", "POST", path, {}, {}, []
|
||||||
|
for method in ("GET", "DELETE"):
|
||||||
|
yield f"disabled-queue-{method}", method, "/api/queue/ticket", {}, {}, []
|
||||||
|
|
||||||
|
for name, payload in (
|
||||||
|
("missing", {}),
|
||||||
|
("blank", {"query": " "}),
|
||||||
|
("wrong-type", {"query": 17}),
|
||||||
|
("null", {"query": None}),
|
||||||
|
("unconfigured", {"query": "hello"}),
|
||||||
|
):
|
||||||
|
yield f"search-{name}", "POST", "/api/search", {"json": payload}, {}, []
|
||||||
|
search_config = {"SERPER_KEY": "server-fixture-key"}
|
||||||
|
yield (
|
||||||
|
"search-success-extra-fields",
|
||||||
|
"POST",
|
||||||
|
"/api/search",
|
||||||
|
{
|
||||||
|
"json": {
|
||||||
|
"query": " hello ",
|
||||||
|
"key": "caller-fixture-key",
|
||||||
|
"unknown_request_field": [1, 2],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
search_config,
|
||||||
|
[
|
||||||
|
(
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
"answerBox": {"answer": "Answer"},
|
||||||
|
"organic": [
|
||||||
|
{"title": "Title", "snippet": "Text", "link": "https://example.test", "unknown": 1},
|
||||||
|
],
|
||||||
|
"future_upstream": {"nested": True},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
yield (
|
||||||
|
"search-loose-values",
|
||||||
|
"POST",
|
||||||
|
"/api/search",
|
||||||
|
{"json": {"query": "hello"}},
|
||||||
|
search_config,
|
||||||
|
[
|
||||||
|
(
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
"answerBox": {"answer": {"future": [1, None]}},
|
||||||
|
"organic": [{"title": None, "snippet": 3, "link": {"href": "unusual"}}, {}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
yield (
|
||||||
|
"search-upstream-error",
|
||||||
|
"POST",
|
||||||
|
"/api/search",
|
||||||
|
{"json": {"query": "hello"}},
|
||||||
|
search_config,
|
||||||
|
[(429, {"message": "Fixture credits exhausted"})],
|
||||||
|
)
|
||||||
|
yield (
|
||||||
|
"search-invalid-upstream-json",
|
||||||
|
"POST",
|
||||||
|
"/api/search",
|
||||||
|
{"json": {"query": "hello"}},
|
||||||
|
search_config,
|
||||||
|
[(200, b"not-json")],
|
||||||
|
)
|
||||||
|
|
||||||
|
yield "profile-missing-phone", "GET", "/api/user/phone", {}, {}, []
|
||||||
|
yield "profile-unconfigured", "GET", "/api/user/phone?phoneNumber=123", {}, {}, []
|
||||||
|
profile_config = {"VOCERO_USER_PHONE_AUTH_URL": "http://profile.test/user"}
|
||||||
|
yield "profile-blank-phone", "GET", "/api/user/phone?phoneNumber=%20", {}, profile_config, []
|
||||||
|
for name, status, payload in (
|
||||||
|
("object", 200, {"result": {"name": "Caller", "unknown": {"nested": [1, None]}}}),
|
||||||
|
("array", 200, [1, {"future": "field"}]),
|
||||||
|
("null", 200, None),
|
||||||
|
("string", 200, "opaque profile"),
|
||||||
|
("error", 403, {"future_error": True}),
|
||||||
|
("invalid-json", 200, b"not-json"),
|
||||||
|
):
|
||||||
|
yield (
|
||||||
|
f"profile-{name}",
|
||||||
|
"GET",
|
||||||
|
"/api/user/phone?phoneNumber=%2B56000000000",
|
||||||
|
{},
|
||||||
|
profile_config,
|
||||||
|
[(status, payload)],
|
||||||
|
)
|
||||||
|
yield (
|
||||||
|
"profile-login-token",
|
||||||
|
"GET",
|
||||||
|
"/api/user/phone?phoneNumber=123",
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
**profile_config,
|
||||||
|
"S2S_LOGIN_BY_PHONE": "http://profile.test/login",
|
||||||
|
},
|
||||||
|
[(200, {"result": {"data": {"accessToken": "fixture-token", "future": True}}}), (200, {"name": "Caller"})],
|
||||||
|
)
|
||||||
|
|
||||||
|
yield "reports-disabled", "GET", "/api/user/reports", {}, {}, []
|
||||||
|
yield "reports-unconfigured", "GET", "/api/user/reports", {}, {"S2S_INJECT_REPORTS": True}, []
|
||||||
|
reports_config = {"S2S_INJECT_REPORTS": True, "S2S_REPORT_LIST": "http://reports.test/list"}
|
||||||
|
yield "reports-empty", "GET", "/api/user/reports", {}, reports_config, [(200, [])]
|
||||||
|
yield "reports-upstream-failure", "GET", "/api/user/reports", {}, reports_config, [(503, {"error": "fixture"})]
|
||||||
|
yield (
|
||||||
|
"reports-normalization",
|
||||||
|
"GET",
|
||||||
|
"/api/user/reports?phoneNumber=123",
|
||||||
|
{},
|
||||||
|
reports_config,
|
||||||
|
[
|
||||||
|
(
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
"result": [
|
||||||
|
{
|
||||||
|
"sourceScheduleLabel": "Daily",
|
||||||
|
"date": "2026-01-02T12:00:00Z",
|
||||||
|
"podcastScript": "private script",
|
||||||
|
"future": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sourceScheduleLabel": "Daily",
|
||||||
|
"date": "2026-01-01T12:00:00Z",
|
||||||
|
"podcastScript": "older script",
|
||||||
|
},
|
||||||
|
{"sourceSchedule": {"name": "Weekly", "date": "2026-01-03T12:00:00Z"}, "text": "weekly script"},
|
||||||
|
],
|
||||||
|
"future": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
lb_config = {"LOAD_BALANCER_URL": "http://lb.test"}
|
||||||
|
for name, status, payload in (
|
||||||
|
(
|
||||||
|
"grant",
|
||||||
|
200,
|
||||||
|
{"session_id": "session", "connect_url": "ws://voice.test?token=fixture", "future": {"nested": True}},
|
||||||
|
),
|
||||||
|
("camelcase", 200, {"sessionId": "session", "connectUrl": "ws://voice.test", "unknown": None}),
|
||||||
|
("queued", 200, {"state": "queued", "queue_id": "ticket", "position": 3, "future": [1]}),
|
||||||
|
("empty", 200, {}),
|
||||||
|
("capacity", 503, {"state": "at_capacity", "future": True}),
|
||||||
|
("other-error", 429, {"error": "fixture"}),
|
||||||
|
("invalid-json", 200, b"not-json"),
|
||||||
|
):
|
||||||
|
yield (
|
||||||
|
f"session-{name}",
|
||||||
|
"POST",
|
||||||
|
"/api/session",
|
||||||
|
{"content": b"arbitrary ignored request body"},
|
||||||
|
lb_config,
|
||||||
|
[(status, payload)],
|
||||||
|
)
|
||||||
|
for name, status, payload in (
|
||||||
|
("waiting", 200, {"state": "queued", "queue_id": "ticket", "position": None, "future": True}),
|
||||||
|
("grant", 200, {"session_id": "session", "connect_url": "ws://voice.test"}),
|
||||||
|
("expired", 404, {"unknown": True}),
|
||||||
|
("error", 500, {}),
|
||||||
|
("invalid-json", 200, b"bad"),
|
||||||
|
):
|
||||||
|
yield f"queue-{name}", "GET", "/api/queue/ticket", {}, lb_config, [(status, payload)]
|
||||||
|
yield "queue-delete", "DELETE", "/api/queue/ticket", {}, lb_config, [(500, {})]
|
||||||
|
for name, kwargs, upstream in (
|
||||||
|
("valid", {"json": {"queueId": "ticket", "future": True}}, [(204, b"")]),
|
||||||
|
("empty", {"content": b""}, []),
|
||||||
|
("invalid-json", {"content": b"bad"}, []),
|
||||||
|
("array", {"json": ["ticket"]}, []),
|
||||||
|
("null-id", {"json": {"queueId": None}}, []),
|
||||||
|
("numeric-id", {"json": {"queueId": 42}}, []),
|
||||||
|
):
|
||||||
|
yield f"queue-end-{name}", "POST", "/api/queue/end", kwargs, lb_config, upstream
|
||||||
|
|
||||||
|
for name, payload in (("missing", {}), ("proxy", {"transcript": "hello"}), ("proxy-blank", {"transcript": " "})):
|
||||||
|
yield f"reasoning-{name}", "POST", "/api/reasoning", {"json": payload}, {}, []
|
||||||
|
for mode in ("OPENAI", "NODE_RED"):
|
||||||
|
yield (
|
||||||
|
f"reasoning-{mode}-legacy-unbound-token",
|
||||||
|
"POST",
|
||||||
|
"/api/reasoning",
|
||||||
|
{"json": {"transcript": "hello"}},
|
||||||
|
{"S2S_REASONING_TYPE": mode},
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
yield (
|
||||||
|
"reasoning-legacy-unbound-profile",
|
||||||
|
"POST",
|
||||||
|
"/api/reasoning",
|
||||||
|
{"json": {"transcript": "hello"}},
|
||||||
|
{
|
||||||
|
"S2S_REASONING_TYPE": "OPENAI",
|
||||||
|
"S2S_INJECT_PROFILE": True,
|
||||||
|
"S2S_LATEST_SUMMARY_TEXT": "fixture summary",
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
reasoning_config = {
|
||||||
|
**profile_config,
|
||||||
|
"S2S_INJECT_PROFILE": True,
|
||||||
|
"S2S_REASONING_TYPE": "NODE_RED",
|
||||||
|
"NODE_RED_REASONING_URL": "http://reasoning.test",
|
||||||
|
}
|
||||||
|
reasoning_request = {
|
||||||
|
"json": {
|
||||||
|
"transcript": " hello ",
|
||||||
|
"phoneNumber": "123",
|
||||||
|
"instructions": "Fixture instructions",
|
||||||
|
"language": "es",
|
||||||
|
"future": True,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for name, payload in (
|
||||||
|
(
|
||||||
|
"answer",
|
||||||
|
{
|
||||||
|
"answer": " answer ",
|
||||||
|
"latency_ms": "loose",
|
||||||
|
"intent": {"future": 1},
|
||||||
|
"usage": {"future": True},
|
||||||
|
"future": True,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
("log-only", {"answer": "", "log_only": True, "debug": {"future": True}}),
|
||||||
|
("invalid-answer", {"answer": 5}),
|
||||||
|
):
|
||||||
|
yield (
|
||||||
|
f"reasoning-node-{name}",
|
||||||
|
"POST",
|
||||||
|
"/api/reasoning",
|
||||||
|
reasoning_request,
|
||||||
|
reasoning_config,
|
||||||
|
[(200, {"name": "Caller"}), (200, payload)],
|
||||||
|
)
|
||||||
|
yield (
|
||||||
|
"reasoning-openai-answer",
|
||||||
|
"POST",
|
||||||
|
"/api/reasoning",
|
||||||
|
reasoning_request,
|
||||||
|
{
|
||||||
|
**reasoning_config,
|
||||||
|
"S2S_REASONING_TYPE": "OPENAI",
|
||||||
|
"OPENAI_API_KEY": "fixture-key",
|
||||||
|
},
|
||||||
|
[
|
||||||
|
(200, {"name": "Caller"}),
|
||||||
|
(
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
"choices": [{"message": {"content": " answer ", "future": True}}],
|
||||||
|
"usage": {"total_tokens": 12},
|
||||||
|
"future": True,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
for method, path, kwargs in (
|
||||||
|
("GET", "/api/apipilot/tools", {}),
|
||||||
|
("POST", "/api/apipilot/execute", {"json": {"name": "fixture_tool"}}),
|
||||||
|
):
|
||||||
|
yield f"apipilot-disabled-{method}", method, path, kwargs, {}, []
|
||||||
|
tool_config = {"S2S_APIPILOT_TOOLS_URL": "http://tools.test/apipilot"}
|
||||||
|
yield "tools-query-validation", "GET", "/api/apipilot/tools?refresh=invalid", {}, tool_config, []
|
||||||
|
for name, payload in (
|
||||||
|
("list", [TOOL]),
|
||||||
|
("result", {"result": [TOOL]}),
|
||||||
|
("envelope", {"data": {"tools": [TOOL]}, "future": True}),
|
||||||
|
("opaque", "anything"),
|
||||||
|
):
|
||||||
|
yield (
|
||||||
|
f"tools-{name}",
|
||||||
|
"GET",
|
||||||
|
"/api/apipilot/tools?include_unauthorized=false&refresh=true",
|
||||||
|
{"headers": {"Authorization": "Bearer fixture-token", "x-demo-session": "fixture-session"}},
|
||||||
|
tool_config,
|
||||||
|
[(200, payload)],
|
||||||
|
)
|
||||||
|
yield "tools-upstream-error", "GET", "/api/apipilot/tools", {}, tool_config, [(403, {"error": "fixture"})]
|
||||||
|
yield (
|
||||||
|
"execute-discovery-error-envelope",
|
||||||
|
"POST",
|
||||||
|
"/api/apipilot/execute",
|
||||||
|
{"json": {"name": "fixture_tool"}},
|
||||||
|
tool_config,
|
||||||
|
[(503, {})],
|
||||||
|
)
|
||||||
|
yield (
|
||||||
|
"execute-unknown-tool",
|
||||||
|
"POST",
|
||||||
|
"/api/apipilot/execute",
|
||||||
|
{"json": {"name": "unknown"}},
|
||||||
|
tool_config,
|
||||||
|
[(200, [TOOL]), (200, [TOOL])],
|
||||||
|
)
|
||||||
|
yield (
|
||||||
|
"execute-missing-argument",
|
||||||
|
"POST",
|
||||||
|
"/api/apipilot/execute",
|
||||||
|
{"json": {"name": "fixture_tool"}},
|
||||||
|
tool_config,
|
||||||
|
[(200, [TOOL])],
|
||||||
|
)
|
||||||
|
yield (
|
||||||
|
"execute-api-key-precheck",
|
||||||
|
"POST",
|
||||||
|
"/api/apipilot/execute",
|
||||||
|
{"json": {"name": "fixture_tool", "arguments": {"count": 1}}},
|
||||||
|
{
|
||||||
|
**tool_config,
|
||||||
|
"S2S_APIPILOT_API_KEY": "fixture-key",
|
||||||
|
},
|
||||||
|
[(200, [{**TOOL, "requires_auth": True}])],
|
||||||
|
)
|
||||||
|
for name, payload in (
|
||||||
|
("envelope", {"success": True, "data": {"future": [1, None]}, "error": None, "future_envelope": True}),
|
||||||
|
("raw-list", [{"future": True}]),
|
||||||
|
("minimal-envelope", {"success": True}),
|
||||||
|
(
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"data": None,
|
||||||
|
"error": {"status_code": 409, "code": "FIXTURE", "message": "Fixture", "future": True},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
):
|
||||||
|
yield (
|
||||||
|
f"execute-{name}",
|
||||||
|
"POST",
|
||||||
|
"/api/apipilot/execute",
|
||||||
|
{
|
||||||
|
"json": {
|
||||||
|
"name": "fixture_tool",
|
||||||
|
"arguments": {"count": "wrong-type-is-accepted", "unknown": 1},
|
||||||
|
"future": True,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tool_config,
|
||||||
|
[(200, [TOOL]), (200, payload)],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def capture_http():
|
||||||
|
captures = {}
|
||||||
|
for name, method, path, kwargs, config, responses in cases():
|
||||||
|
remaining = iter(responses)
|
||||||
|
requests = []
|
||||||
|
|
||||||
|
def upstream(request, *, requests=requests, remaining=remaining):
|
||||||
|
requests.append(
|
||||||
|
{
|
||||||
|
"method": request.method,
|
||||||
|
"url": str(request.url),
|
||||||
|
"body": request.content.decode(),
|
||||||
|
"headers": {
|
||||||
|
key: request.headers[key]
|
||||||
|
for key in (
|
||||||
|
"authorization",
|
||||||
|
"x-demo-session",
|
||||||
|
"x-api-key",
|
||||||
|
"content-type",
|
||||||
|
"x-s2s-caller",
|
||||||
|
)
|
||||||
|
if key in request.headers
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
status, payload = next(remaining)
|
||||||
|
if isinstance(payload, bytes):
|
||||||
|
return httpx.Response(status, content=payload)
|
||||||
|
return httpx.Response(
|
||||||
|
status, content=json.dumps(payload).encode(), headers={"content-type": "application/json"}
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.multiple(settings, **{**BASE_CONFIG, **config}),
|
||||||
|
patch.object(apipilot, "_APIPILOT_CACHE", {}),
|
||||||
|
patch.object(context, "_build_today_fixed_context_block", return_value="Fixture current date: 2026-01-04"),
|
||||||
|
patch.object(
|
||||||
|
httpx,
|
||||||
|
"AsyncClient",
|
||||||
|
side_effect=lambda **opts: ASYNC_CLIENT(transport=httpx.MockTransport(upstream), **opts),
|
||||||
|
),
|
||||||
|
TestClient(server.app, raise_server_exceptions=False) as client,
|
||||||
|
):
|
||||||
|
response = client.request(method, path, follow_redirects=False, **kwargs)
|
||||||
|
captures[name] = {
|
||||||
|
"status": response.status_code,
|
||||||
|
"body": response.text,
|
||||||
|
"headers": {
|
||||||
|
key: response.headers[key]
|
||||||
|
for key in ("content-type", "location", "set-cookie")
|
||||||
|
if key in response.headers
|
||||||
|
},
|
||||||
|
"upstream": requests,
|
||||||
|
}
|
||||||
|
if list(remaining):
|
||||||
|
raise AssertionError(f"Unused upstream fixtures in {name}")
|
||||||
|
return captures
|
||||||
|
|
||||||
|
|
||||||
|
def capture_openapi_requests():
|
||||||
|
schema = server.app.openapi()
|
||||||
|
return {
|
||||||
|
"operations": {
|
||||||
|
f"{method.upper()} {path}": {
|
||||||
|
key: operation[key] for key in ("operationId", "parameters", "requestBody") if key in operation
|
||||||
|
}
|
||||||
|
for path, methods in schema["paths"].items()
|
||||||
|
for method, operation in methods.items()
|
||||||
|
},
|
||||||
|
"models": {
|
||||||
|
name: schema["components"]["schemas"][name]
|
||||||
|
for name in (
|
||||||
|
"SearchRequest",
|
||||||
|
"ReasoningRequest",
|
||||||
|
"ApipilotExecuteRequest",
|
||||||
|
"HTTPValidationError",
|
||||||
|
"ValidationError",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def capture_realtime():
|
||||||
|
frames = []
|
||||||
|
|
||||||
|
class Socket:
|
||||||
|
async def send_text(self, message):
|
||||||
|
frames.append(message)
|
||||||
|
|
||||||
|
async def send_bytes(self, message):
|
||||||
|
frames.append({"binary": message.hex()})
|
||||||
|
|
||||||
|
async def upstream():
|
||||||
|
yield b"\x00\x01\x02\x03"
|
||||||
|
yield json.dumps({"type": "future.event", "future": {"nested": [None, 1]}})
|
||||||
|
yield json.dumps({"type": "response.text.done", "text": "Hello [rightspeak]world[endspeak]", "future": True})
|
||||||
|
yield json.dumps({"type": "error", "error": {"message": "Unknown or invalid event: session.update"}})
|
||||||
|
yield json.dumps({"type": "future.after.suppressed.error"})
|
||||||
|
|
||||||
|
with patch.object(reasoning, "_forward_agent_text_to_nodered", new_callable=AsyncMock):
|
||||||
|
await proxy._upstream_to_client(Socket(), upstream(), "fixture", {})
|
||||||
|
return frames
|
||||||
|
|
||||||
|
|
||||||
|
def capture():
|
||||||
|
return {
|
||||||
|
"http": capture_http(),
|
||||||
|
"openapi_requests": capture_openapi_requests(),
|
||||||
|
"realtime": asyncio.run(capture_realtime()),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class LegacyCompatibilityTests(unittest.TestCase):
|
||||||
|
def test_wire_contracts_match_pre_migration_capture(self):
|
||||||
|
expected = json.loads(FIXTURE.read_text())
|
||||||
|
actual = capture()
|
||||||
|
for name, response in actual["http"].items():
|
||||||
|
with self.subTest(case=name):
|
||||||
|
self.assertEqual(response, expected["http"][name])
|
||||||
|
self.assertEqual(actual["openapi_requests"], expected["openapi_requests"])
|
||||||
|
self.assertEqual(actual["realtime"], expected["realtime"])
|
||||||
|
self.assertEqual(set(actual["http"]), set(expected["http"]))
|
||||||
@@ -1,25 +1,25 @@
|
|||||||
"""Backend contract checks with local HTTP fixtures and a loopback voice server.
|
"""Backend contract checks with local HTTP fixtures and a loopback voice server.
|
||||||
|
|
||||||
Run from the repository root: .venv/bin/python -m unittest discover -s s2s-client/tests
|
Run from the repository root: uv run --locked pytest tests
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
|
||||||
import sys
|
|
||||||
import threading
|
import threading
|
||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import ANY, AsyncMock, Mock, patch
|
from unittest.mock import ANY, AsyncMock, Mock, patch
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
from fastapi import Request
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from websockets.sync.server import serve
|
from websockets.sync.server import serve
|
||||||
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
||||||
# Never consume deployment credentials or contact services from a developer's .env.
|
# Never consume deployment credentials or contact services from a developer's .env.
|
||||||
with patch.dict(os.environ, {}, clear=True), patch("dotenv.load_dotenv", return_value=False):
|
with patch.dict(os.environ, {}, clear=True), patch("dotenv.load_dotenv", return_value=False):
|
||||||
import server
|
from s2s_client import main as server
|
||||||
|
from s2s_client.core import config as settings
|
||||||
|
from s2s_client.providers import apipilot, caller
|
||||||
|
from s2s_client.services import tool_context
|
||||||
|
|
||||||
|
|
||||||
GRANT = {
|
GRANT = {
|
||||||
@@ -34,13 +34,17 @@ ASYNC_CLIENT = httpx.AsyncClient
|
|||||||
class BackendContractTests(unittest.TestCase):
|
class BackendContractTests(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.upstream = Mock(side_effect=AssertionError("Unexpected upstream HTTP request"))
|
self.upstream = Mock(side_effect=AssertionError("Unexpected upstream HTTP request"))
|
||||||
self.enterContext(patch.object(server, "LOAD_BALANCER_URL", "http://lb.test"))
|
self.enterContext(patch.object(settings, "LOAD_BALANCER_URL", "http://lb.test"))
|
||||||
self.enterContext(patch.object(
|
self.enterContext(
|
||||||
server.httpx, "AsyncClient",
|
patch.object(
|
||||||
|
httpx,
|
||||||
|
"AsyncClient",
|
||||||
side_effect=lambda **kwargs: ASYNC_CLIENT(
|
side_effect=lambda **kwargs: ASYNC_CLIENT(
|
||||||
transport=httpx.MockTransport(self.upstream), **kwargs,
|
transport=httpx.MockTransport(self.upstream),
|
||||||
|
**kwargs,
|
||||||
),
|
),
|
||||||
))
|
)
|
||||||
|
)
|
||||||
self.client = self.enterContext(TestClient(server.app))
|
self.client = self.enterContext(TestClient(server.app))
|
||||||
|
|
||||||
def respond(self, *responses):
|
def respond(self, *responses):
|
||||||
@@ -54,7 +58,9 @@ class BackendContractTests(unittest.TestCase):
|
|||||||
self.assertNotIn("auth", response.json())
|
self.assertNotIn("auth", response.json())
|
||||||
paths = self.client.get("/openapi.json").json()["paths"]
|
paths = self.client.get("/openapi.json").json()["paths"]
|
||||||
for method, path in (
|
for method, path in (
|
||||||
("GET", "/api/me"), ("POST", "/api/session/heartbeat"), ("POST", "/api/session/end"),
|
("GET", "/api/me"),
|
||||||
|
("POST", "/api/session/heartbeat"),
|
||||||
|
("POST", "/api/session/end"),
|
||||||
):
|
):
|
||||||
self.assertNotIn(path, paths)
|
self.assertNotIn(path, paths)
|
||||||
self.assertEqual(self.client.request(method, path).status_code, 404)
|
self.assertEqual(self.client.request(method, path).status_code, 404)
|
||||||
@@ -110,7 +116,8 @@ class BackendContractTests(unittest.TestCase):
|
|||||||
self.assertEqual(self.client.delete("/api/queue/local-ticket").json(), {"ok": True})
|
self.assertEqual(self.client.delete("/api/queue/local-ticket").json(), {"ok": True})
|
||||||
# sendBeacon posts a JSON blob, not a form or a session identifier.
|
# sendBeacon posts a JSON blob, not a form or a session identifier.
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
"/api/queue/end", content=json.dumps({"queueId": "local-ticket"}),
|
"/api/queue/end",
|
||||||
|
content=json.dumps({"queueId": "local-ticket"}),
|
||||||
headers={"Content-Type": "application/json"},
|
headers={"Content-Type": "application/json"},
|
||||||
)
|
)
|
||||||
self.assertEqual(response.json(), {"ok": True})
|
self.assertEqual(response.json(), {"ok": True})
|
||||||
@@ -133,16 +140,23 @@ class BackendContractTests(unittest.TestCase):
|
|||||||
self.assertEqual(self.client.get("/api/queue/local-ticket").status_code, 502)
|
self.assertEqual(self.client.get("/api/queue/local-ticket").status_code, 502)
|
||||||
|
|
||||||
def test_direct_mode_disables_lb_and_preserves_pinned_voice_url(self):
|
def test_direct_mode_disables_lb_and_preserves_pinned_voice_url(self):
|
||||||
with patch.object(server, "LOAD_BALANCER_URL", ""), patch.object(
|
with (
|
||||||
server, "SPEECH_TO_SPEECH_URL", GRANT["connect_url"],
|
patch.object(settings, "LOAD_BALANCER_URL", ""),
|
||||||
|
patch.object(
|
||||||
|
settings,
|
||||||
|
"SPEECH_TO_SPEECH_URL",
|
||||||
|
GRANT["connect_url"],
|
||||||
|
),
|
||||||
):
|
):
|
||||||
config = self.client.get("/api/config").json()
|
config = self.client.get("/api/config").json()
|
||||||
self.assertEqual(config["s2sUrl"], GRANT["connect_url"])
|
self.assertEqual(config["s2sUrl"], GRANT["connect_url"])
|
||||||
self.assertFalse(config["lb"])
|
self.assertFalse(config["lb"])
|
||||||
self.assertTrue(config["allowDirect"])
|
self.assertTrue(config["allowDirect"])
|
||||||
for method, path in (
|
for method, path in (
|
||||||
("POST", "/api/session"), ("GET", "/api/queue/local-ticket"),
|
("POST", "/api/session"),
|
||||||
("DELETE", "/api/queue/local-ticket"), ("POST", "/api/queue/end"),
|
("GET", "/api/queue/local-ticket"),
|
||||||
|
("DELETE", "/api/queue/local-ticket"),
|
||||||
|
("POST", "/api/queue/end"),
|
||||||
):
|
):
|
||||||
self.assertEqual(self.client.request(method, path).status_code, 404)
|
self.assertEqual(self.client.request(method, path).status_code, 404)
|
||||||
self.upstream.assert_not_called()
|
self.upstream.assert_not_called()
|
||||||
@@ -153,8 +167,13 @@ class BackendContractTests(unittest.TestCase):
|
|||||||
httpx.Response(200, json={"token": "fixture-token"}),
|
httpx.Response(200, json={"token": "fixture-token"}),
|
||||||
httpx.Response(200, json=profile),
|
httpx.Response(200, json=profile),
|
||||||
)
|
)
|
||||||
with patch.object(server, "S2S_LOGIN_BY_PHONE", "http://profile.test/login"), patch.object(
|
with (
|
||||||
server, "VOCERO_USER_PHONE_AUTH_URL", "http://profile.test/user",
|
patch.object(settings, "S2S_LOGIN_BY_PHONE", "http://profile.test/login"),
|
||||||
|
patch.object(
|
||||||
|
settings,
|
||||||
|
"VOCERO_USER_PHONE_AUTH_URL",
|
||||||
|
"http://profile.test/user",
|
||||||
|
),
|
||||||
):
|
):
|
||||||
response = self.client.get("/api/user/phone", params={"phoneNumber": "+56000000000"})
|
response = self.client.get("/api/user/phone", params={"phoneNumber": "+56000000000"})
|
||||||
self.assertEqual(response.json(), profile)
|
self.assertEqual(response.json(), profile)
|
||||||
@@ -164,23 +183,31 @@ class BackendContractTests(unittest.TestCase):
|
|||||||
self.assertEqual(lookup.url.params["phoneNumber"], "+56000000000")
|
self.assertEqual(lookup.url.params["phoneNumber"], "+56000000000")
|
||||||
|
|
||||||
def test_caller_auth_headers_remain_independent(self):
|
def test_caller_auth_headers_remain_independent(self):
|
||||||
request = server.Request({
|
request = Request(
|
||||||
"type": "http", "headers": [
|
{
|
||||||
(b"authorization", b"Bearer fixture-token"), (b"x-demo-session", b"fixture-demo"),
|
"type": "http",
|
||||||
|
"headers": [
|
||||||
|
(b"authorization", b"Bearer fixture-token"),
|
||||||
|
(b"x-demo-session", b"fixture-demo"),
|
||||||
],
|
],
|
||||||
})
|
}
|
||||||
self.assertEqual(server._apipilot_parse_authorization(request), ("Bearer fixture-token", "fixture-demo"))
|
)
|
||||||
|
self.assertEqual(apipilot._apipilot_parse_authorization(request), ("Bearer fixture-token", "fixture-demo"))
|
||||||
|
|
||||||
|
|
||||||
class VoiceProxyTests(unittest.TestCase):
|
class VoiceProxyTests(unittest.TestCase):
|
||||||
def test_apipilot_catalog_and_caller_defaults_reach_the_voice_session(self):
|
def test_apipilot_catalog_and_caller_defaults_reach_the_voice_session(self):
|
||||||
closed = threading.Event()
|
closed = threading.Event()
|
||||||
catalog = AsyncMock(return_value=[{
|
catalog = AsyncMock(
|
||||||
|
return_value=[
|
||||||
|
{
|
||||||
"name": "get_caller_briefing",
|
"name": "get_caller_briefing",
|
||||||
"description": "Retrieve the caller's local test briefing.",
|
"description": "Retrieve the caller's local test briefing.",
|
||||||
"requires_auth": True,
|
"requires_auth": True,
|
||||||
"parameters": {"required": ["phone", "episode_id", "email"]},
|
"parameters": {"required": ["phone", "episode_id", "email"]},
|
||||||
}])
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
login = AsyncMock(return_value=("fixture-token", {}))
|
login = AsyncMock(return_value=("fixture-token", {}))
|
||||||
profile = AsyncMock(return_value={"user": {"email": "caller@example.test"}})
|
profile = AsyncMock(return_value={"user": {"email": "caller@example.test"}})
|
||||||
|
|
||||||
@@ -198,15 +225,15 @@ class VoiceProxyTests(unittest.TestCase):
|
|||||||
self.addCleanup(thread.join, 5)
|
self.addCleanup(thread.join, 5)
|
||||||
target = f"ws://127.0.0.1:{upstream.socket.getsockname()[1]}/v1/realtime"
|
target = f"ws://127.0.0.1:{upstream.socket.getsockname()[1]}/v1/realtime"
|
||||||
with (
|
with (
|
||||||
patch.object(server, "S2S_PROXY_TARGET", target),
|
patch.object(settings, "S2S_PROXY_TARGET", target),
|
||||||
patch.object(server, "S2S_INJECT_PROFILE", False),
|
patch.object(settings, "S2S_INJECT_PROFILE", False),
|
||||||
patch.object(server, "S2S_APIPILOT_TOOLS_URL", "http://catalog.test/tools"),
|
patch.object(settings, "S2S_APIPILOT_TOOLS_URL", "http://catalog.test/tools"),
|
||||||
patch.object(server, "S2S_LOGIN_BY_PHONE", "http://profile.test/login"),
|
patch.object(settings, "S2S_LOGIN_BY_PHONE", "http://profile.test/login"),
|
||||||
patch.object(server, "VOCERO_USER_PHONE_AUTH_URL", "http://profile.test/user"),
|
patch.object(settings, "VOCERO_USER_PHONE_AUTH_URL", "http://profile.test/user"),
|
||||||
patch.object(server, "_apipilot_fetch_tools", catalog),
|
patch.object(apipilot, "_apipilot_fetch_tools", catalog),
|
||||||
patch.object(server, "_login_by_phone", login),
|
patch.object(caller, "_login_by_phone", login),
|
||||||
patch.object(server, "_fetch_user_profile_for_phone", profile),
|
patch.object(caller, "_fetch_user_profile_for_phone", profile),
|
||||||
patch.object(server.httpx, "AsyncClient", side_effect=AssertionError("Unexpected HTTP request")),
|
patch.object(httpx, "AsyncClient", side_effect=AssertionError("Unexpected HTTP request")),
|
||||||
TestClient(server.app) as client,
|
TestClient(server.app) as client,
|
||||||
):
|
):
|
||||||
with client.websocket_connect(
|
with client.websocket_connect(
|
||||||
@@ -218,8 +245,11 @@ class VoiceProxyTests(unittest.TestCase):
|
|||||||
self.assertEqual(update["type"], "session.update")
|
self.assertEqual(update["type"], "session.update")
|
||||||
instructions = update["session"]["instructions"]
|
instructions = update["session"]["instructions"]
|
||||||
for expected in (
|
for expected in (
|
||||||
"Local fixture", server.APIPILOT_PASS1_LIST_MARKER, "name: get_caller_briefing",
|
"Local fixture",
|
||||||
'phone = "+56000000000"', 'episode_id = "fixture-episode"',
|
tool_context.APIPILOT_PASS1_LIST_MARKER,
|
||||||
|
"name: get_caller_briefing",
|
||||||
|
'phone = "+56000000000"',
|
||||||
|
'episode_id = "fixture-episode"',
|
||||||
'email = "caller@example.test"',
|
'email = "caller@example.test"',
|
||||||
):
|
):
|
||||||
self.assertIn(expected, instructions)
|
self.assertIn(expected, instructions)
|
||||||
@@ -253,7 +283,7 @@ class VoiceProxyTests(unittest.TestCase):
|
|||||||
thread.start()
|
thread.start()
|
||||||
self.addCleanup(thread.join, 5)
|
self.addCleanup(thread.join, 5)
|
||||||
target = f"ws://127.0.0.1:{upstream.socket.getsockname()[1]}/v1/realtime"
|
target = f"ws://127.0.0.1:{upstream.socket.getsockname()[1]}/v1/realtime"
|
||||||
with patch.object(server, "S2S_PROXY_TARGET", target), TestClient(server.app) as client:
|
with patch.object(settings, "S2S_PROXY_TARGET", target), TestClient(server.app) as client:
|
||||||
config = client.get("/api/config", headers={"host": "localhost:19420"}).json()
|
config = client.get("/api/config", headers={"host": "localhost:19420"}).json()
|
||||||
self.assertEqual(config["s2sUrl"], "ws://localhost:19420/v1/realtime")
|
self.assertEqual(config["s2sUrl"], "ws://localhost:19420/v1/realtime")
|
||||||
with client.websocket_connect("/v1/realtime") as socket:
|
with client.websocket_connect("/v1/realtime") as socket:
|
||||||
@@ -0,0 +1,601 @@
|
|||||||
|
version = 1
|
||||||
|
revision = 3
|
||||||
|
requires-python = "==3.12.*"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "annotated-types"
|
||||||
|
version = "0.8.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anyio"
|
||||||
|
version = "4.15.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "idna" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aquaportal-s2s-client"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = { editable = "." }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "fastapi" },
|
||||||
|
{ name = "httpx" },
|
||||||
|
{ name = "pydantic" },
|
||||||
|
{ name = "python-dotenv" },
|
||||||
|
{ name = "uvicorn", extra = ["standard"] },
|
||||||
|
{ name = "websockets" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.dev-dependencies]
|
||||||
|
dev = [
|
||||||
|
{ name = "mypy" },
|
||||||
|
{ name = "pre-commit" },
|
||||||
|
{ name = "pytest" },
|
||||||
|
{ name = "ruff" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
requires-dist = [
|
||||||
|
{ name = "fastapi", specifier = "==0.115.6" },
|
||||||
|
{ name = "httpx", specifier = "==0.28.1" },
|
||||||
|
{ name = "pydantic", specifier = "==2.13.5" },
|
||||||
|
{ name = "python-dotenv", specifier = "==1.2.2" },
|
||||||
|
{ name = "uvicorn", extras = ["standard"], specifier = "==0.34.0" },
|
||||||
|
{ name = "websockets", specifier = "==17.2" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata.requires-dev]
|
||||||
|
dev = [
|
||||||
|
{ name = "mypy", specifier = ">=1.16,<2" },
|
||||||
|
{ name = "pre-commit", specifier = ">=4.4,<5" },
|
||||||
|
{ name = "pytest", specifier = ">=9,<10" },
|
||||||
|
{ name = "ruff", specifier = ">=0.12,<1" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "certifi"
|
||||||
|
version = "2026.7.22"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfgv"
|
||||||
|
version = "3.5.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "click"
|
||||||
|
version = "8.5.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorama"
|
||||||
|
version = "0.4.6"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "distlib"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c9/02/bd72be9134d25ed783ecbbc38a539ffaefbf90c78418c7fb7229600dbac7/distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed", size = 615141, upload-time = "2026-06-12T08:04:52.847Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b", size = 470628, upload-time = "2026-06-12T08:04:50.506Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fastapi"
|
||||||
|
version = "0.115.6"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pydantic" },
|
||||||
|
{ name = "starlette" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/93/72/d83b98cd106541e8f5e5bfab8ef2974ab45a62e8a6c5b5e6940f26d2ed4b/fastapi-0.115.6.tar.gz", hash = "sha256:9ec46f7addc14ea472958a96aae5b5de65f39721a46aaf5705c480d9a8b76654", size = 301336, upload-time = "2024-12-03T22:46:01.629Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/52/b3/7e4df40e585df024fac2f80d1a2d579c854ac37109675db2b0cc22c0bb9e/fastapi-0.115.6-py3-none-any.whl", hash = "sha256:e9240b29e36fa8f4bb7290316988e90c381e5092e0cbe84e7818cc3713bcf305", size = 94843, upload-time = "2024-12-03T22:45:59.368Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "filelock"
|
||||||
|
version = "4.0.12"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/53/e4/34efcb869715cf299e47d1ac7b2624d2bcb6f2d3dffc2f0abe8417f65ab2/filelock-4.0.12.tar.gz", hash = "sha256:cf42711a7ac791818b299fab0332a088c65aeeefa36290de98db92c434303b0c", size = 253880, upload-time = "2026-10-05T13:17:43.677Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/a1/b047e1c486a2c66283633b7dbb73872ca16ca1ce6a426f70d4d037fc8e1c/filelock-4.0.12-py3-none-any.whl", hash = "sha256:5f17ee83ecee8a6f3e389c75822fb70a1c2f0506b99438a6dffa1d56793588c8", size = 111732, upload-time = "2026-10-05T13:17:42.361Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "h11"
|
||||||
|
version = "0.16.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpcore"
|
||||||
|
version = "1.0.9"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "h11" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httptools"
|
||||||
|
version = "0.8.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpx"
|
||||||
|
version = "0.28.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "anyio" },
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "httpcore" },
|
||||||
|
{ name = "idna" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "identify"
|
||||||
|
version = "2.6.20"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/53/35/d70c0006c7cee65999ea94a6273e60b2094f600a3d8b71b04318253fc643/identify-2.6.20.tar.gz", hash = "sha256:ad729860a923858d26917c2f4fb0a1d83d27a75b1e090c06440c573f048f3285", size = 99681, upload-time = "2026-09-26T20:29:07.187Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fa/70/fffc9613501877c0a10a1ef73a165e6ded3e53f6e7502227fef56d973933/identify-2.6.20-py2.py3-none-any.whl", hash = "sha256:6a16b69b93187244e0548cbfd25b3e4a6f9a7a2ad784625c3bec2b8d27b81aaa", size = 99506, upload-time = "2026-09-26T20:29:06.054Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "idna"
|
||||||
|
version = "3.20"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iniconfig"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "librt"
|
||||||
|
version = "0.16.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/04/f5/9dc696772d241814bacac7880bac32f2930b5a6ebc1f85317b83161a011c/librt-0.16.0.tar.gz", hash = "sha256:ac38d6d8d66bf3d744148dbbc0b8e193e195a51e364ed55e224631f5721891fc", size = 219839, upload-time = "2026-09-29T00:55:32.891Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ad/76/bbdaeb87b7c47b5c7343e90222b9bfa4e4a8a83f647e02933ad0129225b1/librt-0.16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe52bf4641069e7978a14253b036cb9002def1926317e710f2e249f8a8c47742", size = 151028, upload-time = "2026-09-29T00:52:41.508Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fd/0c/ab8ed3dab0085931aec4a792c7eaac8dc6c5ff4691fda3a5360d9d8a9cd2/librt-0.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5bcc2c4726ced915b00de0c9856a4eeabfb3fddb93e10e0b8f735b7709358b6d", size = 155283, upload-time = "2026-09-29T00:52:42.873Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/eb/36/494e79d460c80c1f030661e8287c9eca5e1ad652dc2b2180b6cd42abce0a/librt-0.16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff7baa55f8e7c69851419e50a666015d02a74198716fd45c0125a2112e0a389f", size = 503185, upload-time = "2026-09-29T00:52:44.638Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9b/34/a8464038dd9db6e4381fa2b6eb73dc9a50888d77102c4c139304ac35cddc/librt-0.16.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:b95d5d92ab83d39e760a52091bb1baba664f3a2351e39b1e16801e5747c2f0e9", size = 496738, upload-time = "2026-09-29T00:52:46.441Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6a/53/e0e5e334ef0c6ed27039d323819368b9ef6712be87d55ee2bf9799398afd/librt-0.16.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b6d085d70bce51d43c5c7c36d63490770180d8779e71c49305c87b4213918de7", size = 513818, upload-time = "2026-09-29T00:52:48.068Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ad/f7/7ce72cbf19d0addd05090b339152fd0548a02562c2866a603e6e3b3da2df/librt-0.16.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36e53948e99bbe3ffea257124cfcae1cfb01831555c9a9c903c9f9a72db7fd07", size = 531952, upload-time = "2026-09-29T00:52:49.816Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/83/22/0b1bcb6a8e723c8b4fd60dfc8ae8ec6461c54073fbc8685efeb8d900d407/librt-0.16.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:54d11f726aae9df5a6ffbbf0a03a52449bbac84a53ef03669cb41cdfd4ae41bf", size = 524465, upload-time = "2026-09-29T00:52:51.354Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/64/2e/e9c23b8b9df1813da1be205deca9606beb7ddd033972246cd426d05374a0/librt-0.16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4323193ac0cd025f85af531df8ba91bf24d1973b401697347a6282e8fd3fcf5e", size = 543191, upload-time = "2026-09-29T00:52:53.284Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bc/e5/6a8b21b342c03ed7e230fa3afbfd2edc58e6e87ef1f0d11fa2b9a748c252/librt-0.16.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e42f8e098b9c5396fefa05fb1cc7e33b0e08fc51da106b5de4a45fd22aac6743", size = 546979, upload-time = "2026-09-29T00:52:54.932Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/84/9e/b5129023eced1be01e01c22757f53be551d463b1bb7264f787927404c1d6/librt-0.16.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:39ec1d5a14e37baf1450a6cabf03fe552340808bf1ad9d71824ab90117716459", size = 555317, upload-time = "2026-09-29T00:52:56.869Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/89/28bba5938c725fe91f06bf93f7fa6c6b150229df53a87b454b0d5c2a796e/librt-0.16.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d1aabe3925cbb4a08d15b7b20ba4011b53019da0c4173a25155139b7b1baed65", size = 535846, upload-time = "2026-09-29T00:52:58.475Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/92/63773026614f888c5d4e370e395ce42ca604b89f70b3acdfedbf94851b80/librt-0.16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:300c3ffdc459f4a779a8411ecb188e3ac0b1ff3a3a7b099642555dedae06c69b", size = 573700, upload-time = "2026-09-29T00:53:00.127Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/66/8f/347d4677eefb57cd9f8e01d95a1dcee9a91b4e44e664173c32ff6f3752e5/librt-0.16.0-cp312-cp312-win32.whl", hash = "sha256:c17194318e4c0c0348b36f36c2ec7534436fe0a4c15582403162a4f08c80797a", size = 106158, upload-time = "2026-09-29T00:53:02.031Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/2c/5193dc81127cd5ddfad031391b046bf32dda219b38463ab872407ca30646/librt-0.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:25a58a19ea8d83b68209f04912df765e9260635ef77646542ed4b4abe6bc7940", size = 126176, upload-time = "2026-09-29T00:53:03.445Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ff/3d/9668a400c8dd81d162eba38b33fa49fa6205f1a64493570a13fbb815c3ee/librt-0.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7be7cf555bc30ec12622e9447299cc4a9b8ff307548b634794353db0c2065dc", size = 116572, upload-time = "2026-09-29T00:53:04.815Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mypy"
|
||||||
|
version = "1.20.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "librt", marker = "platform_python_implementation != 'PyPy'" },
|
||||||
|
{ name = "mypy-extensions" },
|
||||||
|
{ name = "pathspec" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/04/af/e3d4b3e9ec91a0ff9aabfdb38692952acf49bbb899c2e4c29acb3a6da3ae/mypy-1.20.2.tar.gz", hash = "sha256:e8222c26daaafd9e8626dec58ae36029f82585890589576f769a650dd20fd665", size = 3817349, upload-time = "2026-04-21T17:12:28.473Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/4e/7560e4528db9e9b147e4c0f22660466bf30a0a1fe3d63d1b9d3b0fd354ee/mypy-1.20.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4dbfcf869f6b0517f70cf0030ba6ea1d6645e132337a7d5204a18d8d5636c02b", size = 14539393, upload-time = "2026-04-21T17:07:12.52Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/32/d9/34a5efed8124f5a9234f55ac6a4ced4201e2c5b81e1109c49ad23190ec8c/mypy-1.20.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b6481b228d072315b053210b01ac320e1be243dc17f9e5887ef167f23f5fae4", size = 13361642, upload-time = "2026-04-21T17:06:53.742Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/14/eb377acf78c03c92d566a1510cda8137348215b5335085ef662ab82ecd3a/mypy-1.20.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34397cdced6b90b836e38182076049fdb41424322e0b0728c946b0939ebdf9f6", size = 13740347, upload-time = "2026-04-21T17:12:04.73Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b9/94/7e4634a32b641aa1c112422eed1bbece61ee16205f674190e8b536f884de/mypy-1.20.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5da6976f20cae27059ea8d0c86e7cef3de720e04c4bb9ee18e3690fdb792066", size = 14734042, upload-time = "2026-04-21T17:07:43.16Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7a/f3/f7e62395cb7f434541b4491a01149a4439e28ace4c0c632bbf5431e92d1f/mypy-1.20.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:56908d7e08318d39f85b1f0c6cfd47b0cac1a130da677630dac0de3e0623e102", size = 14964958, upload-time = "2026-04-21T17:11:00.665Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3e/0d/47e3c3a0ec2a876e35aeac365df3cac7776c36bbd4ed18cc521e1b9d255b/mypy-1.20.2-cp312-cp312-win_amd64.whl", hash = "sha256:d52ad8d78522da1d308789df651ee5379088e77c76cb1994858d40a426b343b9", size = 10911340, upload-time = "2026-04-21T17:10:49.179Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d6/b2/6c852d72e0ea8b01f49da817fb52539993cde327e7d010e0103dc12d0dac/mypy-1.20.2-cp312-cp312-win_arm64.whl", hash = "sha256:785b08db19c9f214dc37d65f7c165d19a30fcecb48abfa30f31b01b5acaabb58", size = 9833947, upload-time = "2026-04-21T17:09:05.267Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/28/9a/f23c163e25b11074188251b0b5a0342625fc1cdb6af604757174fa9acc9b/mypy-1.20.2-py3-none-any.whl", hash = "sha256:a94c5a76ab46c5e6257c7972b6c8cff0574201ca7dc05647e33e795d78680563", size = 2637314, upload-time = "2026-04-21T17:05:54.5Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mypy-extensions"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nodeenv"
|
||||||
|
version = "1.11.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/105de02c1322cfada6d9710d9146ef8026419d433c9d08359a2d35805811/nodeenv-1.11.0.tar.gz", hash = "sha256:3ce8fe5b71d16e8af7039ca65257354100bc772965d6bc549070649e53b1b146", size = 99301, upload-time = "2026-09-26T11:29:21.367Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/c8/12811c9b48fde162bb72b6f2e78fada9a247a09d7bb5be2050a5d099c77b/nodeenv-1.11.0-py2.py3-none-any.whl", hash = "sha256:edaa16e6c14d7cf395d75d4bbd5a26390f4dc06501a33b4e76282b02cc688a25", size = 34277, upload-time = "2026-09-26T11:29:19.933Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "packaging"
|
||||||
|
version = "26.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pathspec"
|
||||||
|
version = "1.1.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "platformdirs"
|
||||||
|
version = "4.12.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/42/23/4a86fc741c38c5b69792a4ef954b281afa69bea9f083f881de1b0d23bc07/platformdirs-4.12.3.tar.gz", hash = "sha256:427fc0bb321ae0c5b037fa03238ca74820437be162e78b4848c4d4055b9b766c", size = 59538, upload-time = "2026-10-03T23:40:46.595Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c5/9b/6ce1ead737fe496611bda600c263b9a11ae7bd8f41bb13f9bd7e0a2c37a4/platformdirs-4.12.3-py3-none-any.whl", hash = "sha256:080f3b39423b5abfca9a23d84c4e9795f54d395cd8459867a8ded44084fcd5f8", size = 32493, upload-time = "2026-10-03T23:40:45.104Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pluggy"
|
||||||
|
version = "1.6.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pre-commit"
|
||||||
|
version = "4.6.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "cfgv" },
|
||||||
|
{ name = "identify" },
|
||||||
|
{ name = "nodeenv" },
|
||||||
|
{ name = "pyyaml" },
|
||||||
|
{ name = "virtualenv" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/74/89/1f3e8e1fc3e97de0fa963495832f581f025f29471602a309e48808244292/pre_commit-4.6.2.tar.gz", hash = "sha256:8f5d7bfb021ecdbcd9d49d89847082dd24172ccde534390081a679ad046e2441", size = 198670, upload-time = "2026-08-10T22:07:18.421Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/45/e2/bbb7129c9e7999a6b8ee9cca3b66486c25c423ab5a75f34071798b74ce94/pre_commit-4.6.2-py2.py3-none-any.whl", hash = "sha256:e2dde9a75d3bce11bd3831c26d134df00a2803c1d818be6a0383c3dcda25dc4e", size = 226202, upload-time = "2026-08-10T22:07:16.942Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pydantic"
|
||||||
|
version = "2.13.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "annotated-types" },
|
||||||
|
{ name = "pydantic-core" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
{ name = "typing-inspection" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pydantic-core"
|
||||||
|
version = "2.46.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pygments"
|
||||||
|
version = "2.21.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest"
|
||||||
|
version = "9.1.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
{ name = "iniconfig" },
|
||||||
|
{ name = "packaging" },
|
||||||
|
{ name = "pluggy" },
|
||||||
|
{ name = "pygments" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "python-discovery"
|
||||||
|
version = "1.6.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "filelock" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/0c/57/250bd238b966cece44328235eb85290045d059265fdaf7527a3a958123db/python_discovery-1.6.1.tar.gz", hash = "sha256:cf87d3627dfb4412437fdd5b13eae402607722998d21567993aedbc59b23c15e", size = 84338, upload-time = "2026-09-18T01:31:53.971Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/16/7d/e9ffbadfbf89c93848412d04594135c4ae8c1d37d9e053b9c3ed718fabc4/python_discovery-1.6.1-py3-none-any.whl", hash = "sha256:d43fcdef879fe795352bd13ccf8d185ba5a9f86f36cfcd00529f596e737442b3", size = 38664, upload-time = "2026-09-18T01:31:52.448Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "python-dotenv"
|
||||||
|
version = "1.2.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyyaml"
|
||||||
|
version = "6.0.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ruff"
|
||||||
|
version = "0.16.10"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c4/49/23802c45f093eb14bde54b141d2b2f058edfa63a06db7beed047308cc08f/ruff-0.16.10.tar.gz", hash = "sha256:eff4728c4eaae93f0955cd264d24b2ab348e74bf59986ccf282ba6dc16b3b017", size = 4958724, upload-time = "2026-10-01T18:03:21.697Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2f/21/ebce22e1d90cdb2cd691026b9c6e9bec6499f0b396089481755b5d49efff/ruff-0.16.10-py3-none-linux_armv6l.whl", hash = "sha256:488b0fe3f3574210e5cf80d9f59b9e3ab17a127a8155de3f307b392589cfb511", size = 10095557, upload-time = "2026-10-01T18:02:36.072Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/98/a54de85876a8b2612bfa0d84c7b9abfb39c6a3354aee7800b09c1649b9e3/ruff-0.16.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e748ff95c934c4e978783b8e687bc174e7bd84e8ad24e3243e1ecfcda5e0282d", size = 10340577, upload-time = "2026-10-01T18:02:39.258Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9f/16/1a5a4a2657effe4806110f2b907313802f1367fcdbb29e8122766e407fab/ruff-0.16.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3031a4a2e8e7b8a46f70be45f198c35a11ece509a94b80334d8d397a33c67550", size = 9774282, upload-time = "2026-10-01T18:02:41.79Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6e/fb/470085af734da396e68cd80fb0a3e7c109a459716ae59588c0f6fab8a17d/ruff-0.16.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:494401c86df4c4c25f69b9419605d944467ee98c42fb6ad405ef4fa40b8fb67d", size = 9920895, upload-time = "2026-10-01T18:02:44.458Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/57/de/f10cffe4f88a37ea6615ec460f01bf76f0bc1477737a35d9ee61a630a78b/ruff-0.16.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d203abc0ff2b773ee33d00ab8df0bb67046fbc7c07b119332f08b9b345cf8221", size = 9902707, upload-time = "2026-10-01T18:02:47.041Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ff/44/3fdcedf83ae60ef837dd239170e480dce606a9142cfa2db911afdf855fd9/ruff-0.16.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bd83d1235a5258d318477bc5b576303974cbdef5df0c01a1bff14efcc23bd12a", size = 10619287, upload-time = "2026-10-01T18:02:49.485Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c1/62/02e76a5574002153618eafbb70e159468addc72a5d2c488e65cbb4639d2d/ruff-0.16.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc2610fb269fa56dd8a68669ae470fa6272902668c0fc2ebc3aa112b2633d5b8", size = 11339942, upload-time = "2026-10-01T18:02:52.008Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6b/c4/cde27d47ad8d4126c587e608c67c46e7feac11763f606d261a1bc8a489e6/ruff-0.16.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3e70175e29cc94c26ea296c80e470180b744b7419026898e58f520c6ab32578e", size = 10934316, upload-time = "2026-10-01T18:02:54.811Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/03/17234145f302a645a123e8c3bb2411ecf4669fbc350de3b0d803230a1729/ruff-0.16.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f33f43a864a8483eebd160e713336c8bab02c934feaff0a33cf5ccb41546d09a", size = 10387968, upload-time = "2026-10-01T18:02:57.497Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/29/2493af60240ee7644b38a4b821f5f9c3b5a4fa3178770fe1d0c685217395/ruff-0.16.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1dfc6f0088149fb6a362c1c446bcbb3fd2157b3852fe2fa68409276eab9ad9b3", size = 10537906, upload-time = "2026-10-01T18:03:00.006Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2e/9a/f56b28f3b143bb9e518c34fb89ab191b626bca8b70dbf8af0d2e2d473572/ruff-0.16.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6553498afc35f580f036030795810b9e6bcea31604b0fd9e8d352795473042e3", size = 10012938, upload-time = "2026-10-01T18:03:03.006Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c2/c4/fca37362848ea4d4d80712df13632e645e7c7cfb1bdedf140699ac7a090b/ruff-0.16.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a3b8471dea115d37f123882be852bed13403746d3a76c11de4a19ec5f9ff5a03", size = 9897945, upload-time = "2026-10-01T18:03:05.818Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7d/c7/e0bc57664d6e0c61fd22f620af260fe9662d7e1ddb320ea7f160e76ef165/ruff-0.16.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:92e59a70bcbd9d3a5483656da906ec28edfdacfce00afd99edb8b4e9d15644be", size = 10333134, upload-time = "2026-10-01T18:03:08.25Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/21/aa/5c9f3b68737c0e4a33a1db5dfab44f7b786d96ca91a7233112ddab1e6dc2/ruff-0.16.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7ae7375f803b5520dc9f546bed7e3a0acb70b91812e9bb4b19927de22f25b77d", size = 10741702, upload-time = "2026-10-01T18:03:10.775Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/78/fa/0f9c2020dc316c53d983be011720b8157cc052b97e3f4dfa7db6d0f880a6/ruff-0.16.10-py3-none-win32.whl", hash = "sha256:2a12e01cb9156c10c466f63b46eaae5ecea28dfbd21b5836353ae498e7d1349a", size = 10139176, upload-time = "2026-10-01T18:03:13.267Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/99/29/cfb0df9448d4d4ad48c2de029ada9ebd71baa6da983a6c77ee6c6cd0fe82/ruff-0.16.10-py3-none-win_amd64.whl", hash = "sha256:97f2015c92aa97105b0eab19eb5d224884399281cfc5da86a92db4ab5e7fb2ca", size = 10584734, upload-time = "2026-10-01T18:03:16.006Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fc/05/c16957eb287c3fc062e032619a25868d93d408a844b725bcf514f7a378ff/ruff-0.16.10-py3-none-win_arm64.whl", hash = "sha256:25a65fe998c4e6861ec079ada5826a2fc605e6cbccbe9dcd7fac1f54e791621b", size = 10366440, upload-time = "2026-10-01T18:03:19.04Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "starlette"
|
||||||
|
version = "0.41.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "anyio" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/1a/4c/9b5764bd22eec91c4039ef4c55334e9187085da2d8a2df7bd570869aae18/starlette-0.41.3.tar.gz", hash = "sha256:0e4ab3d16522a255be6b28260b938eae2482f98ce5cc934cb08dce8dc3ba5835", size = 2574159, upload-time = "2024-11-18T19:45:04.283Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/96/00/2b325970b3060c7cecebab6d295afe763365822b1306a12eeab198f74323/starlette-0.41.3-py3-none-any.whl", hash = "sha256:44cedb2b7c77a9de33a8b74b2b90e9f50d11fcf25d8270ea525ad71a25374ff7", size = 73225, upload-time = "2024-11-18T19:45:02.027Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typing-extensions"
|
||||||
|
version = "4.16.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typing-inspection"
|
||||||
|
version = "0.4.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "uvicorn"
|
||||||
|
version = "0.34.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "click" },
|
||||||
|
{ name = "h11" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/4b/4d/938bd85e5bf2edeec766267a5015ad969730bb91e31b44021dfe8b22df6c/uvicorn-0.34.0.tar.gz", hash = "sha256:404051050cd7e905de2c9a7e61790943440b3416f49cb409f965d9dcd0fa73e9", size = 76568, upload-time = "2024-12-15T13:33:30.42Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/61/14/33a3a1352cfa71812a3a21e8c9bfb83f60b0011f5e36f2b1399d51928209/uvicorn-0.34.0-py3-none-any.whl", hash = "sha256:023dc038422502fa28a09c7a30bf2b6991512da7dcdb8fd35fe57cfc154126f4", size = 62315, upload-time = "2024-12-15T13:33:27.467Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.optional-dependencies]
|
||||||
|
standard = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
{ name = "httptools" },
|
||||||
|
{ name = "python-dotenv" },
|
||||||
|
{ name = "pyyaml" },
|
||||||
|
{ name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
|
||||||
|
{ name = "watchfiles" },
|
||||||
|
{ name = "websockets" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "uvloop"
|
||||||
|
version = "0.23.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/fa/42/02c739ce85fb2ee8d99212c61417da8140c6b87e9d97c430bea520d76044/uvloop-0.23.0.tar.gz", hash = "sha256:28d160f51ab4da3b187063652e643dea6831072add4adc1e6d62afbe73b6be27", size = 2559185, upload-time = "2026-10-01T03:17:04.4Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/98/04e766a6de99e6f7f955ecb7829e8d5a557de3427cb85be2236de54dda0c/uvloop-0.23.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:93935ab27b6eaef4c3e5489aebc84284f0644592f7ab516df60ee1b27eaf5eb3", size = 1393055, upload-time = "2026-10-01T03:15:42.526Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/33/8a/499e7b863a848ede009539bce39806b66205da5f8779354228e785601144/uvloop-0.23.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4448e9124537620f9c25d004c227bb5104440b58955c19bbd312d910af919a63", size = 768909, upload-time = "2026-10-01T03:15:43.974Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/95/a880f8ce3b87ac5b307c354e8ee480be4658d24bf01f87921d57e3530b4a/uvloop-0.23.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7548ede3ee908cfabc0d068106e303a9a2d811af959cdf6ab85676344cedcda", size = 4419106, upload-time = "2026-10-01T03:15:45.551Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/51/27/c1d2f9fa977f8f42ea294604166df10e0027e6dc6cd17f85ede386c9bf36/uvloop-0.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:090865d8ce7a03986755a3ce711b7dd0d4b44eb14ab74368b717f3fad1180208", size = 4532597, upload-time = "2026-10-01T03:15:47.258Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/42/dd/2cb6a2c8a30ca55c07a882dd4ae4ceae0fa7d8c15b25b3b7cb9a4b6cf4ca/uvloop-0.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bd6f2f81c7b9da99d301c0b16b82044e76fe887086e42e1590ecf520b94dbdac", size = 4230048, upload-time = "2026-10-01T03:15:49.119Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/52/29989cbaa4022dc4ef35c1dd60a4ab989e4c2065f341ed483ae71d2bd950/uvloop-0.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a6ac96da66c35bf789bdcde78a88dc7d56b7907d8379648c54adc1c61594575d", size = 4394152, upload-time = "2026-10-01T03:15:50.829Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "virtualenv"
|
||||||
|
version = "21.14.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "distlib" },
|
||||||
|
{ name = "filelock" },
|
||||||
|
{ name = "packaging" },
|
||||||
|
{ name = "platformdirs" },
|
||||||
|
{ name = "python-discovery" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c4/f9/f323b3b6058cff3853b31cf6a49c0425ed797bf9611572ec10d065e08bac/virtualenv-21.14.5.tar.gz", hash = "sha256:c4cb6c13e46b57225a999c7e22a09b163393878facc7ac4c059a57f46faa1647", size = 5467127, upload-time = "2026-10-02T17:42:25.613Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/93/084f10ebbecdedf7902ed68fbecc903f0e9c5742155bfffb05eccbc071da/virtualenv-21.14.5-py3-none-any.whl", hash = "sha256:b0651e0174982bba17cc6f0aaa2ef496730cb85a3f35eb5d1119d1c904f7d1da", size = 5487482, upload-time = "2026-10-02T17:42:23.38Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "watchfiles"
|
||||||
|
version = "1.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "anyio" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b3/68/e6aa0b77d217b31f8f486ec0cdfe5e00e6e38dc0be657e7d85819b9faf0a/watchfiles-1.3.0.tar.gz", hash = "sha256:99aee4a07847c06820765fd7b1b49ceac4f3f711ccb7d104655a33231de1c207", size = 108119, upload-time = "2026-09-21T09:08:55.664Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/68/fa/c0b840d5d8bafe640925408ce11095948c8945bb61be1e67d0ab629b872a/watchfiles-1.3.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:000b9688fc8133037a8b075c8ebf98f32844ff8964dda61e85c1db547dafc441", size = 399703, upload-time = "2026-09-21T09:08:13.518Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/8a/894799b485fe9473ad10422a0d9668e53fb78e3a2b5cc6061159572844ad/watchfiles-1.3.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:bbc1198edfdc90fda0600f825aa94150f428dfcbf8138746f55998e0e660d64c", size = 397647, upload-time = "2026-09-21T09:08:15.16Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/61/f277cd5ead05f5d37b98b41fbdd0c943ee0c04d49738f62e902cb291cf43/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4af9c464d410e8c44b58ddf1cbec7c3a02c1cb2c6c80df9c74659ba2fbda0ab5", size = 454994, upload-time = "2026-09-21T09:08:16.587Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/46/66/ea4c01382975e53b1ac1b0a0ea1b353416b8127fb6e04f5c1c313119aab6/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb94e40b9a0db19636b5e3fed0a9803452ecc7381df13b385272abb779ee83ca", size = 460105, upload-time = "2026-09-21T09:08:17.961Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/31/c3/1a501b597817096c9831c255f6270c124ce0e2faeb74124be24dcc93b86d/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7398930a2b76b9dc67a4e6b8bfc7baf30946892a86f417a3920c07bdd6febc30", size = 493761, upload-time = "2026-09-21T09:08:19.327Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/16/77352d05e152c7786a016e9f1decdb4507e8aa5bcf3343c72142a0413d14/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:025b108f2d5cc2799cb941f79380df3e8b22bfb589c218e801767fe78d598c95", size = 577500, upload-time = "2026-09-21T09:08:20.723Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/fe/81001c55b24466fe9911135e50269fd3a29f43e446627a92adfab54553b6/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:be9ef3cd403d756a304a0a08a112163c63da7ec72c8c9d083c98818291ff8f29", size = 469209, upload-time = "2026-09-21T09:08:22.346Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8a/cf/e3ad894ed5909a7c44b0707e89bc25d19a3d78b3a7aec846119443b6ae56/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5768b49e426fd5b550b012c866db347cdf15c398ef98dc557b6e6b72fa74cd1", size = 458353, upload-time = "2026-09-21T09:08:23.7Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7f/f5/2f106829e6e2bc91ad74614730f6df484202b9a4d20d927dbf2f08d37d44/watchfiles-1.3.0-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:d36c72fcdc08f143d87316a4c4c3b064e340da2c7383d39803950740ed418935", size = 468707, upload-time = "2026-09-21T09:08:25.167Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/15/37/2741ab776b978669c1f3bba1e573f1a184f727ab8b4d3a6d9c61090800ed/watchfiles-1.3.0-cp310-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:f0c9865240e065a2247f4b5248528ae3d01e0e8ae071462c250c884dc16e3ee3", size = 632510, upload-time = "2026-09-21T09:08:26.532Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/48/64/db4a4684275fe9b696ee5118c194705c1423b3d42181c3f8ee70194d5c73/watchfiles-1.3.0-cp310-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:380f513d26cc2e598266b88d66456e07d67ef4be8f0f435a1154d9c44b43d509", size = 661723, upload-time = "2026-09-21T09:08:28.004Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/52/80/95ddb24f6a2f595c031c24432ab7b8aa7d6d1ada0c6d63a552166e06e8d1/watchfiles-1.3.0-cp310-abi3-win32.whl", hash = "sha256:509d9f74d2bec5c1f4cd868ddcfe0c9601f7ea53066dfcb19d8ede742c4de661", size = 277761, upload-time = "2026-09-21T09:08:29.335Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/48/a1/1c4a1b3c8030cd03206232612be8a6e8799093490c220b0fcb935d02b75a/watchfiles-1.3.0-cp310-abi3-win_amd64.whl", hash = "sha256:1acabde19b67e673274e89a04d613b7fb1f122e2c4b8ec9a581e59c149264b61", size = 290643, upload-time = "2026-09-21T09:08:30.45Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/82/e0/d2db577c632d26d08078c4390dd870b8555c4e558df25d22270f0166c71f/watchfiles-1.3.0-cp310-abi3-win_arm64.whl", hash = "sha256:d978cc1dd7ba44f5590d7de74f7a9c8be7262d9e469c2c0efb8bbfc2574321dc", size = 285510, upload-time = "2026-09-21T09:08:31.561Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "websockets"
|
||||||
|
version = "17.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/01/89/3f825ab71c242fffb62ea8fe638741c290f62f8d7aadf8125ff897747af3/websockets-17.2.tar.gz", hash = "sha256:36c2fb94c990cc2545143b12690e2de6c16300f9dbe5b4f33fa300cf57dc8792", size = 188355, upload-time = "2026-10-03T14:56:53.5Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bc/de/87854af9b38fe4738fd85f7f21c5b49558ae20aec898880894e435f33375/websockets-17.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:916ebdfd82e7fc68041d36b2b5f60361b9abce1e087454da15f8bd004839e090", size = 217757, upload-time = "2026-10-03T14:53:23.029Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3a/2e/1e80b5efa41544f626d56bd15ccb53dbfc56bf28bf80ab9cd6f82c4b1d20/websockets-17.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3621f3686397708b8eeabfd0a9d75267c1f29a7537d2fe31e65d099e71587fa4", size = 215439, upload-time = "2026-10-03T14:53:24.531Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3b/6e/82c78b595aee05be76a7ee78539323da1593c1848e4fef51c704c696568f/websockets-17.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a81e19710d48da88653473b6b9c366d47e99fe4f58e37ce415be47966748f31f", size = 215703, upload-time = "2026-10-03T14:53:26.226Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f8/c4/905ef6aa80423c03dba99e1e26fc0acf63a2a9a6a2d9e8c0e6a63caaf952/websockets-17.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f2731f9067976c8c4127212c0d2f2ada42d497d935e470419e029802365b12bb", size = 225023, upload-time = "2026-10-03T14:53:27.744Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/03/c0/a6d8be9c43e4456fb9597fdf8b5e0ce1f0a5df41503acce6d869536e4e23/websockets-17.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6627b913b8586b1c06db9516b31dd0dfbc621de3bb9312616d92a7e44f268a5b", size = 225299, upload-time = "2026-10-03T14:53:29.171Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2f/d4/976d34b5491258b0a86c2ce9b9aabb9fdd68919ffd7fe65999c14a502a98/websockets-17.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0198c4ec6a3406a2f7557c032967de426474c2c995c81076585e09d29a9f407b", size = 226540, upload-time = "2026-10-03T14:53:31.635Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/83/2f/c4cfd42f53c697a8ed123fd82b8f85fcd13b6360d47f9f1d1d45d6ec6627/websockets-17.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:88c6a42c2632ff469e84155e44f6ed92cb15ccb047bf5fcb59225ae5a12fd33d", size = 229371, upload-time = "2026-10-03T14:53:33.061Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e7/55/9a221b29c6232ff9282eecb2fc102402cb9e42a3479264db0e5fc4fe6835/websockets-17.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:eb0023e6cdb4b8ece0b33875188dd16104ad8c335361d396a98394f99e30ff7a", size = 227173, upload-time = "2026-10-03T14:53:34.502Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8f/07/125e6d010c56c253d3d2b93cabaea0f96d33898151a16b49066a594acecf/websockets-17.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c1c09d5d4646eb96bda2cfb97493bcea21a0956a981de116e6b1f4a9de07f3fd", size = 225929, upload-time = "2026-10-03T14:53:36.071Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/23/a8/aad3bd902aee84e1b261ad6ab83b405e4a564af43101b8ad1dc0293ff4f4/websockets-17.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0360c4dc13ac569cc245e0efa2f4d4b1e4733d24c47b8ab3f3747227b1356348", size = 223167, upload-time = "2026-10-03T14:53:37.528Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1f/f4/ec8ab9be1a5310b4fea829f088c7aa2b7a58b61d34bce1b2a9338635ff12/websockets-17.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:76693a16dead737946b651375ee3109d7db7ad9569a1c55c60aaed3ef85cfcc6", size = 225974, upload-time = "2026-10-03T14:53:38.959Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/65/45/ba6503f8257d3f98b0f07ebaad0fd099c9023eae744fd5b775416743597e/websockets-17.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:77a42cc507993ec5471b5283f7eef869239173b6000031543e3938a86d1af0fd", size = 224581, upload-time = "2026-10-03T14:53:40.496Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d0/45/05cca59a876c6776727d96fc7ba59e0b6f9aa496afbf13e7e04ad0b63678/websockets-17.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3bbc5543e39ee025d524077c5c15c2d67bc11c9f6676afe5b531839e24d701f6", size = 225347, upload-time = "2026-10-03T14:53:42.061Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1c/00/cf0e43292ae949b13f67535be84317102891d69fd1986ec2bf2ead42747b/websockets-17.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8da58558bfb0ca6ccac2419773521f1111e40654038b1afabdfc69c02cb82614", size = 226457, upload-time = "2026-10-03T14:53:43.575Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/79/0d/9a5c61a18f0cc9876d94c70ccb3daf7614a9fee56abbb37c0e64e757fb96/websockets-17.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:01420cb1cb47433e8e7075d32cb8017ad3ffed0654bd1e48c0251b865920dec3", size = 224011, upload-time = "2026-10-03T14:53:45.077Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/34/ed/991c1ab80ab2ce40e1c939fef6fa8f971c3ef3b21caf988a7a107e0ad27d/websockets-17.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:c49c9edd47d0e44d360299e2d8865e2950d2fcf1b4098782c9d7dcd070919e5a", size = 224990, upload-time = "2026-10-03T14:53:46.8Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e7/7a/363c835d17923e967fb66376188e67b9a261c85d826a0cd5e4dd3471221d/websockets-17.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:96f6c8d0fe21930d1f982bfce2382789d2e8d005d2ab63d21280660f95ef8fe1", size = 225265, upload-time = "2026-10-03T14:53:48.382Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c8/90/6c51f6d78636bd1cd6781fae8ea5ea7bf1d5b4059354f3c1f5f8de793338/websockets-17.2-cp312-cp312-win32.whl", hash = "sha256:b25659ab2d655d742701487d5591e3f98e8f8b329fc999e05e3d59691ab344a1", size = 218228, upload-time = "2026-10-03T14:53:49.867Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c6/2a/90008411c652dcfae34345a2169f4becd066a4ba71eebfa8dd801e0445e1/websockets-17.2-cp312-cp312-win_amd64.whl", hash = "sha256:faa763b677e96f1beccc6b4d7e8c079dfeed2f249f57a19debc321b519ee64ec", size = 218528, upload-time = "2026-10-03T14:53:51.486Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1f/a1/b8ad6c17f8e75ba2215422fffe0d7f0c4b690dcff1c47c0473db0d253d51/websockets-17.2-cp312-cp312-win_arm64.whl", hash = "sha256:63499fc49efe48bccc2fca40723bc7adb198866cbe159093dd979905316994b6", size = 218457, upload-time = "2026-10-03T14:53:52.938Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8a/58/835cd51934d6780fa586f275b5d9901eead6d81569b4343b3767cdbaae4c/websockets-17.2-py3-none-any.whl", hash = "sha256:6aa59f0ef92e796b2db6f5f26550c4713c0e4036899fadf02f55e2ed4db0b7ae", size = 211883, upload-time = "2026-10-03T14:56:51.898Z" },
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user