Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ccb1f708ca
|
||
|
|
62c2692082
|
||
|
|
c06c9194e8
|
||
|
|
65eaf36278
|
||
|
|
f3bae8ae0c
|
||
|
|
264d7905c6
|
||
|
|
e9808cb12b
|
||
|
|
4ce6082b93
|
||
|
|
a56e1082e5
|
||
|
|
0c57f7c76d
|
||
|
|
09bf6ece00
|
||
|
|
ff32c6a605
|
||
|
|
abd03ed07d
|
||
|
|
e0304b2bdb
|
||
|
|
77b60622e9
|
||
|
|
8cc080998f
|
||
|
|
1146dec1ac
|
||
|
|
fa00b36e49
|
||
|
|
f8d5b7857a
|
||
|
|
92c53dc7ca
|
||
|
|
34f0d1b125
|
+5
-1
@@ -11,9 +11,13 @@
|
|||||||
**/.venv/
|
**/.venv/
|
||||||
**/venv/
|
**/venv/
|
||||||
**/node_modules/
|
**/node_modules/
|
||||||
**/.node-red/
|
|
||||||
**/.cache/
|
**/.cache/
|
||||||
**/*.log
|
**/*.log
|
||||||
**/uploads/
|
**/uploads/
|
||||||
**/sessions/
|
**/sessions/
|
||||||
debug_files/
|
debug_files/
|
||||||
|
tmp/
|
||||||
|
**/.mypy_cache/
|
||||||
|
**/.pytest_cache/
|
||||||
|
**/.ruff_cache/
|
||||||
|
**/*.egg-info/
|
||||||
|
|||||||
+6
-15
@@ -38,8 +38,8 @@ node_modules/
|
|||||||
.npm/
|
.npm/
|
||||||
package-lock.json
|
package-lock.json
|
||||||
!speech_to_speech/package-lock.json
|
!speech_to_speech/package-lock.json
|
||||||
!s2s-frontend/package-lock.json
|
!vocero-frontend/package-lock.json
|
||||||
s2s-frontend/.eslintcache
|
vocero-frontend/.eslintcache
|
||||||
yarn-error.log
|
yarn-error.log
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
@@ -122,23 +122,14 @@ CVS/
|
|||||||
*.trae
|
*.trae
|
||||||
.trae/
|
.trae/
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────
|
|
||||||
# Reasoning engine (Node-RED) local state
|
|
||||||
# ─────────────────────────────────────────────────────────────
|
|
||||||
s2s-reasoning-engine/node_modules/
|
|
||||||
s2s-reasoning-engine/.node-red/
|
|
||||||
s2s-reasoning-engine/lib/flows_cred.json
|
|
||||||
s2s-reasoning-engine/.config.*
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
# Client backend and speech-service runtime artifacts
|
# Client backend and speech-service runtime artifacts
|
||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
s2s-client/.cache/
|
vocero-api/.cache/
|
||||||
s2s-client/uploads/
|
vocero-api/uploads/
|
||||||
s2s-client/sessions/
|
vocero-api/sessions/
|
||||||
s2s-client/*.log
|
vocero-api/*.log
|
||||||
speech_to_speech/*.log
|
speech_to_speech/*.log
|
||||||
speech_to_speech/sessions/
|
speech_to_speech/sessions/
|
||||||
speech_to_speech/.cache/
|
speech_to_speech/.cache/
|
||||||
s2s-reasoning-engine/*.log
|
|
||||||
debug_files
|
debug_files
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
# Repository guidance
|
||||||
|
|
||||||
|
## Scope and working conventions
|
||||||
|
|
||||||
|
This repository is Vocero: a browser voice client, its FastAPI integration
|
||||||
|
server, and a speech pipeline. Keep this guide self-contained and base its
|
||||||
|
instructions on this checkout's code, configuration, documentation, and tools.
|
||||||
|
|
||||||
|
All file and directory references in this guide must point within the repository
|
||||||
|
and be relative to its root. Commands may use the project's command-line tools,
|
||||||
|
such as `rg`, `git`, `uv`, `npm`, Docker, and PM2. HTTP routes mentioned below are
|
||||||
|
URLs, not filesystem paths.
|
||||||
|
|
||||||
|
- Check the current branch, worktree, and working-tree changes before editing.
|
||||||
|
Honor the checkout requested by the user, including direct work on `main`.
|
||||||
|
Use an isolated worktree when requested and preserve other worktrees.
|
||||||
|
- An analysis-only or planning request authorizes inspection, not implementation.
|
||||||
|
Return findings with source locations. Present plans in the conversation
|
||||||
|
unless the user asks to save a file; follow any explicit approval gate.
|
||||||
|
- Write clear explanations and ask one conceptual question at a time. Carry out
|
||||||
|
already authorized work without repeatedly asking for permission.
|
||||||
|
- Use the native patch/edit tool for manual changes. Prefer `rg`, `fd`, `jq`,
|
||||||
|
`git`, and standard CLI tools for inspection; check availability before
|
||||||
|
assuming a tool is missing. Do not install system-wide packages unnecessarily.
|
||||||
|
- When commits are requested, make coherent, scoped Conventional Commits as work
|
||||||
|
progresses. Avoid excessive micro-commits and unrelated cleanup.
|
||||||
|
- For a requested pull request, describe the problem, resulting behavior,
|
||||||
|
validation, and relevant limitations. Create issues or post comments only
|
||||||
|
when requested.
|
||||||
|
- When asked to merge locally, use the requested rebase/fast-forward flow;
|
||||||
|
preserve a recovery ref before rewriting and verify retained patches. A local
|
||||||
|
merge does not imply a push, deployment, or restart. Report those separately.
|
||||||
|
- Treat historical proposals in `docs/proposals/` as context. Verify their
|
||||||
|
claims against current code and distinguish proposed authentication or
|
||||||
|
deployment changes from implemented behavior.
|
||||||
|
|
||||||
|
## Project map
|
||||||
|
|
||||||
|
| Location | Responsibility |
|
||||||
|
| ---------------------------------- | ------------------------------------------------------------------------------------------- |
|
||||||
|
| `vocero-api/` | The complete uv project: manifests, environment, Python tooling, tests, and FastAPI package |
|
||||||
|
| `vocero-api/vocero_api/main.py` | Application assembly, documentation mount, and retained redirects |
|
||||||
|
| `vocero-api/vocero_api/api/` | HTTP routes, route registration, and response validation helper |
|
||||||
|
| `vocero-api/vocero_api/models/` | Pydantic request and response contracts |
|
||||||
|
| `vocero-api/vocero_api/core/` | Environment parsing, logging, public URLs, and startup diagnostics |
|
||||||
|
| `vocero-api/vocero_api/providers/` | ApiPilot and caller/profile/report HTTP integrations |
|
||||||
|
| `vocero-api/vocero_api/services/` | Prompt/context construction, reasoning, and tool-context rendering |
|
||||||
|
| `vocero-api/vocero_api/realtime/` | WebSocket relay, event handling, marker filtering, and audio forwarding |
|
||||||
|
| `vocero-frontend/` | React, TypeScript, Tailwind, and Vite browser application |
|
||||||
|
| `speech_to_speech/` | Separately run VAD → STT → LLM → TTS pipeline and realtime API |
|
||||||
|
| `start_ui.sh` | Dependency sync and supervision of FastAPI plus Vite |
|
||||||
|
| `docs/` | Maintained technical documentation, examples, and historical proposals |
|
||||||
|
|
||||||
|
`vocero-api/server.py` is a compatibility shim for `server:app`; new backend
|
||||||
|
implementation belongs in the package. Keep HTTP handlers focused on transport
|
||||||
|
and reuse providers/services for shared behavior.
|
||||||
|
|
||||||
|
The old static browser site, daily talk-time quotas, quota database/accounting,
|
||||||
|
and Hugging Face account UI were deliberately removed. Do not restore their
|
||||||
|
files, routes, dependencies, or compatibility layers as incidental cleanup.
|
||||||
|
Load-balancer allocation/queues and upstream caller authentication remain.
|
||||||
|
|
||||||
|
## Tooling and checks
|
||||||
|
|
||||||
|
The uv project must stay inside `vocero-api/`, including
|
||||||
|
`vocero-api/pyproject.toml`, `vocero-api/uv.lock`, `vocero-api/.python-version`,
|
||||||
|
`vocero-api/.venv/`, `vocero-api/Makefile`, and
|
||||||
|
`vocero-api/.pre-commit-config.yaml`.
|
||||||
|
Use Python 3.12 and the uv version required by `tool.uv.required-version`
|
||||||
|
(currently 0.12.5). Update dependencies through uv and commit the lockfile;
|
||||||
|
do not use pip to modify this environment or move it to the repository root.
|
||||||
|
Keep uv pins in `vocero-api/Dockerfile` and setup documentation aligned when
|
||||||
|
changing the required version.
|
||||||
|
|
||||||
|
The frontend uses npm and `vocero-frontend/package-lock.json`. Its Node requirement is
|
||||||
|
`^22.18.0 || >=24.0.0`; consult `vocero-frontend/package.json` for current scripts.
|
||||||
|
Keep application code and supported configuration files in TypeScript.
|
||||||
|
|
||||||
|
Commands below run from the repository root:
|
||||||
|
|
||||||
|
| Command | Purpose |
|
||||||
|
| ------------------------------------- | --------------------------------------------------------------------- |
|
||||||
|
| `make -C vocero-api install` | Sync the locked Python environment and development tools |
|
||||||
|
| `make -C vocero-api format` | Apply Ruff fixes and formatting |
|
||||||
|
| `make -C vocero-api check` | Check lock freshness, formatting, Ruff lint, mypy, and pytest |
|
||||||
|
| `make -C vocero-api test-launcher` | Test readiness, failures, dependency sync, and process-group shutdown |
|
||||||
|
| `make -C vocero-api test-image` | Build and smoke-test the actual backend Docker image |
|
||||||
|
| `npm --prefix vocero-frontend ci` | Install locked frontend dependencies |
|
||||||
|
| `npm --prefix vocero-frontend run check` | Check formatting, lint, TypeScript, Vitest, and launcher integration |
|
||||||
|
| `npm --prefix vocero-frontend run build` | Type-check and build frontend assets |
|
||||||
|
|
||||||
|
Python uses Ruff with a 120-character line limit, double quotes, import ordering,
|
||||||
|
and typed application functions checked by mypy. Frontend formatting and lint
|
||||||
|
rules live in `vocero-frontend/prettier.config.ts` and
|
||||||
|
`vocero-frontend/eslint.config.ts`; use those tools rather than introducing
|
||||||
|
competing conventions.
|
||||||
|
|
||||||
|
Run checks appropriate to the change: backend checks for Python/API work,
|
||||||
|
frontend checks and build for browser work, launcher integration for supervisor
|
||||||
|
changes, and image tests for packaging/Docker changes. Documentation-only work
|
||||||
|
normally needs link/path and diff checks, not service startup. Wait for commands
|
||||||
|
to finish before reporting success, and state skipped coverage or blockers.
|
||||||
|
|
||||||
|
Tests should use mock upstream HTTP and local WebSocket fixtures, isolating
|
||||||
|
environment configuration. Do not rely on deployed services, real credentials,
|
||||||
|
or model downloads for client tests. The client gate does not test the separate
|
||||||
|
speech pipeline, microphone hardware, or real model output. That pipeline has
|
||||||
|
no complete dependency manifest or general test gate in this checkout; inspect
|
||||||
|
the selected backend and explain the verification limits for speech changes.
|
||||||
|
|
||||||
|
## API and compatibility rules
|
||||||
|
|
||||||
|
Read [backend development and compatibility](docs/client/development.md) before
|
||||||
|
refactoring the server or changing schemas. Existing wire behavior is the
|
||||||
|
baseline, including behavior that looks unsafe, inconsistent, or buggy.
|
||||||
|
Intentional bug fixes or contract changes need their own scope and regression
|
||||||
|
evidence; do not bundle them into tooling or structural refactors.
|
||||||
|
|
||||||
|
- Preserve paths, methods, statuses, error shapes, headers, field names,
|
||||||
|
omissions, coercion, authentication precedence, and outgoing provider calls.
|
||||||
|
Trace both frontend consumers and provider parsing; a permissive consumer is
|
||||||
|
not evidence that an upstream schema can safely be tightened.
|
||||||
|
- Retain existing request-model defaults and extra-field behavior. Do not make
|
||||||
|
new fields required, forbid previously accepted extras, or normalize loosely
|
||||||
|
parsed provider values without evidence and an intentional behavior change.
|
||||||
|
- Response models validate fields constructed by this service. Upstream-owned
|
||||||
|
values remain permissive where the old parser was permissive: `Any`, optional
|
||||||
|
fields, open objects, and arbitrary profile JSON are deliberate boundaries.
|
||||||
|
- `validated_json_response` in `vocero-api/vocero_api/api/responses.py` validates
|
||||||
|
with Pydantic, then serializes the original payload. Do not substitute
|
||||||
|
`model_dump()` or automatic response-model serialization: those can insert
|
||||||
|
defaults, filter unknown keys,
|
||||||
|
coerce values, or alter serialization. Keep OpenAPI declarations aligned with
|
||||||
|
the actual validation boundary.
|
||||||
|
- Preserve environment/dotenv parsing, default flags, and import-time behavior
|
||||||
|
during refactors. Do not introduce stricter configuration validation or change
|
||||||
|
authentication behavior as part of a tooling change.
|
||||||
|
- `vocero-api/tests/fixtures/legacy-contracts.json` captures the server before the
|
||||||
|
migration at `3d42df5`. `vocero-api/tests/test_compatibility.py` compares HTTP
|
||||||
|
response bytes, statuses, selected headers, outgoing requests, request schemas,
|
||||||
|
and realtime frames. Do not regenerate captures merely to make a refactor pass.
|
||||||
|
- Known reasoning failures, ApiPilot authentication/cache quirks, and relay
|
||||||
|
behavior are documented in the compatibility guide. Preserve narrow lint/type
|
||||||
|
exceptions that retain those behaviors until a deliberate fix is in scope.
|
||||||
|
- The pytest warning filter is only for Starlette TestClient's exact deprecated
|
||||||
|
AnyIO `BlockingPortal` alias. Do not broaden warning suppression or upgrade
|
||||||
|
runtime frameworks just to silence that warning without a compatibility review.
|
||||||
|
|
||||||
|
## Browser and realtime behavior
|
||||||
|
|
||||||
|
- React renders the UI; `vocero-frontend/src/realtime/controller.ts` owns observable
|
||||||
|
application state, `vocero-frontend/src/realtime/client.ts` owns protocol/session
|
||||||
|
lifecycle, and `vocero-frontend/src/audio/` owns the audio graph and worklets.
|
||||||
|
Keep high-frequency audio/WebSocket work outside React rendering and preserve
|
||||||
|
cleanup across unmounts and reconnects.
|
||||||
|
- Preserve the existing 16 kHz PCM16 audio path, worklet registration, interruption
|
||||||
|
behavior, and teardown. Session startup initializes audio, waits for
|
||||||
|
`session.created`, and sends `session.update` before audio or the greeting.
|
||||||
|
A `session.updated` acknowledgment is optional; requiring it previously broke
|
||||||
|
startup with this speech service.
|
||||||
|
- Preserve query parameters such as `phoneNumber` and `episode_id`, existing
|
||||||
|
`s2s.ws.*` preference keys, session-only RAG, and TALK/TALKFIRST behavior unless
|
||||||
|
the task explicitly changes them. Context comes from browser instructions and
|
||||||
|
server/provider calls; do not assume a vector database or a completed magic-link
|
||||||
|
authentication flow exists.
|
||||||
|
- Assistant Markdown is parsed with Marked and sanitized with DOMPurify in
|
||||||
|
`vocero-frontend/src/lib/messages.ts`. User text stays literal, and exports retain
|
||||||
|
raw message text. Keep sanitization and unsafe-link regression coverage when
|
||||||
|
changing it.
|
||||||
|
- Reuse the Tailwind tokens in `vocero-frontend/src/styles/theme.css` and shared
|
||||||
|
controls in `vocero-frontend/src/components/ui.tsx`. Keep specialized artwork
|
||||||
|
and sanitized Markdown styles scoped under `vocero-frontend/src/styles/`.
|
||||||
|
Verify both light and dark themes,
|
||||||
|
narrow screens, keyboard focus, dialog dismissal, and voice-state feedback for
|
||||||
|
UI changes.
|
||||||
|
Preserve intentional `?debug=true` controls and browser audio-permission flows.
|
||||||
|
- User-visible times use `America/Santiago` where no saved zone exists; avoid
|
||||||
|
browser-local timezone assumptions. Keep raw timestamps in their existing API
|
||||||
|
and export formats.
|
||||||
|
|
||||||
|
## Startup and deployment boundaries
|
||||||
|
|
||||||
|
`./start_ui.sh` loads the optional root `.env`, syncs `vocero-api/.venv/` with
|
||||||
|
`uv sync --project vocero-api --locked`, and installs frontend packages when the
|
||||||
|
manifests change.
|
||||||
|
It starts Vite on `0.0.0.0:8420` and FastAPI on `127.0.0.1:8421`, with
|
||||||
|
`UI_HOST`, `UI_PORT`, and `S2S_BACKEND_PORT` overrides. Use distinct ports for
|
||||||
|
concurrent worktrees. Preserve readiness checks, failure propagation, and
|
||||||
|
shutdown of both process groups, including Uvicorn's reload worker.
|
||||||
|
|
||||||
|
Staging intentionally retains Vite HMR and Uvicorn reload. The Vite child uses
|
||||||
|
`NODE_ENV=development` even under PM2. Do not replace this with static production
|
||||||
|
serving unless that deployment change is requested. Vite proxies `/api`,
|
||||||
|
`/v1/realtime`, and documentation routes to FastAPI; preserve public Host headers
|
||||||
|
used to construct WebSocket URLs. Remote staging needs explicit
|
||||||
|
`S2S_DEV_ALLOWED_HOSTS` hostnames and reverse-proxy WebSocket forwarding for both
|
||||||
|
`/v1/realtime` and `/__vite_hmr`.
|
||||||
|
|
||||||
|
The established PM2 process name is `s2s_ui`. When it points to this launcher's
|
||||||
|
deployed checkout, `pm2 restart s2s_ui --update-env` also performs the locked uv
|
||||||
|
sync; a separate sync is optional to prepare dependencies before restarting.
|
||||||
|
Ensure the required uv and Node/npm are on that process's PATH. Verify the live
|
||||||
|
PM2 script path and environment before making claims about a deployment.
|
||||||
|
|
||||||
|
The launcher does not start or restart `speech_to_speech/`. A configured
|
||||||
|
speech URL does not prove which source revision or implementation runs there.
|
||||||
|
`SPEECH_TO_SPEECH_URL` pins a direct browser connection; `S2S_PROXY_TARGET` enables
|
||||||
|
the local relay. Either disables load-balancer mode. Reasoning supports `PROXY`
|
||||||
|
and `OPENAI`; unknown `S2S_REASONING_TYPE` values fall back to `PROXY`.
|
||||||
|
|
||||||
|
The backend Docker image serves APIs, WebSockets, and root `docs/`, not the React
|
||||||
|
UI. Build with the repository root as context and `vocero-api/Dockerfile`; the
|
||||||
|
image listens on 7860. FastAPI's root remains 404, `/docs` is the API explorer,
|
||||||
|
and project documentation is served below `/docs/` with retained legacy redirects.
|
||||||
|
|
||||||
|
## Language, documentation, and secrets
|
||||||
|
|
||||||
|
- Use English for code, identifiers, comments, logs, internal instructions, and
|
||||||
|
developer documentation. Spanish is the default for visible UI, spoken output,
|
||||||
|
and user-facing API messages. Preserve literal wake phrases and protocol names;
|
||||||
|
translating an existing response or prompt can change a contract.
|
||||||
|
- Keep maintained documentation under `docs/client/`, `docs/speech/`, `docs/adr/`,
|
||||||
|
`docs/examples/`, or `docs/proposals/`. Update existing documents first and keep
|
||||||
|
`docs/README.md` current when adding a document. Use one canonical English file
|
||||||
|
without language suffixes. Historical proposals do not define current behavior.
|
||||||
|
- Reserve root Markdown for established files such as `README.md` and `AGENTS.md`.
|
||||||
|
Keep task summaries in the conversation unless a saved artifact is requested.
|
||||||
|
Record notable changes under `Unreleased` in `vocero-api/CHANGELOG.md`.
|
||||||
|
- Keep credentials and deployment values out of commits and examples. Never copy
|
||||||
|
the root `.env` into the frontend or expose backend secrets through `VITE_*`.
|
||||||
|
The launcher loads `.env` when present, so its values can override shell values;
|
||||||
|
direct Python startup uses `load_dotenv(..., override=False)`. Preserve both
|
||||||
|
behaviors and avoid importing live configuration when a test can isolate it.
|
||||||
|
|
||||||
|
Start with [usage](docs/client/usage.md), [architecture](docs/client/architecture.md),
|
||||||
|
[development](docs/client/development.md), and [troubleshooting](docs/client/troubleshooting.md).
|
||||||
|
For speech work, read [the realtime protocol](docs/speech/realtime-api.md) and the
|
||||||
|
relevant [STT](docs/speech/stt.md), [LLM](docs/speech/llm.md), or
|
||||||
|
[TTS](docs/speech/tts.md) guide before changing pipeline behavior.
|
||||||
@@ -1,20 +1,19 @@
|
|||||||
# Vocero speech-to-speech
|
# Vocero speech-to-speech
|
||||||
|
|
||||||
Vocero provides a browser voice interface, a Python speech pipeline, and an
|
Vocero provides a browser voice interface, a FastAPI integration server, and a
|
||||||
optional Node-RED reasoning service. The interface defaults to Spanish;
|
Python speech pipeline. The interface defaults to Spanish;
|
||||||
documentation, source identifiers, comments, and internal instructions use English.
|
documentation, source identifiers, comments, and internal instructions use English.
|
||||||
|
|
||||||
| Component | Purpose |
|
| Component | Purpose |
|
||||||
|---|---|
|
|---|---|
|
||||||
| [s2s-frontend](s2s-frontend) | React, TypeScript, and Tailwind browser UI, served by Vite with hot reload |
|
| [vocero-frontend](vocero-frontend) | React, TypeScript, and Tailwind browser UI, served by Vite with hot reload |
|
||||||
| [s2s-client](s2s-client) | FastAPI backend, realtime WebSocket proxy, search and ApiPilot bridges |
|
| [vocero-api](vocero-api) | FastAPI backend, realtime WebSocket proxy, search and ApiPilot bridges |
|
||||||
| [speech_to_speech](speech_to_speech) | Queue-driven VAD, speech recognition, language model, and speech synthesis pipeline |
|
| [speech_to_speech](speech_to_speech) | Queue-driven VAD, speech recognition, language model, and speech synthesis pipeline |
|
||||||
| [s2s-reasoning-engine](s2s-reasoning-engine) | Optional Node-RED HTTP reasoning and conversation logging flows |
|
|
||||||
|
|
||||||
Start with [client setup and usage](docs/client/usage.md), then read the
|
Start with [client setup and usage](docs/client/usage.md), then read the
|
||||||
[architecture](docs/client/architecture.md) and [documentation index](docs/README.md).
|
[architecture](docs/client/architecture.md) and [documentation index](docs/README.md).
|
||||||
The speech backend needs its own installed model and runtime dependencies;
|
The speech backend needs its own installed model and runtime dependencies;
|
||||||
`s2s-client/requirements.txt` installs only the UI server dependencies.
|
The uv project in `vocero-api/` installs only the UI server dependencies.
|
||||||
|
|
||||||
## Run the browser client
|
## Run the browser client
|
||||||
|
|
||||||
@@ -25,25 +24,25 @@ S2S_PROXY_TARGET=ws://localhost:8765/v1/realtime ./start_ui.sh
|
|||||||
```
|
```
|
||||||
|
|
||||||
Open <http://localhost:8420/> and allow microphone access. The launcher creates
|
Open <http://localhost:8420/> and allow microphone access. The launcher creates
|
||||||
`.venv`, installs missing Python requirements and locked frontend dependencies,
|
`vocero-api/.venv`, syncs locked Python and frontend dependencies,
|
||||||
and loads the root `.env` if present. Vite listens on `UI_PORT` (default `8420`)
|
and loads the root `.env` if present. Vite listens on `UI_PORT` (default `8420`)
|
||||||
and forwards HTTP/WebSocket backend routes to FastAPI on `127.0.0.1:8421`.
|
and forwards HTTP/WebSocket backend routes to FastAPI on `127.0.0.1:8421`.
|
||||||
Both servers reload during development; stopping the launcher stops both.
|
Both servers reload during development; stopping the launcher stops both.
|
||||||
Leave `SPEECH_TO_SPEECH_URL` unset to use the same-origin speech proxy.
|
Leave `SPEECH_TO_SPEECH_URL` unset to use the same-origin speech proxy.
|
||||||
|
|
||||||
The launcher requires Python 3 with venv support, Node.js 22.18+ on the 22.x
|
The launcher requires uv 0.12.5 (which provisions Python 3.12), Node.js 22.18+ on the 22.x
|
||||||
line or Node.js 24+, npm, Bash 4.4+, `setsid`, and `curl`. For staging hostnames,
|
line or Node.js 24+, npm, Bash 4.4+, `setsid`, and `curl`. For staging hostnames,
|
||||||
set `S2S_DEV_ALLOWED_HOSTS` and forward the Vite HMR WebSocket through the existing
|
set `S2S_DEV_ALLOWED_HOSTS` and forward the Vite HMR WebSocket through the existing
|
||||||
HTTPS reverse proxy. See [development setup](s2s-frontend/README.md).
|
HTTPS reverse proxy. See [development setup](vocero-frontend/README.md).
|
||||||
|
|
||||||
The Docker image serves backend routes and shared documentation. The React
|
The Docker image serves backend routes and shared documentation. The React
|
||||||
frontend runs separately through Vite. To build the backend image, use the
|
frontend runs separately through Vite. To build the backend image, use the
|
||||||
repository root as the Docker build context so documentation is included:
|
repository root as the Docker build context so documentation is included:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build -f s2s-client/Dockerfile -t s2s-client .
|
docker build -f vocero-api/Dockerfile -t vocero-api .
|
||||||
docker run --rm -p 7860:7860 \
|
docker run --rm -p 7860:7860 \
|
||||||
-e SPEECH_TO_SPEECH_URL=ws://localhost:8765/v1/realtime s2s-client
|
-e SPEECH_TO_SPEECH_URL=ws://localhost:8765/v1/realtime vocero-api
|
||||||
```
|
```
|
||||||
|
|
||||||
The direct WebSocket URL is resolved by the user's browser. For a server-side
|
The direct WebSocket URL is resolved by the user's browser. For a server-side
|
||||||
@@ -51,7 +50,8 @@ proxy deployment, supply a backend address reachable from the container instead.
|
|||||||
|
|
||||||
## Project references
|
## Project references
|
||||||
|
|
||||||
- [Change history](s2s-client/CHANGELOG.md)
|
- [Change history](vocero-api/CHANGELOG.md)
|
||||||
|
- [Backend development and compatibility](docs/client/development.md)
|
||||||
- [Realtime protocol and pipeline](docs/speech/realtime-api.md)
|
- [Realtime protocol and pipeline](docs/speech/realtime-api.md)
|
||||||
- [Context injection](docs/client/context-injection.md)
|
- [Context injection](docs/client/context-injection.md)
|
||||||
- [Tool calling](docs/client/tool-calling.md)
|
- [Tool calling](docs/client/tool-calling.md)
|
||||||
|
|||||||
+2
-1
@@ -8,13 +8,14 @@ do not define the current implementation.
|
|||||||
|
|
||||||
- [Usage and configuration](client/usage.md)
|
- [Usage and configuration](client/usage.md)
|
||||||
- [Architecture and source map](client/architecture.md)
|
- [Architecture and source map](client/architecture.md)
|
||||||
|
- [Backend development and compatibility](client/development.md)
|
||||||
- [Reasoning modes](client/reasoning.md)
|
- [Reasoning modes](client/reasoning.md)
|
||||||
- [Context injection](client/context-injection.md)
|
- [Context injection](client/context-injection.md)
|
||||||
- [Tool calling](client/tool-calling.md)
|
- [Tool calling](client/tool-calling.md)
|
||||||
- [Design language](client/design.md)
|
- [Design language](client/design.md)
|
||||||
- [Glossary](client/glossary.md)
|
- [Glossary](client/glossary.md)
|
||||||
- [Troubleshooting](client/troubleshooting.md)
|
- [Troubleshooting](client/troubleshooting.md)
|
||||||
- [Change history](../s2s-client/CHANGELOG.md)
|
- [Change history](../vocero-api/CHANGELOG.md)
|
||||||
|
|
||||||
## Speech backend
|
## Speech backend
|
||||||
|
|
||||||
|
|||||||
+39
-21
@@ -1,11 +1,11 @@
|
|||||||
# Client architecture
|
# Client architecture
|
||||||
|
|
||||||
The [React browser client](../../s2s-frontend) uses the
|
The [React browser client](../../vocero-frontend) uses the
|
||||||
[FastAPI backend](../../s2s-client) for HTTP APIs and a speech WebSocket relay.
|
[FastAPI backend](../../vocero-api) for HTTP APIs and a speech WebSocket relay.
|
||||||
`start_ui.sh` serves Vite on the existing public port `8420` and FastAPI on
|
`start_ui.sh` serves Vite on the existing public port `8420` and FastAPI on
|
||||||
loopback port `8421`. Vite forwards backend routes and provides hot reload;
|
loopback port `8421`. Vite forwards backend routes and provides hot reload;
|
||||||
Uvicorn reloads Python edits. The launcher supervises both processes.
|
Uvicorn reloads Python edits. The launcher supervises both processes.
|
||||||
The speech pipeline and optional Node-RED reasoning service run separately.
|
The speech pipeline runs separately.
|
||||||
Runtime configuration comes from environment variables and the root `.env`.
|
Runtime configuration comes from environment variables and the root `.env`.
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
@@ -14,7 +14,7 @@ flowchart LR
|
|||||||
Vite[Vite on public port 8420]
|
Vite[Vite on public port 8420]
|
||||||
Server[Client FastAPI server]
|
Server[Client FastAPI server]
|
||||||
Speech[Realtime speech backend]
|
Speech[Realtime speech backend]
|
||||||
Reasoning[Node-RED or HTTP reasoning provider]
|
Reasoning[OpenAI-compatible HTTP reasoning provider]
|
||||||
Tools[ApiPilot backend]
|
Tools[ApiPilot backend]
|
||||||
Browser <-->|Frontend and HMR| Vite
|
Browser <-->|Frontend and HMR| Vite
|
||||||
Vite -->|HTTP configuration and proxies| Server
|
Vite -->|HTTP configuration and proxies| Server
|
||||||
@@ -42,16 +42,24 @@ audio. It is not an unconditional byte-for-byte tunnel.
|
|||||||
|
|
||||||
| Source | Responsibility |
|
| Source | Responsibility |
|
||||||
|---|---|
|
|---|---|
|
||||||
| [server.py](../../s2s-client/server.py) | Configuration, documentation serving, WebSocket relay, context loading, ApiPilot, reasoning, search and queue routes |
|
| [server.py](../../vocero-api/server.py) | Compatibility entry point for existing `server:app` commands |
|
||||||
|
| [main.py](../../vocero-api/vocero_api/main.py) | FastAPI assembly, documentation serving and redirects |
|
||||||
|
| [api](../../vocero-api/vocero_api/api) | HTTP route registration, handlers and compatible response validation |
|
||||||
|
| [models](../../vocero-api/vocero_api/models) | Pydantic request and response contracts |
|
||||||
|
| [core](../../vocero-api/vocero_api/core) | Existing environment parsing, logging, URL helpers and diagnostics |
|
||||||
|
| [providers](../../vocero-api/vocero_api/providers) | Caller context and ApiPilot HTTP integrations |
|
||||||
|
| [services](../../vocero-api/vocero_api/services) | Context construction, reasoning and tool-context rendering |
|
||||||
|
| [realtime](../../vocero-api/vocero_api/realtime) | WebSocket relay, events, markers and audio handling |
|
||||||
| [start_ui.sh](../../start_ui.sh) | Dependency setup, ports, readiness, reload processes, and shutdown |
|
| [start_ui.sh](../../start_ui.sh) | Dependency setup, ports, readiness, reload processes, and shutdown |
|
||||||
| [vite.config.ts](../../s2s-frontend/vite.config.ts) | Public development endpoint, HMR, allowed hosts, and backend routing |
|
| [vite.config.ts](../../vocero-frontend/vite.config.ts) | Public development endpoint, HMR, allowed hosts, and backend routing |
|
||||||
| [App.tsx](../../s2s-frontend/src/App.tsx) | React layout, voice controls, dialogs, profile and reports sidebar |
|
| [App.tsx](../../vocero-frontend/src/App.tsx) | React layout, voice controls, dialogs, profile and reports sidebar |
|
||||||
| [controller.ts](../../s2s-frontend/src/realtime/controller.ts) | Application state, settings, context, queue, tools and external reasoning |
|
| [controller.ts](../../vocero-frontend/src/realtime/controller.ts) | Application state, settings, context, queue, tools and external reasoning |
|
||||||
| [client.ts](../../s2s-frontend/src/realtime/client.ts) | Realtime protocol, session lifecycle, audio and transcript events |
|
| [client.ts](../../vocero-frontend/src/realtime/client.ts) | Realtime protocol, session lifecycle, audio and transcript events |
|
||||||
| [components](../../s2s-frontend/src/components) | Chat, caller profile, settings and voice components |
|
| [components](../../vocero-frontend/src/components) | Chat, caller profile, settings and voice components |
|
||||||
| [audio](../../s2s-frontend/src/audio) | TypeScript capture/playback worklets, PCM codec, and visualizer |
|
| [audio](../../vocero-frontend/src/audio) | TypeScript capture/playback worklets, PCM codec, and visualizer |
|
||||||
| [legacy-theme.css](../../s2s-frontend/src/styles/legacy-theme.css) | Retained themes and responsive visual styles alongside Tailwind |
|
| [theme.css](../../vocero-frontend/src/styles/theme.css) | Tailwind semantic tokens, configured theme variants and responsive variants |
|
||||||
| [flows.json](../../s2s-reasoning-engine/flows.json) | Node-RED reasoning and assistant-response logging |
|
| [ui.tsx](../../vocero-frontend/src/components/ui.tsx) | Shared Tailwind buttons, fields and dialog controls; layout utilities live with components |
|
||||||
|
| [styles](../../vocero-frontend/src/styles) | Minimal base defaults, reduced motion, audio artwork, sanitized Markdown, decorative surfaces and native range controls |
|
||||||
|
|
||||||
## Server routes
|
## Server routes
|
||||||
|
|
||||||
@@ -59,7 +67,7 @@ audio. It is not an unconditional byte-for-byte tunnel.
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `GET /api/config` | Connection settings, primary prompt, reasoning/theme/start modes, and feature flags |
|
| `GET /api/config` | Connection settings, primary prompt, reasoning/theme/start modes, and feature flags |
|
||||||
| `WS /v1/realtime` | Local realtime proxy to `S2S_PROXY_TARGET` |
|
| `WS /v1/realtime` | Local realtime proxy to `S2S_PROXY_TARGET` |
|
||||||
| `POST /api/reasoning` | Node-RED or OpenAI-compatible HTTP reasoning; disabled in `PROXY` mode |
|
| `POST /api/reasoning` | OpenAI-compatible HTTP reasoning; disabled in `PROXY` mode |
|
||||||
| `GET /api/user/phone` | Configured caller profile lookup |
|
| `GET /api/user/phone` | Configured caller profile lookup |
|
||||||
| `GET /api/user/reports` | Configured scheduled report lookup, subject to report-injection controls |
|
| `GET /api/user/reports` | Configured scheduled report lookup, subject to report-injection controls |
|
||||||
| `GET /api/apipilot/tools` | Tool discovery with optional authorization and cache refresh |
|
| `GET /api/apipilot/tools` | Tool discovery with optional authorization and cache refresh |
|
||||||
@@ -73,18 +81,28 @@ explorer, while project documentation files are served in the `/docs/`
|
|||||||
subdirectories. Markdown remains
|
subdirectories. Markdown remains
|
||||||
source text, and directories without `index.html` are not browsable pages.
|
source text, and directories without `index.html` are not browsable pages.
|
||||||
FastAPI serves backend and documentation routes only. The frontend source and
|
FastAPI serves backend and documentation routes only. The frontend source and
|
||||||
assets live in `s2s-frontend` and are served by Vite.
|
assets live in `vocero-frontend` and are served by Vite.
|
||||||
|
|
||||||
|
The uv project and its Python tools are scoped to `vocero-api/`. Response models
|
||||||
|
describe service-owned structure and retain loose upstream values. See
|
||||||
|
[development and compatibility](development.md) before tightening a contract.
|
||||||
|
|
||||||
## Audio path
|
## Audio path
|
||||||
|
|
||||||
1. The browser captures microphone audio with echo cancellation, noise
|
1. The browser captures microphone audio with echo cancellation, noise
|
||||||
suppression and automatic gain-control settings.
|
suppression and automatic gain-control settings.
|
||||||
2. [mic-capture.worklet.ts](../../s2s-frontend/src/audio/mic-capture.worklet.ts) resamples to 16 kHz
|
2. [mic-capture.worklet.ts](../../vocero-frontend/src/audio/mic-capture.worklet.ts) resamples to 16 kHz
|
||||||
mono PCM16. The browser sends base64 `input_audio_buffer.append` frames.
|
mono PCM16. The browser sends base64 `input_audio_buffer.append` frames.
|
||||||
3. The backend processes VAD → STT → LLM → TTS and emits realtime events.
|
3. The backend processes VAD → STT → LLM → TTS and emits realtime events.
|
||||||
4. Assistant PCM16 audio at the current 16 kHz protocol rate is decoded and sent to
|
4. Assistant PCM16 audio at the current 16 kHz protocol rate is decoded and sent to
|
||||||
[audio-playback.worklet.ts](../../s2s-frontend/src/audio/audio-playback.worklet.ts), which buffers,
|
[audio-playback.worklet.ts](../../vocero-frontend/src/audio/audio-playback.worklet.ts), which buffers,
|
||||||
resamples and fades playback edges. Input speech can clear playback for barge-in.
|
resamples and fades playback edges. Input speech can clear playback for barge-in.
|
||||||
|
The client flushes short final chunks on response completion and remains in
|
||||||
|
the speaking state until the worklet reports that playback has drained.
|
||||||
|
Sequence numbers reject drain notifications from superseded playback.
|
||||||
|
|
||||||
|
Microphone meter subscribers are separate from application-state subscribers,
|
||||||
|
so level updates do not rerender the main React tree.
|
||||||
|
|
||||||
Audio worklets are loaded through `AudioWorklet.addModule`; keep their processor
|
Audio worklets are loaded through `AudioWorklet.addModule`; keep their processor
|
||||||
registration structure intact when editing them. The speech backend's thread
|
registration structure intact when editing them. The speech backend's thread
|
||||||
@@ -93,10 +111,10 @@ and cancellation model is documented in [realtime API](../speech/realtime-api.md
|
|||||||
## Reasoning and context
|
## Reasoning and context
|
||||||
|
|
||||||
The browser triggers external reasoning on completed user transcripts only in
|
The browser triggers external reasoning on completed user transcripts only in
|
||||||
`OPENAI` or `NODE_RED` mode. Node-RED `log_only=true` responses leave native speech
|
`OPENAI` mode. A non-empty answer replaces the native response through the
|
||||||
output running; an answer-producing response uses the external-answer path.
|
external-answer path. These HTTP operations supplement the realtime audio path.
|
||||||
The server can independently forward assistant text to Node-RED for paired
|
New user speech and session teardown cancel pending reasoning. Turn identity
|
||||||
conversation logs. These HTTP operations supplement the realtime audio path.
|
checks reject stale HTTP results and speech callbacks even if cancellation is late.
|
||||||
|
|
||||||
Read [reasoning](reasoning.md), [context injection](context-injection.md), and
|
Read [reasoning](reasoning.md), [context injection](context-injection.md), and
|
||||||
[tool calling](tool-calling.md) for the conditions and limitations of each path.
|
[tool calling](tool-calling.md) for the conditions and limitations of each path.
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
|
|
||||||
Context reaches the assistant through browser-built instructions, server-patched
|
Context reaches the assistant through browser-built instructions, server-patched
|
||||||
realtime session instructions, and HTTP reasoning context. The implementation is
|
realtime session instructions, and HTTP reasoning context. The implementation is
|
||||||
in [server.py](../../s2s-client/server.py),
|
in the server's [context services](../../vocero-api/vocero_api/services/context.py),
|
||||||
[context.ts](../../s2s-frontend/src/lib/context.ts), and the
|
[realtime proxy](../../vocero-api/vocero_api/realtime/proxy.py),
|
||||||
[WebSocket client](../../s2s-frontend/src/realtime/client.ts).
|
[context.ts](../../vocero-frontend/src/lib/context.ts), and the
|
||||||
|
[WebSocket client](../../vocero-frontend/src/realtime/client.ts).
|
||||||
|
|
||||||
## Independent controls
|
## Independent controls
|
||||||
|
|
||||||
|
|||||||
+38
-19
@@ -1,8 +1,8 @@
|
|||||||
# Design language
|
# Design language
|
||||||
|
|
||||||
This guide describes the current browser UI. Use
|
This guide describes the current browser UI. Use
|
||||||
[legacy-theme.css](../../s2s-frontend/src/styles/legacy-theme.css) as the token/selector
|
[theme.css](../../vocero-frontend/src/styles/theme.css) as the Tailwind token
|
||||||
reference and [App.tsx](../../s2s-frontend/src/App.tsx) for the theme-specific structure.
|
reference and [App.tsx](../../vocero-frontend/src/App.tsx) for the theme-specific structure.
|
||||||
Earlier documentation described a monochrome-only dark UI; the current design
|
Earlier documentation described a monochrome-only dark UI; the current design
|
||||||
also uses aurora gradients, glass surfaces, and a separate light theme.
|
also uses aurora gradients, glass surfaces, and a separate light theme.
|
||||||
|
|
||||||
@@ -10,8 +10,10 @@ also uses aurora gradients, glass surfaces, and a separate light theme.
|
|||||||
|
|
||||||
`S2S_THEME=white` selects the white/blue interface. Empty or `dark` selects the
|
`S2S_THEME=white` selects the white/blue interface. Empty or `dark` selects the
|
||||||
dark aurora interface. The browser sets `html[data-theme]` after configuration
|
dark aurora interface. The browser sets `html[data-theme]` after configuration
|
||||||
loads; an initial visibility guard prevents the wrong theme flashing on first
|
loads; React renders a loading screen until configuration resolves. Theme values
|
||||||
paint. Extend the existing theme selectors when adding components.
|
live under `:root` and `html[data-theme="white"]`, with `@theme inline` exposing
|
||||||
|
semantic utilities. The `white:` and `dark:` variants follow this attribute,
|
||||||
|
independently of the browser's preferred color scheme.
|
||||||
|
|
||||||
| Token group | Dark theme values/purpose |
|
| Token group | Dark theme values/purpose |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -20,14 +22,20 @@ paint. Extend the existing theme selectors when adding components.
|
|||||||
| Text | `--text`, `--text-dim`, `--text-faint` |
|
| Text | `--text`, `--text-dim`, `--text-faint` |
|
||||||
| Borders | `--border` and `--border-strong` hairlines |
|
| Borders | `--border` and `--border-strong` hairlines |
|
||||||
| Brand | `--brand: #8b5cf6`, `--brand-2: #ec4899`, `--brand-3: #22d3ee` |
|
| Brand | `--brand: #8b5cf6`, `--brand-2: #ec4899`, `--brand-3: #22d3ee` |
|
||||||
| Radius | `--radius-sm: 10px`, `--radius-md: 16px`, `--radius-lg: 26px`, `--radius-xl: 34px` |
|
| Radius | `rounded-control: 10px`, `rounded-panel: 16px`, `rounded-card: 26px` (22px in white) |
|
||||||
| Shadows | `--shadow-soft`, `--shadow-glow-brand`, `--shadow-glass` |
|
| Shadows | `--shadow-soft`, `--shadow-glow-brand`, `--shadow-glass` |
|
||||||
|
|
||||||
|
Use semantic utilities such as `bg-surface`, `bg-surface-raised`, `text-foreground`,
|
||||||
|
`text-muted`, `border-line`, `rounded-control` and `shadow-panel`. Palette values
|
||||||
|
are runtime CSS variables; fonts are declared in `@theme`. Keep audio levels and
|
||||||
|
waveform bands outside the theme token registry.
|
||||||
|
|
||||||
Prefer existing variables and `color-mix` over new unrelated colors. The dark
|
Prefer existing variables and `color-mix` over new unrelated colors. The dark
|
||||||
theme's orb is the strongest visual focus, while gradients and restrained role
|
theme's orb is the strongest visual focus, while gradients and restrained role
|
||||||
tints also appear in the surrounding interface. The white theme changes
|
tints also appear in the surrounding interface. The white theme changes
|
||||||
surfaces, typography contrast, controls and chat presentation through its own
|
surfaces, typography contrast, controls and chat presentation through its own
|
||||||
selector overrides rather than a second stylesheet.
|
tokens and explicit component variants. The two voice presentations retain
|
||||||
|
their separate React markup and scoped artwork.
|
||||||
|
|
||||||
## Voice state
|
## Voice state
|
||||||
|
|
||||||
@@ -62,29 +70,40 @@ details out of ordinary user flows.
|
|||||||
## Layout and components
|
## Layout and components
|
||||||
|
|
||||||
The page uses a column layout: header, voice stage, always-visible inline
|
The page uses a column layout: header, voice stage, always-visible inline
|
||||||
conversation, and sticky footer. The conversation is no longer the old
|
conversation, and footer. The document scrolls naturally while the transcript
|
||||||
toggleable right overlay. Preserve space below the latest message so the sticky
|
has its own scroll area. New messages preserve the reader's position until they
|
||||||
footer does not obscure it; chat autoscroll retries after layout/reflow changes.
|
return to the bottom or use the jump-to-latest action.
|
||||||
|
|
||||||
In the dark theme, the reports sidebar is fixed on the left at desktop widths
|
In the dark theme, the reports sidebar is fixed on the left at desktop widths
|
||||||
of at least 1024px. On smaller screens it becomes an off-canvas panel with a
|
of at least 1024px. On smaller screens it becomes a native dialog drawer with a
|
||||||
toggle and backdrop. The white theme hides the sidebar, its toggle, and hint.
|
toggle and backdrop. The white theme omits the sidebar, its toggle, and hint.
|
||||||
The user avatar opens a profile dialog; profile fields collapse from two
|
The user avatar opens a profile dialog with label/value rows. Some controls are
|
||||||
columns on narrow screens. Some controls are intentionally gated by `?debug=true`.
|
intentionally gated by `?debug=true`.
|
||||||
|
|
||||||
|
Reuse [ui.tsx](../../vocero-frontend/src/components/ui.tsx) for buttons, icon buttons,
|
||||||
|
fields and dialog controls. Keep layout utilities with the owning component.
|
||||||
|
Use complete class names in conditional maps; interpolated utility names cannot
|
||||||
|
be detected reliably. `compact:` preserves the existing maximum width of 600px;
|
||||||
|
`short:` covers heights at or below 640px. Other artwork breakpoints remain local.
|
||||||
|
|
||||||
Use the existing button, icon-button, modal, switch and chat-message classes.
|
|
||||||
The dark theme uses glass bubbles with role tints, borders and restrained glow;
|
The dark theme uses glass bubbles with role tints, borders and restrained glow;
|
||||||
the light theme uses its white/blue message and avatar treatment. Keep required
|
the light theme uses its white/blue message and avatar treatment.
|
||||||
chat DOM elements intact; optional historical overlay controls may be absent.
|
|
||||||
|
The stylesheet entry point imports Tailwind's theme and utilities without
|
||||||
|
Preflight. Keep resets explicit in `base.css`; enabling Preflight is a separate
|
||||||
|
visual change. Custom CSS is reserved for the page background, audio artwork,
|
||||||
|
sanitized Markdown descendants, decorative masks and native range pseudo-elements.
|
||||||
|
Do not add model-supplied utility classes to the Markdown sanitizer.
|
||||||
|
|
||||||
## Motion and accessibility
|
## Motion and accessibility
|
||||||
|
|
||||||
Microphone RMS and assistant audio levels drive the voice visualization.
|
Microphone RMS and assistant audio levels drive the voice visualization.
|
||||||
Breathing, glow and decorative gradients should remain subordinate to speech
|
Breathing, glow and decorative gradients should remain subordinate to speech
|
||||||
feedback. `body.booting` suppresses initial transitions; new animation should
|
feedback. Preserve the reduced-motion rules in `base.css`: they disable both
|
||||||
respect that state and `prefers-reduced-motion`.
|
CSS animations and the audio-driven transform variables. Their `!important`
|
||||||
|
declarations intentionally override inline variables written by the audio engine.
|
||||||
|
|
||||||
For changes, verify both themes, narrow mobile layouts down to 360px, keyboard
|
For changes, verify both themes, narrow mobile layouts down to 320px, keyboard
|
||||||
focus, dialog dismissal, and readable contrast. Prefer touch targets of at least
|
focus, dialog dismissal, and readable contrast. Prefer touch targets of at least
|
||||||
44px and respect safe-area padding. A CSS guideline is not proof that every
|
44px and respect safe-area padding. A CSS guideline is not proof that every
|
||||||
existing control already meets the target.
|
existing control already meets the target.
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# Backend development and compatibility
|
||||||
|
|
||||||
|
The FastAPI project lives entirely in `vocero-api/`. It uses uv 0.12.5, Python
|
||||||
|
3.12, and a committed `uv.lock`. The frontend and speech pipeline keep their
|
||||||
|
own tooling. Runtime dependency versions match the environment used to capture
|
||||||
|
the pre-migration server; framework upgrades require a separate compatibility
|
||||||
|
review.
|
||||||
|
|
||||||
|
The Python distribution is named `vocero-api`, and its import package is
|
||||||
|
`vocero_api`. The browser package and directory are both named `vocero-frontend`.
|
||||||
|
`start_ui.sh` creates the API environment at its current path on the next start;
|
||||||
|
do not move an existing virtual environment because its executables contain
|
||||||
|
absolute paths. Reinstall optional Git hooks after a project-directory rename
|
||||||
|
with `make -C vocero-api hooks-install`.
|
||||||
|
|
||||||
|
Pytest filters only the known `anyio.abc.BlockingPortal` deprecation emitted by
|
||||||
|
Starlette's test client ([upstream issue](https://github.com/Kludex/starlette/issues/3497)).
|
||||||
|
The filter matches the exact message, warning category, and module; other
|
||||||
|
warnings remain visible. Remove it when a compatible Starlette upgrade replaces
|
||||||
|
the deprecated alias. Runtime dependencies and warning handling are unchanged.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
From the repository root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make -C vocero-api install # uv sync --locked, including development tools
|
||||||
|
make -C vocero-api format # Ruff safe fixes and formatting
|
||||||
|
make -C vocero-api check # lock freshness, format, lint, mypy, pytest
|
||||||
|
make -C vocero-api test-launcher # Bash supervisor integration fixtures
|
||||||
|
make -C vocero-api test-image # Docker build and isolated runtime smoke test
|
||||||
|
```
|
||||||
|
|
||||||
|
Run `uv lock` inside `vocero-api/` when intentionally changing dependencies.
|
||||||
|
Do not install server packages into a shared root environment. The launcher
|
||||||
|
syncs `vocero-api/.venv` and retains root `.env` loading, port defaults,
|
||||||
|
readiness checks, and process-group shutdown. Offline starts require the
|
||||||
|
locked packages and Python runtime to have been installed or cached already.
|
||||||
|
|
||||||
|
Optional hooks use `make -C vocero-api hooks-install`; the configuration stays
|
||||||
|
in `vocero-api/.pre-commit-config.yaml`. `make -C vocero-api hooks-run` runs the
|
||||||
|
same hooks without installing them. Hooks and Python quality checks apply
|
||||||
|
only to the client service. The server entry points are `vocero_api.main:app`
|
||||||
|
and the retained `server:app` shim; use `--app-dir vocero-api` from the root.
|
||||||
|
|
||||||
|
## Schema boundaries
|
||||||
|
|
||||||
|
`models/requests.py` retains the existing search, reasoning, and ApiPilot
|
||||||
|
request models, including their defaults, coercion, and ignored extra fields.
|
||||||
|
The queue beacon still accepts malformed JSON and non-object bodies as before;
|
||||||
|
its Pydantic view is applied only after the original object check, and
|
||||||
|
`queueId` remains unconstrained.
|
||||||
|
|
||||||
|
`models/responses.py` validates structure constructed by this server. Fields
|
||||||
|
owned by external services remain `Any` where the old parser imposed no type
|
||||||
|
constraint. Object models allow extra fields. Caller profiles can be arbitrary
|
||||||
|
JSON; load-balancer fields remain optional; execution results and error details
|
||||||
|
remain open. Existing provider parsing, authentication precedence, argument
|
||||||
|
filtering, environment parsing, and WebSocket frames are unchanged.
|
||||||
|
|
||||||
|
HTTP handlers call `validated_json_response`: Pydantic validates the payload,
|
||||||
|
then `JSONResponse` serializes the original object. Returning a dumped model
|
||||||
|
would insert defaults or change values/key order, so do not replace this helper
|
||||||
|
with model serialization without a contract review. Route `response_model`
|
||||||
|
declarations publish the new response schemas in OpenAPI. Existing request
|
||||||
|
schemas and validation errors remain unchanged. HTTP exceptions retain their
|
||||||
|
existing error shapes; there is no new common error envelope.
|
||||||
|
|
||||||
|
## Compatibility evidence
|
||||||
|
|
||||||
|
`tests/fixtures/legacy-contracts.json` was captured from the original monolithic
|
||||||
|
server at commit `3d42df5`, before refactoring. Tests compare 82 HTTP scenarios,
|
||||||
|
including exact response text, statuses, selected headers, and outgoing mock
|
||||||
|
HTTP requests. They also compare request OpenAPI definitions and representative
|
||||||
|
realtime frames. Additional tests cover response validation and permissive
|
||||||
|
upstream values; the original local HTTP/WebSocket tests remain in the suite.
|
||||||
|
Four captures belonging exclusively to the retired workflow reasoning service
|
||||||
|
were removed with that integration; all retained captures are unchanged.
|
||||||
|
|
||||||
|
The captures deliberately preserve unknown fields, omitted fields, unusual JSON
|
||||||
|
values, queue beacon edge cases, permissive tool arguments, and known failures.
|
||||||
|
Examples include reasoning's existing uninitialized local variables in certain
|
||||||
|
configuration combinations, API-key-only tool precheck behavior, the catalog
|
||||||
|
cache's existing key, and relay termination after a suppressed invalid
|
||||||
|
`session.update` error. Fix those in separately reviewed behavior changes;
|
||||||
|
do not regenerate golden captures to make a refactor pass.
|
||||||
|
|
||||||
|
These checks use local fixtures and do not claim exhaustive coverage of all
|
||||||
|
provider responses, live speech models, or production configuration. No deployed
|
||||||
|
services or real credentials are needed. The Docker check verifies the actual
|
||||||
|
runtime image, API configuration, documentation, OpenAPI, and the backend's
|
||||||
|
existing root 404; it removes its temporary container and image afterward.
|
||||||
@@ -37,4 +37,3 @@ names shown in a deployment are configuration examples, not definitions.
|
|||||||
| Claim | The transition from a waiting ticket to an allocated realtime session |
|
| Claim | The transition from a waiting ticket to an allocated realtime session |
|
||||||
| At capacity | A full waiting queue that cannot accept another ticket |
|
| At capacity | A full waiting queue that cannot accept another ticket |
|
||||||
| Barge-in | User speech interrupts an active assistant response, coordinating playback clearing and backend cancellation |
|
| Barge-in | User speech interrupts an active assistant response, coordinating playback clearing and backend cancellation |
|
||||||
| Log-only reasoning | External transcript processing that observes/logs the turn without replacing native speech output |
|
|
||||||
|
|||||||
+14
-37
@@ -1,21 +1,19 @@
|
|||||||
# Reasoning modes
|
# Reasoning modes
|
||||||
|
|
||||||
Reasoning determines the assistant's response text. The realtime backend always
|
Reasoning determines the assistant's response text. The realtime backend always
|
||||||
provides the speech channel; optional HTTP reasoning can observe a transcript or
|
provides the speech channel; optional HTTP reasoning produces a separate answer
|
||||||
produce a separate answer. Set `S2S_REASONING_TYPE` to select the mode.
|
from a transcript. Set `S2S_REASONING_TYPE` to select the mode.
|
||||||
|
|
||||||
| Mode | Response owner | HTTP hook |
|
| Mode | Response owner | HTTP hook |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `PROXY` (default) | Realtime backend | Browser does not call `/api/reasoning`; that endpoint returns 409 |
|
| `PROXY` (default) | Realtime backend | Browser does not call `/api/reasoning`; that endpoint returns 409 |
|
||||||
| `NODE_RED` | Realtime backend for `log_only=true`; external answer otherwise | Server forwards to `S2S_REASONING_NODE_RED_URL/api/reasoning` |
|
|
||||||
| `OPENAI` | OpenAI-compatible Chat Completions response | Server calls `OPENAI_BASE_URL/chat/completions` |
|
| `OPENAI` | OpenAI-compatible Chat Completions response | Server calls `OPENAI_BASE_URL/chat/completions` |
|
||||||
|
|
||||||
The mode is normalized case-insensitively. `NODERED` and `NODE-RED` resolve to
|
The mode is normalized case-insensitively. Unknown values resolve to `PROXY`.
|
||||||
`NODE_RED`; unknown values resolve to `PROXY`.
|
|
||||||
|
|
||||||
## Shared instruction assembly
|
## Shared instruction assembly
|
||||||
|
|
||||||
In [context.ts](../../s2s-frontend/src/lib/context.ts), `buildInstructions()` starts
|
In [context.ts](../../vocero-frontend/src/lib/context.ts), `buildInstructions()` starts
|
||||||
with `config.primaryPrompt || settings.instructions`. `/api/config` pins
|
with `config.primaryPrompt || settings.instructions`. `/api/config` pins
|
||||||
`S2S_PRIMARY_PROMPT` when non-empty; otherwise the settings prompt or
|
`S2S_PRIMARY_PROMPT` when non-empty; otherwise the settings prompt or
|
||||||
`DEFAULT_INSTRUCTIONS` is used. The current server does not fetch a remote agent
|
`DEFAULT_INSTRUCTIONS` is used. The current server does not fetch a remote agent
|
||||||
@@ -40,30 +38,6 @@ Using a direct browser URL bypasses local WebSocket proxy processing. Browser
|
|||||||
instructions still apply, but server-side session context and marker execution
|
instructions still apply, but server-side session context and marker execution
|
||||||
require the local `/v1/realtime` route.
|
require the local `/v1/realtime` route.
|
||||||
|
|
||||||
## Node-RED reasoning
|
|
||||||
|
|
||||||
Configure a Node-RED base URL reachable by the UI server:
|
|
||||||
|
|
||||||
```env
|
|
||||||
S2S_REASONING_TYPE=NODE_RED
|
|
||||||
S2S_REASONING_NODE_RED_URL=http://localhost:1881
|
|
||||||
```
|
|
||||||
|
|
||||||
`VoiceController.reason()` in [controller.ts](../../s2s-frontend/src/realtime/controller.ts)
|
|
||||||
handles both external modes. It runs after a complete user transcript and posts a body with
|
|
||||||
`transcript`, `instructions`, `language`, `user_context`, `phoneNumber`, and
|
|
||||||
`episode_id`. The server builds context and forwards the reasoning fields to
|
|
||||||
Node-RED with a 30-second timeout.
|
|
||||||
|
|
||||||
The checked-in Node-RED flow exposes `POST /api/reasoning` and
|
|
||||||
`POST /api/log/system-response`. Its reasoning response uses `log_only=true` and
|
|
||||||
an empty answer. The browser returns early in that case, preserving the native
|
|
||||||
backend's response. Assistant-text logging is forwarded separately by the relay
|
|
||||||
and is not an audio transport.
|
|
||||||
|
|
||||||
An answer-producing flow must return a non-empty `answer` and omit `log_only` or
|
|
||||||
set it to `false`. That activates the external-answer behavior below.
|
|
||||||
|
|
||||||
## OpenAI-compatible HTTP reasoning
|
## OpenAI-compatible HTTP reasoning
|
||||||
|
|
||||||
```env
|
```env
|
||||||
@@ -76,13 +50,17 @@ OPENAI_REASONING_MODEL=gpt-4.1-mini
|
|||||||
`OPENAI_ORGANIZATION` and `OPENAI_PROJECT` are optional headers. The model value
|
`OPENAI_ORGANIZATION` and `OPENAI_PROJECT` are optional headers. The model value
|
||||||
is a runtime default, not a recommendation about provider availability.
|
is a runtime default, not a recommendation about provider availability.
|
||||||
|
|
||||||
|
`VoiceController.reason()` in [controller.ts](../../vocero-frontend/src/realtime/controller.ts)
|
||||||
|
runs after a complete user transcript and posts a body with `transcript`,
|
||||||
|
`instructions`, `language`, `user_context`, `phoneNumber`, and `episode_id`.
|
||||||
The server combines instructions and context into model messages, requests a
|
The server combines instructions and context into model messages, requests a
|
||||||
final answer, and returns `answer` with available usage metadata. ApiPilot can
|
final answer with a 30-second timeout, and returns `answer`. ApiPilot can
|
||||||
append discovery instructions, execute a detected call marker, and request a
|
append discovery instructions, execute a detected call marker, and request a
|
||||||
second response based on the tool result. The endpoint does not forward the
|
second response based on the tool result, including available `usage_pass2`
|
||||||
browser's native `session.tools` catalog as OpenAI Chat Completions `tools`.
|
metadata. The endpoint does not forward the browser's native `session.tools`
|
||||||
|
catalog as OpenAI Chat Completions `tools`.
|
||||||
|
|
||||||
For a non-log-only answer, the browser cancels/suppresses the active native
|
For an external answer, the browser cancels/suppresses the active native
|
||||||
response and appends the external assistant text. It uses browser
|
response and appends the external assistant text. It uses browser
|
||||||
`SpeechSynthesis` when available. The React client adds the answer to the realtime
|
`SpeechSynthesis` when available. The React client adds the answer to the realtime
|
||||||
conversation as an assistant item; without browser speech synthesis it remains
|
conversation as an assistant item; without browser speech synthesis it remains
|
||||||
@@ -98,8 +76,7 @@ realtime backend instead.
|
|||||||
| Goal | Source |
|
| Goal | Source |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Change the base agent prompt | `S2S_PRIMARY_PROMPT` or browser settings when unpinned |
|
| Change the base agent prompt | `S2S_PRIMARY_PROMPT` or browser settings when unpinned |
|
||||||
| Change browser context assembly | `buildInstructions()` in `s2s-frontend/src/lib/context.ts` |
|
| Change browser context assembly | `buildInstructions()` in `vocero-frontend/src/lib/context.ts` |
|
||||||
| Change HTTP provider messages | `reasoning()` in `server.py` |
|
| Change HTTP provider messages | `reasoning()` in `vocero-api/vocero_api/services/reasoning.py` |
|
||||||
| Change external-answer rendering | `VoiceController.reason()` and `RealtimeClient.renderExternalAnswer()` |
|
| Change external-answer rendering | `VoiceController.reason()` and `RealtimeClient.renderExternalAnswer()` |
|
||||||
| Change Node-RED processing | `s2s-reasoning-engine/flows.json` |
|
|
||||||
| Change native model/tool generation | `speech_to_speech/LLM` and realtime handlers |
|
| Change native model/tool generation | `speech_to_speech/LLM` and realtime handlers |
|
||||||
|
|||||||
@@ -48,8 +48,8 @@ For a native call, the backend emits:
|
|||||||
```
|
```
|
||||||
|
|
||||||
`arguments` is a JSON-encoded string. The
|
`arguments` is a JSON-encoded string. The
|
||||||
[WebSocket client](../../s2s-frontend/src/realtime/client.ts) validates the event and
|
[WebSocket client](../../vocero-frontend/src/realtime/client.ts) validates the event and
|
||||||
dispatches `tool`; [controller.ts](../../s2s-frontend/src/realtime/controller.ts)
|
dispatches `tool`; [controller.ts](../../vocero-frontend/src/realtime/controller.ts)
|
||||||
parses the arguments and runs the supported executor.
|
parses the arguments and runs the supported executor.
|
||||||
Web search uses `POST /api/search`, keeping the deployment's Serper key on the
|
Web search uses `POST /api/search`, keeping the deployment's Serper key on the
|
||||||
server. Tool output returns to the model as:
|
server. Tool output returns to the model as:
|
||||||
|
|||||||
@@ -35,9 +35,10 @@ launcher. If the page loads but edits do not appear, check the browser's
|
|||||||
`/__vite_hmr` WebSocket and the reverse proxy's upgrade handling. Speech uses
|
`/__vite_hmr` WebSocket and the reverse proxy's upgrade handling. Speech uses
|
||||||
the separate `/v1/realtime` WebSocket on the same public origin.
|
the separate `/v1/realtime` WebSocket on the same public origin.
|
||||||
|
|
||||||
For a startup dependency error, check Node/npm on the process manager's PATH,
|
For a startup dependency error, check uv 0.12.5 and Node/npm on the process
|
||||||
Python venv support, and `setsid`/`curl`. The first start installs dependencies;
|
manager's PATH, plus `setsid`/`curl`. uv manages Python 3.12 in
|
||||||
later starts reuse them until frontend manifests change. Changes to root
|
`vocero-api/.venv` and checks the committed lock on every start. Frontend
|
||||||
|
dependencies are reused until their manifests change. Changes to root
|
||||||
`.env` or launcher settings require a restart.
|
`.env` or launcher settings require a restart.
|
||||||
|
|
||||||
## The assistant promises a tool but nothing happens
|
## The assistant promises a tool but nothing happens
|
||||||
@@ -108,25 +109,24 @@ before changing audio timing based on those observations.
|
|||||||
|
|
||||||
## External reasoning and startup
|
## External reasoning and startup
|
||||||
|
|
||||||
`/api/reasoning` returning 409 is expected in `PROXY` mode. In `NODE_RED`, check
|
`/api/reasoning` returning 409 is expected in `PROXY` mode.
|
||||||
that `S2S_REASONING_NODE_RED_URL` is reachable from the server and whether the
|
|
||||||
response is `log_only=true`. An empty answer is valid only in log-only mode.
|
|
||||||
In `OPENAI`, check the configured endpoint/model/key and provider logs; HTTP
|
In `OPENAI`, check the configured endpoint/model/key and provider logs; HTTP
|
||||||
reasoning does not use the native session's tool array.
|
reasoning does not use the native session's tool array.
|
||||||
|
|
||||||
For `TALK`/`TALKFIRST`, the two-second warmup does not override browser
|
`TALK`/`TALKFIRST` attempt startup as soon as configuration and caller context
|
||||||
microphone/audio permissions. If automatic startup fails, use the unlocker
|
are ready. Browser microphone/audio policies can still require a gesture. If
|
||||||
gesture and inspect the permission error rather than increasing the timer
|
audio is blocked, tap the main microphone button when the inline hint appears.
|
||||||
without evidence.
|
If microphone access fails, allow it in the browser's site permissions and use
|
||||||
|
the same button to retry. No startup dialog is shown.
|
||||||
|
|
||||||
## Local validation
|
## Local validation
|
||||||
|
|
||||||
From the repository root, run the frontend checks and local backend fixtures:
|
From the repository root, run the frontend checks and local backend fixtures:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm --prefix s2s-frontend run check
|
npm --prefix vocero-frontend run check
|
||||||
npm --prefix s2s-frontend run build
|
npm --prefix vocero-frontend run build
|
||||||
.venv/bin/python -m unittest discover -s s2s-client/tests -v
|
make -C vocero-api check
|
||||||
```
|
```
|
||||||
|
|
||||||
These checks cover frontend contracts and local session/queue/WebSocket
|
These checks cover frontend contracts and local session/queue/WebSocket
|
||||||
|
|||||||
+43
-18
@@ -12,8 +12,8 @@ From the repository root:
|
|||||||
S2S_PROXY_TARGET=ws://localhost:8765/v1/realtime ./start_ui.sh
|
S2S_PROXY_TARGET=ws://localhost:8765/v1/realtime ./start_ui.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
The launcher loads the root `.env`, creates `.venv` if needed, installs the
|
The launcher loads the root `.env`, syncs `vocero-api/.venv` from the committed
|
||||||
client requirements when the dependency check fails, and installs the locked
|
`vocero-api/uv.lock`, and installs the locked
|
||||||
frontend dependencies when needed. Vite serves the React frontend on port
|
frontend dependencies when needed. Vite serves the React frontend on port
|
||||||
8420 and proxies the backend routes to Uvicorn on `127.0.0.1:8421`.
|
8420 and proxies the backend routes to Uvicorn on `127.0.0.1:8421`.
|
||||||
`UI_PORT` overrides the public port; `S2S_BACKEND_PORT` overrides the internal
|
`UI_PORT` overrides the public port; `S2S_BACKEND_PORT` overrides the internal
|
||||||
@@ -21,22 +21,25 @@ port. Both servers reload source edits, and stopping the launcher stops both.
|
|||||||
The speech pipeline remains a separate service. Because `.env` is sourced by
|
The speech pipeline remains a separate service. Because `.env` is sourced by
|
||||||
the script, values in that file can replace variables supplied on the command line.
|
the script, values in that file can replace variables supplied on the command line.
|
||||||
|
|
||||||
Use Node.js 22.18+ on the 22.x line or Node.js 24+, npm, Python 3 with venv,
|
Use Node.js 22.18+ on the 22.x line or Node.js 24+, npm, uv 0.12.5,
|
||||||
Bash 4.4+, `setsid`, and `curl`. Remote staging access also needs the exact
|
Bash 4.4+, `setsid`, and `curl`. Remote staging access also needs the exact
|
||||||
hostname in `S2S_DEV_ALLOWED_HOSTS`, HTTPS, and WebSocket forwarding for speech
|
hostname in `S2S_DEV_ALLOWED_HOSTS`, HTTPS, and WebSocket forwarding for speech
|
||||||
and Vite HMR. See [frontend development setup](../../s2s-frontend/README.md).
|
and Vite HMR. See [frontend development setup](../../vocero-frontend/README.md).
|
||||||
|
|
||||||
To install and start the backend manually:
|
To install and start the backend manually:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m venv .venv
|
uv sync --project vocero-api --locked
|
||||||
.venv/bin/python -m pip install -r s2s-client/requirements.txt
|
|
||||||
S2S_PROXY_TARGET=ws://localhost:8765/v1/realtime \
|
S2S_PROXY_TARGET=ws://localhost:8765/v1/realtime \
|
||||||
.venv/bin/python -m uvicorn --app-dir s2s-client server:app \
|
uv run --project vocero-api --locked uvicorn --app-dir vocero-api vocero_api.main:app \
|
||||||
--host 127.0.0.1 --port 8421 --reload --reload-dir s2s-client
|
--host 127.0.0.1 --port 8421 --reload --reload-dir vocero-api
|
||||||
```
|
```
|
||||||
|
|
||||||
Then, in a second terminal, run `cd s2s-frontend`, `npm ci`, and `npm run dev`.
|
Then, in a second terminal, run `cd vocero-frontend`, `npm ci`, and `npm run dev`.
|
||||||
|
|
||||||
|
uv provisions Python 3.12 when needed. The Python environment belongs only to
|
||||||
|
`vocero-api`; an existing root `.venv` for speech dependencies is left alone.
|
||||||
|
See [development and compatibility](development.md) for checks and schema rules.
|
||||||
|
|
||||||
Open <http://localhost:8420/>, tap the orb/microphone control, grant microphone
|
Open <http://localhost:8420/>, tap the orb/microphone control, grant microphone
|
||||||
access, and speak. Microphone access requires HTTPS or a local browser origin;
|
access, and speak. Microphone access requires HTTPS or a local browser origin;
|
||||||
@@ -91,9 +94,9 @@ backend address. The image does not run the development launcher or serve a UI.
|
|||||||
Use the project root as the build context:
|
Use the project root as the build context:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build -f s2s-client/Dockerfile -t s2s-client .
|
docker build -f vocero-api/Dockerfile -t vocero-api .
|
||||||
docker run --rm -p 7860:7860 \
|
docker run --rm -p 7860:7860 \
|
||||||
-e SPEECH_TO_SPEECH_URL=ws://localhost:8765/v1/realtime s2s-client
|
-e SPEECH_TO_SPEECH_URL=ws://localhost:8765/v1/realtime vocero-api
|
||||||
```
|
```
|
||||||
|
|
||||||
For the proxy path, replace the direct setting with a server-reachable
|
For the proxy path, replace the direct setting with a server-reachable
|
||||||
@@ -101,7 +104,7 @@ For the proxy path, replace the direct setting with a server-reachable
|
|||||||
appropriate existing network and use that service's hostname. An upstream
|
appropriate existing network and use that service's hostname. An upstream
|
||||||
bound only to host loopback is not reachable via an arbitrary container name.
|
bound only to host loopback is not reachable via an arbitrary container name.
|
||||||
|
|
||||||
The image includes `s2s-client` and root `docs`. `/docs` is still the FastAPI API
|
The image includes `vocero-api` and root `docs`. `/docs` is still the FastAPI API
|
||||||
explorer; documentation lives under `/docs/client/`, `/docs/speech/`,
|
explorer; documentation lives under `/docs/client/`, `/docs/speech/`,
|
||||||
`/docs/adr/`, `/docs/examples/`, and `/docs/proposals/`. Legacy `/project/` and
|
`/docs/adr/`, `/docs/examples/`, and `/docs/proposals/`. Legacy `/project/` and
|
||||||
`/public/docs.html` redirect to the historical proposal. Markdown is served as a
|
`/public/docs.html` redirect to the historical proposal. Markdown is served as a
|
||||||
@@ -114,6 +117,10 @@ browsable pages.
|
|||||||
Voice, editable system instructions and connection settings are kept in browser
|
Voice, editable system instructions and connection settings are kept in browser
|
||||||
`localStorage` under `s2s.ws.*` keys. A non-empty `S2S_PRIMARY_PROMPT` pins the
|
`localStorage` under `s2s.ws.*` keys. A non-empty `S2S_PRIMARY_PROMPT` pins the
|
||||||
instruction field. Restart applies connection/voice changes to a new session.
|
instruction field. Restart applies connection/voice changes to a new session.
|
||||||
|
Settings and tool preferences use a draft: **Guardar** applies them and
|
||||||
|
**Cancelar** or dialog dismissal discards unsaved edits. Voice and instructions
|
||||||
|
update the live session on Save; connection changes apply on the next start.
|
||||||
|
Noise-gate calibration is explicitly live and saves immediately.
|
||||||
Model voice names are runtime identifiers and stay unchanged across UI
|
Model voice names are runtime identifiers and stay unchanged across UI
|
||||||
translations.
|
translations.
|
||||||
|
|
||||||
@@ -132,19 +139,36 @@ enabled data sources and limits.
|
|||||||
`S2S_THEME=white` selects the light interface. `dark` or an empty value selects
|
`S2S_THEME=white` selects the light interface. `dark` or an empty value selects
|
||||||
the dark aurora theme. Theme-specific sections remain hidden until configuration
|
the dark aurora theme. Theme-specific sections remain hidden until configuration
|
||||||
resolves, with a fallback to avoid an invisible page after config failure.
|
resolves, with a fallback to avoid an invisible page after config failure.
|
||||||
|
Configuration failures offer **Reintentar** and block session startup until a
|
||||||
|
retry succeeds. Reports load independently. Caller context gets an eight-second
|
||||||
|
loading window before the interface allows a generic session.
|
||||||
|
|
||||||
`S2S_START_SESSION_MODE` controls initial microphone/session startup:
|
`S2S_START_SESSION_MODE` controls initial microphone/session startup:
|
||||||
|
|
||||||
| Value | Behavior |
|
| Value | Behavior |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Empty/default | User taps to start |
|
| Empty/default | User taps to start |
|
||||||
| `TALK` | After a 2-second warmup, show an audio unlocker; a user gesture starts the session and greeting |
|
| `TALK` | Attempt automatic start and inject the Spanish wake phrase as soon as configuration and caller context are ready |
|
||||||
| `TALKFIRST` | After a 2-second warmup, attempt automatic start and inject the Spanish wake phrase; microphone failure falls back to the unlocker |
|
| `TALKFIRST` | Same automatic startup behavior as `TALK` |
|
||||||
|
|
||||||
The warmup is a timer, not proof that all upstream context requests have
|
Automatic startup runs once per page load, without a startup dialog or artificial
|
||||||
finished. Browser permission and audio policies can still require a gesture.
|
delay; optional reports and tools can still be loading. Browser permission and
|
||||||
|
audio policies can still require a gesture. When audio is blocked, an inline hint
|
||||||
|
asks the user to tap the microphone button. Permission or connection failures
|
||||||
|
show the error and leave the same button available to retry. Stopping a session
|
||||||
|
does not start it again automatically.
|
||||||
The greeting uses the same `hola vocero` input as wake-word handling.
|
The greeting uses the same `hola vocero` input as wake-word handling.
|
||||||
|
|
||||||
|
On browsers with speech recognition, **Activar con «hola vocero»** opts into
|
||||||
|
wake-word listening for the current page session. The interface shows when the
|
||||||
|
microphone is listening for that phrase. It is off by default, pauses while a
|
||||||
|
dialog or voice session is active, and turns off when the conversation stops.
|
||||||
|
|
||||||
|
The transcript keeps your reading position when new messages arrive. **Ir al
|
||||||
|
último mensaje** resumes following the conversation. Names and Santiago-local
|
||||||
|
times appear with each message; downloaded JSON and text retain raw timestamps
|
||||||
|
and message text. The interface respects the browser's reduced-motion setting.
|
||||||
|
|
||||||
## Queue and session lifecycle
|
## Queue and session lifecycle
|
||||||
|
|
||||||
Load-balancer mode proxies `POST /api/session`. A free compute slot returns a
|
Load-balancer mode proxies `POST /api/session`. A free compute slot returns a
|
||||||
@@ -158,8 +182,9 @@ to a keepalive fetch. Expired tickets and missed join deadlines require a new
|
|||||||
attempt. Full queues return `503` with `state: "at_capacity"`.
|
attempt. Full queues return `503` with `state: "at_capacity"`.
|
||||||
|
|
||||||
Once connected, session teardown closes the microphone, audio graph and
|
Once connected, session teardown closes the microphone, audio graph and
|
||||||
WebSocket. The 30-second inactivity timeout remains active when the assistant
|
WebSocket. The 30-second inactivity timeout counts time waiting for user input;
|
||||||
is not speaking. Caller-profile lookup and upstream authorization operate
|
processing, external reasoning and buffered playback do not consume it.
|
||||||
|
Caller-profile lookup and upstream authorization operate
|
||||||
independently of allocation and use their existing configured endpoints.
|
independently of allocation and use their existing configured endpoints.
|
||||||
|
|
||||||
For external transcript processing, configure the selected
|
For external transcript processing, configure the selected
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
# Build from the project root: docker build -f s2s-client/Dockerfile -t s2s-client .
|
|
||||||
# Serve backend APIs, the WebSocket proxy, and shared project documentation.
|
|
||||||
# Run the React/Vite frontend separately; this image contains no browser UI.
|
|
||||||
# HF Spaces (sdk: docker) routes traffic to $PORT, default 7860.
|
|
||||||
FROM python:3.11-slim
|
|
||||||
|
|
||||||
WORKDIR /app/s2s-client
|
|
||||||
|
|
||||||
COPY s2s-client/requirements.txt .
|
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
|
||||||
|
|
||||||
COPY s2s-client/ .
|
|
||||||
COPY docs/ /app/docs/
|
|
||||||
|
|
||||||
EXPOSE 7860
|
|
||||||
|
|
||||||
CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "7860"]
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
fastapi==0.115.6
|
|
||||||
uvicorn[standard]==0.34.0
|
|
||||||
httpx==0.28.1
|
|
||||||
python-dotenv==1.2.2
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,294 +0,0 @@
|
|||||||
import { BookOpen, ChartNoAxesColumn, ChevronRight, Info, PanelLeft, Settings, Wrench, X } from "lucide-react";
|
|
||||||
import { useEffect, useLayoutEffect, useState, useSyncExternalStore } from "react";
|
|
||||||
|
|
||||||
import { Bubbles } from "./components/Bubbles";
|
|
||||||
import { Conversation } from "./components/Conversation";
|
|
||||||
import { AboutDialog, ProfileDialog, RagDialog, SettingsDialog, ToolsDialog } from "./components/SettingsDialogs";
|
|
||||||
import { VoiceStage } from "./components/VoiceStage";
|
|
||||||
import { useWakeWord } from "./hooks/use-wake-word";
|
|
||||||
import { initials, profileName } from "./lib/context";
|
|
||||||
import { reportSuggestions } from "./lib/reports";
|
|
||||||
import { isDebug } from "./lib/settings";
|
|
||||||
import { ACTIVE_STATES, VoiceController } from "./realtime/controller";
|
|
||||||
|
|
||||||
type Modal = "settings" | "tools" | "rag" | "profile" | "about" | null;
|
|
||||||
export default function App() {
|
|
||||||
const [controller] = useState(() => new VoiceController());
|
|
||||||
const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot);
|
|
||||||
const [modal, setModal] = useState<Modal>(null);
|
|
||||||
const [reportsOpen, setReportsOpen] = useState(false);
|
|
||||||
const debug = isDebug();
|
|
||||||
const white = state.config.theme === "white";
|
|
||||||
const name = profileName(state.profile);
|
|
||||||
const hasReports = debug && !white && !!state.reports?.count;
|
|
||||||
useEffect(() => {
|
|
||||||
const abort = new AbortController();
|
|
||||||
void controller.initialize(abort.signal);
|
|
||||||
const leave = () => {
|
|
||||||
void controller.stop();
|
|
||||||
};
|
|
||||||
window.addEventListener("pagehide", leave);
|
|
||||||
return () => {
|
|
||||||
abort.abort();
|
|
||||||
window.removeEventListener("pagehide", leave);
|
|
||||||
void controller.stop();
|
|
||||||
};
|
|
||||||
}, [controller]);
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
if (!state.loading) {
|
|
||||||
document.documentElement.dataset.theme = white ? "white" : "dark";
|
|
||||||
}
|
|
||||||
}, [state.loading, white]);
|
|
||||||
useEffect(() => {
|
|
||||||
if (state.loading) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
controller.offerAutoStart();
|
|
||||||
}, 2000);
|
|
||||||
return () => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
};
|
|
||||||
}, [state.loading, controller]);
|
|
||||||
useWakeWord(!state.loading && !ACTIVE_STATES.has(state.status) && !modal && !state.unlock, controller);
|
|
||||||
useEffect(() => {
|
|
||||||
if (!reportsOpen) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const escape = (event: KeyboardEvent) => {
|
|
||||||
if (event.key === "Escape") {
|
|
||||||
setReportsOpen(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
window.addEventListener("keydown", escape);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("keydown", escape);
|
|
||||||
};
|
|
||||||
}, [reportsOpen]);
|
|
||||||
const close = () => {
|
|
||||||
setModal(null);
|
|
||||||
};
|
|
||||||
if (state.loading) {
|
|
||||||
return (
|
|
||||||
<main className="loading-screen" role="status">
|
|
||||||
Preparando Vocero…
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{state.unlock && (
|
|
||||||
<button
|
|
||||||
className="talk-unlocker"
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
void controller.start();
|
|
||||||
}}
|
|
||||||
aria-label="Empezar a hablar"
|
|
||||||
>
|
|
||||||
<span className="talk-unlocker-hint">
|
|
||||||
<span className="talk-unlocker-spinner" aria-hidden="true" />
|
|
||||||
<span className="talk-unlocker-title">Preparando Vocero…</span>
|
|
||||||
<span className="talk-unlocker-sub">Toca en cualquier lugar para empezar a hablar</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<div id="app" className={hasReports ? "has-reports" : ""}>
|
|
||||||
<header className="topbar">
|
|
||||||
<div className="brand">
|
|
||||||
<div className="ident">
|
|
||||||
<div className="ident-head">
|
|
||||||
<h1 className="m-0 leading-none">
|
|
||||||
<a className="ident-title" href={window.location.pathname + window.location.search}>
|
|
||||||
Vocero
|
|
||||||
</a>
|
|
||||||
</h1>
|
|
||||||
{white && <p className="ident-subtitle">Agente conversacional</p>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{white && name && (
|
|
||||||
<button
|
|
||||||
id="ident-user-name"
|
|
||||||
className="ident-user-name"
|
|
||||||
type="button"
|
|
||||||
aria-label={`Ver datos de ${name}`}
|
|
||||||
aria-haspopup="dialog"
|
|
||||||
onClick={() => {
|
|
||||||
setModal("profile");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{initials(name)}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{hasReports && (
|
|
||||||
<button
|
|
||||||
id="reports-sidebar-toggle"
|
|
||||||
className="icon-btn"
|
|
||||||
type="button"
|
|
||||||
aria-label="Abrir panel de reportes"
|
|
||||||
aria-expanded={reportsOpen}
|
|
||||||
aria-controls="reports-sidebar"
|
|
||||||
onClick={() => {
|
|
||||||
setReportsOpen(!reportsOpen);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PanelLeft size={18} />
|
|
||||||
<span id="reports-sidebar-badge" className="chat-badge" data-count={state.reports?.count}>
|
|
||||||
{state.reports?.count}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{debug && (
|
|
||||||
<div className="topbar-right" data-debug-enabled="true">
|
|
||||||
<button
|
|
||||||
className="icon-btn"
|
|
||||||
type="button"
|
|
||||||
aria-label="Acerca de"
|
|
||||||
onClick={() => {
|
|
||||||
setModal("about");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Info size={18} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="icon-btn"
|
|
||||||
type="button"
|
|
||||||
aria-label="Herramientas"
|
|
||||||
onClick={() => {
|
|
||||||
setModal("tools");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Wrench size={18} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="icon-btn"
|
|
||||||
type="button"
|
|
||||||
aria-label="Contexto RAG personalizado"
|
|
||||||
onClick={() => {
|
|
||||||
setModal("rag");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<BookOpen size={18} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="icon-btn"
|
|
||||||
type="button"
|
|
||||||
aria-label="Configuración"
|
|
||||||
onClick={() => {
|
|
||||||
setModal("settings");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Settings size={18} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</header>
|
|
||||||
<nav className="feedback-navigation" aria-label="Comentarios">
|
|
||||||
<a className="feedback-link" href="https://tally.so/r/pb8LWq" target="_blank" rel="noopener noreferrer">
|
|
||||||
Enviar comentarios
|
|
||||||
</a>
|
|
||||||
</nav>
|
|
||||||
{white && (
|
|
||||||
<div className="status-card" aria-label="Estado de la sesión">
|
|
||||||
<div className="status-card-icon" aria-hidden="true">
|
|
||||||
<ChartNoAxesColumn size={24} />
|
|
||||||
</div>
|
|
||||||
<div className="status-card-text">
|
|
||||||
<h2 className="status-card-title">Listo para conversar</h2>
|
|
||||||
<p className="status-card-sub">Informes, análisis, datos y mucho más.</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
className="status-card-arrow icon-btn"
|
|
||||||
type="button"
|
|
||||||
aria-label="Ver más detalles"
|
|
||||||
onClick={() => {
|
|
||||||
setModal("about");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ChevronRight size={22} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{state.error && (
|
|
||||||
<div className="app-error" role="alert">
|
|
||||||
<span>{state.error}</span>
|
|
||||||
<button
|
|
||||||
className="icon-btn shrink-0"
|
|
||||||
type="button"
|
|
||||||
aria-label="Cerrar aviso"
|
|
||||||
onClick={() => {
|
|
||||||
controller.dismissError();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<X size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<VoiceStage state={state} controller={controller} />
|
|
||||||
<Conversation messages={state.messages} userName={name || "Usuario"} status={state.status} white={white} />
|
|
||||||
</div>
|
|
||||||
{!white && ACTIVE_STATES.has(state.status) && <Bubbles messages={state.messages} userName={name || "Usuario"} />}
|
|
||||||
{hasReports && (
|
|
||||||
<aside
|
|
||||||
id="reports-sidebar"
|
|
||||||
className={`reports-sidebar ${reportsOpen ? "open" : ""}`}
|
|
||||||
aria-label="Reportes programados"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
className="reports-sidebar-backdrop"
|
|
||||||
type="button"
|
|
||||||
aria-label="Cerrar panel de reportes"
|
|
||||||
tabIndex={reportsOpen ? 0 : -1}
|
|
||||||
onClick={() => {
|
|
||||||
setReportsOpen(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="reports-sidebar-inner">
|
|
||||||
<header className="reports-sidebar-header">
|
|
||||||
<h2>Reportes</h2>
|
|
||||||
<button
|
|
||||||
id="reports-sidebar-close"
|
|
||||||
className="icon-btn"
|
|
||||||
type="button"
|
|
||||||
aria-label="Cerrar reportes"
|
|
||||||
onClick={() => {
|
|
||||||
setReportsOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<X size={18} />
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
<div className="reports-hint">
|
|
||||||
<p>{state.reports?.count} reportes disponibles</p>
|
|
||||||
<ul>
|
|
||||||
{state.reports?.hints.map((hint, index) => (
|
|
||||||
<li key={index}>{hint}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
{state.reports?.note && <p>{state.reports.note}</p>}
|
|
||||||
</div>
|
|
||||||
{state.reports && (
|
|
||||||
<div className="reports-suggestions">
|
|
||||||
<h3 className="reports-suggestions-title">Puedes preguntar</h3>
|
|
||||||
<ul className="reports-suggestions-list">
|
|
||||||
{reportSuggestions(state.reports).map((suggestion) => (
|
|
||||||
<li key={suggestion}>{suggestion}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
)}
|
|
||||||
{modal === "settings" && <SettingsDialog state={state} controller={controller} onClose={close} />}
|
|
||||||
{modal === "tools" && <ToolsDialog state={state} controller={controller} onClose={close} />}
|
|
||||||
{modal === "rag" && <RagDialog state={state} controller={controller} onClose={close} />}
|
|
||||||
{modal === "profile" && <ProfileDialog state={state} onClose={close} />}
|
|
||||||
{modal === "about" && <AboutDialog onClose={close} />}
|
|
||||||
<footer className="app-footer" aria-label="Versión de la aplicación">
|
|
||||||
<span className="app-footer-text">Azurian Vocero en tiempo real ver. Beta 2.1</span>
|
|
||||||
</footer>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import { memo, useEffect, useState } from "react";
|
|
||||||
|
|
||||||
import { renderMarkdown, type Message } from "../lib/messages";
|
|
||||||
|
|
||||||
function Bubble({ message, userName }: { message: Message; userName: string }) {
|
|
||||||
const [expiredText, setExpiredText] = useState<string | null>(null);
|
|
||||||
useEffect(() => {
|
|
||||||
if (message.partial) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
setExpiredText(message.text);
|
|
||||||
}, 5000);
|
|
||||||
return () => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
};
|
|
||||||
}, [message.text, message.partial]);
|
|
||||||
if (expiredText === message.text) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className={`bubble ${message.role} in`}>
|
|
||||||
<div className="bubble-role">
|
|
||||||
{message.role === "user" ? userName : message.role === "tool" ? "Herramienta" : "Vocero"}
|
|
||||||
</div>
|
|
||||||
{message.role === "assistant" ? (
|
|
||||||
<div
|
|
||||||
className="bubble-body markdown-content"
|
|
||||||
dangerouslySetInnerHTML={{ __html: renderMarkdown(message.text) }}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="bubble-body">{message.role === "tool" ? message.toolName : message.text}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
export const Bubbles = memo(function Bubbles({ messages, userName }: { messages: Message[]; userName: string }) {
|
|
||||||
return (
|
|
||||||
<div className="bubble-stack" aria-hidden="true">
|
|
||||||
{messages.slice(-3).map((message) => (
|
|
||||||
<Bubble key={message.id} message={message} userName={userName} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
import { Download, MessageCircle, Wrench } from "lucide-react";
|
|
||||||
import { memo, useEffect, useRef, useState } from "react";
|
|
||||||
|
|
||||||
import { initials } from "../lib/context";
|
|
||||||
import type { ConnectionStatus } from "../lib/contracts";
|
|
||||||
import { displayTime, exportConversation, renderMarkdown, type Message } from "../lib/messages";
|
|
||||||
import { LIVE_STATES } from "../realtime/controller";
|
|
||||||
|
|
||||||
const MessageRow = memo(function MessageRow({ message, userName }: { message: Message; userName: string }) {
|
|
||||||
const [expanded, setExpanded] = useState(false);
|
|
||||||
const time = displayTime(message.timestamp);
|
|
||||||
if (message.role === "tool") {
|
|
||||||
return (
|
|
||||||
<div className="hist-msg tool">
|
|
||||||
<div className="hist-role">Llamada a herramienta</div>
|
|
||||||
<button
|
|
||||||
className="hist-tool-header"
|
|
||||||
type="button"
|
|
||||||
aria-expanded={expanded}
|
|
||||||
onClick={() => {
|
|
||||||
setExpanded(!expanded);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Wrench className="hist-tool-icon" />
|
|
||||||
<span className="hist-tool-name">{message.toolName}</span>
|
|
||||||
</button>
|
|
||||||
<div className={`hist-tool-body ${expanded ? "open" : ""}`}>
|
|
||||||
<div className="hist-tool-label">Entrada</div>
|
|
||||||
<pre className="hist-tool-block">{message.toolArguments}</pre>
|
|
||||||
<div className="hist-tool-label">Salida</div>
|
|
||||||
<pre className="hist-tool-block hist-tool-output">{message.text}</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const data = {
|
|
||||||
"data-user-name": userName,
|
|
||||||
"data-user-initials": initials(userName),
|
|
||||||
"data-asst-name": "Vocero",
|
|
||||||
"data-timestamp": time,
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<div className={`hist-msg ${message.role} ${message.interrupted ? "interrupted" : ""}`} {...data}>
|
|
||||||
<div className="hist-role">{message.role === "user" ? userName : "Vocero"}</div>
|
|
||||||
{message.role === "assistant" ? (
|
|
||||||
<div
|
|
||||||
className={`hist-body markdown-content ${message.partial ? "partial" : ""}`}
|
|
||||||
{...data}
|
|
||||||
dangerouslySetInnerHTML={{ __html: renderMarkdown(message.text) }}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className={`hist-body ${message.partial ? "partial" : ""}`} {...data}>
|
|
||||||
{message.text}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{message.interrupted && <div className="hist-note">Interrumpido</div>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
export const Conversation = memo(function Conversation({
|
|
||||||
messages,
|
|
||||||
userName,
|
|
||||||
status,
|
|
||||||
white,
|
|
||||||
}: {
|
|
||||||
messages: Message[];
|
|
||||||
userName: string;
|
|
||||||
status: ConnectionStatus;
|
|
||||||
white: boolean;
|
|
||||||
}) {
|
|
||||||
const history = useRef<HTMLDivElement>(null);
|
|
||||||
const atBottom = useRef(true);
|
|
||||||
useEffect(() => {
|
|
||||||
const element = history.current;
|
|
||||||
if (element && atBottom.current) {
|
|
||||||
element.scrollTop = element.scrollHeight;
|
|
||||||
}
|
|
||||||
}, [messages]);
|
|
||||||
const live = LIVE_STATES.has(status);
|
|
||||||
const waiting = ["connecting", "queued", "your-turn"].includes(status);
|
|
||||||
return (
|
|
||||||
<section id="chat-panel" className="chat-panel chat-panel-inline" aria-label="Transcripción de la conversación">
|
|
||||||
<div className="chat-panel-inner">
|
|
||||||
<header className="chat-panel-header">
|
|
||||||
<h2>Conversación</h2>
|
|
||||||
<div className="chat-header-actions" role="group" aria-label="Acciones de la conversación">
|
|
||||||
{white && (
|
|
||||||
<span
|
|
||||||
className="chat-status-pill"
|
|
||||||
data-status={live ? "online" : status === "error" ? "error" : waiting ? "waiting" : "idle"}
|
|
||||||
role="status"
|
|
||||||
>
|
|
||||||
<span className="chat-status-dot" aria-hidden="true" />
|
|
||||||
<span>
|
|
||||||
{live ? "En línea" : status === "error" ? "Sin conexión" : waiting ? "Conectando" : "Sin sesión"}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
className="chat-export-btn icon-btn"
|
|
||||||
type="button"
|
|
||||||
aria-label="Descargar conversación"
|
|
||||||
title="Descargar conversación (JSON + TXT)"
|
|
||||||
disabled={!messages.length}
|
|
||||||
onClick={() => {
|
|
||||||
exportConversation(messages, userName);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Download size={18} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<div
|
|
||||||
ref={history}
|
|
||||||
id="chat-history"
|
|
||||||
className="chat-history"
|
|
||||||
role="log"
|
|
||||||
aria-live="polite"
|
|
||||||
aria-atomic="false"
|
|
||||||
onScroll={() => {
|
|
||||||
const element = history.current;
|
|
||||||
if (element) {
|
|
||||||
atBottom.current = element.scrollHeight - element.scrollTop - element.clientHeight < 60;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{messages.length ? (
|
|
||||||
messages.map((message) => <MessageRow key={message.id} message={message} userName={userName} />)
|
|
||||||
) : (
|
|
||||||
<div className="chat-empty">
|
|
||||||
<MessageCircle aria-hidden="true" />
|
|
||||||
<span className="chat-empty-title">Aún no hay mensajes</span>
|
|
||||||
<span className="chat-empty-hint">Toca el círculo y empieza a hablar</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { X } from "lucide-react";
|
|
||||||
import { useEffect, useId, useRef, type ReactNode } from "react";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
title: string;
|
|
||||||
children: ReactNode;
|
|
||||||
onClose: () => void;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
export function Dialog({ title, children, onClose, className = "" }: Props) {
|
|
||||||
const ref = useRef<HTMLDialogElement>(null);
|
|
||||||
const titleId = useId();
|
|
||||||
useEffect(() => {
|
|
||||||
const dialog = ref.current;
|
|
||||||
dialog?.showModal();
|
|
||||||
return () => {
|
|
||||||
dialog?.close();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
return (
|
|
||||||
<dialog
|
|
||||||
ref={ref}
|
|
||||||
className={`modal ${className}`}
|
|
||||||
aria-labelledby={titleId}
|
|
||||||
onCancel={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
onClose();
|
|
||||||
}}
|
|
||||||
onClick={(event) => {
|
|
||||||
if (event.target === event.currentTarget) {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="modal-content">
|
|
||||||
<header className="modal-header">
|
|
||||||
<h2 id={titleId}>{title}</h2>
|
|
||||||
<button type="button" className="icon-btn" aria-label="Cerrar" onClick={onClose}>
|
|
||||||
<X size={18} />
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,337 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
import { initials, profileName } from "../lib/context";
|
|
||||||
import { profileRows } from "../lib/profile";
|
|
||||||
import { GATE_MAX, GATE_OFF, VOICES } from "../lib/settings";
|
|
||||||
import { LIVE_STATES, type AppState, type VoiceController } from "../realtime/controller";
|
|
||||||
import { Dialog } from "./Dialog";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
state: AppState;
|
|
||||||
controller: VoiceController;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
export function SettingsDialog({ state, controller, onClose }: Props) {
|
|
||||||
const { config, settings } = state;
|
|
||||||
const meter = Math.max(
|
|
||||||
0,
|
|
||||||
Math.min(100, ((20 * Math.log10(state.level || 0.00001) - GATE_OFF) * 100) / (GATE_MAX - GATE_OFF)),
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<Dialog title="Configuración" onClose={onClose}>
|
|
||||||
<form
|
|
||||||
onSubmit={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
onClose();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="tab-panels">
|
|
||||||
<div className="tab-panel active">
|
|
||||||
{config.allowDirect && !config.lb && (
|
|
||||||
<label className="field">
|
|
||||||
<span>URL del servidor de voz</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
autoComplete="off"
|
|
||||||
spellCheck={false}
|
|
||||||
placeholder="http://localhost:8080"
|
|
||||||
value={config.s2sUrl || settings.directUrl}
|
|
||||||
readOnly={!!config.s2sUrl}
|
|
||||||
onChange={(event) => {
|
|
||||||
controller.changeSettings({ directUrl: event.target.value });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<small>
|
|
||||||
{config.s2sUrl
|
|
||||||
? "El servidor fija esta dirección para todas las conversaciones."
|
|
||||||
: "Introduce la dirección de tu servidor de voz. La aplicación añade /v1/realtime."}
|
|
||||||
</small>
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
<div className="field-row">
|
|
||||||
<label className="field">
|
|
||||||
<span>Voz</span>
|
|
||||||
<select
|
|
||||||
value={settings.voice}
|
|
||||||
onChange={(event) => {
|
|
||||||
controller.changeSettings({ voice: event.target.value });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{VOICES.map((voice) => (
|
|
||||||
<option key={voice}>{voice}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="field">
|
|
||||||
<label className="field-head" htmlFor="noise-gate">
|
|
||||||
Puerta de ruido
|
|
||||||
<span className="field-value">
|
|
||||||
{settings.noiseGate <= GATE_OFF ? "Desactivada" : `${String(settings.noiseGate)} dB`}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<div className="gate">
|
|
||||||
<div className="gate-track">
|
|
||||||
<div className="gate-meter-fill" style={{ width: `${String(meter)}%` }} aria-hidden="true" />
|
|
||||||
<input
|
|
||||||
id="noise-gate"
|
|
||||||
aria-label="Umbral de la puerta de ruido"
|
|
||||||
type="range"
|
|
||||||
min={GATE_OFF}
|
|
||||||
max={GATE_MAX}
|
|
||||||
step="1"
|
|
||||||
value={settings.noiseGate}
|
|
||||||
onChange={(event) => {
|
|
||||||
controller.changeSettings({ noiseGate: Number(event.target.value) });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="gate-ends">
|
|
||||||
<span>Desactivada</span>
|
|
||||||
<span>−3 dB</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<small>
|
|
||||||
Silencia el micrófono por debajo del umbral para evitar enviar ruido. Desliza hasta la izquierda para
|
|
||||||
desactivarla.
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
<label className="field">
|
|
||||||
<span>Instrucciones</span>
|
|
||||||
<textarea
|
|
||||||
rows={5}
|
|
||||||
value={config.primaryPrompt || settings.instructions}
|
|
||||||
readOnly={!!config.primaryPrompt}
|
|
||||||
onChange={(event) => {
|
|
||||||
controller.changeSettings({ instructions: event.target.value });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{config.primaryPrompt && <small>Las instrucciones están definidas por el servidor.</small>}
|
|
||||||
</label>
|
|
||||||
<div className="field">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn primary wide"
|
|
||||||
disabled={!LIVE_STATES.has(state.status)}
|
|
||||||
onClick={() => {
|
|
||||||
onClose();
|
|
||||||
void controller.restart();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Reiniciar conversación con esta configuración
|
|
||||||
</button>
|
|
||||||
<small>La voz, las instrucciones y la puerta de ruido se actualizan en la sesión actual.</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<footer className="modal-footer">
|
|
||||||
<button className="btn primary" type="submit">
|
|
||||||
Guardar
|
|
||||||
</button>
|
|
||||||
</footer>
|
|
||||||
</form>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RagDialog({ state, controller, onClose }: Props) {
|
|
||||||
const [text, setText] = useState(state.rag);
|
|
||||||
const [saved, setSaved] = useState(false);
|
|
||||||
return (
|
|
||||||
<Dialog title="Contexto RAG personalizado" onClose={onClose}>
|
|
||||||
<p className="tools-intro">
|
|
||||||
Añade documentos, datos o instrucciones que Vocero debe tener en cuenta durante esta conversación. El contexto
|
|
||||||
se mantiene mientras esta página esté abierta.
|
|
||||||
</p>
|
|
||||||
<form
|
|
||||||
onSubmit={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
controller.setRag(text);
|
|
||||||
setSaved(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<label className="field">
|
|
||||||
<span>Contexto RAG (texto libre)</span>
|
|
||||||
<textarea
|
|
||||||
rows={12}
|
|
||||||
spellCheck={false}
|
|
||||||
value={text}
|
|
||||||
onChange={(event) => {
|
|
||||||
setText(event.target.value);
|
|
||||||
setSaved(false);
|
|
||||||
}}
|
|
||||||
placeholder="Pega o escribe el contexto que Vocero debe usar al responder…"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<p className="tool-hint" role="status">
|
|
||||||
{saved
|
|
||||||
? "Contexto actualizado para esta conversación."
|
|
||||||
: state.rag
|
|
||||||
? `${String(state.rag.length)} caracteres de contexto cargados.`
|
|
||||||
: "No hay contexto RAG cargado."}
|
|
||||||
</p>
|
|
||||||
<footer className="modal-footer">
|
|
||||||
<button
|
|
||||||
className="btn secondary"
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setText("");
|
|
||||||
controller.setRag("");
|
|
||||||
setSaved(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Vaciar
|
|
||||||
</button>
|
|
||||||
<div className="flex-1" />
|
|
||||||
<button className="btn primary" type="submit">
|
|
||||||
Enviar a la sesión
|
|
||||||
</button>
|
|
||||||
</footer>
|
|
||||||
</form>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ToolsDialog({ state, controller, onClose }: Props) {
|
|
||||||
return (
|
|
||||||
<Dialog title="Herramientas" onClose={onClose}>
|
|
||||||
<p className="tools-intro">
|
|
||||||
Permite que el asistente actúe durante la conversación. Los cambios se aplican de inmediato.
|
|
||||||
</p>
|
|
||||||
<div className="tool-list">
|
|
||||||
<div className="tool-row">
|
|
||||||
<div className="tool-info">
|
|
||||||
<span className="tool-name">Búsqueda web</span>
|
|
||||||
<span className="tool-desc">Busca información en Google mediante Serper.</span>
|
|
||||||
</div>
|
|
||||||
<label className="switch">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
aria-label="Búsqueda web"
|
|
||||||
checked={state.settings.webSearch}
|
|
||||||
onChange={(event) => {
|
|
||||||
controller.changeSettings({ webSearch: event.target.checked });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span className="switch-track" aria-hidden="true" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<label className="field tools-key">
|
|
||||||
<span>Clave de la API de búsqueda</span>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
autoComplete="off"
|
|
||||||
value={state.settings.searchKey}
|
|
||||||
disabled={state.config.search}
|
|
||||||
onChange={(event) => {
|
|
||||||
controller.changeSettings({ searchKey: event.target.value });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<small>
|
|
||||||
{state.config.search
|
|
||||||
? "La búsqueda está configurada en el servidor."
|
|
||||||
: "Introduce tu clave de Serper para activar la búsqueda. Se guarda en este navegador."}
|
|
||||||
</small>
|
|
||||||
</label>
|
|
||||||
<div className="tool-row tool-row-sep">
|
|
||||||
<div className="tool-info">
|
|
||||||
<span className="tool-name">Herramientas MCP cargadas</span>
|
|
||||||
<span className="tool-desc">Herramientas publicadas por el perfil remoto del agente.</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="tool-hint" role="status">
|
|
||||||
{state.toolsLoading
|
|
||||||
? "Cargando herramientas…"
|
|
||||||
: state.toolsError ||
|
|
||||||
(state.tools.length
|
|
||||||
? `${String(state.tools.length)} herramientas disponibles.`
|
|
||||||
: "No hay herramientas remotas disponibles.")}
|
|
||||||
</p>
|
|
||||||
<div className="agent-tools-list">
|
|
||||||
{state.tools.map((tool) => (
|
|
||||||
<article key={tool.name} className="agent-tool-item">
|
|
||||||
<strong className="block text-sm">{tool.name}</strong>
|
|
||||||
<p className="mt-1 text-sm opacity-75">{tool.description}</p>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ProfileDialog({ state, onClose }: Omit<Props, "controller">) {
|
|
||||||
const name = profileName(state.profile) || "Usuario";
|
|
||||||
const rows = profileRows(state.profile);
|
|
||||||
return (
|
|
||||||
<Dialog title="Datos del usuario" className="user-profile-modal" onClose={onClose}>
|
|
||||||
<div className="user-profile-hero">
|
|
||||||
<div className="user-profile-avatar" aria-hidden="true">
|
|
||||||
{initials(name)}
|
|
||||||
</div>
|
|
||||||
<h3 className="user-profile-title">{name}</h3>
|
|
||||||
</div>
|
|
||||||
<dl className="user-profile-metadata">
|
|
||||||
{rows.map(({ label, value, href }) => (
|
|
||||||
<div key={label} className="user-profile-row">
|
|
||||||
<dt className="user-profile-label">{label}</dt>
|
|
||||||
<dd className="user-profile-value">{href ? <a href={href}>{value}</a> : value}</dd>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</dl>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AboutDialog({ onClose }: { onClose: () => void }) {
|
|
||||||
return (
|
|
||||||
<Dialog title="Acerca de" className="about-modal" onClose={onClose}>
|
|
||||||
<p className="tools-intro">
|
|
||||||
Vocero convierte tu voz en respuestas habladas, en tiempo real. Puedes interrumpirlo para continuar la
|
|
||||||
conversación.
|
|
||||||
</p>
|
|
||||||
<div className="about-pipeline">
|
|
||||||
<ol className="pipeline">
|
|
||||||
<li className="pipe-endpoint">Tú hablas</li>
|
|
||||||
<li className="pipe-stage">
|
|
||||||
<span className="pipe-tag">STT</span>
|
|
||||||
<span className="pipe-job">transcribe tu voz</span>
|
|
||||||
<a
|
|
||||||
className="pipe-model"
|
|
||||||
href="https://huggingface.co/nvidia/parakeet-tdt-0.6b-v3"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
nvidia/parakeet-tdt-0.6b-v3
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li className="pipe-stage">
|
|
||||||
<span className="pipe-tag">LLM</span>
|
|
||||||
<span className="pipe-job">genera una respuesta</span>
|
|
||||||
<a
|
|
||||||
className="pipe-model"
|
|
||||||
href="https://huggingface.co/google/gemma-4-31B-it"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
google/gemma-4-31B-it
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li className="pipe-stage">
|
|
||||||
<span className="pipe-tag">TTS</span>
|
|
||||||
<span className="pipe-job">la convierte en voz</span>
|
|
||||||
<a
|
|
||||||
className="pipe-model"
|
|
||||||
href="https://huggingface.co/Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li className="pipe-endpoint">Vocero responde</li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest";
|
|
||||||
|
|
||||||
import type { ClientOptions } from "./client";
|
|
||||||
import { VoiceController } from "./controller";
|
|
||||||
|
|
||||||
const { MockClient, closeContext } = vi.hoisted(() => {
|
|
||||||
const closeContext = vi.fn().mockResolvedValue(undefined);
|
|
||||||
class MockClient {
|
|
||||||
static instances: MockClient[] = [];
|
|
||||||
readonly options: ClientOptions;
|
|
||||||
private listeners = new Map<string, Set<(value: unknown) => void>>();
|
|
||||||
constructor(options: ClientOptions) {
|
|
||||||
this.options = options;
|
|
||||||
MockClient.instances.push(this);
|
|
||||||
}
|
|
||||||
on(name: string, listener: (value: unknown) => void) {
|
|
||||||
const listeners = this.listeners.get(name) ?? new Set();
|
|
||||||
listeners.add(listener);
|
|
||||||
this.listeners.set(name, listeners);
|
|
||||||
return () => {
|
|
||||||
listeners.delete(listener);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
emit(name: string, value: unknown) {
|
|
||||||
this.listeners.get(name)?.forEach((listener) => {
|
|
||||||
listener(value);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
connect = vi.fn(() => {
|
|
||||||
this.emit("status", "listening");
|
|
||||||
return Promise.resolve();
|
|
||||||
});
|
|
||||||
close = vi.fn(() => {
|
|
||||||
this.emit("status", "closed");
|
|
||||||
return Promise.resolve();
|
|
||||||
});
|
|
||||||
updateSession = vi.fn();
|
|
||||||
setGate = vi.fn();
|
|
||||||
setMuted = vi.fn();
|
|
||||||
renderExternalAnswer = vi.fn();
|
|
||||||
sendToolOutput = vi.fn();
|
|
||||||
join = vi.fn();
|
|
||||||
}
|
|
||||||
return { MockClient, closeContext };
|
|
||||||
});
|
|
||||||
vi.mock("./client", async (importOriginal) => ({ ...(await importOriginal()), RealtimeClient: MockClient }));
|
|
||||||
vi.mock("../audio/engine", () => ({ createAudioContext: () => ({ state: "running", close: closeContext }) }));
|
|
||||||
let pendingReasoning: ((value: Response) => void) | undefined;
|
|
||||||
let controller: VoiceController;
|
|
||||||
let fetchMock: Mock<(path: string, init?: RequestInit) => Promise<Response>>;
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
vi.clearAllMocks();
|
|
||||||
MockClient.instances = [];
|
|
||||||
pendingReasoning = undefined;
|
|
||||||
vi.stubGlobal("AudioContext", vi.fn());
|
|
||||||
vi.stubGlobal("navigator", { mediaDevices: { getUserMedia: vi.fn() } });
|
|
||||||
fetchMock = vi.fn((path: string) => {
|
|
||||||
if (path === "/api/reasoning") {
|
|
||||||
return new Promise<Response>((resolve) => {
|
|
||||||
pendingReasoning = resolve;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const pathname = new URL(path, "http://localhost").pathname;
|
|
||||||
const responses: Record<string, unknown> = {
|
|
||||||
"/api/config": { reasoningType: "NODE_RED", injectProfile: false, s2sUrl: "ws://localhost/v1/realtime" },
|
|
||||||
"/api/user/phone": { result: { name: "Ana" } },
|
|
||||||
"/api/user/reports": { count: 0 },
|
|
||||||
};
|
|
||||||
return Promise.resolve(new Response(JSON.stringify(responses[pathname] ?? {})));
|
|
||||||
});
|
|
||||||
vi.stubGlobal("fetch", fetchMock);
|
|
||||||
controller = new VoiceController({ phoneNumber: "1234", episodeId: "episode1" });
|
|
||||||
});
|
|
||||||
afterEach(async () => {
|
|
||||||
await controller.stop();
|
|
||||||
vi.useRealTimers();
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
async function start() {
|
|
||||||
await controller.initialize(new AbortController().signal);
|
|
||||||
await controller.start();
|
|
||||||
const client = MockClient.instances[0];
|
|
||||||
if (!client) {
|
|
||||||
throw new Error("No client created");
|
|
||||||
}
|
|
||||||
return client;
|
|
||||||
}
|
|
||||||
describe("voice application lifecycle", () => {
|
|
||||||
it("loads configuration and caller context without an account request on startup or stop", async () => {
|
|
||||||
const client = await start();
|
|
||||||
await controller.stop();
|
|
||||||
expect(client.close).toHaveBeenCalledOnce();
|
|
||||||
expect(fetchMock.mock.calls.map(([path]) => new URL(path, "http://localhost").pathname)).toEqual([
|
|
||||||
"/api/config",
|
|
||||||
"/api/user/phone",
|
|
||||||
"/api/user/reports",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps LOG_ONLY reasoning on the native voice path and preserves separate profile context", async () => {
|
|
||||||
const client = await start();
|
|
||||||
client.emit("transcript", { id: "u1", role: "user", text: "Hola", partial: false });
|
|
||||||
pendingReasoning?.(new Response(JSON.stringify({ log_only: true, answer: "" })));
|
|
||||||
await vi.advanceTimersByTimeAsync(1);
|
|
||||||
const request = fetchMock.mock.calls.find(([path]) => path === "/api/reasoning");
|
|
||||||
expect(request?.[1]?.body).toContain('"phoneNumber":"1234"');
|
|
||||||
expect(request?.[1]?.body).toContain("Caller profile resolved");
|
|
||||||
expect(client.options.instructions).not.toContain("Caller profile resolved");
|
|
||||||
expect(client.renderExternalAnswer).not.toHaveBeenCalled();
|
|
||||||
expect(controller.getSnapshot().messages).toHaveLength(1);
|
|
||||||
});
|
|
||||||
it("discards external reasoning that finishes after the session was stopped", async () => {
|
|
||||||
const client = await start();
|
|
||||||
client.emit("transcript", { id: "u1", role: "user", text: "Consulta", partial: false });
|
|
||||||
await controller.stop();
|
|
||||||
pendingReasoning?.(new Response(JSON.stringify({ answer: "Respuesta tardía" })));
|
|
||||||
await vi.advanceTimersByTimeAsync(1);
|
|
||||||
expect(client.renderExternalAnswer).not.toHaveBeenCalled();
|
|
||||||
expect(controller.getSnapshot().messages.map((message) => message.text)).toEqual(["Consulta"]);
|
|
||||||
});
|
|
||||||
it("applies and removes RAG context in the live session", async () => {
|
|
||||||
const client = await start();
|
|
||||||
controller.setRag("Datos importantes");
|
|
||||||
expect(client.updateSession).toHaveBeenLastCalledWith(expect.stringContaining("Datos importantes"), "Aiden", []);
|
|
||||||
controller.setRag("");
|
|
||||||
expect(client.updateSession).toHaveBeenLastCalledWith(expect.not.stringContaining("CUSTOM_RAG_BEGIN"), "Aiden", []);
|
|
||||||
});
|
|
||||||
it("closes after silence but does not cut off the assistant while speaking", async () => {
|
|
||||||
const client = await start();
|
|
||||||
client.emit("status", "ai-speaking");
|
|
||||||
await vi.advanceTimersByTimeAsync(60000);
|
|
||||||
expect(client.close).not.toHaveBeenCalled();
|
|
||||||
client.emit("status", "listening");
|
|
||||||
await vi.advanceTimersByTimeAsync(30000);
|
|
||||||
expect(client.close).toHaveBeenCalledOnce();
|
|
||||||
expect(controller.getSnapshot().status).toBe("closed");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
@layer theme, base, components, utilities;
|
|
||||||
@import "tailwindcss/theme.css" layer(theme);
|
|
||||||
@import "./styles/legacy-theme.css" layer(components);
|
|
||||||
@import "tailwindcss/utilities.css" layer(utilities);
|
|
||||||
|
|
||||||
@layer components {
|
|
||||||
.chat-empty,
|
|
||||||
.chat-empty-hint {
|
|
||||||
opacity: 1;
|
|
||||||
color: var(--text-dim);
|
|
||||||
}
|
|
||||||
html[data-theme="white"] .chat-empty,
|
|
||||||
html[data-theme="white"] .chat-empty-hint {
|
|
||||||
color: #64748b;
|
|
||||||
}
|
|
||||||
.feedback-navigation {
|
|
||||||
align-self: flex-end;
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
#root {
|
|
||||||
display: contents;
|
|
||||||
}
|
|
||||||
.state-idle .ind-mic {
|
|
||||||
opacity: 1;
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
html[data-theme="white"] .topbar-right[data-debug-enabled="true"] {
|
|
||||||
display: flex;
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
grid-row: 2;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: center;
|
|
||||||
margin-top: 12px;
|
|
||||||
}
|
|
||||||
html[data-theme="white"] .white-mic-circle svg.wmc-mic {
|
|
||||||
fill: none;
|
|
||||||
}
|
|
||||||
#app:not(.has-reports) {
|
|
||||||
padding-left: 0;
|
|
||||||
}
|
|
||||||
.loading-screen {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
background: white;
|
|
||||||
color: #334155;
|
|
||||||
font: 500 16px system-ui;
|
|
||||||
z-index: 1000;
|
|
||||||
}
|
|
||||||
.app-error {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
margin: 12px auto;
|
|
||||||
padding: 10px 16px;
|
|
||||||
max-width: 680px;
|
|
||||||
border: 1px solid var(--error);
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 13px;
|
|
||||||
background: var(--bg-elev);
|
|
||||||
}
|
|
||||||
.talk-unlocker {
|
|
||||||
border: 0;
|
|
||||||
width: 100%;
|
|
||||||
color: inherit;
|
|
||||||
font: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.talk-unlocker-hint > span {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
.agent-tool-item {
|
|
||||||
padding: 12px 0;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
.queue-actions {
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
.queue-actions .circle-subcaption {
|
|
||||||
flex-basis: 100%;
|
|
||||||
}
|
|
||||||
.reports-sidebar-backdrop {
|
|
||||||
border: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
visibility: hidden;
|
|
||||||
}
|
|
||||||
.reports-sidebar-inner {
|
|
||||||
visibility: hidden;
|
|
||||||
}
|
|
||||||
.reports-sidebar {
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
.reports-sidebar.open .reports-sidebar-inner,
|
|
||||||
.reports-sidebar.open .reports-sidebar-backdrop {
|
|
||||||
visibility: visible;
|
|
||||||
}
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
#reports-sidebar-close {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.reports-sidebar-inner {
|
|
||||||
visibility: visible;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.bubble-body {
|
|
||||||
max-height: 240px;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
.white-session-controls svg {
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
}
|
|
||||||
.mic-gate-arc:focus-visible {
|
|
||||||
outline: 2px solid var(--accent-2);
|
|
||||||
outline-offset: 4px;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
.user-profile-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 90px 1fr;
|
|
||||||
gap: 16px;
|
|
||||||
padding: 12px 0;
|
|
||||||
}
|
|
||||||
.user-profile-value {
|
|
||||||
margin: 0;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
.user-profile-label {
|
|
||||||
color: var(--text-dim);
|
|
||||||
}
|
|
||||||
.hist-tool-block {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,39 +0,0 @@
|
|||||||
# Environment configuration for s2s-reasoning-engine
|
|
||||||
#
|
|
||||||
# Copy this file to .env in the project root or s2s-reasoning-engine directory
|
|
||||||
# and adjust the values for your deployment.
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────
|
|
||||||
# 1) LLM (OpenAI-compatible)
|
|
||||||
# ─────────────────────────────────────────────────────────────
|
|
||||||
OPENAI_API_KEY=sk-....................................
|
|
||||||
OPENAI_BASE_URL=https://api.openai.com/v1
|
|
||||||
OPENAI_REASONING_MODEL=gpt-4.1-mini
|
|
||||||
OPENAI_ORGANIZATION=
|
|
||||||
OPENAI_PROJECT=
|
|
||||||
OPENAI_REASONING_TEMPERATURE=0.2
|
|
||||||
OPENAI_REASONING_MAX_TOKENS=512
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────
|
|
||||||
# 2) Aquachile (agent profile, MCP tools, authentication)
|
|
||||||
# ─────────────────────────────────────────────────────────────
|
|
||||||
S2S_AGENT_PROFILE=https://aquachile.azurianlab.com/api/v1/agents/profile
|
|
||||||
S2S_USER_PHONE_AUTH_URL=https://aquachile.azurianlab.com/api/v1/auth/user-by-phone
|
|
||||||
S2S_TOOL_EXECUTION_MCP_URL=https://aquachile.azurianlab.com/api/v1/mcp
|
|
||||||
S2S_TOOL_EXECUTION_MCP_API_KEY=....................................
|
|
||||||
S2S_TOOL_EXECUTION_MCP_API_SECRET=....................................
|
|
||||||
S2S_SEARCH_MCP=true
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────
|
|
||||||
# 3) Node-RED runtime
|
|
||||||
# ─────────────────────────────────────────────────────────────
|
|
||||||
NODE_RED_PORT=1881
|
|
||||||
NODE_RED_LOG_LEVEL=info
|
|
||||||
NODE_RED_CREDENTIAL_SECRET=change-me-in-production
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────
|
|
||||||
# 4) S2S UI integration (server.py)
|
|
||||||
# ─────────────────────────────────────────────────────────────
|
|
||||||
# URL reachable from the Python UI container
|
|
||||||
# (for a shared Docker network, use http://s2s-reasoning:1880).
|
|
||||||
S2S_REASONING_NODE_RED_URL=http://localhost:1881
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
# s2s-reasoning-engine: Node-RED 5.0 custom image
|
|
||||||
|
|
||||||
# Use the official Node-RED 5.x Alpine image to keep the image small.
|
|
||||||
FROM nodered/node-red:5.0.3
|
|
||||||
|
|
||||||
# Default Node-RED port, exposed to the container network.
|
|
||||||
EXPOSE 1880
|
|
||||||
|
|
||||||
# 1) Install additional nodes in the Node-RED user data directory.
|
|
||||||
# - node-red-contrib-openai: native Chat Completions / Embeddings integration
|
|
||||||
# - node-red-contrib-cache: optional in-memory cache for Aquachile data
|
|
||||||
# - node-red-node-rbe: report-by-exception event deduplication
|
|
||||||
RUN cd /data && npm install --no-audit --no-fund \
|
|
||||||
node-red-contrib-openai@0.11.0 \
|
|
||||||
node-red-contrib-cache@1.1.1 \
|
|
||||||
node-red-node-rbe@0.5.0
|
|
||||||
|
|
||||||
# 2) Copy the configuration and initial flows.
|
|
||||||
COPY settings.js /data/settings.js
|
|
||||||
COPY flows.json /data/flows.json
|
|
||||||
|
|
||||||
# 3) Load static flows with projects and authentication disabled for development.
|
|
||||||
# Enable adminAuth in settings.js for production.
|
|
||||||
ENV NODE_RED_FLOWS_FILE=/data/flows.json
|
|
||||||
ENV NODE_RED_CREDENTIAL_SECRET=s2s-reasoning-dev-secret
|
|
||||||
ENV FLOWS=/data/flows.json
|
|
||||||
|
|
||||||
# Start Node-RED.
|
|
||||||
CMD ["npm", "start", "--", "--userDir", "/data", "--settings", "/data/settings.js"]
|
|
||||||
@@ -1,254 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"id": "s2s-reasoning-tab",
|
|
||||||
"type": "tab",
|
|
||||||
"label": "S2S Reasoning",
|
|
||||||
"disabled": false,
|
|
||||||
"info": "# S2S reasoning flow — phase 1: log only\n\nThis flow does not generate answers or change the prompt. It:\n 1. Receives POST /api/reasoning from the client server.\n 2. Extracts the transcript, language, and phoneNumber.\n 3. Logs the user request to the console and debug sidebar.\n 4. Returns HTTP 200 with the standard contract and `log_only: true`.\n\nResponse contract (phase 1):\n { answer: \"\", log_only: true, intent: \"log_only\", usage: {prompt_tokens:0,completion_tokens:0,total_tokens:0}, latency_ms: <ms> }\n",
|
|
||||||
"wires": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "http-in-reasoning",
|
|
||||||
"type": "http in",
|
|
||||||
"z": "s2s-reasoning-tab",
|
|
||||||
"name": "POST /api/reasoning",
|
|
||||||
"url": "/api/reasoning",
|
|
||||||
"method": "post",
|
|
||||||
"upload": false,
|
|
||||||
"swaggerDoc": "",
|
|
||||||
"x": 160,
|
|
||||||
"y": 160,
|
|
||||||
"wires": [
|
|
||||||
[
|
|
||||||
"parse-request"
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "parse-request",
|
|
||||||
"type": "function",
|
|
||||||
"z": "s2s-reasoning-tab",
|
|
||||||
"name": "Parse request (log only)",
|
|
||||||
"func": "// Phase 1: log only; no answer is generated.\n// Validate and normalize the payload received from server.py.\n// msg.req.body = { transcript, instructions, language, user_context, phoneNumber? }\n\nconst body = msg.req?.body || {};\nconst startedAt = Date.now();\n\nconst transcript = String(body.transcript || \"\").trim();\nconst instructions = String(body.instructions || \"\").trim();\nconst language = String(body.language || \"\").trim();\nconst userContext = String(body.user_context || body.user_context || \"\").trim();\nconst phoneNumber = String(body.phoneNumber || body.phone_number || msg.req?.query?.phoneNumber || \"\").trim();\n\nif (!transcript) {\n msg.statusCode = 400;\n msg.payload = { error: \"missing_transcript\", detail: \"transcript field is required\" };\n return [null, msg]; // Output 2 is the error path.\n}\n\nconst detectedLang = language || (transcript.match(/[áéíóúñ¿¡]/i) ? \"es-CL\" : \"en-US\");\n\nmsg.state = {\n startedAt,\n transcript,\n instructionsLen: instructions.length,\n language: detectedLang,\n userContextLen: userContext.length,\n phoneNumber: phoneNumber || null,\n intent: \"log_only\",\n};\n\nmsg.payload = {\n transcript,\n language: detectedLang,\n phone: phoneNumber || \"none\",\n};\n\nreturn [msg, null];",
|
|
||||||
"outputs": 2,
|
|
||||||
"timeout": 0,
|
|
||||||
"noerr": 0,
|
|
||||||
"initialize": "",
|
|
||||||
"finalize": "",
|
|
||||||
"libs": [],
|
|
||||||
"x": 400,
|
|
||||||
"y": 160,
|
|
||||||
"wires": [
|
|
||||||
[
|
|
||||||
"log-transcript-console",
|
|
||||||
"debug-parsed"
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"http-response-error"
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "log-transcript-console",
|
|
||||||
"type": "function",
|
|
||||||
"z": "s2s-reasoning-tab",
|
|
||||||
"name": "[LOG] user request",
|
|
||||||
"func": "// Phase 1: log the user request to the Node-RED console and debug sidebar.\n// The native S2S backend generates the response; this node does not.\n\nconst state = msg.state || {};\nconst transcript = state.transcript || \"\";\nconst lang = state.language || \"\";\nconst phone = state.phoneNumber || \"none\";\nconst ts = new Date(state.startedAt || Date.now()).toISOString();\n\nconst line = `[S2S NODE_RED LOG_ONLY] ts=${ts} lang=${lang} phone=${phone} transcript=\"${transcript.replace(/\"/g, '\\\\\"')}\"`;\n\nnode.warn(line);\n\nmsg.logLine = line;\nreturn msg;",
|
|
||||||
"outputs": 1,
|
|
||||||
"timeout": 0,
|
|
||||||
"noerr": 0,
|
|
||||||
"initialize": "",
|
|
||||||
"finalize": "",
|
|
||||||
"libs": [],
|
|
||||||
"x": 660,
|
|
||||||
"y": 120,
|
|
||||||
"wires": [
|
|
||||||
[
|
|
||||||
"build-response"
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "debug-parsed",
|
|
||||||
"type": "debug",
|
|
||||||
"z": "s2s-reasoning-tab",
|
|
||||||
"name": "[Debug] parsed state",
|
|
||||||
"active": true,
|
|
||||||
"tosidebar": true,
|
|
||||||
"console": true,
|
|
||||||
"tostatus": false,
|
|
||||||
"complete": "state",
|
|
||||||
"targetType": "msg",
|
|
||||||
"status": "",
|
|
||||||
"x": 650,
|
|
||||||
"y": 200,
|
|
||||||
"wires": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "build-response",
|
|
||||||
"type": "function",
|
|
||||||
"z": "s2s-reasoning-tab",
|
|
||||||
"name": "Build 200 (log_only)",
|
|
||||||
"func": "// Phase 1: build the HTTP 200 response with an empty answer and log_only=true.\n// The client leaves the native S2S response untouched: it does not call\n// cancelActiveResponse(), appendAssistantText(), or speechSynthesis.\n\nconst state = msg.state || {};\nconst latencyMs = Math.max(0, Date.now() - (state.startedAt || Date.now()));\n\nmsg.statusCode = 200;\nmsg.headers = {\n \"Content-Type\": \"application/json; charset=utf-8\",\n \"Cache-Control\": \"no-store\",\n};\nmsg.payload = {\n answer: \"\",\n log_only: true,\n intent: \"log_only\",\n usage: {\n prompt_tokens: 0,\n completion_tokens: 0,\n total_tokens: 0,\n },\n latency_ms: latencyMs,\n mcp_tools_used: [],\n caller_profile_keys: [],\n debug: {\n phase: \"log_only_phase1\",\n language: state.language || \"\",\n phone: state.phoneNumber || null,\n transcript_len: (state.transcript || \"\").length,\n },\n};\n\nreturn msg;",
|
|
||||||
"outputs": 1,
|
|
||||||
"timeout": 0,
|
|
||||||
"noerr": 0,
|
|
||||||
"initialize": "",
|
|
||||||
"finalize": "",
|
|
||||||
"libs": [],
|
|
||||||
"x": 920,
|
|
||||||
"y": 160,
|
|
||||||
"wires": [
|
|
||||||
[
|
|
||||||
"http-response-ok",
|
|
||||||
"debug-response"
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "http-response-ok",
|
|
||||||
"type": "http response",
|
|
||||||
"z": "s2s-reasoning-tab",
|
|
||||||
"name": "HTTP 200 OK",
|
|
||||||
"statusCode": "",
|
|
||||||
"headers": {},
|
|
||||||
"x": 1160,
|
|
||||||
"y": 120,
|
|
||||||
"wires": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "debug-response",
|
|
||||||
"type": "debug",
|
|
||||||
"z": "s2s-reasoning-tab",
|
|
||||||
"name": "[Debug] response payload",
|
|
||||||
"active": true,
|
|
||||||
"tosidebar": true,
|
|
||||||
"console": false,
|
|
||||||
"tostatus": false,
|
|
||||||
"complete": "payload",
|
|
||||||
"targetType": "msg",
|
|
||||||
"status": "",
|
|
||||||
"x": 1180,
|
|
||||||
"y": 200,
|
|
||||||
"wires": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "http-response-error",
|
|
||||||
"type": "http response",
|
|
||||||
"z": "s2s-reasoning-tab",
|
|
||||||
"name": "HTTP Error",
|
|
||||||
"statusCode": "",
|
|
||||||
"headers": {},
|
|
||||||
"x": 400,
|
|
||||||
"y": 240,
|
|
||||||
"wires": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "http-in-log-response",
|
|
||||||
"type": "http in",
|
|
||||||
"z": "s2s-reasoning-tab",
|
|
||||||
"name": "POST /api/log/system-response",
|
|
||||||
"url": "/api/log/system-response",
|
|
||||||
"method": "post",
|
|
||||||
"upload": false,
|
|
||||||
"swaggerDoc": "",
|
|
||||||
"x": 160,
|
|
||||||
"y": 360,
|
|
||||||
"wires": [
|
|
||||||
[
|
|
||||||
"parse-log-response"
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "parse-log-response",
|
|
||||||
"type": "function",
|
|
||||||
"z": "s2s-reasoning-tab",
|
|
||||||
"name": "Parse system-response",
|
|
||||||
"func": "// Receive the native S2S backend's TTS response from server.py.\n// msg.req.body = { tts, response_id?, item_id?, client_ip?, role?, source_event?, user_transcript?, phone? }\n// Phase 1: log only; no other side effects.\n\nconst body = msg.req?.body || {};\nconst startedAt = Date.now();\n\nconst tts = String(body.tts || body.text || body.response || \"\").trim();\nconst responseId = String(body.response_id || body.responseId || \"\").trim() || null;\nconst itemId = String(body.item_id || body.itemId || \"\").trim() || null;\nconst clientIp = String(body.client_ip || body.clientIp || body.ip || \"\").trim() || null;\nconst role = String(body.role || \"assistant\").trim() || \"assistant\";\nconst sourceEvent = String(body.source_event || body.sourceEvent || \"\").trim() || null;\nconst userTranscript = String(body.user_transcript || body.userTranscript || \"\").trim() || null;\nconst phone = String(body.phone || body.phoneNumber || \"\").trim() || null;\n\nif (!tts) {\n msg.statusCode = 400;\n msg.headers = { \"Content-Type\": \"application/json\" };\n msg.payload = { error: \"missing_tts\", detail: \"field 'tts' (or 'text'/'response') is required\" };\n return [null, msg];\n}\n\nmsg.state = {\n startedAt,\n kind: \"system_response_log\",\n role,\n tts,\n responseId,\n itemId,\n clientIp,\n sourceEvent,\n userTranscript,\n phone,\n};\n\nmsg.payload = { tts, role, source: sourceEvent, client: clientIp };\nreturn [msg, null];",
|
|
||||||
"outputs": 2,
|
|
||||||
"timeout": 0,
|
|
||||||
"noerr": 0,
|
|
||||||
"initialize": "",
|
|
||||||
"finalize": "",
|
|
||||||
"libs": [],
|
|
||||||
"x": 420,
|
|
||||||
"y": 360,
|
|
||||||
"wires": [
|
|
||||||
[
|
|
||||||
"log-system-response",
|
|
||||||
"debug-system-response"
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"http-response-error"
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "log-system-response",
|
|
||||||
"type": "function",
|
|
||||||
"z": "s2s-reasoning-tab",
|
|
||||||
"name": "[LOG] system response",
|
|
||||||
"func": "// Log the native backend's TTS response to Node-RED.\n// Use one searchable line per response.\nconst state = msg.state || {};\nconst tts = state.tts || \"\";\nconst role = state.role || \"assistant\";\nconst src = state.sourceEvent || \"\";\nconst user = state.userTranscript || \"\";\nconst phone = state.phone || \"none\";\nconst ts = new Date(state.startedAt || Date.now()).toISOString();\n\nconst userShort = user.length > 120 ? user.slice(0, 117) + \"...\" : user;\nconst ttsShort = tts.length > 240 ? tts.slice(0, 237) + \"...\" : tts;\nconst esc = (s) => String(s || \"\").replace(/\\\\/g, \"\\\\\\\\\").replace(/\"/g, '\\\\\"');\n\nconst line = `[S2S NODE_RED SYSTEM_RESPONSE] ts=${ts} role=${role} phone=${esc(phone)} user=\"${esc(userShort)}\" tts=\"${esc(ttsShort)}\"`;\n\nnode.warn(line);\nmsg.logLine = line;\nreturn msg;",
|
|
||||||
"outputs": 1,
|
|
||||||
"timeout": 0,
|
|
||||||
"noerr": 0,
|
|
||||||
"initialize": "",
|
|
||||||
"finalize": "",
|
|
||||||
"libs": [],
|
|
||||||
"x": 690,
|
|
||||||
"y": 320,
|
|
||||||
"wires": [
|
|
||||||
[
|
|
||||||
"build-log-ack"
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "debug-system-response",
|
|
||||||
"type": "debug",
|
|
||||||
"z": "s2s-reasoning-tab",
|
|
||||||
"name": "[Debug] system-response state",
|
|
||||||
"active": true,
|
|
||||||
"tosidebar": true,
|
|
||||||
"console": true,
|
|
||||||
"tostatus": false,
|
|
||||||
"complete": "state",
|
|
||||||
"targetType": "msg",
|
|
||||||
"status": "",
|
|
||||||
"x": 710,
|
|
||||||
"y": 400,
|
|
||||||
"wires": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "build-log-ack",
|
|
||||||
"type": "function",
|
|
||||||
"z": "s2s-reasoning-tab",
|
|
||||||
"name": "Build 200 (ack)",
|
|
||||||
"func": "// Return a minimal HTTP 200 acknowledgment with received=true.\n// No response data is returned.\nconst state = msg.state || {};\nconst latencyMs = Math.max(0, Date.now() - (state.startedAt || Date.now()));\nmsg.statusCode = 200;\nmsg.headers = {\n \"Content-Type\": \"application/json; charset=utf-8\",\n \"Cache-Control\": \"no-store\",\n};\nmsg.payload = {\n received: true,\n log_only: true,\n intent: \"system_response_log\",\n latency_ms: latencyMs,\n};\nreturn msg;",
|
|
||||||
"outputs": 1,
|
|
||||||
"timeout": 0,
|
|
||||||
"noerr": 0,
|
|
||||||
"initialize": "",
|
|
||||||
"finalize": "",
|
|
||||||
"libs": [],
|
|
||||||
"x": 950,
|
|
||||||
"y": 360,
|
|
||||||
"wires": [
|
|
||||||
[
|
|
||||||
"http-response-log-ack"
|
|
||||||
]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "http-response-log-ack",
|
|
||||||
"type": "http response",
|
|
||||||
"z": "s2s-reasoning-tab",
|
|
||||||
"name": "HTTP 200 ACK",
|
|
||||||
"statusCode": "",
|
|
||||||
"headers": {},
|
|
||||||
"x": 1180,
|
|
||||||
"y": 360,
|
|
||||||
"wires": []
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "s2s-reasoning-engine",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "Node-RED 5.0 reasoning engine for S2S/Aquachile",
|
|
||||||
"main": "settings.js",
|
|
||||||
"scripts": {
|
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
|
||||||
},
|
|
||||||
"keywords": [],
|
|
||||||
"author": "",
|
|
||||||
"license": "ISC",
|
|
||||||
"type": "commonjs",
|
|
||||||
"private": "true",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"dotenv": "^16.4.7",
|
|
||||||
"node-red": "^5.0.3",
|
|
||||||
"node-red-contrib-cache": "^2.0.3",
|
|
||||||
"node-red-contrib-openai": "^1.0.3",
|
|
||||||
"node-red-node-rbe": "^0.5.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
/**
|
|
||||||
* Node-RED 5.0 settings for s2s-reasoning-engine — local installation.
|
|
||||||
* File: <project_root>/s2s-reasoning-engine/.node-red/settings.js
|
|
||||||
* or <project_root>/s2s-reasoning-engine/settings.js (source)
|
|
||||||
*
|
|
||||||
* start.sh copies this into .node-red/ when missing or older than the source.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const path = require("path");
|
|
||||||
const fs = require("fs");
|
|
||||||
|
|
||||||
// start.sh uses <s2s-reasoning-engine>/.node-red as its local user directory.
|
|
||||||
const ENGINE_ROOT = path.resolve(__dirname, "..");
|
|
||||||
const LOCAL_NODE_MODULES = path.join(ENGINE_ROOT, "node_modules");
|
|
||||||
const USERDIR_NODE_MODULES = path.join(__dirname, "node_modules");
|
|
||||||
|
|
||||||
// Fix the editor's "tslib.es6.js 404 / net::ERR_ABORTED" error.
|
|
||||||
// Flat npm installations place tslib in the root node_modules/tslib directory
|
|
||||||
// rather than @node-red/editor-client/node_modules/tslib. The Node-RED 5 editor
|
|
||||||
// loads /tslib.es6.js from the HTTP root. Refresh the editor's public files at
|
|
||||||
// startup so the workaround survives dependency reinstallation.
|
|
||||||
(function _installTslibVendor() {
|
|
||||||
try {
|
|
||||||
const tslibRoot = path.join(LOCAL_NODE_MODULES, "tslib");
|
|
||||||
if (!fs.existsSync(path.join(tslibRoot, "tslib.es6.js"))) return;
|
|
||||||
const pub = path.join(LOCAL_NODE_MODULES, "@node-red", "editor-client", "public");
|
|
||||||
if (!fs.existsSync(pub)) return;
|
|
||||||
for (const f of ["tslib.es6.js", "tslib.js", "tslib.mjs"]) {
|
|
||||||
const src = path.join(tslibRoot, f);
|
|
||||||
const dst = path.join(pub, f);
|
|
||||||
if (!fs.existsSync(src)) continue;
|
|
||||||
try {
|
|
||||||
const s = fs.statSync(src);
|
|
||||||
const d = fs.existsSync(dst) ? fs.statSync(dst) : null;
|
|
||||||
if (!d || d.size !== s.size || d.mtimeMs < s.mtimeMs) {
|
|
||||||
fs.copyFileSync(src, dst);
|
|
||||||
}
|
|
||||||
} catch (e) { /* ignore */ }
|
|
||||||
}
|
|
||||||
} catch (e) { /* no-op */ }
|
|
||||||
})();
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
uiPort: process.env.PORT || process.env.NODE_RED_PORT || 1881,
|
|
||||||
uiHost: "0.0.0.0",
|
|
||||||
|
|
||||||
httpRequestTimeout: 120000,
|
|
||||||
httpRequestLegacyMsgOverrides: true,
|
|
||||||
|
|
||||||
// Native HTTP helpers for function nodes, without externalModules.
|
|
||||||
// functionExternalModules can stall palette loading in Node-RED 5.
|
|
||||||
// Load core Node.js modules here before startup and expose them through
|
|
||||||
// functionGlobalContext, accessed with global.get("_core_https") etc.
|
|
||||||
functionModuleRequireAllowList: ["https","http","url","path","util","fs","os","buffer","zlib","stream","crypto"],
|
|
||||||
functionGlobalContext: {
|
|
||||||
_core_https: require("https"),
|
|
||||||
_core_http: require("http"),
|
|
||||||
_core_url: require("url"),
|
|
||||||
},
|
|
||||||
|
|
||||||
// adminAuth: {...} // Uncomment and configure for production.
|
|
||||||
|
|
||||||
// HTTPS (optional for production).
|
|
||||||
// https: {
|
|
||||||
// key: require("fs").readFileSync(path.join(__dirname, "privkey.pem")),
|
|
||||||
// cert: require("fs").readFileSync(path.join(__dirname, "fullchain.pem"))
|
|
||||||
// },
|
|
||||||
|
|
||||||
flowFile: process.env.FLOWS || path.join(__dirname, "flows.json"),
|
|
||||||
flowFilePretty: true,
|
|
||||||
credentialSecret: process.env.NODE_RED_CREDENTIAL_SECRET || "s2s-reasoning-local-dev",
|
|
||||||
|
|
||||||
httpNodeCors: {
|
|
||||||
origin: "*",
|
|
||||||
methods: "GET,PUT,POST,DELETE,OPTIONS",
|
|
||||||
credentials: true,
|
|
||||||
},
|
|
||||||
csrf: false,
|
|
||||||
|
|
||||||
// Additional node loading: Node-RED 5 looks for plugins in:
|
|
||||||
// 1) {userDir}/node_modules (our .node-red/node_modules)
|
|
||||||
// 2) {nodesDir} (the project-root installation)
|
|
||||||
// 3) npm global
|
|
||||||
// Set nodesDir so packages installed at the project root are discovered.
|
|
||||||
nodesDir: LOCAL_NODE_MODULES,
|
|
||||||
|
|
||||||
externalModules: {
|
|
||||||
autoInstall: true, // Install missing nodes at startup.
|
|
||||||
autoInstallRetry: 3,
|
|
||||||
palette: {
|
|
||||||
allowInstall: true, // Allow installation from the editor.
|
|
||||||
allowUpload: true,
|
|
||||||
modules: ["node-red-contrib-openai", "node-red-contrib-cache", "node-red-node-rbe"],
|
|
||||||
allowList: ["node-red-contrib-*", "node-red-node-*"],
|
|
||||||
denyList: []
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
editorTheme: { projects: { enabled: false } },
|
|
||||||
|
|
||||||
logging: {
|
|
||||||
console: {
|
|
||||||
level: process.env.NODE_RED_LOG_LEVEL || process.env.LOG_LEVEL || "info",
|
|
||||||
metrics: false,
|
|
||||||
audit: false,
|
|
||||||
timestamp: true,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
contextStorage: {
|
|
||||||
default: { module: "memory" },
|
|
||||||
file: { module: "localfilesystem" }
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# =============================================================
|
|
||||||
# start.sh — Run Node-RED 5.0 locally for s2s-reasoning-engine
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# ./start.sh # default port 1881
|
|
||||||
# PORT=1900 ./start.sh # custom port
|
|
||||||
# LOG_LEVEL=debug ./start.sh # detailed logging
|
|
||||||
# DEBUG=nodes:* ./start.sh # internal Node-RED diagnostics
|
|
||||||
# Stop: Ctrl+C
|
|
||||||
# =============================================================
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
PARENT_DIR="$(dirname "$ROOT_DIR")"
|
|
||||||
|
|
||||||
cd "$ROOT_DIR"
|
|
||||||
|
|
||||||
# ----------------------------------------------------------
|
|
||||||
# 1) Load the local .env first, then the parent ../.env when present.
|
|
||||||
# The parent file takes precedence when both define the same variable.
|
|
||||||
# ----------------------------------------------------------
|
|
||||||
set -a
|
|
||||||
if [ -f "$ROOT_DIR/.env" ]; then
|
|
||||||
echo "[env] loaded $ROOT_DIR/.env"
|
|
||||||
# shellcheck disable=SC1091
|
|
||||||
source "$ROOT_DIR/.env"
|
|
||||||
fi
|
|
||||||
if [ -f "$PARENT_DIR/.env" ]; then
|
|
||||||
echo "[env] loaded $PARENT_DIR/.env"
|
|
||||||
# shellcheck disable=SC1091
|
|
||||||
source "$PARENT_DIR/.env"
|
|
||||||
fi
|
|
||||||
set +a
|
|
||||||
|
|
||||||
# ----------------------------------------------------------
|
|
||||||
# 2) Node-RED runtime parameters
|
|
||||||
# ----------------------------------------------------------
|
|
||||||
PORT="${PORT:-${NODE_RED_PORT:-1881}}"
|
|
||||||
export PORT
|
|
||||||
export NODE_RED_PORT="$PORT"
|
|
||||||
export NODE_RED_LOG_LEVEL="${LOG_LEVEL:-${NODE_RED_LOG_LEVEL:-info}}"
|
|
||||||
export NODE_RED_CREDENTIAL_SECRET="${NODE_RED_CREDENTIAL_SECRET:-s2s-reasoning-local-dev}"
|
|
||||||
export FLOWS="${FLOWS:-$ROOT_DIR/.node-red/flows.json}"
|
|
||||||
|
|
||||||
# Local user directory; keep state here rather than in ~/.node-red.
|
|
||||||
USERDIR="${USERDIR:-$ROOT_DIR/.node-red}"
|
|
||||||
SETTINGS="${SETTINGS:-$USERDIR/settings.js}"
|
|
||||||
|
|
||||||
# Create the user directory and refresh its configuration from the source files.
|
|
||||||
mkdir -p "$USERDIR"
|
|
||||||
if [ ! -f "$SETTINGS" ] || [ "$ROOT_DIR/settings.js" -nt "$SETTINGS" ]; then
|
|
||||||
cp -f "$ROOT_DIR/settings.js" "$SETTINGS"
|
|
||||||
echo "[init] copied settings.js -> $SETTINGS"
|
|
||||||
fi
|
|
||||||
if [ ! -f "$FLOWS" ] || [ "$ROOT_DIR/flows.json" -nt "$FLOWS" ]; then
|
|
||||||
cp -f "$ROOT_DIR/flows.json" "$FLOWS"
|
|
||||||
echo "[init] copied flows.json -> $FLOWS"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check that the local Node-RED dependencies are installed.
|
|
||||||
if [ ! -d "$ROOT_DIR/node_modules/node-red" ]; then
|
|
||||||
echo "[init] node_modules was not found. Running npm install..."
|
|
||||||
npm install --no-audit --no-fund
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "╔════════════════════════════════════════════════════════════╗"
|
|
||||||
echo "║ s2s-reasoning-engine — Local Node-RED v5 ║"
|
|
||||||
echo "╠════════════════════════════════════════════════════════════╣"
|
|
||||||
echo "║ Editor: http://localhost:${PORT}/ ║"
|
|
||||||
echo "║ API: http://localhost:${PORT}/api/reasoning ║"
|
|
||||||
echo "║ UserDir: ${USERDIR}"
|
|
||||||
echo "║ LogLevel: ${NODE_RED_LOG_LEVEL}"
|
|
||||||
echo "╚════════════════════════════════════════════════════════════╝"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# ----------------------------------------------------------
|
|
||||||
# 3) Start Node-RED
|
|
||||||
# ----------------------------------------------------------
|
|
||||||
exec "$ROOT_DIR/node_modules/.bin/node-red" \
|
|
||||||
--userDir "$USERDIR" \
|
|
||||||
--settings "$SETTINGS" \
|
|
||||||
--port "$PORT"
|
|
||||||
+13
-22
@@ -2,9 +2,9 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
VENV_DIR="$ROOT_DIR/.venv"
|
API_DIR="$ROOT_DIR/vocero-api"
|
||||||
REQ_FILE="$ROOT_DIR/s2s-client/requirements.txt"
|
VENV_DIR="$API_DIR/.venv"
|
||||||
FRONTEND_DIR="$ROOT_DIR/s2s-frontend"
|
FRONTEND_DIR="$ROOT_DIR/vocero-frontend"
|
||||||
if [ -f "$ROOT_DIR/.env" ]; then
|
if [ -f "$ROOT_DIR/.env" ]; then
|
||||||
set -a
|
set -a
|
||||||
source "$ROOT_DIR/.env"
|
source "$ROOT_DIR/.env"
|
||||||
@@ -20,7 +20,7 @@ if (( BASH_VERSINFO[0] < 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] < 4) ))
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
for required_command in python3 node npm setsid curl; do
|
for required_command in uv node npm setsid curl; do
|
||||||
if ! command -v "$required_command" >/dev/null 2>&1; then
|
if ! command -v "$required_command" >/dev/null 2>&1; then
|
||||||
echo "Error: $required_command was not found."
|
echo "Error: $required_command was not found."
|
||||||
exit 1
|
exit 1
|
||||||
@@ -28,7 +28,7 @@ for required_command in python3 node npm setsid curl; do
|
|||||||
done
|
done
|
||||||
|
|
||||||
if ! node -e 'const [major, minor] = process.versions.node.split(".").map(Number); process.exit((major === 22 && minor >= 18) || major >= 24 ? 0 : 1)'; then
|
if ! node -e 'const [major, minor] = process.versions.node.split(".").map(Number); process.exit((major === 22 && minor >= 18) || major >= 24 ? 0 : 1)'; then
|
||||||
echo "Error: s2s-frontend requires Node.js 22.18+ on the 22.x line, or Node.js 24+."
|
echo "Error: vocero-frontend requires Node.js 22.18+ on the 22.x line, or Node.js 24+."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -46,28 +46,19 @@ if [ "$UI_PORT" = "$S2S_BACKEND_PORT" ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ ! -d "$VENV_DIR" ]; then
|
if [ ! -f "$API_DIR/pyproject.toml" ] || [ ! -f "$API_DIR/uv.lock" ]; then
|
||||||
echo "Creating the virtual environment in .venv..."
|
echo "Error: vocero-api/pyproject.toml and vocero-api/uv.lock are required."
|
||||||
python3 -m venv "$VENV_DIR"
|
|
||||||
fi
|
|
||||||
|
|
||||||
source "$VENV_DIR/bin/activate"
|
|
||||||
|
|
||||||
if [ ! -f "$REQ_FILE" ]; then
|
|
||||||
echo "Error: requirements file was not found: $REQ_FILE"
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! python -c "import fastapi, uvicorn, httpx, dotenv, websockets" >/dev/null 2>&1; then
|
# uv manages only the API environment; an existing root speech venv is separate.
|
||||||
echo "Installing dependencies from s2s-client/requirements.txt..."
|
UV_PROJECT_ENVIRONMENT="$VENV_DIR" uv sync --project "$API_DIR" --locked
|
||||||
python -m pip install -r "$REQ_FILE"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Keep restarts offline when the installed frontend manifests have not changed.
|
# Keep restarts offline when the installed frontend manifests have not changed.
|
||||||
if [ ! -f "$FRONTEND_DIR/node_modules/vite/bin/vite.js" ] || \
|
if [ ! -f "$FRONTEND_DIR/node_modules/vite/bin/vite.js" ] || \
|
||||||
! cmp -s "$FRONTEND_DIR/package-lock.json" "$FRONTEND_DIR/node_modules/.s2s-package-lock.json" || \
|
! cmp -s "$FRONTEND_DIR/package-lock.json" "$FRONTEND_DIR/node_modules/.s2s-package-lock.json" || \
|
||||||
! cmp -s "$FRONTEND_DIR/package.json" "$FRONTEND_DIR/node_modules/.s2s-package.json"; then
|
! cmp -s "$FRONTEND_DIR/package.json" "$FRONTEND_DIR/node_modules/.s2s-package.json"; then
|
||||||
echo "Installing frontend dependencies from s2s-frontend/package-lock.json..."
|
echo "Installing frontend dependencies from vocero-frontend/package-lock.json..."
|
||||||
npm --prefix "$FRONTEND_DIR" ci --include=dev
|
npm --prefix "$FRONTEND_DIR" ci --include=dev
|
||||||
cp "$FRONTEND_DIR/package-lock.json" "$FRONTEND_DIR/node_modules/.s2s-package-lock.json"
|
cp "$FRONTEND_DIR/package-lock.json" "$FRONTEND_DIR/node_modules/.s2s-package-lock.json"
|
||||||
cp "$FRONTEND_DIR/package.json" "$FRONTEND_DIR/node_modules/.s2s-package.json"
|
cp "$FRONTEND_DIR/package.json" "$FRONTEND_DIR/node_modules/.s2s-package.json"
|
||||||
@@ -118,10 +109,10 @@ echo " Press Ctrl+C in this terminal to stop both servers."
|
|||||||
echo "----------------------------------------------------"
|
echo "----------------------------------------------------"
|
||||||
|
|
||||||
cd "$ROOT_DIR"
|
cd "$ROOT_DIR"
|
||||||
setsid "$VENV_DIR/bin/python" -m uvicorn server:app \
|
setsid "$VENV_DIR/bin/python" -m uvicorn vocero_api.main:app \
|
||||||
--app-dir "$ROOT_DIR/s2s-client" \
|
--app-dir "$API_DIR" \
|
||||||
--host 127.0.0.1 --port "$S2S_BACKEND_PORT" \
|
--host 127.0.0.1 --port "$S2S_BACKEND_PORT" \
|
||||||
--reload --reload-dir "$ROOT_DIR/s2s-client" &
|
--reload --reload-dir "$API_DIR" &
|
||||||
backend_pid=$!
|
backend_pid=$!
|
||||||
child_pids+=("$backend_pid")
|
child_pids+=("$backend_pid")
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
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 --project vocero-api --locked ruff check --force-exclude
|
||||||
|
language: unsupported
|
||||||
|
types_or: [python, pyi]
|
||||||
|
files: ^vocero-api/
|
||||||
|
require_serial: true
|
||||||
|
- id: ruff-format
|
||||||
|
name: Ruff formatting
|
||||||
|
entry: uv run --project vocero-api --locked ruff format --check --force-exclude
|
||||||
|
language: unsupported
|
||||||
|
types_or: [python, pyi]
|
||||||
|
files: ^vocero-api/
|
||||||
|
require_serial: true
|
||||||
|
- id: mypy
|
||||||
|
name: Python types
|
||||||
|
entry: uv run --directory vocero-api --locked mypy
|
||||||
|
language: unsupported
|
||||||
|
pass_filenames: false
|
||||||
|
files: ^vocero-api/(vocero_api/|server\.py|pyproject\.toml|uv\.lock)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
3.12
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# Changelog — s2s-client (Azurian Vocero)
|
# Changelog — vocero-api (Azurian Vocero)
|
||||||
|
|
||||||
All significant client changes are recorded here. This English history
|
All significant client changes are recorded here. This English history
|
||||||
supersedes the former Italian and Spanish versions. Historical entries describe
|
supersedes the former Italian and Spanish versions. Historical entries describe
|
||||||
@@ -7,10 +7,60 @@ the implementation at the recorded date; current operating instructions live in
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Consolidate responsive transcript layout, keep export controls visible on narrow screens,
|
||||||
|
and place feedback and version details below the conversation. Add jump-to-latest,
|
||||||
|
semantic message metadata, state-aware empty hints, shared sanitized Markdown rendering,
|
||||||
|
and reduced-motion support. Remove retired camera, account, and overlay styles.
|
||||||
|
|
||||||
|
- Use native dialogs for startup and mobile reports, restore focus on dismissal,
|
||||||
|
and make duplicate transcript bubbles inert. Require explicit wake-word opt-in,
|
||||||
|
show when it is listening, and turn it off when the conversation stops.
|
||||||
|
|
||||||
|
- Isolate microphone meter subscriptions from the application tree. Apply draft settings only
|
||||||
|
on Save, preserve explicitly live noise calibration, and persist/send only relevant changes.
|
||||||
|
|
||||||
|
- Keep the voice UI speaking until buffered playback finishes, flush short final audio chunks,
|
||||||
|
and exclude response processing from the user inactivity timeout.
|
||||||
|
|
||||||
|
- Make frontend configuration failures retryable, bound startup requests, and
|
||||||
|
load optional reports independently while waiting for caller context before
|
||||||
|
starting a session. Cancel superseded external reasoning and reject stale
|
||||||
|
answers after a new turn, interruption, or session teardown.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
- Use the AquaPortal admin favicon in the Vocero browser frontend.
|
||||||
|
|
||||||
|
- Start `TALK` and `TALKFIRST` conversations as soon as configuration and caller
|
||||||
|
context are ready. Remove the startup dialog and two-second delay; use an
|
||||||
|
inline hint and the existing microphone button when a browser requires a tap.
|
||||||
|
|
||||||
|
- Replace the frontend legacy stylesheet with Tailwind v4 semantic theme tokens,
|
||||||
|
shared form/dialog controls and component-owned layout utilities. Preserve both
|
||||||
|
configured themes, responsive layouts, sanitized Markdown and audio-driven
|
||||||
|
visuals in focused stylesheets, and remove unused historical selectors.
|
||||||
|
|
||||||
|
- Renamed the API and browser projects to `vocero-api/` and `vocero-frontend/`.
|
||||||
|
Python uses the `vocero-api` distribution and `vocero_api` import package;
|
||||||
|
npm uses `vocero-frontend`. Updated the launcher, Docker setup, tooling,
|
||||||
|
tests, and documentation while preserving HTTP/WebSocket contracts and
|
||||||
|
existing environment variables, browser preferences, and PM2 process names.
|
||||||
|
- Removed the optional workflow reasoning service, its configuration, transcript
|
||||||
|
forwarding, and browser integration. Reasoning supports `PROXY` and `OPENAI`;
|
||||||
|
unsupported mode values fall back to `PROXY`.
|
||||||
|
- Filter the specific Starlette TestClient / AnyIO `BlockingPortal` deprecation
|
||||||
|
in pytest while retaining the runtime versions used for compatibility captures.
|
||||||
|
- Added a uv-managed Python 3.12 environment and Ruff, mypy, and pytest quality
|
||||||
|
checks, all contained in `vocero-api/`. Startup and Docker use the committed
|
||||||
|
lockfile. Split the FastAPI service into configuration, route, provider,
|
||||||
|
context/reasoning, and realtime modules while retaining `server:app` startup.
|
||||||
|
- Added Pydantic response contracts with permissive upstream fields and original
|
||||||
|
payload serialization. Captured HTTP, request-schema, and realtime regression
|
||||||
|
fixtures preserve existing API behavior, including known failures.
|
||||||
- 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.
|
`vocero-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
|
||||||
UI. Load-balancer allocation, queue lifecycle, caller profiles, upstream
|
UI. Load-balancer allocation, queue lifecycle, caller profiles, upstream
|
||||||
authentication, and voice-session cleanup remain available.
|
authentication, and voice-session cleanup remain available.
|
||||||
@@ -24,8 +74,6 @@ the implementation at the recorded date; current operating instructions live in
|
|||||||
- Translated developer comments, diagnostics, internal model instructions, and
|
- Translated developer comments, diagnostics, internal model instructions, and
|
||||||
local identifiers into English. Sanitization accepts both the former Spanish
|
local identifiers into English. Sanitization accepts both the former Spanish
|
||||||
and new English internal tool-result wrappers.
|
and new English internal tool-result wrappers.
|
||||||
- Renamed the reasoning engine's log-only acknowledgment field to `received`
|
|
||||||
and its debug phase to `log_only_phase1`.
|
|
||||||
- Standardized browser labels, accessibility text, account/profile dialogs,
|
- Standardized browser labels, accessibility text, account/profile dialogs,
|
||||||
session feedback, and built-in API errors in Spanish. The interface declares
|
session feedback, and built-in API errors in Spanish. The interface declares
|
||||||
`lang="es"`; internal instructions and diagnostics remain English.
|
`lang="es"`; internal instructions and diagnostics remain English.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Build from the project root: docker build -f vocero-api/Dockerfile -t vocero-api .
|
||||||
|
# Serve backend APIs, the WebSocket proxy, and shared project documentation.
|
||||||
|
# Run the React/Vite frontend separately; this image contains no browser UI.
|
||||||
|
# HF Spaces (sdk: docker) routes traffic to $PORT, default 7860.
|
||||||
|
FROM python:3.12.14-slim
|
||||||
|
|
||||||
|
COPY --from=ghcr.io/astral-sh/uv:0.12.5 /uv /uvx /bin/
|
||||||
|
|
||||||
|
ENV UV_LINK_MODE=copy \
|
||||||
|
UV_PYTHON_DOWNLOADS=never \
|
||||||
|
PATH="/app/vocero-api/.venv/bin:$PATH"
|
||||||
|
|
||||||
|
WORKDIR /app/vocero-api
|
||||||
|
|
||||||
|
COPY vocero-api/pyproject.toml vocero-api/uv.lock ./
|
||||||
|
RUN uv sync --locked --no-dev --no-install-project
|
||||||
|
|
||||||
|
COPY vocero-api/README.md vocero-api/server.py ./
|
||||||
|
COPY vocero-api/vocero_api/ ./vocero_api/
|
||||||
|
COPY docs/ /app/docs/
|
||||||
|
RUN uv sync --locked --no-dev
|
||||||
|
|
||||||
|
EXPOSE 7860
|
||||||
|
|
||||||
|
CMD ["uvicorn", "vocero_api.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
||||||
@@ -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 --config vocero-api/.pre-commit-config.yaml
|
||||||
|
|
||||||
|
hooks-run:
|
||||||
|
$(UV_RUN) pre-commit run --all-files --config vocero-api/.pre-commit-config.yaml
|
||||||
|
|
||||||
|
test-launcher:
|
||||||
|
npm --prefix ../vocero-frontend run test:launcher
|
||||||
|
|
||||||
|
test-image:
|
||||||
|
bash scripts/test_docker_image.sh
|
||||||
@@ -9,15 +9,15 @@ pinned: false
|
|||||||
short_description: Voice chat over WebSocket against a HF speech-to-speech
|
short_description: Voice chat over WebSocket against a HF speech-to-speech
|
||||||
---
|
---
|
||||||
|
|
||||||
# Realtime voice backend
|
# Vocero API
|
||||||
|
|
||||||
FastAPI APIs and WebSocket relay for the [React voice frontend](../s2s-frontend).
|
FastAPI APIs and WebSocket relay for the [React voice frontend](../vocero-frontend).
|
||||||
This service handles configuration, caller context, upstream authentication,
|
This service handles configuration, caller context, upstream authentication,
|
||||||
search, reasoning, ApiPilot tools, and optional load-balancer allocation.
|
search, reasoning, ApiPilot tools, and optional load-balancer allocation.
|
||||||
|
|
||||||
The root `start_ui.sh` now serves the [React frontend](../s2s-frontend) with
|
The root `start_ui.sh` now serves the [React frontend](../vocero-frontend) with
|
||||||
Vite on public port `8420`, proxying backend requests here on loopback port
|
Vite on public port `8420`, proxying backend requests here on loopback port
|
||||||
`8421`. The browser UI lives entirely in `s2s-frontend`; running FastAPI directly
|
`8421`. The browser UI lives entirely in `vocero-frontend`; running FastAPI directly
|
||||||
or through Docker exposes only backend routes and shared documentation.
|
or through Docker exposes only backend routes and shared documentation.
|
||||||
|
|
||||||
The complete [usage guide](../docs/client/usage.md) covers local startup,
|
The complete [usage guide](../docs/client/usage.md) covers local startup,
|
||||||
@@ -28,20 +28,28 @@ and [change history](CHANGELOG.md) for implementation details and history.
|
|||||||
Build from the repository root so the container includes the documentation:
|
Build from the repository root so the container includes the documentation:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build -f s2s-client/Dockerfile -t s2s-client .
|
docker build -f vocero-api/Dockerfile -t vocero-api .
|
||||||
```
|
```
|
||||||
|
|
||||||
Run the backend regression tests from the repository root after installing
|
The Python project is contained in this directory: `pyproject.toml`, `uv.lock`,
|
||||||
`requirements.txt` in `.venv`:
|
`.python-version`, `.venv`, quality tools, and tests. Use uv 0.12.5 and Python
|
||||||
|
3.12 (uv can provision Python). From the repository root:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
.venv/bin/python -m unittest discover -s s2s-client/tests -v
|
make -C vocero-api install
|
||||||
|
make -C vocero-api check
|
||||||
```
|
```
|
||||||
|
|
||||||
Tests isolate environment configuration, use HTTP fixtures for allocation and
|
Tests isolate environment configuration, use HTTP fixtures for allocation and
|
||||||
caller authentication, and start a loopback WebSocket service to verify audio
|
caller authentication, and start a loopback WebSocket service to verify audio
|
||||||
frames, keepalive, and teardown. No deployed services or database are needed.
|
frames, keepalive, and teardown. No deployed services or database are needed.
|
||||||
|
|
||||||
|
See [backend development and compatibility](../docs/client/development.md) for
|
||||||
|
formatting, typing, schema policy, optional Git hooks, and deployment checks.
|
||||||
|
`uvicorn server:app --app-dir vocero-api` remains a supported entry point;
|
||||||
|
the implementation lives in the `vocero_api/` package. Its Python distribution
|
||||||
|
name is `vocero-api`.
|
||||||
|
|
||||||
FastAPI's API explorer remains at `/docs`. Project documents are served as files below
|
FastAPI's API explorer remains at `/docs`. Project documents are served as files below
|
||||||
`/docs/client/`, `/docs/speech/`, `/docs/adr/`, `/docs/examples/`, and
|
`/docs/client/`, `/docs/speech/`, `/docs/adr/`, `/docs/examples/`, and
|
||||||
`/docs/proposals/`. The historical `/project/` and `/public/docs.html` URLs
|
`/docs/proposals/`. The historical `/project/` and `/public/docs.html` URLs
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=68", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "vocero-api"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "FastAPI integration and realtime proxy for the Vocero frontend"
|
||||||
|
readme = "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 = ["vocero_api*"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
target-version = "py312"
|
||||||
|
line-length = 120
|
||||||
|
include = ["pyproject.toml", "vocero_api/**/*.py", "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 = ["vocero_api", "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 = ["."]
|
||||||
|
# Starlette 0.41.3 uses an alias deprecated by AnyIO 4.15. Keep the runtime
|
||||||
|
# versions used for compatibility captures; remove this test-only exception
|
||||||
|
# when upgrading Starlette. Upstream: https://github.com/Kludex/starlette/issues/3497
|
||||||
|
filterwarnings = [
|
||||||
|
'ignore:^The anyio\.abc\.BlockingPortal alias is deprecated, use anyio\.from_thread\.BlockingPortal instead\.$:DeprecationWarning:^starlette\.testclient$',
|
||||||
|
]
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build the actual runtime and check HTTP behavior without external services.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
IMAGE="vocero-api-smoke:$(date +%s)-$$"
|
||||||
|
container_id=""
|
||||||
|
cleanup() {
|
||||||
|
if [ -n "$container_id" ]; then
|
||||||
|
docker rm --force "$container_id" >/dev/null
|
||||||
|
fi
|
||||||
|
docker image rm "$IMAGE" >/dev/null 2>&1 || true
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
docker build --file "$ROOT_DIR/vocero-api/Dockerfile" --tag "$IMAGE" "$ROOT_DIR"
|
||||||
|
container_id="$(docker run --detach --publish 127.0.0.1::7860 "$IMAGE")"
|
||||||
|
port="$(docker port "$container_id" 7860/tcp)"
|
||||||
|
ready=false
|
||||||
|
for ((attempt = 0; attempt < 60; attempt++)); do
|
||||||
|
if curl --noproxy '*' --silent --fail --max-time 1 "http://$port/api/config" >/dev/null; then
|
||||||
|
ready=true
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 0.5
|
||||||
|
done
|
||||||
|
if [ "$ready" != true ]; then
|
||||||
|
docker logs "$container_id"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
docker exec -i "$container_id" python - <<'PY'
|
||||||
|
import json
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
base = "http://127.0.0.1:7860"
|
||||||
|
with urllib.request.urlopen(base + "/api/config") as response:
|
||||||
|
assert json.load(response) == {
|
||||||
|
"search": False, "lb": False, "allowDirect": True, "s2sUrl": "",
|
||||||
|
"primaryPrompt": "", "reasoningType": "PROXY", "injectProfile": False,
|
||||||
|
"debugPrompt": False, "startSessionMode": "", "theme": "", "apipilot": False,
|
||||||
|
}
|
||||||
|
for path in ["/docs", "/docs/README.md", "/openapi.json"]:
|
||||||
|
with urllib.request.urlopen(base + path) as response:
|
||||||
|
assert response.status == 200
|
||||||
|
try:
|
||||||
|
urllib.request.urlopen(base + "/")
|
||||||
|
except urllib.error.HTTPError as error:
|
||||||
|
assert error.code == 404
|
||||||
|
else:
|
||||||
|
raise AssertionError("The backend root must remain 404")
|
||||||
|
print("Docker runtime smoke checks passed")
|
||||||
|
PY
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
"""Compatibility entry point for existing ``uvicorn server:app`` deployments."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Also support --app-dir /path/to/vocero-api when launched outside the checkout.
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
|
||||||
|
from vocero_api.main import app as app # noqa: E402
|
||||||
+1342
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,573 @@
|
|||||||
|
"""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 patch
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {}, clear=True), patch("dotenv.load_dotenv", return_value=False):
|
||||||
|
from vocero_api import main as server
|
||||||
|
from vocero_api.core import config as settings
|
||||||
|
from vocero_api.providers import apipilot
|
||||||
|
from vocero_api.realtime import proxy
|
||||||
|
from vocero_api.services import context
|
||||||
|
|
||||||
|
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": "",
|
||||||
|
"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}, {}, []
|
||||||
|
yield (
|
||||||
|
"reasoning-OPENAI-legacy-unbound-token",
|
||||||
|
"POST",
|
||||||
|
"/api/reasoning",
|
||||||
|
{"json": {"transcript": "hello"}},
|
||||||
|
{"S2S_REASONING_TYPE": "OPENAI"},
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
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": "OPENAI",
|
||||||
|
}
|
||||||
|
reasoning_request = {
|
||||||
|
"json": {
|
||||||
|
"transcript": " hello ",
|
||||||
|
"phoneNumber": "123",
|
||||||
|
"instructions": "Fixture instructions",
|
||||||
|
"language": "es",
|
||||||
|
"future": True,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
yield (
|
||||||
|
"reasoning-openai-answer",
|
||||||
|
"POST",
|
||||||
|
"/api/reasoning",
|
||||||
|
reasoning_request,
|
||||||
|
{
|
||||||
|
**reasoning_config,
|
||||||
|
"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",
|
||||||
|
)
|
||||||
|
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"})
|
||||||
|
|
||||||
|
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_native_transcripts_are_relayed_without_external_http_calls(self):
|
||||||
|
events = [
|
||||||
|
{"type": "conversation.item.input_audio_transcription.completed", "transcript": "Hola", "item_id": "u1"},
|
||||||
|
{"type": "response.audio_transcript.done", "transcript": "Respuesta", "response_id": "r1"},
|
||||||
|
{"type": "response.output_audio_transcript.done", "transcript": "Respuesta", "response_id": "r1"},
|
||||||
|
{"type": "response.content_part.done", "part": {"type": "text", "text": "Respuesta"}},
|
||||||
|
*[
|
||||||
|
{
|
||||||
|
"type": event_type,
|
||||||
|
"item": {"id": "a1", "role": "assistant", "content": [{"type": "text", "text": "Respuesta"}]},
|
||||||
|
}
|
||||||
|
for event_type in ("response.output_item.done", "conversation.item.created")
|
||||||
|
],
|
||||||
|
{"type": "response.text.done", "text": "Respuesta", "response_id": "r1"},
|
||||||
|
]
|
||||||
|
messages = [json.dumps(event) for event in events]
|
||||||
|
forwarded = []
|
||||||
|
|
||||||
|
class Socket:
|
||||||
|
async def send_text(self, message):
|
||||||
|
forwarded.append(message)
|
||||||
|
|
||||||
|
async def upstream():
|
||||||
|
for message in messages:
|
||||||
|
yield message
|
||||||
|
|
||||||
|
async def relay():
|
||||||
|
await proxy._upstream_to_client(Socket(), upstream(), "fixture", {})
|
||||||
|
# Allow any accidentally scheduled background HTTP work to run.
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
for mode in ("PROXY", "OPENAI"):
|
||||||
|
forwarded.clear()
|
||||||
|
with (
|
||||||
|
self.subTest(mode=mode),
|
||||||
|
patch.multiple(settings, **{**BASE_CONFIG, "S2S_REASONING_TYPE": mode}),
|
||||||
|
patch.object(httpx, "AsyncClient", side_effect=AssertionError("Unexpected HTTP call")) as http,
|
||||||
|
):
|
||||||
|
asyncio.run(relay())
|
||||||
|
self.assertEqual(forwarded, messages)
|
||||||
|
http.assert_not_called()
|
||||||
|
|
||||||
|
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,97 @@
|
|||||||
|
"""Validate owned fields without tightening or rewriting upstream payloads."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from vocero_api.api.responses import validated_json_response
|
||||||
|
from vocero_api.models.requests import ApipilotExecuteRequest, QueueEndPayload, SearchRequest
|
||||||
|
from vocero_api.models.responses import (
|
||||||
|
AllocationResponse,
|
||||||
|
QueueCancellationResponse,
|
||||||
|
ReasoningResponse,
|
||||||
|
ReportsResponse,
|
||||||
|
SearchResponse,
|
||||||
|
ToolExecutionResponse,
|
||||||
|
UserProfileResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("model", "payload"),
|
||||||
|
[
|
||||||
|
(AllocationResponse, {}),
|
||||||
|
(AllocationResponse, {"state": [1, None], "connectUrl": "wss://host", "future": {"x": 1.0}}),
|
||||||
|
(ToolExecutionResponse, {"success": True, "unknown": {"nested": [None, 1.0, False]}}),
|
||||||
|
(ToolExecutionResponse, {"success": False, "data": 42, "error": "loose error", "future": []}),
|
||||||
|
(ReasoningResponse, {"answer": "á", "usage_pass2": [1, None], "future": True}),
|
||||||
|
(SearchResponse, {"query": "q", "answer": {"x": 2}, "results": [{"title": [], "snippet": 7, "url": None}]}),
|
||||||
|
(
|
||||||
|
ReportsResponse,
|
||||||
|
{"count": 1, "wrappedLen": 2, "hints": [], "entries": [{"sourceScheduleLabel": 42, "date": None}]},
|
||||||
|
),
|
||||||
|
*[(UserProfileResponse, payload) for payload in [{}, [], 42, False, None, "profile"]],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_upstream_values_and_missing_fields_keep_exact_wire_bytes(model, payload):
|
||||||
|
original = JSONResponse(payload, status_code=202)
|
||||||
|
validated = validated_json_response(payload, model, status_code=202)
|
||||||
|
assert validated.body == original.body
|
||||||
|
assert validated.raw_headers == original.raw_headers
|
||||||
|
assert validated.status_code == original.status_code
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("payload", [{}, {"ok": "true"}, {"ok": 1}, {"ok": None}])
|
||||||
|
def test_owned_response_fields_are_actually_validated(payload):
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
validated_json_response(payload, QueueCancellationResponse)
|
||||||
|
|
||||||
|
|
||||||
|
def test_request_models_keep_legacy_extra_field_and_argument_behavior():
|
||||||
|
assert SearchRequest.model_validate({"query": "q", "future": True}).model_dump() == {"query": "q", "key": None}
|
||||||
|
arguments = {"arbitrary": {"nested": [1, None]}, "unknown": True}
|
||||||
|
assert ApipilotExecuteRequest.model_validate({"name": "tool", "arguments": arguments}).arguments == arguments
|
||||||
|
for value in [None, "id", 42, False, [], {}, {"future": True}]:
|
||||||
|
assert QueueEndPayload.model_validate({"queueId": value, "future": 1}).queueId == value
|
||||||
|
assert QueueEndPayload.model_validate({}).queueId == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_http_routes_publish_response_schemas():
|
||||||
|
# Import the app only after the compatibility suite's isolated configuration.
|
||||||
|
from vocero_api.main import app
|
||||||
|
|
||||||
|
spec = app.openapi()
|
||||||
|
for path, operations in spec["paths"].items():
|
||||||
|
if path.startswith("/api/"):
|
||||||
|
for operation in operations.values():
|
||||||
|
schema = operation["responses"]["200"]["content"]["application/json"]["schema"]
|
||||||
|
assert "$ref" in schema, path
|
||||||
|
allocation = spec["components"]["schemas"]["AllocationResponse"]
|
||||||
|
assert not allocation.get("required")
|
||||||
|
assert allocation["additionalProperties"] is True
|
||||||
|
config = spec["components"]["schemas"]["ConfigResponse"]
|
||||||
|
assert config["properties"]["reasoningType"]["enum"] == ["PROXY", "OPENAI"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_entry_point_exports_the_renamed_application():
|
||||||
|
import server
|
||||||
|
from vocero_api.main import app
|
||||||
|
|
||||||
|
assert server.app is app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("value", "expected"),
|
||||||
|
[
|
||||||
|
("OPENAI", "OPENAI"),
|
||||||
|
(" 'openai' ", "OPENAI"),
|
||||||
|
('"OPENAI"', "OPENAI"),
|
||||||
|
("PROXY", "PROXY"),
|
||||||
|
("", "PROXY"),
|
||||||
|
("UNSUPPORTED", "PROXY"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_reasoning_configuration_preserves_normalization_and_native_fallback(value, expected):
|
||||||
|
from vocero_api.core.config import _normalize_reasoning_type
|
||||||
|
|
||||||
|
assert _normalize_reasoning_type(value) == expected
|
||||||
@@ -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 vocero-api: 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 vocero_api import main as server
|
||||||
|
from vocero_api.core import config as settings
|
||||||
|
from vocero_api.providers import apipilot, caller
|
||||||
|
from vocero_api.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:
|
||||||
Generated
+601
@@ -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 = "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 = "vocero-api"
|
||||||
|
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 = "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" },
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
"""Validate service-owned response structure without rewriting the wire body."""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
def validated_json_response(
|
||||||
|
content: Any,
|
||||||
|
model: type[BaseModel],
|
||||||
|
*,
|
||||||
|
status_code: int = 200,
|
||||||
|
) -> JSONResponse:
|
||||||
|
model.model_validate(content)
|
||||||
|
# model_dump would insert defaults, potentially coerce values, and reorder
|
||||||
|
# fields. Keeping the original JSONResponse serialization is intentional.
|
||||||
|
return JSONResponse(content=content, status_code=status_code)
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"""Register routes in their original order without changing public paths."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from vocero_api.api.routes import apipilot, caller, config, search, sessions
|
||||||
|
from vocero_api.models.responses import ReasoningResponse
|
||||||
|
from vocero_api.realtime.proxy import realtime_proxy
|
||||||
|
from vocero_api.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"], response_model=ReasoningResponse)
|
||||||
|
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,147 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from vocero_api.api.responses import validated_json_response
|
||||||
|
from vocero_api.core import config as settings
|
||||||
|
from vocero_api.core import logging as app_logging
|
||||||
|
from vocero_api.models import requests as models_requests
|
||||||
|
from vocero_api.models.responses import ToolCatalogResponse, ToolExecutionResponse
|
||||||
|
from vocero_api.providers import apipilot as providers_apipilot
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/apipilot/tools", response_model=ToolCatalogResponse)
|
||||||
|
async def apipilot_list_tools(
|
||||||
|
request: Request,
|
||||||
|
include_unauthorized: bool = True,
|
||||||
|
refresh: bool = False,
|
||||||
|
) -> 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 validated_json_response(
|
||||||
|
model=ToolCatalogResponse,
|
||||||
|
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 validated_json_response(
|
||||||
|
model=ToolCatalogResponse,
|
||||||
|
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 validated_json_response(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"data": {
|
||||||
|
"count": len(tools),
|
||||||
|
"include_unauthorized": bool(include_unauthorized),
|
||||||
|
"tools": tools,
|
||||||
|
},
|
||||||
|
"error": None,
|
||||||
|
},
|
||||||
|
ToolCatalogResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/apipilot/execute", response_model=ToolExecutionResponse)
|
||||||
|
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 validated_json_response(
|
||||||
|
model=ToolExecutionResponse,
|
||||||
|
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 validated_json_response(envelope, ToolExecutionResponse, status_code=status_code)
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from vocero_api.api.responses import validated_json_response
|
||||||
|
from vocero_api.core import config as settings
|
||||||
|
from vocero_api.core import logging as app_logging
|
||||||
|
from vocero_api.models.responses import ReportsResponse, UserProfileResponse
|
||||||
|
from vocero_api.providers import caller as providers_caller
|
||||||
|
from vocero_api.services import context as services_context
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/user/phone", response_model=UserProfileResponse)
|
||||||
|
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 validated_json_response(
|
||||||
|
payload, UserProfileResponse, status_code=resp.status_code if resp.status_code != 204 else 200
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/user/reports", response_model=ReportsResponse)
|
||||||
|
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 validated_json_response(
|
||||||
|
model=ReportsResponse,
|
||||||
|
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 validated_json_response(
|
||||||
|
{
|
||||||
|
"count": 0,
|
||||||
|
"wrappedLen": 0,
|
||||||
|
"hints": [],
|
||||||
|
"entries": [],
|
||||||
|
"skipped": True,
|
||||||
|
"reason": "Lista vacía, error del servicio de origen o ningún contenido disponible.",
|
||||||
|
},
|
||||||
|
ReportsResponse,
|
||||||
|
)
|
||||||
|
# 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 validated_json_response(resp_body, ReportsResponse)
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from vocero_api.api.responses import validated_json_response
|
||||||
|
from vocero_api.core import config as settings
|
||||||
|
from vocero_api.core import logging as app_logging
|
||||||
|
from vocero_api.core import urls as core_urls
|
||||||
|
from vocero_api.models.responses import ConfigResponse
|
||||||
|
|
||||||
|
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", response_model=ConfigResponse)
|
||||||
|
async def config(request: Request) -> JSONResponse:
|
||||||
|
"""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 validated_json_response(out, ConfigResponse)
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from vocero_api.api.responses import validated_json_response
|
||||||
|
from vocero_api.core import config as settings
|
||||||
|
from vocero_api.core import logging as app_logging
|
||||||
|
from vocero_api.models import requests as models_requests
|
||||||
|
from vocero_api.models.responses import SearchResponse
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/search", response_model=SearchResponse)
|
||||||
|
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 validated_json_response({"query": query, "answer": answer, "results": results}, SearchResponse)
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from vocero_api.api.responses import validated_json_response
|
||||||
|
from vocero_api.core import config as settings
|
||||||
|
from vocero_api.core import logging as app_logging
|
||||||
|
from vocero_api.models.requests import QueueEndPayload
|
||||||
|
from vocero_api.models.responses import AllocationResponse, QueueCancellationResponse
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/session", response_model=AllocationResponse)
|
||||||
|
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 validated_json_response({"state": "at_capacity"}, AllocationResponse, 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 validated_json_response(data, AllocationResponse)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/queue/{queue_id}", response_model=AllocationResponse)
|
||||||
|
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 validated_json_response({"state": "expired"}, AllocationResponse, 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 validated_json_response(data, AllocationResponse)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/api/queue/{queue_id}", response_model=QueueCancellationResponse)
|
||||||
|
async def queue_leave(queue_id: str) -> JSONResponse:
|
||||||
|
"""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 validated_json_response({"ok": True}, QueueCancellationResponse)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/queue/end", response_model=QueueCancellationResponse)
|
||||||
|
async def queue_end(request: Request) -> JSONResponse:
|
||||||
|
"""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 validated_json_response({"ok": True}, QueueCancellationResponse)
|
||||||
|
|
||||||
|
|
||||||
|
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) -> Any:
|
||||||
|
"""Pull `queueId` from a JSON body, tolerating sendBeacon's blob posts."""
|
||||||
|
try:
|
||||||
|
data = await request.json()
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
return QueueEndPayload.model_validate(data).queueId if isinstance(data, dict) else ""
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
from vocero_api.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"
|
||||||
|
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 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
|
||||||
|
|
||||||
|
|
||||||
|
# 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,61 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from vocero_api.core import config as settings
|
||||||
|
from vocero_api.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 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,
|
||||||
|
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 vocero_api.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 vocero_api.api.router import router
|
||||||
|
from vocero_api.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,47 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
# OpenAI-compatible provider;
|
||||||
|
# 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 = {}
|
||||||
|
|
||||||
|
|
||||||
|
class QueueEndPayload(BaseModel):
|
||||||
|
"""A view of a decoded beacon object, preserving the legacy loose value.
|
||||||
|
|
||||||
|
Missing/invalid JSON and non-object bodies are still handled by the route.
|
||||||
|
In particular, a truthy non-string ID must retain its existing behavior.
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="allow")
|
||||||
|
queueId: Any = ""
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
"""Response contracts without imposing new assumptions on upstream JSON.
|
||||||
|
|
||||||
|
Only fields constructed by this service have concrete types. Upstream-owned
|
||||||
|
values remain Any, and unknown fields remain allowed at every modeled level.
|
||||||
|
Responses are validated but serialized from their original payloads so omitted
|
||||||
|
fields, numeric representations, key order, and unknown values stay unchanged.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, RootModel
|
||||||
|
|
||||||
|
|
||||||
|
class OpenResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="allow", strict=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigResponse(OpenResponse):
|
||||||
|
search: bool
|
||||||
|
lb: bool
|
||||||
|
allowDirect: bool
|
||||||
|
s2sUrl: str
|
||||||
|
primaryPrompt: str
|
||||||
|
reasoningType: Literal["PROXY", "OPENAI"]
|
||||||
|
injectProfile: bool
|
||||||
|
debugPrompt: bool
|
||||||
|
startSessionMode: str
|
||||||
|
theme: str
|
||||||
|
apipilot: bool
|
||||||
|
|
||||||
|
|
||||||
|
class SearchResult(OpenResponse):
|
||||||
|
# Serper values have historically passed through, including nulls, numbers,
|
||||||
|
# and objects. The service guarantees these keys, not their value types.
|
||||||
|
title: Any
|
||||||
|
snippet: Any
|
||||||
|
url: Any
|
||||||
|
|
||||||
|
|
||||||
|
class SearchResponse(OpenResponse):
|
||||||
|
query: str
|
||||||
|
answer: Any
|
||||||
|
results: list[SearchResult]
|
||||||
|
|
||||||
|
|
||||||
|
class ReportEntry(OpenResponse):
|
||||||
|
sourceScheduleLabel: Any
|
||||||
|
date: Any
|
||||||
|
|
||||||
|
|
||||||
|
class ReportsResponse(OpenResponse):
|
||||||
|
count: int
|
||||||
|
wrappedLen: int
|
||||||
|
hints: list[str]
|
||||||
|
entries: list[ReportEntry]
|
||||||
|
rawCount: int | None = None
|
||||||
|
cap: int | None = None
|
||||||
|
skipped: bool | None = None
|
||||||
|
reason: str | None = None
|
||||||
|
truncated: bool | None = None
|
||||||
|
note: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ToolInvocation(OpenResponse):
|
||||||
|
name: str
|
||||||
|
success: bool
|
||||||
|
result_length: int
|
||||||
|
|
||||||
|
|
||||||
|
class ReasoningResponse(OpenResponse):
|
||||||
|
answer: str
|
||||||
|
usage_pass2: Any = None
|
||||||
|
apipilot_invoked: ToolInvocation | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ToolDefinition(OpenResponse):
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
parameters: dict[str, Any]
|
||||||
|
requires_auth: bool
|
||||||
|
|
||||||
|
|
||||||
|
class ToolCatalog(OpenResponse):
|
||||||
|
count: int
|
||||||
|
include_unauthorized: bool
|
||||||
|
tools: list[ToolDefinition]
|
||||||
|
|
||||||
|
|
||||||
|
class ToolCatalogResponse(OpenResponse):
|
||||||
|
success: bool
|
||||||
|
data: ToolCatalog | None
|
||||||
|
error: Any
|
||||||
|
|
||||||
|
|
||||||
|
class ToolExecutionResponse(OpenResponse):
|
||||||
|
success: bool
|
||||||
|
# The upstream may omit these fields or return any JSON value for them.
|
||||||
|
data: Any = None
|
||||||
|
error: Any = None
|
||||||
|
|
||||||
|
|
||||||
|
class AllocationResponse(OpenResponse):
|
||||||
|
"""LB-owned object: no new required fields or value-type restrictions."""
|
||||||
|
|
||||||
|
state: Any = None
|
||||||
|
session_id: Any = None
|
||||||
|
connect_url: Any = None
|
||||||
|
pending_timeout_s: Any = None
|
||||||
|
queue_id: Any = None
|
||||||
|
position: Any = None
|
||||||
|
poll_interval_s: Any = None
|
||||||
|
|
||||||
|
|
||||||
|
class UserProfileResponse(RootModel[Any]):
|
||||||
|
"""The profile endpoint relays objects, arrays, scalars, and null verbatim."""
|
||||||
|
|
||||||
|
|
||||||
|
class QueueCancellationResponse(OpenResponse):
|
||||||
|
ok: bool
|
||||||
@@ -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 vocero_api.core import config as settings
|
||||||
|
from vocero_api.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 vocero_api.core import config as settings
|
||||||
|
from vocero_api.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, reasoning 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 vocero_api.core import logging as app_logging
|
||||||
|
from vocero_api.services import context as services_context
|
||||||
|
from vocero_api.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,651 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from vocero_api.api.responses import validated_json_response
|
||||||
|
from vocero_api.core import config as settings
|
||||||
|
from vocero_api.core import logging as app_logging
|
||||||
|
from vocero_api.models import requests as models_requests
|
||||||
|
from vocero_api.models.responses import ReasoningResponse
|
||||||
|
from vocero_api.providers import apipilot as providers_apipilot
|
||||||
|
from vocero_api.providers import caller as providers_caller
|
||||||
|
from vocero_api.services import context as services_context
|
||||||
|
from vocero_api.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),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Call the OpenAI-compatible Chat Completions provider.
|
||||||
|
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 validated_json_response(final_out_oai, ReasoningResponse)
|
||||||
|
# Without a marker, return the original pass-1 answer.
|
||||||
|
return validated_json_response({"answer": answer}, ReasoningResponse)
|
||||||
@@ -0,0 +1,629 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
from vocero_api.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()
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# Vocero React frontend
|
# Vocero React frontend
|
||||||
|
|
||||||
React 19, TypeScript, Tailwind CSS 4, and Vite 8 client for the existing
|
React 19, TypeScript, Tailwind CSS 4, and Vite 8 client for the existing
|
||||||
`s2s-client` FastAPI backend. This is a client-side application with React-owned
|
`vocero-api` FastAPI backend. This is a client-side application with React-owned
|
||||||
rendering and state, including the realtime WebSocket and audio integration.
|
rendering and state, including the realtime WebSocket and audio integration.
|
||||||
|
|
||||||
## Run during development
|
## Run during development
|
||||||
@@ -10,9 +10,9 @@ From the repository root, run `./start_ui.sh`. Open `http://localhost:8420`.
|
|||||||
Add `?debug=true` to access settings, tools, and custom RAG context. Existing
|
Add `?debug=true` to access settings, tools, and custom RAG context. Existing
|
||||||
`phoneNumber` and `episode_id` query parameters are supported.
|
`phoneNumber` and `episode_id` query parameters are supported.
|
||||||
|
|
||||||
The launcher requires Python 3 with venv support, Node.js 22.18 or later on the
|
The launcher requires uv 0.12.5, Node.js 22.18 or later on the 22.x line or
|
||||||
22.x line or Node.js 24+, npm, Bash 4.4+, `setsid` (util-linux), and `curl`.
|
Node.js 24+, npm, Bash 4.4+, `setsid` (util-linux), and `curl`.
|
||||||
It creates `.venv` and installs missing Python dependencies. It runs
|
It provisions Python 3.12 and syncs `vocero-api/.venv` from the API lockfile. It runs
|
||||||
`npm ci --include=dev` on first start and when either frontend package manifest
|
`npm ci --include=dev` on first start and when either frontend package manifest
|
||||||
changes. Subsequent restarts reuse the installed dependencies.
|
changes. Subsequent restarts reuse the installed dependencies.
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ changes. Subsequent restarts reuse the installed dependencies.
|
|||||||
must differ. Occupied ports fail startup rather than silently selecting a new
|
must differ. Occupied ports fail startup rather than silently selecting a new
|
||||||
endpoint. The launcher waits for FastAPI before starting Vite, stops both
|
endpoint. The launcher waits for FastAPI before starting Vite, stops both
|
||||||
process groups on Ctrl+C/SIGTERM, and exits with a failure if either server exits.
|
process groups on Ctrl+C/SIGTERM, and exits with a failure if either server exits.
|
||||||
Uvicorn watches only `s2s-client`, avoiding frontend and dependency directories.
|
Uvicorn watches only `vocero-api`, avoiding frontend and dependency directories.
|
||||||
The Vite child explicitly uses `NODE_ENV=development`, including when a process
|
The Vite child explicitly uses `NODE_ENV=development`, including when a process
|
||||||
manager sets `NODE_ENV=production`, so React Fast Refresh remains enabled.
|
manager sets `NODE_ENV=production`, so React Fast Refresh remains enabled.
|
||||||
|
|
||||||
@@ -49,8 +49,8 @@ variables, which are exposed to browser code.
|
|||||||
## Staging with hot reload
|
## Staging with hot reload
|
||||||
|
|
||||||
PM2 can continue running the root `start_ui.sh` as its existing `s2s_ui` process.
|
PM2 can continue running the root `start_ui.sh` as its existing `s2s_ui` process.
|
||||||
Ensure the Node/npm versions above are on PM2's PATH and deploy `s2s-frontend`
|
Ensure uv and the Node/npm versions above are on PM2's PATH and deploy `vocero-frontend`
|
||||||
alongside `s2s-client` and `docs`. The reverse proxy still targets port `8420`.
|
alongside `vocero-api` and `docs`. The reverse proxy still targets port `8420`.
|
||||||
|
|
||||||
Set the exact public hostname in the root `.env`, without scheme or port:
|
Set the exact public hostname in the root `.env`, without scheme or port:
|
||||||
|
|
||||||
@@ -74,7 +74,7 @@ Start FastAPI on `127.0.0.1:8421` as described in the
|
|||||||
[client usage guide](../docs/client/usage.md), then run:
|
[client usage guide](../docs/client/usage.md), then run:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd s2s-frontend
|
cd vocero-frontend
|
||||||
npm ci
|
npm ci
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
@@ -115,7 +115,7 @@ by Git. The launcher serves source through Vite; it does not use `dist/` or
|
|||||||
|
|
||||||
Tests cover session initialization, response sequencing, cumulative transcripts,
|
Tests cover session initialization, response sequencing, cumulative transcripts,
|
||||||
interruptions, queue polling, admission deadlines, cancellation, microphone-permission cancellation, tool
|
interruptions, queue polling, admission deadlines, cancellation, microphone-permission cancellation, tool
|
||||||
results, external reasoning and `LOG_ONLY`, late-response cleanup, the idle
|
results, external reasoning, late-response cleanup, the idle
|
||||||
timeout, settings, RAG, profiles, and sanitized Markdown/export behavior.
|
timeout, settings, RAG, profiles, and sanitized Markdown/export behavior.
|
||||||
Launcher tests start temporary local children to check readiness, environment
|
Launcher tests start temporary local children to check readiness, environment
|
||||||
forwarding, failures, and shutdown of complete process groups.
|
forwarding, failures, and shutdown of complete process groups.
|
||||||
@@ -131,10 +131,13 @@ Vitest handles Node 26's experimental web storage behavior internally.
|
|||||||
- `src/lib/`: validated backend contracts, settings, prompt context, profile
|
- `src/lib/`: validated backend contracts, settings, prompt context, profile
|
||||||
and report presentation, Markdown sanitization, and conversation exports.
|
and report presentation, Markdown sanitization, and conversation exports.
|
||||||
- `config/backend-proxy.ts`: the shared HTTP/WebSocket development proxy.
|
- `config/backend-proxy.ts`: the shared HTTP/WebSocket development proxy.
|
||||||
- `src/styles.css`: Tailwind integration and React-specific layout adjustments.
|
- `src/styles.css`: Tailwind layer/import entry point. Preflight remains disabled.
|
||||||
- `src/styles/legacy-theme.css`: the existing visual styles, retained for
|
- `src/styles/theme.css`: semantic Tailwind tokens and server-selected theme
|
||||||
fidelity to both the white microphone and dark orb themes. The UI is ported
|
variants. Use `bg-surface`, `text-foreground`, `border-line`, and related utilities.
|
||||||
to React; the full design has not been rewritten as Tailwind utilities.
|
- `src/components/ui.tsx`: shared buttons, icon buttons, fields and dialog controls.
|
||||||
|
Components own their layout, responsive utilities and interaction styling.
|
||||||
|
- `src/styles/`: minimal element defaults and reduced motion, voice artwork,
|
||||||
|
sanitized Markdown, decorative surfaces and native range pseudo-elements.
|
||||||
|
|
||||||
The current Spanish interface, local preference keys, session-only RAG,
|
The current Spanish interface, local preference keys, session-only RAG,
|
||||||
Markdown replies, JSON/TXT exports, optional browser wake word, TALK/TALKFIRST
|
Markdown replies, JSON/TXT exports, optional browser wake word, TALK/TALKFIRST
|
||||||
@@ -144,8 +147,7 @@ quick actions remain absent, matching their disabled state in the old UI.
|
|||||||
Native speech stays at the current project's 16 kHz PCM rate. The backend
|
Native speech stays at the current project's 16 kHz PCM rate. The backend
|
||||||
continues to own VAD, transcription, model inference, synthesis, upstream
|
continues to own VAD, transcription, model inference, synthesis, upstream
|
||||||
credentials, and ApiPilot execution. Browser `SpeechSynthesis` is used only
|
credentials, and ApiPilot execution. Browser `SpeechSynthesis` is used only
|
||||||
when the configured external reasoning service returns an answer instead of
|
when the configured external reasoning service returns an answer.
|
||||||
`log_only`.
|
|
||||||
|
|
||||||
Session startup waits for audio initialization and `session.created`, then sends
|
Session startup waits for audio initialization and `session.created`, then sends
|
||||||
`session.update` before microphone audio or the initial greeting. The speech
|
`session.update` before microphone audio or the initial greeting. The speech
|
||||||
+45
-6
@@ -64,16 +64,21 @@ async function waitForFile(filename: string) {
|
|||||||
|
|
||||||
async function launcher(context: TestContext, env: Record<string, string> = {}) {
|
async function launcher(context: TestContext, env: Record<string, string> = {}) {
|
||||||
const root = await mkdtemp(join(tmpdir(), "s2s-launcher-"));
|
const root = await mkdtemp(join(tmpdir(), "s2s-launcher-"));
|
||||||
const frontend = join(root, "s2s-frontend");
|
const frontend = join(root, "vocero-frontend");
|
||||||
const modules = join(frontend, "node_modules");
|
const modules = join(frontend, "node_modules");
|
||||||
await mkdir(join(root, ".venv/bin"), { recursive: true });
|
await mkdir(join(root, "vocero-api/.venv/bin"), { recursive: true });
|
||||||
await mkdir(join(root, "s2s-client"));
|
await mkdir(join(root, "bin"));
|
||||||
await mkdir(join(modules, "vite/bin"), { recursive: true });
|
await mkdir(join(modules, "vite/bin"), { recursive: true });
|
||||||
await copyFile(new URL("../../start_ui.sh", import.meta.url), join(root, "start_ui.sh"));
|
await copyFile(new URL("../../start_ui.sh", import.meta.url), join(root, "start_ui.sh"));
|
||||||
await writeFile(join(root, "s2s-client/requirements.txt"), "");
|
await writeFile(join(root, "vocero-api/pyproject.toml"), "");
|
||||||
await writeFile(join(root, ".venv/bin/activate"), 'export PATH="$S2S_TEST_ROOT/.venv/bin:$PATH"\n');
|
await writeFile(join(root, "vocero-api/uv.lock"), "");
|
||||||
await writeFile(
|
await writeFile(
|
||||||
join(root, ".venv/bin/python"),
|
join(root, "bin/uv"),
|
||||||
|
'#!/bin/bash\nprintf "%s\\n" "$@" > "$S2S_TEST_ROOT/uv-args"\nprintf "%s" "$UV_PROJECT_ENVIRONMENT" > "$S2S_TEST_ROOT/uv-environment"\nexit "${S2S_TEST_UV_EXIT:-0}"\n',
|
||||||
|
{ mode: 0o755 },
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
join(root, "vocero-api/.venv/bin/python"),
|
||||||
'#!/bin/bash\nif [ "$1" = "-c" ]; then exit 0; fi\nexec node "$S2S_TEST_ROOT/child.mjs" backend "$@"\n',
|
'#!/bin/bash\nif [ "$1" = "-c" ]; then exit 0; fi\nexec node "$S2S_TEST_ROOT/child.mjs" backend "$@"\n',
|
||||||
{ mode: 0o755 },
|
{ mode: 0o755 },
|
||||||
);
|
);
|
||||||
@@ -94,6 +99,7 @@ async function launcher(context: TestContext, env: Record<string, string> = {})
|
|||||||
cwd: tmpdir(),
|
cwd: tmpdir(),
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
|
PATH: `${join(root, "bin")}:${process.env.PATH ?? ""}`,
|
||||||
UI_PORT: String(publicPort),
|
UI_PORT: String(publicPort),
|
||||||
UI_HOST: "0.0.0.0",
|
UI_HOST: "0.0.0.0",
|
||||||
NODE_ENV: "production",
|
NODE_ENV: "production",
|
||||||
@@ -138,6 +144,32 @@ for (const signal of ["SIGINT", "SIGTERM"] as const) {
|
|||||||
nodeEnv: "development",
|
nodeEnv: "development",
|
||||||
});
|
});
|
||||||
assert.equal(run.output().includes("Installing"), false);
|
assert.equal(run.output().includes("Installing"), false);
|
||||||
|
assert.equal(
|
||||||
|
await readFile(join(run.root, "uv-args"), "utf8"),
|
||||||
|
`sync\n--project\n${run.root}/vocero-api\n--locked\n`,
|
||||||
|
);
|
||||||
|
assert.equal(await readFile(join(run.root, "uv-environment"), "utf8"), `${run.root}/vocero-api/.venv`);
|
||||||
|
const backendMetadata: unknown = JSON.parse(await readFile(join(run.root, "backend.json"), "utf8"));
|
||||||
|
assert.deepEqual(backendMetadata, {
|
||||||
|
args: [
|
||||||
|
"backend",
|
||||||
|
"-m",
|
||||||
|
"uvicorn",
|
||||||
|
"vocero_api.main:app",
|
||||||
|
"--app-dir",
|
||||||
|
`${run.root}/vocero-api`,
|
||||||
|
"--host",
|
||||||
|
"127.0.0.1",
|
||||||
|
"--port",
|
||||||
|
String(run.backendPort),
|
||||||
|
"--reload",
|
||||||
|
"--reload-dir",
|
||||||
|
`${run.root}/vocero-api`,
|
||||||
|
],
|
||||||
|
target: `http://127.0.0.1:${run.backendPort}`,
|
||||||
|
hosts: "s2s.test",
|
||||||
|
nodeEnv: "production",
|
||||||
|
});
|
||||||
run.child.kill(signal);
|
run.child.kill(signal);
|
||||||
assert.equal(await run.exited, signal === "SIGINT" ? 130 : 143);
|
assert.equal(await run.exited, signal === "SIGINT" ? 130 : 143);
|
||||||
for (const role of ["backend", "frontend"]) {
|
for (const role of ["backend", "frontend"]) {
|
||||||
@@ -168,6 +200,13 @@ await test("does not start Vite when the backend fails readiness", { timeout: 10
|
|||||||
assert.match(run.output(), /FastAPI exited before it was ready/);
|
assert.match(run.output(), /FastAPI exited before it was ready/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await test("does not start either server when uv sync fails", { timeout: 10_000 }, async (context) => {
|
||||||
|
const run = await launcher(context, { S2S_TEST_UV_EXIT: "23" });
|
||||||
|
assert.equal(await run.exited, 23, run.output());
|
||||||
|
await assert.rejects(readFile(join(run.root, "backend.json")));
|
||||||
|
await assert.rejects(readFile(join(run.root, "frontend.json")));
|
||||||
|
});
|
||||||
|
|
||||||
await test("stops Vite if the backend exits after startup", { timeout: 10_000 }, async (context) => {
|
await test("stops Vite if the backend exits after startup", { timeout: 10_000 }, async (context) => {
|
||||||
const run = await launcher(context);
|
const run = await launcher(context);
|
||||||
await waitForFile(join(run.root, "frontend-worker-ready"));
|
await waitForFile(join(run.root, "frontend-worker-ready"));
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
<meta name="theme-color" content="#07080d" />
|
<meta name="theme-color" content="#07080d" />
|
||||||
<title>Vocero · Conversación de voz</title>
|
<title>Vocero · Conversación de voz</title>
|
||||||
|
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link
|
<link
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "s2s-frontend",
|
"name": "vocero-frontend",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "s2s-frontend",
|
"name": "vocero-frontend",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dompurify": "^3.4.16",
|
"dompurify": "^3.4.16",
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "s2s-frontend",
|
"name": "vocero-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
@@ -4,6 +4,7 @@ import { StrictMode } from "react";
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
|
import { VoiceController } from "./realtime/controller";
|
||||||
|
|
||||||
const bootstrap = { theme: "white", s2sUrl: "ws://localhost:8420/v1/realtime", apipilot: true };
|
const bootstrap = { theme: "white", s2sUrl: "ws://localhost:8420/v1/realtime", apipilot: true };
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -43,6 +44,62 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("React application", () => {
|
describe("React application", () => {
|
||||||
|
it.each(["TALK", "TALKFIRST"])(
|
||||||
|
"attempts %s immediately without a startup dialog under StrictMode",
|
||||||
|
async (startSessionMode) => {
|
||||||
|
const original = vi.mocked(fetch).getMockImplementation();
|
||||||
|
if (!original) {
|
||||||
|
throw new Error("Missing fetch mock");
|
||||||
|
}
|
||||||
|
vi.mocked(fetch).mockImplementation((input, init) =>
|
||||||
|
input === "/api/config"
|
||||||
|
? Promise.resolve(new Response(JSON.stringify({ ...bootstrap, startSessionMode })))
|
||||||
|
: original(input, init),
|
||||||
|
);
|
||||||
|
const start = vi.spyOn(VoiceController.prototype, "start").mockResolvedValue(undefined);
|
||||||
|
render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(start).toHaveBeenCalledExactlyOnceWith(true);
|
||||||
|
});
|
||||||
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
it.each(["white", "dark"])(
|
||||||
|
"shows an inline microphone fallback in the %s theme when autoplay is blocked",
|
||||||
|
async (theme) => {
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce(
|
||||||
|
new Response(JSON.stringify({ ...bootstrap, theme, startSessionMode: "TALK" })),
|
||||||
|
);
|
||||||
|
const getUserMedia = vi.fn();
|
||||||
|
vi.stubGlobal("navigator", { mediaDevices: { getUserMedia } });
|
||||||
|
vi.stubGlobal(
|
||||||
|
"AudioContext",
|
||||||
|
class {
|
||||||
|
state = "suspended";
|
||||||
|
resume = vi.fn().mockResolvedValue(undefined);
|
||||||
|
close = vi.fn().mockResolvedValue(undefined);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
render(<App />);
|
||||||
|
expect(await screen.findByText("Toca el micrófono para empezar a conversar.")).toHaveAttribute("role", "status");
|
||||||
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Iniciar conversación de voz" })).toBeEnabled();
|
||||||
|
expect(getUserMedia).not.toHaveBeenCalled();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
it("offers configuration retry without exposing a broken start control", async () => {
|
||||||
|
vi.mocked(fetch).mockRejectedValueOnce(new Error("offline"));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<App />);
|
||||||
|
await screen.findByRole("alert");
|
||||||
|
expect(screen.queryByRole("button", { name: "Iniciar conversación de voz" })).not.toBeInTheDocument();
|
||||||
|
await user.click(screen.getByRole("button", { name: "Reintentar" }));
|
||||||
|
expect(await screen.findByRole("button", { name: "Iniciar conversación de voz" })).toBeEnabled();
|
||||||
|
});
|
||||||
it("loads the configured theme and persists voice settings across remounts under StrictMode", async () => {
|
it("loads the configured theme and persists voice settings across remounts under StrictMode", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const view = render(
|
const view = render(
|
||||||
@@ -53,8 +110,9 @@ describe("React application", () => {
|
|||||||
await user.click(await screen.findByRole("button", { name: "Configuración" }));
|
await user.click(await screen.findByRole("button", { name: "Configuración" }));
|
||||||
expect(document.documentElement.dataset.theme).toBe("white");
|
expect(document.documentElement.dataset.theme).toBe("white");
|
||||||
await user.selectOptions(screen.getByRole("combobox"), "Serena");
|
await user.selectOptions(screen.getByRole("combobox"), "Serena");
|
||||||
expect(localStorage.getItem("s2s.ws.voice")).toBe("Serena");
|
expect(localStorage.getItem("s2s.ws.voice")).toBeNull();
|
||||||
await user.click(screen.getByRole("button", { name: "Guardar" }));
|
await user.click(screen.getByRole("button", { name: "Guardar" }));
|
||||||
|
expect(localStorage.getItem("s2s.ws.voice")).toBe("Serena");
|
||||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||||
view.unmount();
|
view.unmount();
|
||||||
render(
|
render(
|
||||||
@@ -65,6 +123,19 @@ describe("React application", () => {
|
|||||||
await user.click(await screen.findByRole("button", { name: "Configuración" }));
|
await user.click(await screen.findByRole("button", { name: "Configuración" }));
|
||||||
expect(screen.getByRole("combobox")).toHaveValue("Serena");
|
expect(screen.getByRole("combobox")).toHaveValue("Serena");
|
||||||
});
|
});
|
||||||
|
it("discards unsaved settings on cancel while keeping live noise-gate calibration", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<App />);
|
||||||
|
await user.click(await screen.findByRole("button", { name: "Configuración" }));
|
||||||
|
await user.selectOptions(screen.getByRole("combobox"), "Serena");
|
||||||
|
fireEvent.change(screen.getByRole("slider"), { target: { value: "-35" } });
|
||||||
|
await user.click(screen.getByRole("button", { name: "Cancelar" }));
|
||||||
|
await user.click(screen.getByRole("button", { name: "Configuración" }));
|
||||||
|
expect(screen.getByRole("combobox")).toHaveValue("Aiden");
|
||||||
|
expect(screen.getByRole("slider")).toHaveValue("-35");
|
||||||
|
expect(localStorage.getItem("s2s.ws.voice")).toBeNull();
|
||||||
|
expect(localStorage.getItem("s2s.ws.noiseGate")).toBe("-35");
|
||||||
|
});
|
||||||
it("saves and clears session-only RAG context and supports keyboard dialog dismissal", async () => {
|
it("saves and clears session-only RAG context and supports keyboard dialog dismissal", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<App />);
|
render(<App />);
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
import { BookOpen, Info, PanelLeft, Settings, Wrench, X } from "lucide-react";
|
||||||
|
import { useEffect, useLayoutEffect, useState, useSyncExternalStore } from "react";
|
||||||
|
|
||||||
|
import { Bubbles } from "./components/Bubbles";
|
||||||
|
import { Conversation } from "./components/Conversation";
|
||||||
|
import { ReportsPanel } from "./components/ReportsPanel";
|
||||||
|
import { AboutDialog, ProfileDialog, RagDialog, SettingsDialog, ToolsDialog } from "./components/SettingsDialogs";
|
||||||
|
import { VoiceStage } from "./components/VoiceStage";
|
||||||
|
import { WakeWordControl } from "./components/WakeWordControl";
|
||||||
|
import { Button, IconButton } from "./components/ui";
|
||||||
|
import { useMediaQuery } from "./hooks/use-media-query";
|
||||||
|
import { initials, profileName } from "./lib/context";
|
||||||
|
import { isDebug } from "./lib/settings";
|
||||||
|
import { ACTIVE_STATES, VoiceController } from "./realtime/controller";
|
||||||
|
|
||||||
|
type Modal = "settings" | "tools" | "rag" | "profile" | "about" | null;
|
||||||
|
export default function App() {
|
||||||
|
const [controller] = useState(() => new VoiceController());
|
||||||
|
const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot);
|
||||||
|
const [modal, setModal] = useState<Modal>(null);
|
||||||
|
const [reportsOpen, setReportsOpen] = useState(false);
|
||||||
|
const desktop = useMediaQuery("(min-width: 1024px)");
|
||||||
|
const debug = isDebug();
|
||||||
|
const white = state.config.theme === "white";
|
||||||
|
const name = profileName(state.profile);
|
||||||
|
const hasReports = debug && !white && !!state.reports?.count;
|
||||||
|
const ready = state.configReady && !state.contextLoading;
|
||||||
|
useEffect(() => {
|
||||||
|
const abort = new AbortController();
|
||||||
|
void controller.initialize(abort.signal);
|
||||||
|
const leave = () => {
|
||||||
|
controller.dispose();
|
||||||
|
};
|
||||||
|
window.addEventListener("pagehide", leave);
|
||||||
|
return () => {
|
||||||
|
abort.abort();
|
||||||
|
window.removeEventListener("pagehide", leave);
|
||||||
|
controller.dispose();
|
||||||
|
};
|
||||||
|
}, [controller]);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!state.loading) {
|
||||||
|
document.documentElement.dataset.theme = white ? "white" : "dark";
|
||||||
|
}
|
||||||
|
}, [state.loading, white]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (ready) {
|
||||||
|
controller.offerAutoStart();
|
||||||
|
}
|
||||||
|
}, [ready, controller]);
|
||||||
|
const close = () => {
|
||||||
|
setModal(null);
|
||||||
|
};
|
||||||
|
if (state.loading) {
|
||||||
|
return (
|
||||||
|
<main
|
||||||
|
className="fixed inset-0 z-1000 grid place-items-center bg-white font-sans text-[16px] font-medium text-slate-700"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
Preparando Vocero…
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!state.configReady) {
|
||||||
|
return (
|
||||||
|
<main className="fixed inset-0 z-1000 grid place-items-center bg-white font-sans text-[16px] font-medium text-slate-700">
|
||||||
|
<div className="max-w-lg p-6 text-center">
|
||||||
|
<h1>No pudimos preparar Vocero</h1>
|
||||||
|
<p role="alert">{state.configError}</p>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => {
|
||||||
|
void controller.initialize();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reintentar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div id="app" className={`flex w-full min-w-0 flex-1 flex-col ${hasReports ? "lg:pl-[320px]" : ""}`}>
|
||||||
|
<header className="relative z-2 mt-[max(6px,calc(env(safe-area-inset-top,0px)*0.2))] flex w-full flex-none flex-wrap items-start justify-between gap-2 px-4 pt-4 white:static white:grid white:auto-rows-min white:grid-cols-[52px_minmax(0,1fr)_52px] white:justify-items-center white:gap-x-1 white:gap-y-0 white:px-2.5 white:pt-4 white:max-[480px]:px-1.5 dark:compact:gap-3 dark:[@media(560px<height<=780px)]:px-5 dark:[@media(560px<height<=780px)]:pt-3 dark:[@media(max-height:560px)]:px-3.5 dark:[@media(max-height:560px)]:pt-1.5">
|
||||||
|
<div className="brand-art relative flex min-w-0 flex-[1_1_360px] items-start gap-2.5 overflow-hidden rounded-panel border border-solid border-line px-3 py-2.5 text-[15px] font-semibold tracking-[0.01em] text-muted white:col-start-2 white:row-start-1 white:m-0 white:w-full white:flex-none white:justify-center white:rounded-none white:border-0 white:p-0 white:text-center compact:text-[14px]">
|
||||||
|
<div className="flex w-full min-w-0 items-center justify-between gap-3 white:flex-col white:justify-center white:gap-0.5 white:px-1">
|
||||||
|
<div className="flex items-center gap-[9px] white:mx-auto white:w-full white:flex-col white:justify-center white:gap-0.5">
|
||||||
|
<h1 className="m-0 leading-none">
|
||||||
|
<a
|
||||||
|
className="block border-0 bg-(image:--brand-gradient) bg-clip-text text-[24px] leading-[1.1] font-semibold tracking-[0.005em] text-transparent hover:underline hover:underline-offset-2 white:bg-none white:text-[clamp(24px,6vw,32px)] white:leading-[1.05] white:font-extrabold white:tracking-[-0.02em] white:whitespace-nowrap white:text-foreground dark:compact:text-[22px] dark:[@media(560px<height<=780px)]:text-[20px] dark:[@media(max-height:560px)]:text-[17px]"
|
||||||
|
href={window.location.pathname + window.location.search}
|
||||||
|
>
|
||||||
|
Vocero
|
||||||
|
</a>
|
||||||
|
</h1>
|
||||||
|
{white && (
|
||||||
|
<p className="m-0 text-[clamp(12px,2.6vw,14px)] leading-[1.3] font-medium tracking-[-0.005em] whitespace-nowrap text-slate-900/60">
|
||||||
|
Agente conversacional
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{white && name && (
|
||||||
|
<button
|
||||||
|
id="ident-user-name"
|
||||||
|
className="z-2 col-start-3 row-start-1 mr-0.5 inline-flex size-[42px] cursor-pointer items-center justify-center self-center justify-self-end rounded-full border-2 border-solid border-brand-secondary/25 bg-linear-to-br from-speaking to-accent p-0 text-[13px] font-bold text-white shadow-[0_3px_10px_rgb(37_99_235/0.22)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
|
||||||
|
type="button"
|
||||||
|
aria-label={`Ver datos de ${name}`}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
onClick={() => {
|
||||||
|
setModal("profile");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{initials(name)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{hasReports && !desktop && (
|
||||||
|
<IconButton
|
||||||
|
id="reports-sidebar-toggle"
|
||||||
|
type="button"
|
||||||
|
aria-label="Abrir panel de reportes"
|
||||||
|
aria-expanded={reportsOpen}
|
||||||
|
aria-controls="reports-sidebar"
|
||||||
|
onClick={() => {
|
||||||
|
setReportsOpen(!reportsOpen);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PanelLeft size={18} />
|
||||||
|
<span
|
||||||
|
id="reports-sidebar-badge"
|
||||||
|
className="pointer-events-none absolute -top-[3px] -right-[3px] inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-linear-to-br from-brand-secondary to-brand px-[5px] text-[10px] leading-[18px] font-bold text-white"
|
||||||
|
data-count={state.reports?.count}
|
||||||
|
>
|
||||||
|
{state.reports?.count}
|
||||||
|
</span>
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
{debug && (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 white:col-span-full white:row-start-2 white:mt-3 white:flex-wrap white:justify-center compact:flex-wrap [&_svg]:size-5"
|
||||||
|
data-debug-enabled="true"
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
aria-label="Acerca de"
|
||||||
|
onClick={() => {
|
||||||
|
setModal("about");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Info size={18} />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
aria-label="Herramientas"
|
||||||
|
onClick={() => {
|
||||||
|
setModal("tools");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Wrench size={18} />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
aria-label="Contexto RAG personalizado"
|
||||||
|
onClick={() => {
|
||||||
|
setModal("rag");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<BookOpen size={18} />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
aria-label="Configuración"
|
||||||
|
onClick={() => {
|
||||||
|
setModal("settings");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Settings size={18} />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
{state.error && (
|
||||||
|
<div
|
||||||
|
className="mx-auto my-3 flex max-w-[680px] items-center gap-3 rounded-xl border border-solid border-error bg-surface-raised px-4 py-2.5 text-[13px]"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<span>{state.error}</span>
|
||||||
|
<IconButton
|
||||||
|
aria-label="Cerrar aviso"
|
||||||
|
onClick={() => {
|
||||||
|
controller.dismissError();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<VoiceStage state={state} controller={controller} />
|
||||||
|
{ready && !ACTIVE_STATES.has(state.status) && !modal && !(reportsOpen && !desktop) && (
|
||||||
|
<WakeWordControl enabled={state.wakeWordEnabled} controller={controller} />
|
||||||
|
)}
|
||||||
|
<Conversation
|
||||||
|
messages={state.messages}
|
||||||
|
userName={name || "Usuario"}
|
||||||
|
status={state.status}
|
||||||
|
white={white}
|
||||||
|
muted={state.muted}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{!white && ACTIVE_STATES.has(state.status) && <Bubbles messages={state.messages} userName={name || "Usuario"} />}
|
||||||
|
{hasReports && state.reports && (
|
||||||
|
<ReportsPanel
|
||||||
|
reports={state.reports}
|
||||||
|
desktop={desktop}
|
||||||
|
open={reportsOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setReportsOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{modal === "settings" && <SettingsDialog state={state} controller={controller} onClose={close} />}
|
||||||
|
{modal === "tools" && <ToolsDialog state={state} controller={controller} onClose={close} />}
|
||||||
|
{modal === "rag" && <RagDialog state={state} controller={controller} onClose={close} />}
|
||||||
|
{modal === "profile" && <ProfileDialog state={state} onClose={close} />}
|
||||||
|
{modal === "about" && <AboutDialog onClose={close} />}
|
||||||
|
<footer
|
||||||
|
className={`relative flex flex-none flex-col items-center gap-2 px-3 pt-4 pb-[max(12px,env(safe-area-inset-bottom))] text-center before:absolute before:top-0 before:left-1/2 before:h-px before:w-[min(640px,70vw)] before:-translate-x-1/2 before:bg-linear-to-r before:from-transparent before:via-brand/40 before:to-transparent before:opacity-70 before:content-[''] ${hasReports ? "lg:ml-[320px]" : ""}`}
|
||||||
|
aria-label="Información de la aplicación"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
className="rounded border-0 p-1 text-[13px] text-muted underline underline-offset-3 hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-accent"
|
||||||
|
href="https://tally.so/r/pb8LWq"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
Enviar comentarios
|
||||||
|
</a>
|
||||||
|
<span className="max-w-full bg-(image:--brand-gradient) bg-clip-text text-[clamp(10px,1.5vw,11px)] leading-[1.35] font-medium tracking-[0.18em] wrap-anywhere text-transparent uppercase white:bg-none white:font-semibold white:tracking-[0.12em] white:text-slate-900/60">
|
||||||
|
Azurian Vocero en tiempo real ver. Beta 2.1
|
||||||
|
</span>
|
||||||
|
</footer>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
let processor: AudioWorkletProcessor;
|
||||||
|
const port = { onmessage: undefined as ((event: MessageEvent) => void) | undefined, postMessage: vi.fn() };
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
port.postMessage.mockClear();
|
||||||
|
vi.stubGlobal("sampleRate", 48000);
|
||||||
|
vi.stubGlobal(
|
||||||
|
"AudioWorkletProcessor",
|
||||||
|
class {
|
||||||
|
port = port;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
vi.stubGlobal("registerProcessor", (_name: string, Processor: new () => AudioWorkletProcessor) => {
|
||||||
|
processor = new Processor();
|
||||||
|
});
|
||||||
|
await import("./audio-playback.worklet");
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
function send(data: unknown) {
|
||||||
|
port.onmessage?.(new MessageEvent("message", { data }));
|
||||||
|
}
|
||||||
|
function render(blocks: number) {
|
||||||
|
let audible = false;
|
||||||
|
for (let index = 0; index < blocks; index++) {
|
||||||
|
const output = new Float32Array(128);
|
||||||
|
processor.process([], [[output]]);
|
||||||
|
audible ||= output.some((value) => value !== 0);
|
||||||
|
}
|
||||||
|
return audible;
|
||||||
|
}
|
||||||
|
it("flushes a final chunk shorter than the prebuffer and signals after playback finishes", () => {
|
||||||
|
send({ kind: "audio", samples: new Float32Array(160).fill(0.5), sequence: 1 });
|
||||||
|
expect(render(1)).toBe(false);
|
||||||
|
expect(port.postMessage).not.toHaveBeenCalled();
|
||||||
|
send({ kind: "finish", sequence: 1 });
|
||||||
|
expect(render(1)).toBe(true);
|
||||||
|
expect(port.postMessage).not.toHaveBeenCalled();
|
||||||
|
render(12);
|
||||||
|
expect(port.postMessage).toHaveBeenCalledExactlyOnceWith({ kind: "drained", sequence: 1 });
|
||||||
|
});
|
||||||
|
it("discards cleared samples and identifies the latest playback generation", () => {
|
||||||
|
send({ kind: "audio", samples: new Float32Array(16000).fill(0.5), sequence: 1 });
|
||||||
|
expect(render(1)).toBe(true);
|
||||||
|
send({ kind: "clear", sequence: 2 });
|
||||||
|
render(2);
|
||||||
|
expect(port.postMessage).toHaveBeenLastCalledWith({ kind: "drained", sequence: 2 });
|
||||||
|
expect(render(10)).toBe(false);
|
||||||
|
send({ kind: "audio", samples: new Float32Array(160).fill(0.25), sequence: 3 });
|
||||||
|
send({ kind: "finish", sequence: 3 });
|
||||||
|
expect(render(12)).toBe(true);
|
||||||
|
expect(port.postMessage).toHaveBeenLastCalledWith({ kind: "drained", sequence: 3 });
|
||||||
|
});
|
||||||
+18
-2
@@ -1,5 +1,7 @@
|
|||||||
type PlaybackMessage =
|
type PlaybackMessage =
|
||||||
{ kind: "config"; inputRate: number } | { kind: "audio"; samples: Float32Array } | { kind: "clear" };
|
| { kind: "config"; inputRate: number }
|
||||||
|
| { kind: "audio"; samples: Float32Array; sequence: number }
|
||||||
|
| { kind: "clear" | "finish"; sequence: number };
|
||||||
|
|
||||||
class AudioPlaybackProcessor extends AudioWorkletProcessor {
|
class AudioPlaybackProcessor extends AudioWorkletProcessor {
|
||||||
private rate = 16000;
|
private rate = 16000;
|
||||||
@@ -10,6 +12,8 @@ class AudioPlaybackProcessor extends AudioWorkletProcessor {
|
|||||||
private fadeIn = 0;
|
private fadeIn = 0;
|
||||||
private fadeOut = 0;
|
private fadeOut = 0;
|
||||||
private lastSample = 0;
|
private lastSample = 0;
|
||||||
|
private sequence = 0;
|
||||||
|
private finished = false;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
@@ -22,16 +26,24 @@ class AudioPlaybackProcessor extends AudioWorkletProcessor {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "audio":
|
case "audio":
|
||||||
|
this.sequence = data.sequence;
|
||||||
|
this.finished = false;
|
||||||
if (data.samples.length) {
|
if (data.samples.length) {
|
||||||
this.queue.push(data.samples);
|
this.queue.push(data.samples);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "clear":
|
case "clear":
|
||||||
|
this.sequence = data.sequence;
|
||||||
|
this.finished = true;
|
||||||
this.queue = [];
|
this.queue = [];
|
||||||
this.offset = 0;
|
this.offset = 0;
|
||||||
this.fraction = 0;
|
this.fraction = 0;
|
||||||
this.fadeOut = 32;
|
this.fadeOut = 32;
|
||||||
break;
|
break;
|
||||||
|
case "finish":
|
||||||
|
this.sequence = data.sequence;
|
||||||
|
this.finished = true;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -66,7 +78,7 @@ class AudioPlaybackProcessor extends AudioWorkletProcessor {
|
|||||||
}
|
}
|
||||||
for (let index = 0; index < output.length; index++) {
|
for (let index = 0; index < output.length; index++) {
|
||||||
let value = 0;
|
let value = 0;
|
||||||
if (!this.playing && this.queue.length && this.bufferedMs() >= 90) {
|
if (!this.playing && this.queue.length && (this.finished || this.bufferedMs() >= 90)) {
|
||||||
this.playing = true;
|
this.playing = true;
|
||||||
this.fadeIn = 32;
|
this.fadeIn = 32;
|
||||||
this.fadeOut = 0;
|
this.fadeOut = 0;
|
||||||
@@ -102,6 +114,10 @@ class AudioPlaybackProcessor extends AudioWorkletProcessor {
|
|||||||
stereo[index] = value;
|
stereo[index] = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (this.finished && !this.playing && !this.queue.length) {
|
||||||
|
this.finished = false;
|
||||||
|
this.port.postMessage({ kind: "drained", sequence: this.sequence });
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -26,13 +26,19 @@ export class AudioEngine {
|
|||||||
private closed = false;
|
private closed = false;
|
||||||
private gate: NoiseGate;
|
private gate: NoiseGate;
|
||||||
private muted = false;
|
private muted = false;
|
||||||
|
private playbackSequence = 0;
|
||||||
|
|
||||||
constructor(context: AudioContext, gate: NoiseGate) {
|
constructor(context: AudioContext, gate: NoiseGate) {
|
||||||
this.context = context;
|
this.context = context;
|
||||||
this.gate = gate;
|
this.gate = gate;
|
||||||
}
|
}
|
||||||
|
|
||||||
async start(onChunk: (chunk: ArrayBuffer) => void, onLevel: (rms: number) => void, speaking: () => boolean) {
|
async start(
|
||||||
|
onChunk: (chunk: ArrayBuffer) => void,
|
||||||
|
onLevel: (rms: number) => void,
|
||||||
|
speaking: () => boolean,
|
||||||
|
onDrained: () => void,
|
||||||
|
) {
|
||||||
const stream = await navigator.mediaDevices.getUserMedia(MIC_CONSTRAINTS);
|
const stream = await navigator.mediaDevices.getUserMedia(MIC_CONSTRAINTS);
|
||||||
if (this.closed) {
|
if (this.closed) {
|
||||||
stream.getTracks().forEach((track) => {
|
stream.getTracks().forEach((track) => {
|
||||||
@@ -58,6 +64,11 @@ export class AudioEngine {
|
|||||||
playback.port.postMessage({ kind: "config", inputRate: 16000 });
|
playback.port.postMessage({ kind: "config", inputRate: 16000 });
|
||||||
this.capture = capture;
|
this.capture = capture;
|
||||||
this.playback = playback;
|
this.playback = playback;
|
||||||
|
playback.port.onmessage = (event: MessageEvent<{ kind: string; sequence: number }>) => {
|
||||||
|
if (!this.closed && event.data.kind === "drained" && event.data.sequence === this.playbackSequence) {
|
||||||
|
onDrained();
|
||||||
|
}
|
||||||
|
};
|
||||||
this.setGate(this.gate);
|
this.setGate(this.gate);
|
||||||
this.setMuted(this.muted);
|
this.setMuted(this.muted);
|
||||||
capture.port.onmessage = (event: MessageEvent<ArrayBuffer | { kind: "level"; rms: number }>) => {
|
capture.port.onmessage = (event: MessageEvent<ArrayBuffer | { kind: "level"; rms: number }>) => {
|
||||||
@@ -110,10 +121,13 @@ export class AudioEngine {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const samples = decodePcm(base64);
|
const samples = decodePcm(base64);
|
||||||
this.playback?.port.postMessage({ kind: "audio", samples }, [samples.buffer]);
|
this.playback?.port.postMessage({ kind: "audio", samples, sequence: ++this.playbackSequence }, [samples.buffer]);
|
||||||
}
|
}
|
||||||
clear() {
|
clear() {
|
||||||
this.playback?.port.postMessage({ kind: "clear" });
|
this.playback?.port.postMessage({ kind: "clear", sequence: ++this.playbackSequence });
|
||||||
|
}
|
||||||
|
finishPlayback() {
|
||||||
|
this.playback?.port.postMessage({ kind: "finish", sequence: this.playbackSequence });
|
||||||
}
|
}
|
||||||
setGate(gate: NoiseGate) {
|
setGate(gate: NoiseGate) {
|
||||||
this.gate = gate;
|
this.gate = gate;
|
||||||
Vendored
@@ -0,0 +1,14 @@
|
|||||||
|
import { memo, useMemo } from "react";
|
||||||
|
|
||||||
|
import { renderMarkdown } from "../lib/messages";
|
||||||
|
|
||||||
|
export const AssistantMarkdown = memo(function AssistantMarkdown({
|
||||||
|
text,
|
||||||
|
className = "",
|
||||||
|
}: {
|
||||||
|
text: string;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const html = useMemo(() => renderMarkdown(text), [text]);
|
||||||
|
return <div className={`markdown-content ${className}`} dangerouslySetInnerHTML={{ __html: html }} />;
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user