chore(vocero): remove the legacy ApiPilot integration (#3)
Vocero now retrieves AquaPortal content through authenticated MCP. This removes the obsolete ApiPilot integration from the API and browser while preserving the separately deployed speech pipeline. Release notes: - Removed ApiPilot endpoints, configuration, credential overrides, and HTTP/realtime tool execution. - Removed browser ApiPilot catalog loading and prompt generation while retaining authenticated MCP discovery. - Updated regression coverage and operating documentation for the retired routes and settings. Reviewed-on: AquaPortal/s2s#3
This commit is contained in:
@@ -43,7 +43,7 @@ URLs, not filesystem paths.
|
|||||||
| `vocero-api/vocero_api/api/` | HTTP routes, route registration, and response validation helper |
|
| `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/models/` | Pydantic request and response contracts |
|
||||||
| `vocero-api/vocero_api/core/` | Environment parsing, logging, public URLs, and startup diagnostics |
|
| `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/providers/` | Authenticated MCP transport, backend login, and caller/profile/report HTTP integrations |
|
||||||
| `vocero-api/vocero_api/services/` | Prompt/context construction, reasoning, and tool-context rendering |
|
| `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-api/vocero_api/realtime/` | WebSocket relay, event handling, marker filtering, and audio forwarding |
|
||||||
| `vocero-frontend/` | React, TypeScript, Tailwind, and Vite browser application |
|
| `vocero-frontend/` | React, TypeScript, Tailwind, and Vite browser application |
|
||||||
@@ -139,7 +139,7 @@ evidence; do not bundle them into tooling or structural refactors.
|
|||||||
migration at `3d42df5`. `vocero-api/tests/test_compatibility.py` compares HTTP
|
migration at `3d42df5`. `vocero-api/tests/test_compatibility.py` compares HTTP
|
||||||
response bytes, statuses, selected headers, outgoing requests, request schemas,
|
response bytes, statuses, selected headers, outgoing requests, request schemas,
|
||||||
and realtime frames. Do not regenerate captures merely to make a refactor pass.
|
and realtime frames. Do not regenerate captures merely to make a refactor pass.
|
||||||
- Known reasoning failures, ApiPilot authentication/cache quirks, and relay
|
- Known reasoning failures and relay
|
||||||
behavior are documented in the compatibility guide. Preserve narrow lint/type
|
behavior are documented in the compatibility guide. Preserve narrow lint/type
|
||||||
exceptions that retain those behaviors until a deliberate fix is in scope.
|
exceptions that retain those behaviors until a deliberate fix is in scope.
|
||||||
- The pytest warning filter is only for Starlette TestClient's exact deprecated
|
- The pytest warning filter is only for Starlette TestClient's exact deprecated
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ documentation, source identifiers, comments, and internal instructions use Engli
|
|||||||
| Component | Purpose |
|
| Component | Purpose |
|
||||||
|---|---|
|
|---|---|
|
||||||
| [vocero-frontend](vocero-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 |
|
||||||
| [vocero-api](vocero-api) | FastAPI backend, realtime WebSocket proxy, search and ApiPilot bridges |
|
| [vocero-api](vocero-api) | FastAPI backend, realtime WebSocket proxy, search and authenticated AquaPortal MCP |
|
||||||
| [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 |
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -15,14 +15,14 @@ flowchart LR
|
|||||||
Server[Client FastAPI server]
|
Server[Client FastAPI server]
|
||||||
Speech[Realtime speech backend]
|
Speech[Realtime speech backend]
|
||||||
Reasoning[OpenAI-compatible HTTP reasoning provider]
|
Reasoning[OpenAI-compatible HTTP reasoning provider]
|
||||||
Tools[ApiPilot backend]
|
Tools[AquaPortal MCP backend]
|
||||||
Browser <-->|Frontend and HMR| Vite
|
Browser <-->|Frontend and HMR| Vite
|
||||||
Vite -->|HTTP configuration and proxies| Server
|
Vite -->|HTTP configuration and proxies| Server
|
||||||
Vite <-->|Speech WebSocket relay| Server
|
Vite <-->|Speech WebSocket relay| Server
|
||||||
Server <-->|Realtime events and audio| Speech
|
Server <-->|Realtime events and audio| Speech
|
||||||
Browser <-->|Optional direct connection| Speech
|
Browser <-->|Optional direct connection| Speech
|
||||||
Server -->|Optional transcript reasoning| Reasoning
|
Server -->|Optional transcript reasoning| Reasoning
|
||||||
Server -->|Tool discovery and execution| Tools
|
Server -->|Authenticated tool discovery and execution| Tools
|
||||||
```
|
```
|
||||||
|
|
||||||
## Connection modes
|
## Connection modes
|
||||||
@@ -34,8 +34,8 @@ session/queue proxy, or the user can enter a direct URL in settings. See
|
|||||||
[usage](usage.md) for the configuration matrix.
|
[usage](usage.md) for the configuration matrix.
|
||||||
|
|
||||||
The local realtime proxy relays audio and events in both directions. It also
|
The local realtime proxy relays audio and events in both directions. It also
|
||||||
patches selected `session.update` instructions, handles ApiPilot marker-based
|
patches selected `session.update` instructions, handles authenticated MCP
|
||||||
tool execution, filters internal text, and can suppress speculative/tool-marker
|
tool execution when enabled, filters internal text, and suppresses MCP tool-marker
|
||||||
audio. It is not an unconditional byte-for-byte tunnel.
|
audio. It is not an unconditional byte-for-byte tunnel.
|
||||||
|
|
||||||
## Source map
|
## Source map
|
||||||
@@ -47,7 +47,7 @@ audio. It is not an unconditional byte-for-byte tunnel.
|
|||||||
| [api](../../vocero-api/vocero_api/api) | HTTP route registration, handlers and compatible response validation |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [providers](../../vocero-api/vocero_api/providers) | Authenticated MCP transport, backend login, and caller HTTP integrations |
|
||||||
| [services](../../vocero-api/vocero_api/services) | Context construction, reasoning and tool-context rendering |
|
| [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 |
|
| [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 |
|
||||||
@@ -74,8 +74,6 @@ audio. It is not an unconditional byte-for-byte tunnel.
|
|||||||
| `GET /api/mcp/tools` | Discover the selected conversation's authenticated MCP catalog |
|
| `GET /api/mcp/tools` | Discover the selected conversation's authenticated MCP catalog |
|
||||||
| `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 |
|
|
||||||
| `POST /api/apipilot/execute` | Validated tool execution returning a `success/data/error` envelope |
|
|
||||||
| `POST /api/search` | Serper web search proxy |
|
| `POST /api/search` | Serper web search proxy |
|
||||||
| `/api/session`, `/api/queue/*` | Load-balancer session and waiting-queue proxies |
|
| `/api/session`, `/api/queue/*` | Load-balancer session and waiting-queue proxies |
|
||||||
|
|
||||||
|
|||||||
@@ -27,10 +27,10 @@ episode, report and prior-conversation context. It also controls whether the
|
|||||||
browser appends caller profile text to its realtime instructions.
|
browser appends caller profile text to its realtime instructions.
|
||||||
|
|
||||||
It is not a master switch for every context source. Operator-supplied RAG text
|
It is not a master switch for every context source. Operator-supplied RAG text
|
||||||
and configured ApiPilot discovery work independently. `S2S_INJECT_TODAY`
|
and authenticated MCP orchestration work independently. `S2S_INJECT_TODAY`
|
||||||
defaults to true; date and voice-output guards are built on paths that invoke
|
defaults to true; date and voice-output guards are built on paths that invoke
|
||||||
instruction augmentation or HTTP context assembly. A WebSocket session with no
|
instruction augmentation or HTTP context assembly. A WebSocket session with no
|
||||||
profile/episode/report/summary or ApiPilot block skips proxy augmentation.
|
profile/episode/report/summary block skips non-MCP proxy augmentation.
|
||||||
Direct browser connections bypass server-side WebSocket injection entirely.
|
Direct browser connections bypass server-side WebSocket injection entirely.
|
||||||
|
|
||||||
| Setting | Default | Effect |
|
| Setting | Default | Effect |
|
||||||
@@ -51,7 +51,6 @@ Direct browser connections bypass server-side WebSocket injection entirely.
|
|||||||
| Scheduled reports | `S2S_REPORT_LIST`, profile gate and report gate | Newest N labels/dates by default; full mode includes scripts with a 12,000-character block limit |
|
| Scheduled reports | `S2S_REPORT_LIST`, profile gate and report gate | Newest N labels/dates by default; full mode includes scripts with a 12,000-character block limit |
|
||||||
| Latest conversation | `S2S_LATEST_SUMMARY_TEXT`, or `S2S_LATEST_SUMMARY` with a phone number | Up to 1,600 characters; static text overrides HTTP fetch |
|
| Latest conversation | `S2S_LATEST_SUMMARY_TEXT`, or `S2S_LATEST_SUMMARY` with a phone number | Up to 1,600 characters; static text overrides HTTP fetch |
|
||||||
| Custom RAG | Operator applies text through the RAG dialog | Up to 12,000 characters; appended in browser instructions and HTTP `user_context` |
|
| Custom RAG | Operator applies text through the RAG dialog | Up to 12,000 characters; appended in browser instructions and HTTP `user_context` |
|
||||||
| ApiPilot discovery | Configured `S2S_APIPILOT_TOOLS_URL` | Tool names/descriptions/auth requirements and call-marker rules, independent of the profile gate |
|
|
||||||
|
|
||||||
### Episode
|
### Episode
|
||||||
|
|
||||||
@@ -113,25 +112,25 @@ session. This context does not require an external retrieval endpoint.
|
|||||||
At local WebSocket connect, the server resolves enabled context and stores it in
|
At local WebSocket connect, the server resolves enabled context and stores it in
|
||||||
`session_ctx`. Each client `session.update` then passes through
|
`session_ctx`. Each client `session.update` then passes through
|
||||||
`_patch_client_session_update_with_profile()`. Other client frame types are not
|
`_patch_client_session_update_with_profile()`. Other client frame types are not
|
||||||
modified by this helper. The broader relay still performs ApiPilot handling and
|
modified by this helper. The non-MCP relay retains its general assistant-text
|
||||||
text/audio filtering.
|
filtering and forwards audio without a tool-execution pass.
|
||||||
|
|
||||||
The current augmentation function **keeps base instructions first**, then
|
The current augmentation function **keeps base instructions first**, then
|
||||||
appends surviving voice-output guards, date context, episode, profile, reports,
|
appends surviving voice-output guards, date context, episode, profile, reports,
|
||||||
and summary blocks. ApiPilot discovery is appended separately. If a block type
|
and summary blocks. If a block type is already present in the base, deduplication
|
||||||
is already present in the base, deduplication may omit its server candidate.
|
may omit its server candidate.
|
||||||
This describes string assembly, not a guarantee of model attention or priority.
|
This describes string assembly, not a guarantee of model attention or priority.
|
||||||
|
|
||||||
Every HTTP reasoning request rebuilds enabled context. Its `user_context`
|
Every HTTP reasoning request rebuilds enabled context. Its `user_context`
|
||||||
assembly orders voice guard, date, episode, profile, reports, summary, ApiPilot
|
assembly orders voice guard, date, episode, profile, reports, summary,
|
||||||
and browser-provided context, then deduplicates recognized types and strips
|
and browser-provided context, then deduplicates recognized types and strips
|
||||||
internal markers. Server instruction augmentation remains separate, so avoid
|
internal markers. Server instruction augmentation remains separate, so avoid
|
||||||
assuming the WebSocket and HTTP prompts have identical ordering.
|
assuming the WebSocket and HTTP prompts have identical ordering.
|
||||||
|
|
||||||
Context markers support internal deduplication and diagnostics; ordinary
|
Context markers support internal deduplication and diagnostics; ordinary
|
||||||
profile/report/episode/history sentinels are stripped before final augmentation.
|
profile/report/episode/history sentinels are stripped before final augmentation.
|
||||||
ApiPilot call/catalog markers have their own handling. Treat the source helpers
|
MCP uses its separate conversation context and call-marker handling described
|
||||||
as the reference for the exact final payload.
|
above. Treat the source helpers as the reference for the exact final payload.
|
||||||
|
|
||||||
## Failure behavior and inspection
|
## Failure behavior and inspection
|
||||||
|
|
||||||
|
|||||||
+19
-10
@@ -45,7 +45,7 @@ and the retained `server:app` shim; use `--app-dir vocero-api` from the root.
|
|||||||
|
|
||||||
## Schema boundaries
|
## Schema boundaries
|
||||||
|
|
||||||
`models/requests.py` retains the existing search, reasoning, and ApiPilot
|
`models/requests.py` retains the existing search and reasoning
|
||||||
request models, including their defaults, coercion, and ignored extra fields.
|
request models, including their defaults, coercion, and ignored extra fields.
|
||||||
The queue beacon still accepts malformed JSON and non-object bodies as before;
|
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
|
its Pydantic view is applied only after the original object check, and
|
||||||
@@ -54,9 +54,10 @@ its Pydantic view is applied only after the original object check, and
|
|||||||
`models/responses.py` validates structure constructed by this server. Fields
|
`models/responses.py` validates structure constructed by this server. Fields
|
||||||
owned by external services remain `Any` where the old parser imposed no type
|
owned by external services remain `Any` where the old parser imposed no type
|
||||||
constraint. Object models allow extra fields. Caller profiles can be arbitrary
|
constraint. Object models allow extra fields. Caller profiles can be arbitrary
|
||||||
JSON; load-balancer fields remain optional; execution results and error details
|
JSON; load-balancer fields remain optional. Existing provider parsing,
|
||||||
remain open. Existing provider parsing, authentication precedence, argument
|
authentication precedence, environment parsing, and WebSocket frames are
|
||||||
filtering, environment parsing, and WebSocket frames are unchanged.
|
preserved outside explicitly retired integrations. Authenticated conversation
|
||||||
|
and MCP contracts live in `models/mcp.py` and `models/content.py`.
|
||||||
|
|
||||||
HTTP handlers call `validated_json_response`: Pydantic validates the payload,
|
HTTP handlers call `validated_json_response`: Pydantic validates the payload,
|
||||||
then `JSONResponse` serializes the original object. Returning a dumped model
|
then `JSONResponse` serializes the original object. Returning a dumped model
|
||||||
@@ -69,19 +70,23 @@ existing error shapes; there is no new common error envelope.
|
|||||||
## Compatibility evidence
|
## Compatibility evidence
|
||||||
|
|
||||||
`tests/fixtures/legacy-contracts.json` was captured from the original monolithic
|
`tests/fixtures/legacy-contracts.json` was captured from the original monolithic
|
||||||
server at commit `3d42df5`, before refactoring. Tests compare 82 HTTP scenarios,
|
server at commit `3d42df5`, before refactoring. Tests compare 66 HTTP scenarios,
|
||||||
including exact response text, statuses, selected headers, and outgoing mock
|
including exact response text, statuses, selected headers, and outgoing mock
|
||||||
HTTP requests. They also compare request OpenAPI definitions and representative
|
HTTP requests. They also compare request OpenAPI definitions and representative
|
||||||
realtime frames. Additional tests cover response validation and permissive
|
realtime frames. Additional tests cover response validation and permissive
|
||||||
upstream values; the original local HTTP/WebSocket tests remain in the suite.
|
upstream values; the original local HTTP/WebSocket tests remain in the suite.
|
||||||
Four captures belonging exclusively to the retired workflow reasoning service
|
Four captures belonging exclusively to the retired workflow reasoning service
|
||||||
were removed with that integration; all retained captures are unchanged.
|
were removed with that integration. ApiPilot retirement removed another 16 HTTP
|
||||||
|
captures, its two OpenAPI operations, and its request model. The four bootstrap
|
||||||
|
captures now omit the retired `apipilot` flag. One reasoning failure case was
|
||||||
|
renamed because removing ApiPilot eliminated its token access; it still exposes
|
||||||
|
the existing uninitialized profile failure. All other retained capture values
|
||||||
|
are unchanged.
|
||||||
|
|
||||||
The captures deliberately preserve unknown fields, omitted fields, unusual JSON
|
The captures deliberately preserve unknown fields, omitted fields, unusual JSON
|
||||||
values, queue beacon edge cases, permissive tool arguments, and known failures.
|
values, queue beacon edge cases, and known failures. Examples include reasoning's
|
||||||
Examples include reasoning's existing uninitialized local variables in certain
|
existing uninitialized profile in certain non-MCP configuration combinations
|
||||||
configuration combinations, API-key-only tool precheck behavior, the catalog
|
and relay termination after a suppressed invalid
|
||||||
cache's existing key, and relay termination after a suppressed invalid
|
|
||||||
`session.update` error. Fix those in separately reviewed behavior changes;
|
`session.update` error. Fix those in separately reviewed behavior changes;
|
||||||
do not regenerate golden captures to make a refactor pass.
|
do not regenerate golden captures to make a refactor pass.
|
||||||
|
|
||||||
@@ -90,3 +95,7 @@ provider responses, live speech models, or production configuration. No deployed
|
|||||||
services or real credentials are needed. The Docker check verifies the actual
|
services or real credentials are needed. The Docker check verifies the actual
|
||||||
runtime image, API configuration, documentation, OpenAPI, and the backend's
|
runtime image, API configuration, documentation, OpenAPI, and the backend's
|
||||||
existing root 404; it removes its temporary container and image afterward.
|
existing root 404; it removes its temporary container and image afterward.
|
||||||
|
|
||||||
|
ApiPilot removal deliberately excludes `speech_to_speech/`. The MCP orchestration
|
||||||
|
test still exercises its existing pure marker sanitizer without importing model
|
||||||
|
dependencies, verifying that `VOCERO_MCP_CALL` reaches the Vocero relay unchanged.
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ names shown in a deployment are configuration examples, not definitions.
|
|||||||
| Tool executor | Code that receives a tool call, executes it and returns the result to the model |
|
| Tool executor | Code that receives a tool call, executes it and returns the result to the model |
|
||||||
| Web search | The browser-supported native tool whose request goes through the server's `/api/search` proxy |
|
| Web search | The browser-supported native tool whose request goes through the server's `/api/search` proxy |
|
||||||
| Search proxy | Same-origin server route holding a deployment search key and calling the external provider |
|
| Search proxy | Same-origin server route holding a deployment search key and calling the external provider |
|
||||||
| ApiPilot | The dynamic-tool discovery/execution bridge, with both HTTP endpoints and model-text call markers |
|
| AquaPortal MCP | Authenticated tool discovery and execution using the current conversation's backend session |
|
||||||
| Call marker | Internal structured text such as `<<<APIPILOT_CALL:...>>>` used by the ApiPilot bridge; separate from native function-call events |
|
| Call marker | Internal structured text such as `<<<VOCERO_MCP_CALL:...>>>` used by the MCP orchestrator; separate from native function-call events |
|
||||||
| Tools panel | Controls for available browser tools and search-key configuration |
|
| Tools panel | Controls for available browser tools and search-key configuration |
|
||||||
| Settings | Connection, voice, audio and instruction controls persisted in browser storage |
|
| Settings | Connection, voice, audio and instruction controls persisted in browser storage |
|
||||||
| Identity block | The header's product/user identity, separate from configuration dialogs |
|
| Identity block | The header's product/user identity, separate from configuration dialogs |
|
||||||
|
|||||||
+15
-10
@@ -19,8 +19,9 @@ with `config.primaryPrompt || settings.instructions`. `/api/config` pins
|
|||||||
`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
|
||||||
profile to choose this base prompt.
|
profile to choose this base prompt.
|
||||||
|
|
||||||
The browser adds caller context when `injectProfile` is enabled, custom RAG text
|
The browser adds caller context when `injectProfile` is enabled and custom RAG
|
||||||
when the operator has applied it, and an available ApiPilot catalog block.
|
text when the operator has applied it. In MCP mode, the server constructs tool
|
||||||
|
instructions from the authenticated conversation's catalog and content selection.
|
||||||
`speechInstructions()` adds the speech-rendering hint only in `OPENAI` mode.
|
`speechInstructions()` adds the speech-rendering hint only in `OPENAI` mode.
|
||||||
The WebSocket client packages these instructions in `session.update.session`;
|
The WebSocket client packages these instructions in `session.update.session`;
|
||||||
the local proxy may then add server-resolved context. Details belong in
|
the local proxy may then add server-resolved context. Details belong in
|
||||||
@@ -30,13 +31,14 @@ the local proxy may then add server-resolved context. Details belong in
|
|||||||
|
|
||||||
The realtime backend receives session instructions and enabled native tools,
|
The realtime backend receives session instructions and enabled native tools,
|
||||||
then processes each voice turn through STT, model generation and TTS.
|
then processes each voice turn through STT, model generation and TTS.
|
||||||
Native function-call events are executed by the browser. ApiPilot also has a
|
Native function-call events are executed by the browser. Authenticated MCP has a
|
||||||
separate marker-based path handled by the local proxy. These are distinct
|
separate marker-based path handled by the local proxy. These are distinct
|
||||||
protocols, described in [tool calling](tool-calling.md).
|
protocols, described in [tool calling](tool-calling.md).
|
||||||
|
|
||||||
Using a direct browser URL bypasses local WebSocket proxy processing. Browser
|
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. MCP in `PROXY` mode requires this relay;
|
||||||
|
configuration rejects a pinned direct URL or load-balancer bypass.
|
||||||
|
|
||||||
## OpenAI-compatible HTTP reasoning
|
## OpenAI-compatible HTTP reasoning
|
||||||
|
|
||||||
@@ -53,12 +55,14 @@ is a runtime default, not a recommendation about provider availability.
|
|||||||
`VoiceController.reason()` in [controller.ts](../../vocero-frontend/src/realtime/controller.ts)
|
`VoiceController.reason()` in [controller.ts](../../vocero-frontend/src/realtime/controller.ts)
|
||||||
runs after a complete user transcript and posts a body with `transcript`,
|
runs after a complete user transcript and posts a body with `transcript`,
|
||||||
`instructions`, `language`, `user_context`, `phoneNumber`, and `episode_id`.
|
`instructions`, `language`, `user_context`, `phoneNumber`, and `episode_id`.
|
||||||
The server combines instructions and context into model messages, requests a
|
Without MCP, the server combines instructions and context into model messages,
|
||||||
final answer with a 30-second timeout, and returns `answer`. ApiPilot can
|
requests a final answer with a 30-second timeout, and returns `answer`.
|
||||||
append discovery instructions, execute a detected call marker, and request a
|
With MCP, the browser also sends conversation and turn headers. The server uses
|
||||||
second response based on the tool result, including available `usage_pass2`
|
that conversation's backend token, linked/current content, and bounded history
|
||||||
metadata. The endpoint does not forward the browser's native `session.tools`
|
to run tool selections before returning `answer`. This path has a 180-second
|
||||||
catalog as OpenAI Chat Completions `tools`.
|
total timeout and configured per-operation limits. Retired ApiPilot execution
|
||||||
|
metadata is no longer returned. The endpoint does not forward the browser's
|
||||||
|
native `session.tools` catalog as OpenAI Chat Completions `tools`.
|
||||||
|
|
||||||
For an external 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
|
||||||
@@ -78,5 +82,6 @@ realtime backend instead.
|
|||||||
| 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 `vocero-frontend/src/lib/context.ts` |
|
| Change browser context assembly | `buildInstructions()` in `vocero-frontend/src/lib/context.ts` |
|
||||||
| Change HTTP provider messages | `reasoning()` in `vocero-api/vocero_api/services/reasoning.py` |
|
| Change HTTP provider messages | `reasoning()` in `vocero-api/vocero_api/services/reasoning.py` |
|
||||||
|
| Change MCP reasoning or tool context | `vocero-api/vocero_api/services/mcp_reasoning.py` and `vocero-api/vocero_api/services/mcp_tools.py` |
|
||||||
| Change external-answer rendering | `VoiceController.reason()` and `RealtimeClient.renderExternalAnswer()` |
|
| Change external-answer rendering | `VoiceController.reason()` and `RealtimeClient.renderExternalAnswer()` |
|
||||||
| Change native model/tool generation | `speech_to_speech/LLM` and realtime handlers |
|
| Change native model/tool generation | `speech_to_speech/LLM` and realtime handlers |
|
||||||
|
|||||||
+17
-54
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
The application supports native realtime function calling and server-side
|
The application supports native realtime function calling and server-side
|
||||||
tool selection from model text. AquaPortal content uses authenticated MCP when
|
tool selection from model text. AquaPortal content uses authenticated MCP when
|
||||||
`S2S_MCP_URL` is set; otherwise the legacy ApiPilot bridge remains available.
|
`S2S_MCP_URL` is set. Without MCP, the native browser tools remain available.
|
||||||
A spoken promise to fetch something is not evidence that a tool executed.
|
A spoken promise to fetch something is not evidence that a tool executed.
|
||||||
|
|
||||||
## Authenticated AquaPortal MCP
|
## Authenticated AquaPortal MCP
|
||||||
@@ -172,69 +172,32 @@ result should be spoken. A `function_call_output` item alone does not start
|
|||||||
generation. See the [realtime API guide](../speech/realtime-api.md) for local
|
generation. See the [realtime API guide](../speech/realtime-api.md) for local
|
||||||
model `<code>` extraction and native remote-provider tool calls.
|
model `<code>` extraction and native remote-provider tool calls.
|
||||||
|
|
||||||
## ApiPilot discovery and execution
|
|
||||||
|
|
||||||
When MCP is disabled, `S2S_APIPILOT_TOOLS_URL` enables the legacy dynamic-tool bridge. The backend
|
|
||||||
discovers tools through its `/tools` route and executes through `/execute`.
|
|
||||||
`S2S_APIPILOT_TOOLS_EXECUTE_URL` can override the execution target. The same-origin UI
|
|
||||||
server exposes:
|
|
||||||
|
|
||||||
- `GET /api/apipilot/tools?include_unauthorized=true`: discovery envelope with
|
|
||||||
`data.tools`, `data.count`, and `data.include_unauthorized`.
|
|
||||||
- `POST /api/apipilot/execute`: body `{"name":"tool_name","arguments":{}}` and
|
|
||||||
a `success/data/error` envelope.
|
|
||||||
|
|
||||||
Tool definitions contain `name`, `description`, JSON Schema `parameters`, and
|
|
||||||
`requires_auth`. Execution looks up the current discovery cache, refreshes once
|
|
||||||
if the tool is missing, and applies an authorization gate and argument
|
|
||||||
validation before forwarding. Caller `Authorization` and
|
|
||||||
`x-demo-session` can be forwarded; configured service credentials and
|
|
||||||
login-by-phone provide additional server paths. Display `error.message` to the
|
|
||||||
user; keep `error.code`, `error.status_code`, and `error.raw` for diagnostics.
|
|
||||||
|
|
||||||
The discovery cache defaults to 300 seconds server-side
|
|
||||||
(`S2S_APIPILOT_TTL_SEC`) and can be refreshed with `refresh=true`. The browser
|
|
||||||
also keeps a per-user catalog cache. ApiPilot configuration is independent of
|
|
||||||
`S2S_INJECT_PROFILE`.
|
|
||||||
|
|
||||||
## Marker-based two-pass reasoning
|
|
||||||
|
|
||||||
The browser and proxy add an ApiPilot catalog as **instruction text**, including
|
|
||||||
tool names, descriptions, authentication requirements and required parameters.
|
|
||||||
The model is instructed to emit a call marker when it chooses a matching tool:
|
|
||||||
|
|
||||||
```text
|
|
||||||
<<<APIPILOT_CALL:NAME=tool_name,ARGUMENTS={"parameter":"value"}>>>
|
|
||||||
```
|
|
||||||
|
|
||||||
Tool names are exact identifiers and must not be translated. The local realtime
|
|
||||||
proxy accumulates assistant text, detects the marker, validates and executes
|
|
||||||
the ApiPilot call, then injects the result as context and requests a new
|
|
||||||
response. The HTTP reasoning route has analogous discovery/execution and
|
|
||||||
second-answer handling. Internal marker text and its speculative audio are
|
|
||||||
filtered from user output where those handlers run.
|
|
||||||
|
|
||||||
This marker is not `response.function_call_arguments.done`, and ApiPilot prompt
|
|
||||||
discovery is not the same as declaring a native `session.tools` array. Direct
|
|
||||||
browser connections bypass the local relay's marker executor even if the
|
|
||||||
browser includes catalog instructions.
|
|
||||||
|
|
||||||
## Retired documentation assumptions
|
## Retired documentation assumptions
|
||||||
|
|
||||||
|
ApiPilot discovery/execution endpoints, catalog injection, credential overrides,
|
||||||
|
and two-pass marker handling have been removed from Vocero. Its former HTTP
|
||||||
|
routes return 404 regardless of MCP configuration. The separate speech service
|
||||||
|
still has its existing legacy marker sanitizers; no ApiPilot requests can be
|
||||||
|
made through Vocero. See [usage](usage.md#authenticated-aquaportal-mcp) for
|
||||||
|
deployment configuration cleanup.
|
||||||
|
|
||||||
The former `HOWITWORKS.md` described `/api/mcp/tool`, remote
|
The former `HOWITWORKS.md` described `/api/mcp/tool`, remote
|
||||||
`S2S_AGENT_PROFILE` loading, and Spanish/Italian-to-English client ranking.
|
`S2S_AGENT_PROFILE` loading, and Spanish/Italian-to-English client ranking.
|
||||||
Those APIs/rankers are absent from the current client/server path. The retained
|
Those APIs/rankers are absent from the current client/server path. The retained
|
||||||
agent-profile JSON is an illustrative historical/example shape. Add or modify
|
agent-profile JSON is an illustrative historical/example shape. Content tool
|
||||||
current ApiPilot tool definitions in the provider; do not restore obsolete
|
definitions and permissions belong to the AquaPortal MCP backend; Vocero's
|
||||||
endpoints solely to match older documentation.
|
provider enforces its allowlist. Do not restore obsolete endpoints solely to
|
||||||
|
match older documentation.
|
||||||
|
|
||||||
## Verify a tool turn
|
## Verify a tool turn
|
||||||
|
|
||||||
1. Confirm `/api/config` reports the intended feature and connection mode.
|
1. Confirm `/api/config` reports the intended feature and connection mode.
|
||||||
2. Inspect `session.update`: native `tools` and ApiPilot instruction text are
|
2. For MCP, confirm conversation creation and authenticated `/api/mcp/tools`
|
||||||
separate evidence.
|
discovery succeed. Inspect the relay's `session.update` for MCP instructions;
|
||||||
|
these are separate from native `session.tools` declarations.
|
||||||
3. For native calls, find `response.function_call_arguments.done`, then the
|
3. For native calls, find `response.function_call_arguments.done`, then the
|
||||||
browser `toolcall` and executor request.
|
browser `toolcall` and executor request.
|
||||||
4. For ApiPilot, find the discovery, call-marker detection and execution logs.
|
4. For MCP, find `mcp_tool_completed` for the expected user/tool and any
|
||||||
|
`vocero.tool.started` / `vocero.tool.failed` voice events.
|
||||||
5. Confirm the tool result reaches the model and follow-up text/audio reaches
|
5. Confirm the tool result reaches the model and follow-up text/audio reaches
|
||||||
the browser. A spoken acknowledgement alone does not complete the flow.
|
the browser. A spoken acknowledgement alone does not complete the flow.
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ Inspect the server configuration without printing secret environment files:
|
|||||||
curl -fsS http://localhost:8420/api/config | jq
|
curl -fsS http://localhost:8420/api/config | jq
|
||||||
```
|
```
|
||||||
|
|
||||||
Confirm `s2sUrl`, `lb`, `reasoningType`, `injectProfile`, `apipilot`, `theme`
|
Confirm `s2sUrl`, `lb`, `reasoningType`, `injectProfile`, `mcp`, `theme`
|
||||||
and `startSessionMode`. A direct WebSocket URL bypasses the local relay's
|
and `startSessionMode`. A direct WebSocket URL bypasses the local relay's
|
||||||
server-side context injection and marker execution. For a local proxy, confirm
|
server-side context injection and marker execution. For a local proxy, confirm
|
||||||
the server can resolve and reach `S2S_PROXY_TARGET`.
|
the server can resolve and reach `S2S_PROXY_TARGET`.
|
||||||
@@ -50,8 +50,10 @@ Follow the actual execution mechanism:
|
|||||||
JSON-string arguments.
|
JSON-string arguments.
|
||||||
3. Confirm the browser dispatched `toolcall` and the executor sent its request.
|
3. Confirm the browser dispatched `toolcall` and the executor sent its request.
|
||||||
4. Confirm `function_call_output` and any required `response.create` were sent.
|
4. Confirm `function_call_output` and any required `response.create` were sent.
|
||||||
5. For ApiPilot, inspect discovery, marker detection, authorization/schema
|
5. For MCP, inspect conversation creation, `/api/mcp/tools` discovery,
|
||||||
validation, execution, and second-pass result injection instead.
|
`mcp_tool_completed` logs, and the returned tool data/follow-up response.
|
||||||
|
Verify the conversation belongs to the browser and backend authorization
|
||||||
|
succeeds; do not retry with shared credentials or legacy content endpoints.
|
||||||
|
|
||||||
A spoken sentence such as “Voy a buscar el reporte” does not satisfy step 2.
|
A spoken sentence such as “Voy a buscar el reporte” does not satisfy step 2.
|
||||||
If the function-call event is absent, inspect model/backend tool generation
|
If the function-call event is absent, inspect model/backend tool generation
|
||||||
@@ -59,7 +61,7 @@ before debugging a browser executor that has not been invoked.
|
|||||||
|
|
||||||
Earlier local debug notes recorded a reproduction where tools and required
|
Earlier local debug notes recorded a reproduction where tools and required
|
||||||
tool policy were sent but the model produced only spoken text. Those notes
|
tool policy were sent but the model produced only spoken text. Those notes
|
||||||
refer to the retired MCP ranking path; they do not establish a current ApiPilot
|
refer to the retired MCP ranking path; they do not establish a current AquaPortal MCP
|
||||||
failure. Both local investigation notes remain marked `OPEN`. Their useful
|
failure. Both local investigation notes remain marked `OPEN`. Their useful
|
||||||
lesson is to collect evidence at each boundary and reproduce against current
|
lesson is to collect evidence at each boundary and reproduce against current
|
||||||
code before claiming a root cause is resolved.
|
code before claiming a root cause is resolved.
|
||||||
@@ -114,7 +116,7 @@ Useful checks are:
|
|||||||
|
|
||||||
The historical investigation introduced a 90ms playback prebuffer and reduced
|
The historical investigation introduced a 90ms playback prebuffer and reduced
|
||||||
MCP prompt reinjection. The old 150ms MCP fallback timer is historical, not a
|
MCP prompt reinjection. The old 150ms MCP fallback timer is historical, not a
|
||||||
current ApiPilot configuration knob. Verify the live worklet and current relay
|
current MCP configuration knob. Verify the live worklet and current relay
|
||||||
before changing audio timing based on those observations.
|
before changing audio timing based on those observations.
|
||||||
|
|
||||||
## External reasoning and startup
|
## External reasoning and startup
|
||||||
|
|||||||
+18
-7
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
The browser voice client connects to an existing OpenAI Realtime-compatible
|
The browser voice client connects to an existing OpenAI Realtime-compatible
|
||||||
speech backend. The UI server can also proxy WebSocket traffic, search,
|
speech backend. The UI server can also proxy WebSocket traffic, search,
|
||||||
reasoning, caller context, ApiPilot tools and load-balancer requests.
|
reasoning, caller context, authenticated AquaPortal MCP tools and load-balancer
|
||||||
|
requests.
|
||||||
|
|
||||||
## Local startup
|
## Local startup
|
||||||
|
|
||||||
@@ -126,8 +127,8 @@ translations.
|
|||||||
|
|
||||||
Web search needs `SERPER_API_KEY` on the server or a user-supplied key in the
|
Web search needs `SERPER_API_KEY` on the server or a user-supplied key in the
|
||||||
tools panel. The server proxies requests through `/api/search`; its own key is
|
tools panel. The server proxies requests through `/api/search`; its own key is
|
||||||
not returned to the browser. Dynamic ApiPilot tools are configured through
|
not returned to the browser. AquaPortal content tools use authenticated MCP;
|
||||||
`S2S_APIPILOT_TOOLS_URL`; see [tool calling](tool-calling.md).
|
see [tool calling](tool-calling.md).
|
||||||
|
|
||||||
Operator RAG text is applied through the RAG dialog and updates live
|
Operator RAG text is applied through the RAG dialog and updates live
|
||||||
instructions. Caller links may include `phoneNumber=%2B...`; episode links may
|
instructions. Caller links may include `phoneNumber=%2B...`; episode links may
|
||||||
@@ -141,7 +142,7 @@ enabled data sources and limits.
|
|||||||
Streamable HTTP endpoint, for example `https://backend.example.test/api/v1/mcp/`.
|
Streamable HTTP endpoint, for example `https://backend.example.test/api/v1/mcp/`.
|
||||||
Set `S2S_LOGIN_BY_PHONE` to that backend's `POST /api/v1/auth/phone-login` URL.
|
Set `S2S_LOGIN_BY_PHONE` to that backend's `POST /api/v1/auth/phone-login` URL.
|
||||||
The phone parameter remains an identity selector without proof of ownership.
|
The phone parameter remains an identity selector without proof of ownership.
|
||||||
MCP uses the returned opaque `sessionToken`, never an ApiPilot API key or service token.
|
MCP uses the returned opaque `sessionToken` for that user, with no service-token override.
|
||||||
|
|
||||||
For voice reasoning (`S2S_REASONING_TYPE=PROXY`), set `S2S_PROXY_TARGET` to the
|
For voice reasoning (`S2S_REASONING_TYPE=PROXY`), set `S2S_PROXY_TARGET` to the
|
||||||
speech service and leave `SPEECH_TO_SPEECH_URL` and `LOAD_BALANCER_URL` unset.
|
speech service and leave `SPEECH_TO_SPEECH_URL` and `LOAD_BALANCER_URL` unset.
|
||||||
@@ -153,10 +154,20 @@ or audio-format change is required.
|
|||||||
|
|
||||||
The backend must expose the eight-tool catalog and phone-login contract described
|
The backend must expose the eight-tool catalog and phone-login contract described
|
||||||
in [tool calling](tool-calling.md#authenticated-aquaportal-mcp). Leave
|
in [tool calling](tool-calling.md#authenticated-aquaportal-mcp). Leave
|
||||||
`S2S_MCP_URL` unset to retain the existing ApiPilot and caller-context paths.
|
`S2S_MCP_URL` unset to use the existing caller-context paths without MCP tools.
|
||||||
Setting it makes MCP the exclusive AquaPortal content path: legacy profile,
|
Setting it makes MCP the exclusive AquaPortal content path: legacy profile,
|
||||||
report, latest-summary, and episode preloads are skipped. Existing ApiPilot
|
report, latest-summary, and episode preloads are skipped. Legacy caller HTTP
|
||||||
and caller HTTP routes return 409 while MCP is enabled.
|
routes return 409 while MCP is enabled.
|
||||||
|
|
||||||
|
ApiPilot has been retired. `/api/apipilot/tools` and `/api/apipilot/execute`
|
||||||
|
return 404, and `/api/config` no longer includes `apipilot`. Remove obsolete
|
||||||
|
`S2S_APIPILOT_TOOLS_URL`, `S2S_APIPILOT_TOOLS_EXECUTE_URL`,
|
||||||
|
`S2S_APIPILOT_SERVICE_TOKEN`, `S2S_APIPILOT_API_KEY`, `S2S_APIPILOT_TTL_SEC`,
|
||||||
|
and `S2S_APIPILOT_TIMEOUT_SEC` from deployment configuration; they are ignored.
|
||||||
|
Keep `S2S_LOGIN_BY_PHONE`, which is still required for MCP authentication.
|
||||||
|
Deploy the API and frontend together and refresh existing browser sessions.
|
||||||
|
The separately deployed `speech_to_speech/` service is unchanged by this removal;
|
||||||
|
its existing legacy marker sanitizers remain, with no speech restart required.
|
||||||
|
|
||||||
| Setting | Default | Meaning |
|
| Setting | Default | Meaning |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|||||||
@@ -46,6 +46,11 @@ the implementation at the recorded date; current operating instructions live in
|
|||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
- Removed the ApiPilot API bridge, configuration, credential overrides, browser
|
||||||
|
catalog/prompt handling, and two-pass HTTP/realtime execution. AquaPortal tools
|
||||||
|
use authenticated MCP; the separate speech pipeline retains its existing
|
||||||
|
marker sanitizers.
|
||||||
|
|
||||||
- Use the AquaPortal admin favicon in the Vocero browser frontend.
|
- Use the AquaPortal admin favicon in the Vocero browser frontend.
|
||||||
|
|
||||||
- Start `TALK` and `TALKFIRST` conversations as soon as configuration and caller
|
- Start `TALK` and `TALKFIRST` conversations as soon as configuration and caller
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ short_description: Voice chat over WebSocket against a HF speech-to-speech
|
|||||||
|
|
||||||
FastAPI APIs and WebSocket relay for the [React voice frontend](../vocero-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, authenticated AquaPortal MCP tools, and optional load-balancer
|
||||||
|
allocation.
|
||||||
|
|
||||||
The root `start_ui.sh` now serves the [React frontend](../vocero-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
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ with urllib.request.urlopen(base + "/api/config") as response:
|
|||||||
assert json.load(response) == {
|
assert json.load(response) == {
|
||||||
"search": False, "lb": False, "allowDirect": True, "s2sUrl": "",
|
"search": False, "lb": False, "allowDirect": True, "s2sUrl": "",
|
||||||
"primaryPrompt": "", "reasoningType": "PROXY", "injectProfile": False,
|
"primaryPrompt": "", "reasoningType": "PROXY", "injectProfile": False,
|
||||||
"debugPrompt": False, "startSessionMode": "", "theme": "", "apipilot": False,
|
"debugPrompt": False, "startSessionMode": "", "theme": "",
|
||||||
}
|
}
|
||||||
for path in ["/docs", "/docs/README.md", "/openapi.json"]:
|
for path in ["/docs", "/docs/README.md", "/openapi.json"]:
|
||||||
with urllib.request.urlopen(base + path) as response:
|
with urllib.request.urlopen(base + path) as response:
|
||||||
|
|||||||
+5
-336
@@ -2,7 +2,7 @@
|
|||||||
"http": {
|
"http": {
|
||||||
"config-default": {
|
"config-default": {
|
||||||
"status": 200,
|
"status": 200,
|
||||||
"body": "{\"search\":false,\"lb\":false,\"allowDirect\":true,\"s2sUrl\":\"\",\"primaryPrompt\":\"\",\"reasoningType\":\"PROXY\",\"injectProfile\":false,\"debugPrompt\":false,\"startSessionMode\":\"\",\"theme\":\"\",\"apipilot\":false}",
|
"body": "{\"search\":false,\"lb\":false,\"allowDirect\":true,\"s2sUrl\":\"\",\"primaryPrompt\":\"\",\"reasoningType\":\"PROXY\",\"injectProfile\":false,\"debugPrompt\":false,\"startSessionMode\":\"\",\"theme\":\"\"}",
|
||||||
"headers": {
|
"headers": {
|
||||||
"content-type": "application/json"
|
"content-type": "application/json"
|
||||||
},
|
},
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
},
|
},
|
||||||
"config-proxy": {
|
"config-proxy": {
|
||||||
"status": 200,
|
"status": 200,
|
||||||
"body": "{\"search\":false,\"lb\":false,\"allowDirect\":true,\"s2sUrl\":\"wss://voice.test/v1/realtime\",\"primaryPrompt\":\"\",\"reasoningType\":\"PROXY\",\"injectProfile\":false,\"debugPrompt\":false,\"startSessionMode\":\"\",\"theme\":\"\",\"apipilot\":false}",
|
"body": "{\"search\":false,\"lb\":false,\"allowDirect\":true,\"s2sUrl\":\"wss://voice.test/v1/realtime\",\"primaryPrompt\":\"\",\"reasoningType\":\"PROXY\",\"injectProfile\":false,\"debugPrompt\":false,\"startSessionMode\":\"\",\"theme\":\"\"}",
|
||||||
"headers": {
|
"headers": {
|
||||||
"content-type": "application/json"
|
"content-type": "application/json"
|
||||||
},
|
},
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
},
|
},
|
||||||
"config-direct": {
|
"config-direct": {
|
||||||
"status": 200,
|
"status": 200,
|
||||||
"body": "{\"search\":false,\"lb\":false,\"allowDirect\":true,\"s2sUrl\":\"wss://voice.test/realtime?token=fixture\",\"primaryPrompt\":\"\",\"reasoningType\":\"PROXY\",\"injectProfile\":false,\"debugPrompt\":false,\"startSessionMode\":\"\",\"theme\":\"\",\"apipilot\":false}",
|
"body": "{\"search\":false,\"lb\":false,\"allowDirect\":true,\"s2sUrl\":\"wss://voice.test/realtime?token=fixture\",\"primaryPrompt\":\"\",\"reasoningType\":\"PROXY\",\"injectProfile\":false,\"debugPrompt\":false,\"startSessionMode\":\"\",\"theme\":\"\"}",
|
||||||
"headers": {
|
"headers": {
|
||||||
"content-type": "application/json"
|
"content-type": "application/json"
|
||||||
},
|
},
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
},
|
},
|
||||||
"config-lb": {
|
"config-lb": {
|
||||||
"status": 200,
|
"status": 200,
|
||||||
"body": "{\"search\":false,\"lb\":true,\"allowDirect\":false,\"s2sUrl\":\"\",\"primaryPrompt\":\"\",\"reasoningType\":\"PROXY\",\"injectProfile\":false,\"debugPrompt\":false,\"startSessionMode\":\"\",\"theme\":\"\",\"apipilot\":false}",
|
"body": "{\"search\":false,\"lb\":true,\"allowDirect\":false,\"s2sUrl\":\"\",\"primaryPrompt\":\"\",\"reasoningType\":\"PROXY\",\"injectProfile\":false,\"debugPrompt\":false,\"startSessionMode\":\"\",\"theme\":\"\"}",
|
||||||
"headers": {
|
"headers": {
|
||||||
"content-type": "application/json"
|
"content-type": "application/json"
|
||||||
},
|
},
|
||||||
@@ -736,7 +736,7 @@
|
|||||||
},
|
},
|
||||||
"upstream": []
|
"upstream": []
|
||||||
},
|
},
|
||||||
"reasoning-OPENAI-legacy-unbound-token": {
|
"reasoning-openai-profile-disabled": {
|
||||||
"status": 500,
|
"status": 500,
|
||||||
"body": "Internal Server Error",
|
"body": "Internal Server Error",
|
||||||
"headers": {
|
"headers": {
|
||||||
@@ -775,279 +775,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
|
||||||
"apipilot-disabled-GET": {
|
|
||||||
"status": 501,
|
|
||||||
"body": "{\"success\":false,\"data\":null,\"error\":{\"code\":\"APIPILOT_NOT_CONFIGURED\",\"message\":\"ApiPilot no está disponible en este despliegue (S2S_APIPILOT_TOOLS_URL no establecido).\",\"status_code\":501,\"raw\":null}}",
|
|
||||||
"headers": {
|
|
||||||
"content-type": "application/json"
|
|
||||||
},
|
|
||||||
"upstream": []
|
|
||||||
},
|
|
||||||
"apipilot-disabled-POST": {
|
|
||||||
"status": 501,
|
|
||||||
"body": "{\"success\":false,\"data\":null,\"error\":{\"code\":\"APIPILOT_NOT_CONFIGURED\",\"message\":\"ApiPilot no está disponible en este despliegue (S2S_APIPILOT_TOOLS_URL no establecido).\",\"status_code\":501,\"raw\":null}}",
|
|
||||||
"headers": {
|
|
||||||
"content-type": "application/json"
|
|
||||||
},
|
|
||||||
"upstream": []
|
|
||||||
},
|
|
||||||
"tools-query-validation": {
|
|
||||||
"status": 422,
|
|
||||||
"body": "{\"detail\":[{\"type\":\"bool_parsing\",\"loc\":[\"query\",\"refresh\"],\"msg\":\"Input should be a valid boolean, unable to interpret input\",\"input\":\"invalid\"}]}",
|
|
||||||
"headers": {
|
|
||||||
"content-type": "application/json"
|
|
||||||
},
|
|
||||||
"upstream": []
|
|
||||||
},
|
|
||||||
"tools-list": {
|
|
||||||
"status": 200,
|
|
||||||
"body": "{\"success\":true,\"data\":{\"count\":1,\"include_unauthorized\":false,\"tools\":[{\"name\":\"fixture_tool\",\"description\":\"A fixture tool.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"count\":{\"type\":\"integer\"}},\"required\":[\"count\"]},\"requires_auth\":false}]},\"error\":null}",
|
|
||||||
"headers": {
|
|
||||||
"content-type": "application/json"
|
|
||||||
},
|
|
||||||
"upstream": [
|
|
||||||
{
|
|
||||||
"method": "GET",
|
|
||||||
"url": "http://tools.test/apipilot/tools?include_unauthorized=false",
|
|
||||||
"body": "",
|
|
||||||
"headers": {
|
|
||||||
"authorization": "Bearer fixture-token",
|
|
||||||
"x-demo-session": "fixture-session"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"tools-result": {
|
|
||||||
"status": 200,
|
|
||||||
"body": "{\"success\":true,\"data\":{\"count\":1,\"include_unauthorized\":false,\"tools\":[{\"name\":\"fixture_tool\",\"description\":\"A fixture tool.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"count\":{\"type\":\"integer\"}},\"required\":[\"count\"]},\"requires_auth\":false}]},\"error\":null}",
|
|
||||||
"headers": {
|
|
||||||
"content-type": "application/json"
|
|
||||||
},
|
|
||||||
"upstream": [
|
|
||||||
{
|
|
||||||
"method": "GET",
|
|
||||||
"url": "http://tools.test/apipilot/tools?include_unauthorized=false",
|
|
||||||
"body": "",
|
|
||||||
"headers": {
|
|
||||||
"authorization": "Bearer fixture-token",
|
|
||||||
"x-demo-session": "fixture-session"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"tools-envelope": {
|
|
||||||
"status": 200,
|
|
||||||
"body": "{\"success\":true,\"data\":{\"count\":1,\"include_unauthorized\":false,\"tools\":[{\"name\":\"fixture_tool\",\"description\":\"A fixture tool.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"count\":{\"type\":\"integer\"}},\"required\":[\"count\"]},\"requires_auth\":false}]},\"error\":null}",
|
|
||||||
"headers": {
|
|
||||||
"content-type": "application/json"
|
|
||||||
},
|
|
||||||
"upstream": [
|
|
||||||
{
|
|
||||||
"method": "GET",
|
|
||||||
"url": "http://tools.test/apipilot/tools?include_unauthorized=false",
|
|
||||||
"body": "",
|
|
||||||
"headers": {
|
|
||||||
"authorization": "Bearer fixture-token",
|
|
||||||
"x-demo-session": "fixture-session"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"tools-opaque": {
|
|
||||||
"status": 200,
|
|
||||||
"body": "{\"success\":true,\"data\":{\"count\":0,\"include_unauthorized\":false,\"tools\":[]},\"error\":null}",
|
|
||||||
"headers": {
|
|
||||||
"content-type": "application/json"
|
|
||||||
},
|
|
||||||
"upstream": [
|
|
||||||
{
|
|
||||||
"method": "GET",
|
|
||||||
"url": "http://tools.test/apipilot/tools?include_unauthorized=false",
|
|
||||||
"body": "",
|
|
||||||
"headers": {
|
|
||||||
"authorization": "Bearer fixture-token",
|
|
||||||
"x-demo-session": "fixture-session"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"tools-upstream-error": {
|
|
||||||
"status": 502,
|
|
||||||
"body": "{\"success\":false,\"data\":null,\"error\":{\"code\":\"APIPILOT_BAD_UPSTREAM\",\"message\":\"Backend ApiPilot tools devolvió HTTP 403\",\"status_code\":403}}",
|
|
||||||
"headers": {
|
|
||||||
"content-type": "application/json"
|
|
||||||
},
|
|
||||||
"upstream": [
|
|
||||||
{
|
|
||||||
"method": "GET",
|
|
||||||
"url": "http://tools.test/apipilot/tools?include_unauthorized=true",
|
|
||||||
"body": "",
|
|
||||||
"headers": {}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"execute-discovery-error-envelope": {
|
|
||||||
"status": 502,
|
|
||||||
"body": "{\"detail\":{\"code\":\"APIPILOT_BAD_UPSTREAM\",\"message\":\"Backend ApiPilot tools devolvió HTTP 503\",\"status_code\":503}}",
|
|
||||||
"headers": {
|
|
||||||
"content-type": "application/json"
|
|
||||||
},
|
|
||||||
"upstream": [
|
|
||||||
{
|
|
||||||
"method": "GET",
|
|
||||||
"url": "http://tools.test/apipilot/tools?include_unauthorized=true",
|
|
||||||
"body": "",
|
|
||||||
"headers": {}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"execute-unknown-tool": {
|
|
||||||
"status": 404,
|
|
||||||
"body": "{\"success\":false,\"data\":null,\"error\":{\"code\":\"TOOL_NOT_FOUND\",\"message\":\"Herramienta «unknown» no encontrada en el catálogo ApiPilot. Actualice la lista de herramientas y vuelva a intentarlo.\",\"status_code\":404,\"raw\":null}}",
|
|
||||||
"headers": {
|
|
||||||
"content-type": "application/json"
|
|
||||||
},
|
|
||||||
"upstream": [
|
|
||||||
{
|
|
||||||
"method": "GET",
|
|
||||||
"url": "http://tools.test/apipilot/tools?include_unauthorized=true",
|
|
||||||
"body": "",
|
|
||||||
"headers": {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"method": "GET",
|
|
||||||
"url": "http://tools.test/apipilot/tools?include_unauthorized=true",
|
|
||||||
"body": "",
|
|
||||||
"headers": {}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"execute-missing-argument": {
|
|
||||||
"status": 400,
|
|
||||||
"body": "{\"success\":false,\"data\":null,\"error\":{\"code\":\"INVALID_ARGUMENTS\",\"message\":\"Falta el parámetro requerido: count\",\"status_code\":400,\"raw\":{\"removed\":[]}}}",
|
|
||||||
"headers": {
|
|
||||||
"content-type": "application/json"
|
|
||||||
},
|
|
||||||
"upstream": [
|
|
||||||
{
|
|
||||||
"method": "GET",
|
|
||||||
"url": "http://tools.test/apipilot/tools?include_unauthorized=true",
|
|
||||||
"body": "",
|
|
||||||
"headers": {}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"execute-api-key-precheck": {
|
|
||||||
"status": 401,
|
|
||||||
"body": "{\"success\":false,\"data\":null,\"error\":{\"code\":\"AUTH_REQUIRED\",\"message\":\"La herramienta «fixture_tool» requiere autenticación. Inicie sesión o proporcione un token para continuar.\",\"status_code\":401,\"raw\":null}}",
|
|
||||||
"headers": {
|
|
||||||
"content-type": "application/json"
|
|
||||||
},
|
|
||||||
"upstream": [
|
|
||||||
{
|
|
||||||
"method": "GET",
|
|
||||||
"url": "http://tools.test/apipilot/tools?include_unauthorized=true",
|
|
||||||
"body": "",
|
|
||||||
"headers": {
|
|
||||||
"authorization": "Bearer fixture-key",
|
|
||||||
"x-demo-session": "fixture-key",
|
|
||||||
"x-api-key": "fixture-key"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"execute-envelope": {
|
|
||||||
"status": 200,
|
|
||||||
"body": "{\"success\":true,\"data\":{\"future\":[1,null]},\"error\":null,\"future_envelope\":true}",
|
|
||||||
"headers": {
|
|
||||||
"content-type": "application/json"
|
|
||||||
},
|
|
||||||
"upstream": [
|
|
||||||
{
|
|
||||||
"method": "GET",
|
|
||||||
"url": "http://tools.test/apipilot/tools?include_unauthorized=true",
|
|
||||||
"body": "",
|
|
||||||
"headers": {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"method": "POST",
|
|
||||||
"url": "http://tools.test/apipilot/execute",
|
|
||||||
"body": "{\"name\":\"fixture_tool\",\"arguments\":{\"count\":\"wrong-type-is-accepted\"}}",
|
|
||||||
"headers": {
|
|
||||||
"content-type": "application/json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"execute-raw-list": {
|
|
||||||
"status": 200,
|
|
||||||
"body": "{\"success\":true,\"data\":[{\"future\":true}],\"error\":null}",
|
|
||||||
"headers": {
|
|
||||||
"content-type": "application/json"
|
|
||||||
},
|
|
||||||
"upstream": [
|
|
||||||
{
|
|
||||||
"method": "GET",
|
|
||||||
"url": "http://tools.test/apipilot/tools?include_unauthorized=true",
|
|
||||||
"body": "",
|
|
||||||
"headers": {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"method": "POST",
|
|
||||||
"url": "http://tools.test/apipilot/execute",
|
|
||||||
"body": "{\"name\":\"fixture_tool\",\"arguments\":{\"count\":\"wrong-type-is-accepted\"}}",
|
|
||||||
"headers": {
|
|
||||||
"content-type": "application/json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"execute-minimal-envelope": {
|
|
||||||
"status": 200,
|
|
||||||
"body": "{\"success\":true}",
|
|
||||||
"headers": {
|
|
||||||
"content-type": "application/json"
|
|
||||||
},
|
|
||||||
"upstream": [
|
|
||||||
{
|
|
||||||
"method": "GET",
|
|
||||||
"url": "http://tools.test/apipilot/tools?include_unauthorized=true",
|
|
||||||
"body": "",
|
|
||||||
"headers": {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"method": "POST",
|
|
||||||
"url": "http://tools.test/apipilot/execute",
|
|
||||||
"body": "{\"name\":\"fixture_tool\",\"arguments\":{\"count\":\"wrong-type-is-accepted\"}}",
|
|
||||||
"headers": {
|
|
||||||
"content-type": "application/json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"execute-error": {
|
|
||||||
"status": 409,
|
|
||||||
"body": "{\"success\":false,\"data\":null,\"error\":{\"status_code\":409,\"code\":\"FIXTURE\",\"message\":\"Fixture\",\"future\":true}}",
|
|
||||||
"headers": {
|
|
||||||
"content-type": "application/json"
|
|
||||||
},
|
|
||||||
"upstream": [
|
|
||||||
{
|
|
||||||
"method": "GET",
|
|
||||||
"url": "http://tools.test/apipilot/tools?include_unauthorized=true",
|
|
||||||
"body": "",
|
|
||||||
"headers": {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"method": "POST",
|
|
||||||
"url": "http://tools.test/apipilot/execute",
|
|
||||||
"body": "{\"name\":\"fixture_tool\",\"arguments\":{\"count\":\"wrong-type-is-accepted\"}}",
|
|
||||||
"headers": {
|
|
||||||
"content-type": "application/json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"openapi_requests": {
|
"openapi_requests": {
|
||||||
@@ -1055,44 +782,6 @@
|
|||||||
"GET /api/config": {
|
"GET /api/config": {
|
||||||
"operationId": "config_api_config_get"
|
"operationId": "config_api_config_get"
|
||||||
},
|
},
|
||||||
"GET /api/apipilot/tools": {
|
|
||||||
"operationId": "apipilot_list_tools_api_apipilot_tools_get",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"name": "include_unauthorized",
|
|
||||||
"in": "query",
|
|
||||||
"required": false,
|
|
||||||
"schema": {
|
|
||||||
"type": "boolean",
|
|
||||||
"default": true,
|
|
||||||
"title": "Include Unauthorized"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "refresh",
|
|
||||||
"in": "query",
|
|
||||||
"required": false,
|
|
||||||
"schema": {
|
|
||||||
"type": "boolean",
|
|
||||||
"default": false,
|
|
||||||
"title": "Refresh"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"POST /api/apipilot/execute": {
|
|
||||||
"operationId": "apipilot_execute_api_apipilot_execute_post",
|
|
||||||
"requestBody": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ApipilotExecuteRequest"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"POST /api/reasoning": {
|
"POST /api/reasoning": {
|
||||||
"operationId": "reasoning_api_reasoning_post",
|
"operationId": "reasoning_api_reasoning_post",
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
@@ -1264,26 +953,6 @@
|
|||||||
],
|
],
|
||||||
"title": "ReasoningRequest"
|
"title": "ReasoningRequest"
|
||||||
},
|
},
|
||||||
"ApipilotExecuteRequest": {
|
|
||||||
"properties": {
|
|
||||||
"name": {
|
|
||||||
"type": "string",
|
|
||||||
"title": "Name"
|
|
||||||
},
|
|
||||||
"arguments": {
|
|
||||||
"additionalProperties": true,
|
|
||||||
"type": "object",
|
|
||||||
"title": "Arguments",
|
|
||||||
"default": {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"type": "object",
|
|
||||||
"required": [
|
|
||||||
"name"
|
|
||||||
],
|
|
||||||
"title": "ApipilotExecuteRequest",
|
|
||||||
"description": "Body for the ApiPilot execute proxy endpoint. Mirrors §2.2 request shape."
|
|
||||||
},
|
|
||||||
"HTTPValidationError": {
|
"HTTPValidationError": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"detail": {
|
"detail": {
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ from fastapi.testclient import TestClient
|
|||||||
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):
|
||||||
from vocero_api import main as server
|
from vocero_api import main as server
|
||||||
from vocero_api.core import config as settings
|
from vocero_api.core import config as settings
|
||||||
from vocero_api.providers import apipilot
|
|
||||||
from vocero_api.realtime import proxy
|
from vocero_api.realtime import proxy
|
||||||
from vocero_api.services import context
|
from vocero_api.services import context
|
||||||
|
|
||||||
@@ -42,21 +41,10 @@ BASE_CONFIG = {
|
|||||||
"S2S_LOGIN_BY_PHONE": "",
|
"S2S_LOGIN_BY_PHONE": "",
|
||||||
"S2S_LATEST_SUMMARY": "",
|
"S2S_LATEST_SUMMARY": "",
|
||||||
"S2S_LATEST_SUMMARY_TEXT": "",
|
"S2S_LATEST_SUMMARY_TEXT": "",
|
||||||
"S2S_APIPILOT_TOOLS_URL": "",
|
|
||||||
"S2S_APIPILOT_TOOLS_EXECUTE_URL": "",
|
|
||||||
"S2S_APIPILOT_SERVICE_TOKEN": "",
|
|
||||||
"S2S_APIPILOT_API_KEY": "",
|
|
||||||
"OPENAI_API_KEY": "",
|
"OPENAI_API_KEY": "",
|
||||||
"S2S_REPORTS_QUICK_HINT_MAX": 5,
|
"S2S_REPORTS_QUICK_HINT_MAX": 5,
|
||||||
"S2S_REPORTS_INJECT_MODE": "minimal",
|
"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():
|
def cases():
|
||||||
@@ -268,7 +256,7 @@ def cases():
|
|||||||
for name, payload in (("missing", {}), ("proxy", {"transcript": "hello"}), ("proxy-blank", {"transcript": " "})):
|
for name, payload in (("missing", {}), ("proxy", {"transcript": "hello"}), ("proxy-blank", {"transcript": " "})):
|
||||||
yield f"reasoning-{name}", "POST", "/api/reasoning", {"json": payload}, {}, []
|
yield f"reasoning-{name}", "POST", "/api/reasoning", {"json": payload}, {}, []
|
||||||
yield (
|
yield (
|
||||||
"reasoning-OPENAI-legacy-unbound-token",
|
"reasoning-openai-profile-disabled",
|
||||||
"POST",
|
"POST",
|
||||||
"/api/reasoning",
|
"/api/reasoning",
|
||||||
{"json": {"transcript": "hello"}},
|
{"json": {"transcript": "hello"}},
|
||||||
@@ -323,91 +311,6 @@ def cases():
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
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():
|
def capture_http():
|
||||||
captures = {}
|
captures = {}
|
||||||
@@ -442,7 +345,6 @@ def capture_http():
|
|||||||
|
|
||||||
with (
|
with (
|
||||||
patch.multiple(settings, **{**BASE_CONFIG, **config}),
|
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(context, "_build_today_fixed_context_block", return_value="Fixture current date: 2026-01-04"),
|
||||||
patch.object(
|
patch.object(
|
||||||
httpx,
|
httpx,
|
||||||
@@ -482,7 +384,6 @@ def capture_openapi_requests():
|
|||||||
for name in (
|
for name in (
|
||||||
"SearchRequest",
|
"SearchRequest",
|
||||||
"ReasoningRequest",
|
"ReasoningRequest",
|
||||||
"ApipilotExecuteRequest",
|
|
||||||
"HTTPValidationError",
|
"HTTPValidationError",
|
||||||
"ValidationError",
|
"ValidationError",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -143,8 +143,8 @@ def test_http_chains_tools_retains_history_and_ignores_browser_identity(enabled,
|
|||||||
assert client.post("/api/reasoning", json=body).status_code == 401
|
assert client.post("/api/reasoning", json=body).status_code == 401
|
||||||
assert client.get("/api/user/phone?phoneNumber=123").status_code == 409
|
assert client.get("/api/user/phone?phoneNumber=123").status_code == 409
|
||||||
assert client.get("/api/user/reports").status_code == 409
|
assert client.get("/api/user/reports").status_code == 409
|
||||||
assert client.get("/api/apipilot/tools").status_code == 409
|
assert client.get("/api/apipilot/tools").status_code == 404
|
||||||
assert client.post("/api/apipilot/execute", json={"name": "get_report"}).status_code == 409
|
assert client.post("/api/apipilot/execute", json={"name": "get_report"}).status_code == 404
|
||||||
enabled.assert_awaited_once_with("+56000000001")
|
enabled.assert_awaited_once_with("+56000000001")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,14 +5,13 @@ from fastapi.responses import JSONResponse
|
|||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from vocero_api.api.responses import validated_json_response
|
from vocero_api.api.responses import validated_json_response
|
||||||
from vocero_api.models.requests import ApipilotExecuteRequest, QueueEndPayload, SearchRequest
|
from vocero_api.models.requests import QueueEndPayload, SearchRequest
|
||||||
from vocero_api.models.responses import (
|
from vocero_api.models.responses import (
|
||||||
AllocationResponse,
|
AllocationResponse,
|
||||||
QueueCancellationResponse,
|
QueueCancellationResponse,
|
||||||
ReasoningResponse,
|
ReasoningResponse,
|
||||||
ReportsResponse,
|
ReportsResponse,
|
||||||
SearchResponse,
|
SearchResponse,
|
||||||
ToolExecutionResponse,
|
|
||||||
UserProfileResponse,
|
UserProfileResponse,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -22,9 +21,7 @@ from vocero_api.models.responses import (
|
|||||||
[
|
[
|
||||||
(AllocationResponse, {}),
|
(AllocationResponse, {}),
|
||||||
(AllocationResponse, {"state": [1, None], "connectUrl": "wss://host", "future": {"x": 1.0}}),
|
(AllocationResponse, {"state": [1, None], "connectUrl": "wss://host", "future": {"x": 1.0}}),
|
||||||
(ToolExecutionResponse, {"success": True, "unknown": {"nested": [None, 1.0, False]}}),
|
(ReasoningResponse, {"answer": "á", "future": [1, None, True]}),
|
||||||
(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}]}),
|
(SearchResponse, {"query": "q", "answer": {"x": 2}, "results": [{"title": [], "snippet": 7, "url": None}]}),
|
||||||
(
|
(
|
||||||
ReportsResponse,
|
ReportsResponse,
|
||||||
@@ -47,10 +44,8 @@ def test_owned_response_fields_are_actually_validated(payload):
|
|||||||
validated_json_response(payload, QueueCancellationResponse)
|
validated_json_response(payload, QueueCancellationResponse)
|
||||||
|
|
||||||
|
|
||||||
def test_request_models_keep_legacy_extra_field_and_argument_behavior():
|
def test_request_models_keep_legacy_extra_field_and_beacon_behavior():
|
||||||
assert SearchRequest.model_validate({"query": "q", "future": True}).model_dump() == {"query": "q", "key": None}
|
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}]:
|
for value in [None, "id", 42, False, [], {}, {"future": True}]:
|
||||||
assert QueueEndPayload.model_validate({"queueId": value, "future": 1}).queueId == value
|
assert QueueEndPayload.model_validate({"queueId": value, "future": 1}).queueId == value
|
||||||
assert QueueEndPayload.model_validate({}).queueId == ""
|
assert QueueEndPayload.model_validate({}).queueId == ""
|
||||||
|
|||||||
@@ -7,10 +7,9 @@ import json
|
|||||||
import os
|
import os
|
||||||
import threading
|
import threading
|
||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import ANY, AsyncMock, Mock, patch
|
from unittest.mock import 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
|
||||||
|
|
||||||
@@ -18,8 +17,7 @@ from websockets.sync.server import serve
|
|||||||
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):
|
||||||
from vocero_api import main as server
|
from vocero_api import main as server
|
||||||
from vocero_api.core import config as settings
|
from vocero_api.core import config as settings
|
||||||
from vocero_api.providers import apipilot, caller
|
from vocero_api.providers import caller
|
||||||
from vocero_api.services import tool_context
|
|
||||||
|
|
||||||
|
|
||||||
GRANT = {
|
GRANT = {
|
||||||
@@ -182,34 +180,27 @@ class BackendContractTests(unittest.TestCase):
|
|||||||
self.assertEqual(lookup.headers["authorization"], "Bearer fixture-token")
|
self.assertEqual(lookup.headers["authorization"], "Bearer fixture-token")
|
||||||
self.assertEqual(lookup.url.params["phoneNumber"], "+56000000000")
|
self.assertEqual(lookup.url.params["phoneNumber"], "+56000000000")
|
||||||
|
|
||||||
def test_caller_auth_headers_remain_independent(self):
|
def test_apipilot_routes_and_contracts_are_retired(self):
|
||||||
request = Request(
|
self.assertNotIn("apipilot", self.client.get("/api/config").json())
|
||||||
{
|
spec = self.client.get("/openapi.json").json()
|
||||||
"type": "http",
|
for method, path in (("GET", "/api/apipilot/tools"), ("POST", "/api/apipilot/execute")):
|
||||||
"headers": [
|
self.assertNotIn(path, spec["paths"])
|
||||||
(b"authorization", b"Bearer fixture-token"),
|
response = self.client.request(method, path, json={"name": "get_report"})
|
||||||
(b"x-demo-session", b"fixture-demo"),
|
self.assertEqual(response.status_code, 404)
|
||||||
],
|
schemas = spec["components"]["schemas"]
|
||||||
}
|
for name in ("ApipilotExecuteRequest", "ToolCatalogResponse", "ToolExecutionResponse", "ToolInvocation"):
|
||||||
)
|
self.assertNotIn(name, schemas)
|
||||||
self.assertEqual(apipilot._apipilot_parse_authorization(request), ("Bearer fixture-token", "fixture-demo"))
|
self.assertNotIn("apipilot", schemas["ConfigResponse"]["properties"])
|
||||||
|
self.assertEqual(set(schemas["ReasoningResponse"]["properties"]), {"answer"})
|
||||||
|
self.upstream.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
class VoiceProxyTests(unittest.TestCase):
|
class VoiceProxyTests(unittest.TestCase):
|
||||||
def test_apipilot_catalog_and_caller_defaults_reach_the_voice_session(self):
|
def test_caller_and_episode_context_reach_the_voice_session(self):
|
||||||
closed = threading.Event()
|
closed = threading.Event()
|
||||||
catalog = AsyncMock(
|
|
||||||
return_value=[
|
|
||||||
{
|
|
||||||
"name": "get_caller_briefing",
|
|
||||||
"description": "Retrieve the caller's local test briefing.",
|
|
||||||
"requires_auth": True,
|
|
||||||
"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": {"name": "Local Caller", "email": "caller@example.test"}})
|
||||||
|
episode = AsyncMock(return_value={"title": "Local episode", "text": "Local episode text"})
|
||||||
|
|
||||||
def voice(socket):
|
def voice(socket):
|
||||||
try:
|
try:
|
||||||
@@ -226,13 +217,12 @@ class VoiceProxyTests(unittest.TestCase):
|
|||||||
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(settings, "S2S_PROXY_TARGET", target),
|
patch.object(settings, "S2S_PROXY_TARGET", target),
|
||||||
patch.object(settings, "S2S_INJECT_PROFILE", False),
|
patch.object(settings, "S2S_INJECT_PROFILE", True),
|
||||||
patch.object(settings, "S2S_APIPILOT_TOOLS_URL", "http://catalog.test/tools"),
|
|
||||||
patch.object(settings, "S2S_LOGIN_BY_PHONE", "http://profile.test/login"),
|
patch.object(settings, "S2S_LOGIN_BY_PHONE", "http://profile.test/login"),
|
||||||
patch.object(settings, "VOCERO_USER_PHONE_AUTH_URL", "http://profile.test/user"),
|
patch.object(settings, "VOCERO_USER_PHONE_AUTH_URL", "http://profile.test/user"),
|
||||||
patch.object(apipilot, "_apipilot_fetch_tools", catalog),
|
|
||||||
patch.object(caller, "_login_by_phone", login),
|
patch.object(caller, "_login_by_phone", login),
|
||||||
patch.object(caller, "_fetch_user_profile_for_phone", profile),
|
patch.object(caller, "_fetch_user_profile_for_phone", profile),
|
||||||
|
patch.object(caller, "_fetch_podcast_episode", episode),
|
||||||
patch.object(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,
|
||||||
):
|
):
|
||||||
@@ -246,11 +236,9 @@ class VoiceProxyTests(unittest.TestCase):
|
|||||||
instructions = update["session"]["instructions"]
|
instructions = update["session"]["instructions"]
|
||||||
for expected in (
|
for expected in (
|
||||||
"Local fixture",
|
"Local fixture",
|
||||||
tool_context.APIPILOT_PASS1_LIST_MARKER,
|
"Local Caller",
|
||||||
"name: get_caller_briefing",
|
"Local episode text",
|
||||||
'phone = "+56000000000"',
|
"caller@example.test",
|
||||||
'episode_id = "fixture-episode"',
|
|
||||||
'email = "caller@example.test"',
|
|
||||||
):
|
):
|
||||||
self.assertIn(expected, instructions)
|
self.assertIn(expected, instructions)
|
||||||
# Configuration injection must preserve ordinary audio forwarding.
|
# Configuration injection must preserve ordinary audio forwarding.
|
||||||
@@ -260,7 +248,7 @@ class VoiceProxyTests(unittest.TestCase):
|
|||||||
self.assertTrue(closed.wait(3), "Client disconnect must close the upstream WebSocket")
|
self.assertTrue(closed.wait(3), "Client disconnect must close the upstream WebSocket")
|
||||||
login.assert_awaited_once_with("+56000000000")
|
login.assert_awaited_once_with("+56000000000")
|
||||||
profile.assert_awaited_once_with("+56000000000", auth_token="fixture-token")
|
profile.assert_awaited_once_with("+56000000000", auth_token="fixture-token")
|
||||||
catalog.assert_awaited_once_with(auth_header="Bearer fixture-token", demo_session=ANY, refresh=False)
|
episode.assert_awaited_once_with("fixture-episode")
|
||||||
|
|
||||||
def test_proxy_voice_frames_keepalive_and_client_disconnect(self):
|
def test_proxy_voice_frames_keepalive_and_client_disconnect(self):
|
||||||
closed = threading.Event()
|
closed = threading.Event()
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from vocero_api.api.routes import apipilot, caller, config, conversations, search, sessions
|
from vocero_api.api.routes import caller, config, conversations, search, sessions
|
||||||
from vocero_api.models.responses import ReasoningResponse
|
from vocero_api.models.responses import ReasoningResponse
|
||||||
from vocero_api.realtime.proxy import realtime_proxy
|
from vocero_api.realtime.proxy import realtime_proxy
|
||||||
from vocero_api.services.reasoning import reasoning
|
from vocero_api.services.reasoning import reasoning
|
||||||
@@ -10,7 +10,6 @@ from vocero_api.services.reasoning import reasoning
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
router.include_router(config.router)
|
router.include_router(config.router)
|
||||||
router.include_router(conversations.router)
|
router.include_router(conversations.router)
|
||||||
router.include_router(apipilot.router)
|
|
||||||
router.add_api_route("/api/reasoning", reasoning, methods=["POST"], response_model=ReasoningResponse)
|
router.add_api_route("/api/reasoning", reasoning, methods=["POST"], response_model=ReasoningResponse)
|
||||||
router.add_api_websocket_route("/v1/realtime", realtime_proxy)
|
router.add_api_websocket_route("/v1/realtime", realtime_proxy)
|
||||||
router.include_router(caller.router)
|
router.include_router(caller.router)
|
||||||
|
|||||||
@@ -1,151 +0,0 @@
|
|||||||
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 settings.S2S_MCP_URL:
|
|
||||||
raise HTTPException(409, "Las herramientas de AquaPortal usan la sesión de conversación.")
|
|
||||||
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 settings.S2S_MCP_URL:
|
|
||||||
raise HTTPException(409, "Las herramientas de AquaPortal usan la sesión de conversación.")
|
|
||||||
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)
|
|
||||||
@@ -21,12 +21,7 @@ async def config(request: Request) -> JSONResponse:
|
|||||||
"""Client bootstrap: whether web search is available, whether the deploy runs
|
"""Client bootstrap: whether web search is available, whether the deploy runs
|
||||||
behind a load balancer (so the browser uses the /api/session proxy),
|
behind a load balancer (so the browser uses the /api/session proxy),
|
||||||
and whether the user may instead set a direct
|
and whether the user may instead set a direct
|
||||||
s2s server URL. The LB address itself is intentionally NOT included.
|
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(
|
app_logging.logger.debug(
|
||||||
"[INTERNAL] /api/config requested client=%s", request.client.host if request.client else "?"
|
"[INTERNAL] /api/config requested client=%s", request.client.host if request.client else "?"
|
||||||
)
|
)
|
||||||
@@ -42,7 +37,6 @@ async def config(request: Request) -> JSONResponse:
|
|||||||
"debugPrompt": settings.S2S_DEBUG_PROMPT,
|
"debugPrompt": settings.S2S_DEBUG_PROMPT,
|
||||||
"startSessionMode": settings.S2S_START_SESSION_MODE,
|
"startSessionMode": settings.S2S_START_SESSION_MODE,
|
||||||
"theme": settings.S2S_THEME,
|
"theme": settings.S2S_THEME,
|
||||||
"apipilot": bool(settings.S2S_APIPILOT_TOOLS_URL),
|
|
||||||
}
|
}
|
||||||
if settings.S2S_MCP_URL:
|
if settings.S2S_MCP_URL:
|
||||||
if settings.S2S_REASONING_TYPE == "PROXY" and (
|
if settings.S2S_REASONING_TYPE == "PROXY" and (
|
||||||
@@ -50,13 +44,12 @@ async def config(request: Request) -> JSONResponse:
|
|||||||
):
|
):
|
||||||
raise HTTPException(503, "MCP en modo PROXY requiere la conexión de voz a través de Vocero.")
|
raise HTTPException(503, "MCP en modo PROXY requiere la conexión de voz a través de Vocero.")
|
||||||
out["mcp"] = True
|
out["mcp"] = True
|
||||||
out["apipilot"] = False
|
|
||||||
app_logging.logger.debug(
|
app_logging.logger.debug(
|
||||||
"[INTERNAL] /api/config returned promptLen=%d reasoning=%s startSessionMode=%s theme=%s apipilot=%s",
|
"[INTERNAL] /api/config returned promptLen=%d reasoning=%s startSessionMode=%s theme=%s mcp=%s",
|
||||||
len(primary_prompt),
|
len(primary_prompt),
|
||||||
settings.S2S_REASONING_TYPE,
|
settings.S2S_REASONING_TYPE,
|
||||||
settings.S2S_START_SESSION_MODE or "-",
|
settings.S2S_START_SESSION_MODE or "-",
|
||||||
settings.S2S_THEME or "-",
|
settings.S2S_THEME or "-",
|
||||||
out["apipilot"],
|
bool(settings.S2S_MCP_URL),
|
||||||
)
|
)
|
||||||
return validated_json_response(out, ConfigResponse)
|
return validated_json_response(out, ConfigResponse)
|
||||||
|
|||||||
@@ -137,50 +137,6 @@ OPENAI_BASE_URL = os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1")
|
|||||||
OPENAI_REASONING_MODEL = os.environ.get("OPENAI_REASONING_MODEL", "gpt-4.1-mini").strip()
|
OPENAI_REASONING_MODEL = os.environ.get("OPENAI_REASONING_MODEL", "gpt-4.1-mini").strip()
|
||||||
OPENAI_ORGANIZATION = os.environ.get("OPENAI_ORGANIZATION", "").strip()
|
OPENAI_ORGANIZATION = os.environ.get("OPENAI_ORGANIZATION", "").strip()
|
||||||
OPENAI_PROJECT = os.environ.get("OPENAI_PROJECT", "").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
|
# Speech-to-speech load balancer URL. When set, the browser POSTs /api/session
|
||||||
|
|||||||
@@ -3,23 +3,8 @@ from __future__ import annotations
|
|||||||
from vocero_api.core import config as settings
|
from vocero_api.core import config as settings
|
||||||
from vocero_api.core import logging as app_logging
|
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(
|
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",
|
"[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",
|
||||||
settings.S2S_REASONING_TYPE,
|
settings.S2S_REASONING_TYPE,
|
||||||
bool(settings.SERPER_KEY),
|
bool(settings.SERPER_KEY),
|
||||||
bool(settings.LOAD_BALANCER_URL),
|
bool(settings.LOAD_BALANCER_URL),
|
||||||
@@ -38,27 +23,7 @@ app_logging.logger.info(
|
|||||||
settings.S2S_DEBUG_PROMPT,
|
settings.S2S_DEBUG_PROMPT,
|
||||||
settings.S2S_START_SESSION_MODE or "-",
|
settings.S2S_START_SESSION_MODE or "-",
|
||||||
settings.S2S_THEME 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
|
|
||||||
|
|
||||||
if settings.S2S_MCP_URL:
|
if settings.S2S_MCP_URL:
|
||||||
app_logging.logger.info(
|
app_logging.logger.info(
|
||||||
|
|||||||
@@ -29,13 +29,6 @@ class ReasoningRequest(BaseModel):
|
|||||||
episode_id: str | None = None
|
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):
|
class QueueEndPayload(BaseModel):
|
||||||
"""A view of a decoded beacon object, preserving the legacy loose value.
|
"""A view of a decoded beacon object, preserving the legacy loose value.
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ class ConfigResponse(OpenResponse):
|
|||||||
debugPrompt: bool
|
debugPrompt: bool
|
||||||
startSessionMode: str
|
startSessionMode: str
|
||||||
theme: str
|
theme: str
|
||||||
apipilot: bool
|
|
||||||
|
|
||||||
|
|
||||||
class SearchResult(OpenResponse):
|
class SearchResult(OpenResponse):
|
||||||
@@ -62,42 +61,8 @@ class ReportsResponse(OpenResponse):
|
|||||||
note: str | None = None
|
note: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class ToolInvocation(OpenResponse):
|
|
||||||
name: str
|
|
||||||
success: bool
|
|
||||||
result_length: int
|
|
||||||
|
|
||||||
|
|
||||||
class ReasoningResponse(OpenResponse):
|
class ReasoningResponse(OpenResponse):
|
||||||
answer: str
|
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):
|
class AllocationResponse(OpenResponse):
|
||||||
|
|||||||
@@ -1,597 +0,0 @@
|
|||||||
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),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,6 @@ from typing import Any, cast
|
|||||||
|
|
||||||
from vocero_api.core import logging as app_logging
|
from vocero_api.core import logging as app_logging
|
||||||
from vocero_api.services import context as services_context
|
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:
|
async def _report_server_debug(hypothesis_id: str, location: str, msg: str, data: dict | None = None) -> None:
|
||||||
@@ -324,10 +323,6 @@ def _sanitize_realtime_event_raw(raw_message: str, session_ctx: dict[str, Any] |
|
|||||||
nonlocal changed
|
nonlocal changed
|
||||||
if isinstance(v, str) and v.strip():
|
if isinstance(v, str) and v.strip():
|
||||||
cleaned = _sanitize_agent_output(v, session_ctx)
|
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:
|
if cleaned != v:
|
||||||
changed = True
|
changed = True
|
||||||
return cleaned
|
return cleaned
|
||||||
|
|||||||
@@ -13,12 +13,10 @@ from websockets.typing import Subprotocol
|
|||||||
from vocero_api.core import config as settings
|
from vocero_api.core import config as settings
|
||||||
from vocero_api.core import logging as app_logging
|
from vocero_api.core import logging as app_logging
|
||||||
from vocero_api.core import urls as core_urls
|
from vocero_api.core import urls as core_urls
|
||||||
from vocero_api.providers import apipilot as providers_apipilot
|
|
||||||
from vocero_api.providers import caller as providers_caller
|
from vocero_api.providers import caller as providers_caller
|
||||||
from vocero_api.realtime import events as realtime_events
|
from vocero_api.realtime import events as realtime_events
|
||||||
from vocero_api.realtime.mcp_proxy import realtime_mcp_proxy
|
from vocero_api.realtime.mcp_proxy import realtime_mcp_proxy
|
||||||
from vocero_api.services import context as services_context
|
from vocero_api.services import context as services_context
|
||||||
from vocero_api.services import tool_context as services_tool_context
|
|
||||||
|
|
||||||
|
|
||||||
async def realtime_proxy(ws: WebSocket) -> None:
|
async def realtime_proxy(ws: WebSocket) -> None:
|
||||||
@@ -46,102 +44,20 @@ async def realtime_proxy(ws: WebSocket) -> None:
|
|||||||
injected_profile_context = ""
|
injected_profile_context = ""
|
||||||
injected_reports_context = ""
|
injected_reports_context = ""
|
||||||
injected_summary_context = ""
|
injected_summary_context = ""
|
||||||
injected_apipilot_pass1 = ""
|
|
||||||
apipilot_tools_pass1: list = []
|
|
||||||
inject_phone = ""
|
inject_phone = ""
|
||||||
inject_episode_id = ""
|
inject_episode_id = ""
|
||||||
client_query = {}
|
client_query = {}
|
||||||
login_token: str | None = None
|
login_token: str | None = None
|
||||||
login_response_obj: dict | None = None
|
|
||||||
profile_obj: dict | None = None
|
profile_obj: dict | None = None
|
||||||
needs_apipilot = bool(settings.S2S_APIPILOT_TOOLS_URL) or bool(settings.S2S_APIPILOT_TOOLS_EXECUTE_URL)
|
|
||||||
try:
|
try:
|
||||||
client_query = dict(parse_qsl(ws.url.query, keep_blank_values=True))
|
client_query = dict(parse_qsl(ws.url.query, keep_blank_values=True))
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
client_query = {}
|
client_query = {}
|
||||||
|
|
||||||
# ApiPilot works independently of profile injection. When enabled, extract
|
|
||||||
# phone/episode query parameters, resolve the bearer for authenticated tools,
|
|
||||||
# and load the profile for email defaults when its endpoint is configured.
|
|
||||||
if needs_apipilot:
|
|
||||||
inject_phone = (client_query.get("phoneNumber") or "").strip() or inject_phone
|
|
||||||
inject_episode_id = (client_query.get("episode_id") or "").strip() or inject_episode_id
|
|
||||||
_needs_login_apipilot = bool(settings.S2S_LOGIN_BY_PHONE.strip())
|
|
||||||
_needs_profile_apipilot = bool(settings.VOCERO_USER_PHONE_AUTH_URL.strip())
|
|
||||||
# Always log initial WS state to diagnose missing phone, token or login
|
|
||||||
# configuration that could leave the AquaPortal user anonymous.
|
|
||||||
_s_init = {
|
|
||||||
"needs_apipilot": needs_apipilot,
|
|
||||||
"phoneLen": len(inject_phone or ""),
|
|
||||||
"phoneHead": (
|
|
||||||
("+" + inject_phone.lstrip("+")[:3] + "...")
|
|
||||||
if (inject_phone and len(inject_phone) > 6)
|
|
||||||
else (inject_phone or "")
|
|
||||||
),
|
|
||||||
"loginTokenLen": (len(login_token) if isinstance(login_token, str) and login_token.strip() else 0),
|
|
||||||
"loginByPhone": ("YES" if _needs_login_apipilot else "NO"),
|
|
||||||
"profileUrl": ("YES" if _needs_profile_apipilot else "NO"),
|
|
||||||
"S2S_INJECT_PROFILE": settings.S2S_INJECT_PROFILE,
|
|
||||||
}
|
|
||||||
app_logging.logger.info(
|
|
||||||
"[INJECT-APIPILOT][WS connect] INITIAL STATE: " + ", ".join(f"{k}={v}" for k, v in sorted(_s_init.items())),
|
|
||||||
)
|
|
||||||
# The API-key bypass makes login_by_phone unnecessary.
|
|
||||||
if (
|
|
||||||
not settings.S2S_APIPILOT_API_KEY
|
|
||||||
and _needs_login_apipilot
|
|
||||||
and inject_phone
|
|
||||||
and (
|
|
||||||
not isinstance(login_token, str)
|
|
||||||
or not login_token.strip()
|
|
||||||
or (isinstance(login_token, str) and 0 < len(login_token.strip()) < 80)
|
|
||||||
)
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
login_token, _lresp = await providers_caller._login_by_phone(inject_phone)
|
|
||||||
if isinstance(_lresp, dict) and _lresp:
|
|
||||||
login_response_obj = _lresp
|
|
||||||
app_logging.logger.info(
|
|
||||||
"[INJECT-APIPILOT][WS connect] login_by_phone for authenticated ApiPilot execution phoneLen=%d token=%s",
|
|
||||||
len(inject_phone or ""),
|
|
||||||
f"YES len={len(login_token or '')}"
|
|
||||||
if isinstance(login_token, str) and login_token
|
|
||||||
else "NONE (403!)",
|
|
||||||
)
|
|
||||||
except Exception as _exc_login: # noqa: BLE001
|
|
||||||
app_logging.logger.info(
|
|
||||||
"[INJECT-APIPILOT][WS connect] login_by_phone failed (anonymous ApiPilot user, likely 403) phoneLen=%d err=%r",
|
|
||||||
len(inject_phone or ""),
|
|
||||||
_exc_login,
|
|
||||||
)
|
|
||||||
if _needs_profile_apipilot and inject_phone and (not isinstance(profile_obj, dict) or not profile_obj):
|
|
||||||
try:
|
|
||||||
profile_obj = await providers_caller._fetch_user_profile_for_phone(inject_phone, auth_token=login_token)
|
|
||||||
app_logging.logger.info(
|
|
||||||
"[INJECT-APIPILOT][WS connect] profile fetch for email defaults phoneLen=%d profile=%s",
|
|
||||||
len(inject_phone or ""),
|
|
||||||
("FOUND user=%s" % (((profile_obj or {}).get("user") or {}).get("name") or "?"))
|
|
||||||
if isinstance(profile_obj, dict)
|
|
||||||
else "NONE",
|
|
||||||
)
|
|
||||||
except Exception as _exc_p: # noqa: BLE001
|
|
||||||
app_logging.logger.info(
|
|
||||||
"[INJECT-APIPILOT][WS connect] profile fetch failed phoneLen=%d err=%r",
|
|
||||||
len(inject_phone or ""),
|
|
||||||
_exc_p,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not needs_apipilot:
|
|
||||||
app_logging.logger.info(
|
|
||||||
"[INJECT-APIPILOT] ⏭️ WS connect: APIPILOT tools injection DISABLED (S2S_APIPILOT_TOOLS_URL='%s' S2S_APIPILOT_TOOLS_EXECUTE_URL='%s'). "
|
|
||||||
"No ApiPilot fetch or session.update patch in this tunnel.",
|
|
||||||
settings.S2S_APIPILOT_TOOLS_URL,
|
|
||||||
settings.S2S_APIPILOT_TOOLS_EXECUTE_URL,
|
|
||||||
)
|
|
||||||
if not settings.S2S_INJECT_PROFILE:
|
if not settings.S2S_INJECT_PROFILE:
|
||||||
app_logging.logger.info(
|
app_logging.logger.info(
|
||||||
"[INJECT-PROFILE] ⏭️ WS connect: SKIPPED (S2S_INJECT_PROFILE=false). "
|
"[INJECT-PROFILE] ⏭️ WS connect: SKIPPED (S2S_INJECT_PROFILE=false). "
|
||||||
"No profile/episode fetch; tunnel stays pure passthrough for injects EXCEPT apipilot."
|
"No profile/episode fetch; tunnel stays pure passthrough for context injection."
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
needs_phone_authed = bool(settings.VOCERO_USER_PHONE_AUTH_URL.strip())
|
needs_phone_authed = bool(settings.VOCERO_USER_PHONE_AUTH_URL.strip())
|
||||||
@@ -177,7 +93,7 @@ async def realtime_proxy(ws: WebSocket) -> None:
|
|||||||
# 0) Login-by-phone (optional — only if S2S_LOGIN_BY_PHONE env is set).
|
# 0) Login-by-phone (optional — only if S2S_LOGIN_BY_PHONE env is set).
|
||||||
# Obtains a bearer token used on subsequent profile/reports/summary fetches.
|
# Obtains a bearer token used on subsequent profile/reports/summary fetches.
|
||||||
if needs_login and inject_phone:
|
if needs_login and inject_phone:
|
||||||
login_token, login_response_obj = await providers_caller._login_by_phone(inject_phone)
|
login_token, _ = await providers_caller._login_by_phone(inject_phone)
|
||||||
# 0.5) Podcast episode fetch (independent of phone — uses only episode_id from query)
|
# 0.5) Podcast episode fetch (independent of phone — uses only episode_id from query)
|
||||||
if inject_episode_id:
|
if inject_episode_id:
|
||||||
if settings.S2S_DEBUG_PROMPT:
|
if settings.S2S_DEBUG_PROMPT:
|
||||||
@@ -259,121 +175,12 @@ async def realtime_proxy(ws: WebSocket) -> None:
|
|||||||
app_logging.logger.info(
|
app_logging.logger.info(
|
||||||
"[INJECT-SUMMARY] ⏭️ WS connect: latest summary skipped (context empty / upstream returned empty summary / fetch failed)."
|
"[INJECT-SUMMARY] ⏭️ WS connect: latest summary skipped (context empty / upstream returned empty summary / fetch failed)."
|
||||||
)
|
)
|
||||||
# 3) Fetch the ApiPilot pass-1 tool list independently of S2S_INJECT_PROFILE,
|
|
||||||
# even without login/phone. Use the resolved token or anonymous fingerprint,
|
|
||||||
# matching the reasoning endpoint's server cache.
|
|
||||||
if needs_apipilot:
|
|
||||||
apipilot_auth_header = f"Bearer {login_token}" if isinstance(login_token, str) and login_token.strip() else None
|
|
||||||
# Phone login resolves the AquaPortal JWT for execution. The configured
|
|
||||||
# S2S_APIPILOT_SERVICE_TOKEN overrides it.
|
|
||||||
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
|
|
||||||
demo_session_key = client_query.get("x-demo-session") if isinstance(client_query, dict) else None
|
|
||||||
if not demo_session_key:
|
|
||||||
demo_session_key = f"anon-ws-{client_ip}-{id(ws)}"
|
|
||||||
try:
|
|
||||||
# Always log discovery, matching reasoning pass 1.
|
|
||||||
app_logging.logger.info(
|
app_logging.logger.info(
|
||||||
"[INJECT-APIPILOT] WS connect: fetching tools (auth=%s demoSession=%.40s...) upstream=%s...",
|
"[INJECT-PROFILE] WS connect FINAL: injectEpisode=%s injectProfile=%s injectReports=%s injectSummary=%s login=%s episodeIdLen=%d phoneLen=%d episodeCtxLen=%d profileCtxLen=%d reportsCtxLen=%d summaryCtxLen=%d target=%s",
|
||||||
"yes" if apipilot_auth_header else "anon",
|
|
||||||
(demo_session_key[:40] if isinstance(demo_session_key, str) else "?"),
|
|
||||||
settings.S2S_APIPILOT_TOOLS_URL or "<using override execute>",
|
|
||||||
)
|
|
||||||
if settings.S2S_DEBUG_PROMPT:
|
|
||||||
app_logging.logger.info(
|
|
||||||
"[DEBUG-APIPILOT][WS connect] tools fetch detail toolsURL=%s executeURL=%s overrideExec=%s",
|
|
||||||
settings.S2S_APIPILOT_TOOLS_URL,
|
|
||||||
settings.S2S_APIPILOT_TOOLS_EXECUTE_URL,
|
|
||||||
providers_apipilot._apipilot_resolve_execute_url(),
|
|
||||||
)
|
|
||||||
_t0 = providers_apipilot._monotonic_now()
|
|
||||||
apipilot_tools_pass1 = (
|
|
||||||
await providers_apipilot._apipilot_fetch_tools(
|
|
||||||
auth_header=apipilot_auth_header,
|
|
||||||
demo_session=demo_session_key if isinstance(demo_session_key, str) else None,
|
|
||||||
refresh=False,
|
|
||||||
)
|
|
||||||
or []
|
|
||||||
)
|
|
||||||
_el = providers_apipilot._monotonic_now() - _t0
|
|
||||||
# Supply known phone/episode defaults to pass 1 so the model does
|
|
||||||
# not leave required arguments empty (observed ARGUMENTS=>>> bug).
|
|
||||||
_apipilot_defaults_p1: dict[str, str] = {}
|
|
||||||
if isinstance(inject_phone, str) and inject_phone.strip():
|
|
||||||
_apipilot_defaults_p1["phone"] = inject_phone.strip()
|
|
||||||
if isinstance(inject_episode_id, str) and inject_episode_id.strip():
|
|
||||||
_apipilot_defaults_p1["episode_id"] = inject_episode_id.strip()
|
|
||||||
# Also supply email when the resolved profile has a valid value.
|
|
||||||
if isinstance(profile_obj, dict) and profile_obj:
|
|
||||||
try:
|
|
||||||
u = profile_obj.get("user") if isinstance(profile_obj.get("user"), dict) else None
|
|
||||||
if isinstance(u, dict):
|
|
||||||
email = cast(str, u.get("email")) if isinstance(u.get("email"), str) else ""
|
|
||||||
if email.strip():
|
|
||||||
_apipilot_defaults_p1["email"] = email.strip()
|
|
||||||
except Exception: # noqa: BLE001
|
|
||||||
pass
|
|
||||||
session_ctx_apipilot_defaults = dict(_apipilot_defaults_p1)
|
|
||||||
injected_apipilot_pass1 = (
|
|
||||||
services_tool_context._resolve_apipilot_reasoning_context_block(
|
|
||||||
tools_list=apipilot_tools_pass1,
|
|
||||||
context_defaults=_apipilot_defaults_p1,
|
|
||||||
)
|
|
||||||
or ""
|
|
||||||
)
|
|
||||||
if injected_apipilot_pass1.strip():
|
|
||||||
app_logging.logger.info(
|
|
||||||
"[APIPILOT] REASONING PASS-1 (WS realtime): tool list ready N=%d t=%.0fms auth=%s defaults=%s",
|
|
||||||
len(apipilot_tools_pass1),
|
|
||||||
_el,
|
|
||||||
"Y" if isinstance(apipilot_auth_header, str) else "anon",
|
|
||||||
list(_apipilot_defaults_p1.keys()),
|
|
||||||
)
|
|
||||||
app_logging.logger.info(
|
|
||||||
"[INJECT-APIPILOT] ✅ WS connect: APIPILOT TOOLS list PASS-1 READY Ntools=%d wrappedLen=%d defaultsPhone=%s -> will patch session.update frames in this tunnel.",
|
|
||||||
len(apipilot_tools_pass1),
|
|
||||||
len(injected_apipilot_pass1),
|
|
||||||
("yes" if _apipilot_defaults_p1.get("phone") else "no"),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
app_logging.logger.info(
|
|
||||||
"[INJECT-APIPILOT] ⏭️ WS connect: apipilot fetch returned empty tools list; PASS-1 injection skipped for this tunnel.",
|
|
||||||
)
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
app_logging.logger.info(
|
|
||||||
"[INJECT-APIPILOT] ⛔ WS connect: apipilot tools fetch failed. Skipping PASS-1 injection for tunnel. err=%r",
|
|
||||||
exc,
|
|
||||||
)
|
|
||||||
injected_apipilot_pass1 = ""
|
|
||||||
apipilot_tools_pass1 = []
|
|
||||||
session_ctx_apipilot_defaults = {}
|
|
||||||
# Preserve the exact discovery authentication and demo-session values.
|
|
||||||
# AquaPortal binds authorized tools to GET /tools' x-demo-session; a new
|
|
||||||
# ws-pass2 value would appear anonymous and produce a 403 response.
|
|
||||||
_ws_apipilot_auth_for_execute = apipilot_auth_header
|
|
||||||
_ws_apipilot_demo_for_execute = demo_session_key if isinstance(demo_session_key, str) else None
|
|
||||||
# Preserve API-key identity in every propagation variable, matching the
|
|
||||||
# discovery cache fingerprint and HTTP headers even during pass-2 fallback.
|
|
||||||
if settings.S2S_APIPILOT_API_KEY:
|
|
||||||
_ws_apipilot_auth_for_execute = f"Bearer {settings.S2S_APIPILOT_API_KEY}"
|
|
||||||
_ws_apipilot_demo_for_execute = settings.S2S_APIPILOT_API_KEY
|
|
||||||
else:
|
|
||||||
# With ApiPilot unconfigured, keep defaults as an empty dict rather than None.
|
|
||||||
session_ctx_apipilot_defaults = {}
|
|
||||||
injected_apipilot_pass1 = ""
|
|
||||||
apipilot_tools_pass1 = []
|
|
||||||
_ws_apipilot_auth_for_execute = None
|
|
||||||
_ws_apipilot_demo_for_execute = None
|
|
||||||
app_logging.logger.info(
|
|
||||||
"[INJECT-PROFILE] WS connect FINAL: injectEpisode=%s injectProfile=%s injectReports=%s injectSummary=%s injectApipilotPass1=%s(Ntools=%d) login=%s episodeIdLen=%d phoneLen=%d episodeCtxLen=%d profileCtxLen=%d reportsCtxLen=%d summaryCtxLen=%d apipilotCtxLen=%d target=%s",
|
|
||||||
bool(injected_episode_context),
|
bool(injected_episode_context),
|
||||||
bool(injected_profile_context),
|
bool(injected_profile_context),
|
||||||
bool(injected_reports_context),
|
bool(injected_reports_context),
|
||||||
bool(injected_summary_context),
|
bool(injected_summary_context),
|
||||||
bool(injected_apipilot_pass1.strip()),
|
|
||||||
len(apipilot_tools_pass1),
|
|
||||||
("token-present" if login_token else "skipped"),
|
("token-present" if login_token else "skipped"),
|
||||||
len(inject_episode_id),
|
len(inject_episode_id),
|
||||||
len(inject_phone),
|
len(inject_phone),
|
||||||
@@ -381,7 +188,6 @@ async def realtime_proxy(ws: WebSocket) -> None:
|
|||||||
len(injected_profile_context),
|
len(injected_profile_context),
|
||||||
len(injected_reports_context),
|
len(injected_reports_context),
|
||||||
len(injected_summary_context),
|
len(injected_summary_context),
|
||||||
len(injected_apipilot_pass1),
|
|
||||||
upstream_url,
|
upstream_url,
|
||||||
)
|
)
|
||||||
if settings.S2S_DEBUG_PROMPT and injected_episode_context.strip():
|
if settings.S2S_DEBUG_PROMPT and injected_episode_context.strip():
|
||||||
@@ -415,11 +221,6 @@ async def realtime_proxy(ws: WebSocket) -> None:
|
|||||||
"injected_profile_context": injected_profile_context,
|
"injected_profile_context": injected_profile_context,
|
||||||
"injected_reports_context": injected_reports_context,
|
"injected_reports_context": injected_reports_context,
|
||||||
"injected_summary_context": injected_summary_context,
|
"injected_summary_context": injected_summary_context,
|
||||||
"injected_apipilot_pass1": injected_apipilot_pass1,
|
|
||||||
"inject_phone": inject_phone,
|
|
||||||
"inject_episode_id": inject_episode_id,
|
|
||||||
"login_token": login_token,
|
|
||||||
"login_response": login_response_obj,
|
|
||||||
# 2026-09-11 proper-name anglicization guard: keep the full
|
# 2026-09-11 proper-name anglicization guard: keep the full
|
||||||
# upstream profile object so:
|
# upstream profile object so:
|
||||||
# (a) _build_voice_rules_head_lock_block can emit rule #5
|
# (a) _build_voice_rules_head_lock_block can emit rule #5
|
||||||
@@ -429,24 +230,6 @@ async def realtime_proxy(ws: WebSocket) -> None:
|
|||||||
# Alessandro with word-boundary regex even when the
|
# Alessandro with word-boundary regex even when the
|
||||||
# prompt lock fails on GPTQ.
|
# prompt lock fails on GPTQ.
|
||||||
"profile_obj": profile_obj,
|
"profile_obj": profile_obj,
|
||||||
"apipilot_tools_pass1": apipilot_tools_pass1,
|
|
||||||
# Accumulate transcripts per response_id and prevent pass-2
|
|
||||||
# injection loops after ApiPilot execution.
|
|
||||||
"_ws_apipilot_asst_transcript_acc": {}, # response_id -> str full transcript
|
|
||||||
"_ws_apipilot_pass2_active": False, # True during the second response; prevents infinite marker loops.
|
|
||||||
"_ws_apipilot_pass2_count": 0, # Session execution count, limited to three attempts.
|
|
||||||
# Defaults prepared by pass 1 fill missing pass-2 arguments.
|
|
||||||
"apipilot_defaults": (session_ctx_apipilot_defaults if needs_apipilot else {}),
|
|
||||||
# Pass-2 phone fallback also works when defaults are empty but
|
|
||||||
# the query string supplied phoneNumber.
|
|
||||||
"inject_phone": (inject_phone if isinstance(inject_phone, str) else ""), # noqa: F601
|
|
||||||
# Bearer for authenticated execution, resolved by phone login
|
|
||||||
# independently of S2S_INJECT_PROFILE.
|
|
||||||
"login_token": (login_token if isinstance(login_token, str) else None), # noqa: F601
|
|
||||||
# Execute with the exact auth/demo values used for WS discovery;
|
|
||||||
# AquaPortal binds the authorized tool list to that demo session.
|
|
||||||
"apipilot_exec_auth": _ws_apipilot_auth_for_execute,
|
|
||||||
"apipilot_exec_demo": _ws_apipilot_demo_for_execute,
|
|
||||||
}
|
}
|
||||||
client_to_upstream = asyncio.create_task(_client_to_upstream(ws, upstream, client_ip, session_ctx))
|
client_to_upstream = asyncio.create_task(_client_to_upstream(ws, upstream, client_ip, session_ctx))
|
||||||
upstream_to_client = asyncio.create_task(_upstream_to_client(ws, upstream, client_ip, session_ctx))
|
upstream_to_client = asyncio.create_task(_upstream_to_client(ws, upstream, client_ip, session_ctx))
|
||||||
@@ -542,7 +325,6 @@ async def _client_to_upstream(
|
|||||||
ctx_profile_context = session_ctx.get("injected_profile_context") if isinstance(session_ctx, dict) else ""
|
ctx_profile_context = session_ctx.get("injected_profile_context") if isinstance(session_ctx, dict) else ""
|
||||||
ctx_reports_context = session_ctx.get("injected_reports_context") if isinstance(session_ctx, dict) else ""
|
ctx_reports_context = session_ctx.get("injected_reports_context") if isinstance(session_ctx, dict) else ""
|
||||||
ctx_summary_context = session_ctx.get("injected_summary_context") if isinstance(session_ctx, dict) else ""
|
ctx_summary_context = session_ctx.get("injected_summary_context") if isinstance(session_ctx, dict) else ""
|
||||||
ctx_apipilot_pass1 = session_ctx.get("injected_apipilot_pass1") if isinstance(session_ctx, dict) else ""
|
|
||||||
ctx_profile_obj = session_ctx.get("profile_obj") if isinstance(session_ctx, dict) else None
|
ctx_profile_obj = session_ctx.get("profile_obj") if isinstance(session_ctx, dict) else None
|
||||||
forward_text = services_context._patch_client_session_update_with_profile(
|
forward_text = services_context._patch_client_session_update_with_profile(
|
||||||
message["text"],
|
message["text"],
|
||||||
@@ -551,7 +333,6 @@ async def _client_to_upstream(
|
|||||||
ctx_summary_context or "",
|
ctx_summary_context or "",
|
||||||
ctx_episode_context or "",
|
ctx_episode_context or "",
|
||||||
profile_obj=ctx_profile_obj,
|
profile_obj=ctx_profile_obj,
|
||||||
apipilot_context_text_pass1=ctx_apipilot_pass1 or "",
|
|
||||||
)
|
)
|
||||||
await upstream.send(forward_text)
|
await upstream.send(forward_text)
|
||||||
elif message.get("bytes") is not None:
|
elif message.get("bytes") is not None:
|
||||||
@@ -576,38 +357,6 @@ async def _upstream_to_client(
|
|||||||
) -> None:
|
) -> None:
|
||||||
async for message in upstream:
|
async for message in upstream:
|
||||||
if isinstance(message, bytes):
|
if isinstance(message, bytes):
|
||||||
# Drop audio only for the current response_id marked as containing
|
|
||||||
# a CALL marker: Qwen3 may already have spoken it. Matching any ID
|
|
||||||
# would also discard valid pass-2 audio.
|
|
||||||
drop_now = False
|
|
||||||
matched_rid = ""
|
|
||||||
drop_ids = session_ctx.get("_ws_drop_audio_for_response_ids")
|
|
||||||
if isinstance(drop_ids, dict) and drop_ids:
|
|
||||||
rid_ctx = session_ctx.get("_ws_current_response_id")
|
|
||||||
if isinstance(rid_ctx, str) and rid_ctx and drop_ids.get(rid_ctx):
|
|
||||||
drop_now = True
|
|
||||||
matched_rid = rid_ctx
|
|
||||||
elif (not isinstance(rid_ctx, str) or not rid_ctx) and len(drop_ids) == 1:
|
|
||||||
# Fallback when no current ID exists but exactly one response
|
|
||||||
# remains in the dictionary.
|
|
||||||
only_rid = next(iter(drop_ids.keys()))
|
|
||||||
if drop_ids.get(only_rid):
|
|
||||||
drop_now = True
|
|
||||||
matched_rid = only_rid
|
|
||||||
if drop_now:
|
|
||||||
app_logging.logger.info(
|
|
||||||
"[APIPILOT-AUDIO-DROP] FRAME DROP bytes=%dB matchedResponse=%s (CALL marker in this response → leaked Qwen3 audio DISCARDED)",
|
|
||||||
len(message),
|
|
||||||
(matched_rid or session_ctx.get("_ws_current_response_id") or "-"),
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
else:
|
|
||||||
app_logging.logger.debug(
|
|
||||||
"[APIPILOT-AUDIO-ALLOW] FRAME PASS bytes=%dB responseCtx=%s dropIds=%s",
|
|
||||||
len(message),
|
|
||||||
(session_ctx.get("_ws_current_response_id") or "-"),
|
|
||||||
(sorted(list(drop_ids.keys())) if isinstance(drop_ids, dict) else "none"),
|
|
||||||
)
|
|
||||||
# #region debug-point B:upstream-bytes-forward
|
# #region debug-point B:upstream-bytes-forward
|
||||||
await realtime_events._report_server_debug(
|
await realtime_events._report_server_debug(
|
||||||
"B",
|
"B",
|
||||||
@@ -623,535 +372,7 @@ async def _upstream_to_client(
|
|||||||
# #endregion
|
# #endregion
|
||||||
await ws.send_bytes(message)
|
await ws.send_bytes(message)
|
||||||
else:
|
else:
|
||||||
# Extract the original message before sanitation removes pass-2 markers.
|
# Remove internal context markers before forwarding text to the client.
|
||||||
pre_sanitize_summary = realtime_events._realtime_event_summary(message)
|
|
||||||
pre_event_type = pre_sanitize_summary.get("type") if isinstance(pre_sanitize_summary, dict) else None
|
|
||||||
pre_response_id: str | None = None
|
|
||||||
if isinstance(pre_sanitize_summary, dict):
|
|
||||||
if isinstance(pre_sanitize_summary.get("response_id"), str):
|
|
||||||
pre_response_id = pre_sanitize_summary["response_id"]
|
|
||||||
elif isinstance(pre_sanitize_summary.get("id"), str):
|
|
||||||
pre_response_id = pre_sanitize_summary["id"]
|
|
||||||
# Track the current response_id for the binary-audio gate.
|
|
||||||
if isinstance(pre_response_id, str) and pre_response_id:
|
|
||||||
session_ctx["_ws_current_response_id"] = pre_response_id
|
|
||||||
# Detect CALL markers in every text event, including before response.done.
|
|
||||||
# Mark this response_id to drop its audio and sanitize later events.
|
|
||||||
if (
|
|
||||||
isinstance(pre_sanitize_summary, dict)
|
|
||||||
and isinstance(pre_event_type, str)
|
|
||||||
and pre_event_type not in {"response.done"}
|
|
||||||
):
|
|
||||||
quick_text_parts: list[str] = []
|
|
||||||
for _k in (
|
|
||||||
"text",
|
|
||||||
"transcript",
|
|
||||||
"output_text",
|
|
||||||
"audio_text",
|
|
||||||
"output_transcript",
|
|
||||||
"final_transcript",
|
|
||||||
"output_audio_text",
|
|
||||||
"content",
|
|
||||||
"message",
|
|
||||||
"utterance",
|
|
||||||
"delta",
|
|
||||||
"_raw_preview",
|
|
||||||
):
|
|
||||||
_v = pre_sanitize_summary.get(_k)
|
|
||||||
if isinstance(_v, str) and _v.strip():
|
|
||||||
quick_text_parts.append(_v)
|
|
||||||
for _cname in ("item", "part", "output", "audio"):
|
|
||||||
_sub = pre_sanitize_summary.get(_cname)
|
|
||||||
if isinstance(_sub, dict):
|
|
||||||
for _k2 in ("text", "transcript", "output_text"):
|
|
||||||
_v2 = _sub.get(_k2)
|
|
||||||
if isinstance(_v2, str) and _v2.strip():
|
|
||||||
quick_text_parts.append(_v2)
|
|
||||||
quick_text = (" ".join(quick_text_parts)).strip()
|
|
||||||
if quick_text:
|
|
||||||
_detected_name, _detected_args = services_tool_context._apipilot_parse_call_marker(quick_text)
|
|
||||||
if isinstance(_detected_name, str) and _detected_name:
|
|
||||||
drop_ids2 = session_ctx.get("_ws_drop_audio_for_response_ids")
|
|
||||||
if not isinstance(drop_ids2, dict):
|
|
||||||
drop_ids2 = {}
|
|
||||||
session_ctx["_ws_drop_audio_for_response_ids"] = drop_ids2
|
|
||||||
_flag_rid = (
|
|
||||||
pre_response_id
|
|
||||||
if isinstance(pre_response_id, str) and pre_response_id
|
|
||||||
else f"auto-{id(session_ctx)}"
|
|
||||||
)
|
|
||||||
drop_ids2[_flag_rid] = True
|
|
||||||
# Remove older IDs when more than ten remain.
|
|
||||||
if len(drop_ids2) > 10:
|
|
||||||
try:
|
|
||||||
for _old_k in list(drop_ids2.keys())[:-10]:
|
|
||||||
drop_ids2.pop(_old_k, None)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
app_logging.logger.info(
|
|
||||||
"[APIPILOT-SANITIZE] CALL marker in event=%s response=%s name=%s → AUDIO DROP GATE enabled (discard potential leaked Qwen3 audio)",
|
|
||||||
pre_event_type,
|
|
||||||
_flag_rid,
|
|
||||||
_detected_name,
|
|
||||||
)
|
|
||||||
# 1a) Accumulate original TTS transcripts for pass-2 marker parsing.
|
|
||||||
if (
|
|
||||||
isinstance(pre_sanitize_summary, dict)
|
|
||||||
and isinstance(pre_event_type, str)
|
|
||||||
and pre_event_type in {"response.audio_transcript.done", "response.output_audio_transcript.done"}
|
|
||||||
):
|
|
||||||
pre_agent_text = (
|
|
||||||
cast(str, pre_sanitize_summary.get("text"))
|
|
||||||
if isinstance(pre_sanitize_summary.get("text"), str)
|
|
||||||
else ""
|
|
||||||
).strip()
|
|
||||||
rid_key = (
|
|
||||||
pre_response_id
|
|
||||||
if isinstance(pre_response_id, str) and pre_response_id
|
|
||||||
else f"auto-{id(session_ctx)}"
|
|
||||||
)
|
|
||||||
if rid_key and pre_agent_text:
|
|
||||||
acc = session_ctx.get("_ws_apipilot_asst_transcript_acc")
|
|
||||||
if not isinstance(acc, dict):
|
|
||||||
acc = {}
|
|
||||||
session_ctx["_ws_apipilot_asst_transcript_acc"] = acc
|
|
||||||
prev = acc.get(rid_key, "")
|
|
||||||
if prev:
|
|
||||||
acc[rid_key] = f"{prev} {pre_agent_text}".strip()
|
|
||||||
else:
|
|
||||||
acc[rid_key] = pre_agent_text
|
|
||||||
# 1b) Also accumulate assistant conversation.item.created text,
|
|
||||||
# which can contain markers outside TTS transcripts.
|
|
||||||
elif (
|
|
||||||
isinstance(pre_sanitize_summary, dict)
|
|
||||||
and isinstance(pre_event_type, str)
|
|
||||||
and pre_event_type == "conversation.item.created"
|
|
||||||
and pre_sanitize_summary.get("role") == "assistant"
|
|
||||||
):
|
|
||||||
pre_conv_text = (
|
|
||||||
cast(str, pre_sanitize_summary.get("text"))
|
|
||||||
if isinstance(pre_sanitize_summary.get("text"), str)
|
|
||||||
else ""
|
|
||||||
).strip()
|
|
||||||
rid_key2 = (
|
|
||||||
pre_response_id
|
|
||||||
if isinstance(pre_response_id, str) and pre_response_id
|
|
||||||
else f"auto-{id(session_ctx)}"
|
|
||||||
)
|
|
||||||
if rid_key2 and pre_conv_text:
|
|
||||||
acc2 = session_ctx.get("_ws_apipilot_asst_transcript_acc")
|
|
||||||
if not isinstance(acc2, dict):
|
|
||||||
acc2 = {}
|
|
||||||
session_ctx["_ws_apipilot_asst_transcript_acc"] = acc2
|
|
||||||
prev2 = acc2.get(rid_key2, "")
|
|
||||||
if prev2:
|
|
||||||
acc2[rid_key2] = f"{prev2} {pre_conv_text}".strip()
|
|
||||||
else:
|
|
||||||
acc2[rid_key2] = pre_conv_text
|
|
||||||
# 2) Parse the original marker at response.done to trigger pass 2.
|
|
||||||
if (
|
|
||||||
isinstance(pre_sanitize_summary, dict)
|
|
||||||
and isinstance(pre_event_type, str)
|
|
||||||
and pre_event_type == "response.done"
|
|
||||||
):
|
|
||||||
rid_val: str = (
|
|
||||||
pre_response_id
|
|
||||||
if isinstance(pre_response_id, str)
|
|
||||||
else (
|
|
||||||
cast(str, pre_sanitize_summary.get("id"))
|
|
||||||
if isinstance(pre_sanitize_summary.get("id"), str)
|
|
||||||
else f"auto-{id(session_ctx)}"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
full_text = ""
|
|
||||||
acc3 = session_ctx.get("_ws_apipilot_asst_transcript_acc")
|
|
||||||
if rid_val and isinstance(acc3, dict):
|
|
||||||
full_text = (acc3.pop(rid_val, "") if isinstance(acc3, dict) else "") or ""
|
|
||||||
# Use the sole remaining accumulator when an upstream changes
|
|
||||||
# response_id between events.
|
|
||||||
if (
|
|
||||||
(not isinstance(full_text, str) or not full_text.strip())
|
|
||||||
and isinstance(acc3, dict)
|
|
||||||
and len(acc3) == 1
|
|
||||||
):
|
|
||||||
_only_key = next(iter(acc3.keys()))
|
|
||||||
full_text = (acc3.pop(_only_key, "") if isinstance(acc3, dict) else "") or ""
|
|
||||||
marker_name, marker_args = None, None
|
|
||||||
if isinstance(full_text, str) and full_text.strip():
|
|
||||||
_p1_defaults = (
|
|
||||||
session_ctx.get("apipilot_defaults")
|
|
||||||
if isinstance(session_ctx.get("apipilot_defaults"), dict)
|
|
||||||
else {}
|
|
||||||
)
|
|
||||||
marker_name, marker_args = services_tool_context._apipilot_parse_call_marker(
|
|
||||||
full_text, defaults=_p1_defaults
|
|
||||||
)
|
|
||||||
app_logging.logger.info(
|
|
||||||
"[APIPILOT-CALL-NAME] USE=WS_RESPONSE_DONE_DETECTION markerFound=%s name=%r argsKeys=%s",
|
|
||||||
("Y" if isinstance(marker_name, str) and marker_name else "N"),
|
|
||||||
(marker_name or ""),
|
|
||||||
list((marker_args or {}).keys()),
|
|
||||||
)
|
|
||||||
# Always log pass-2 marker detection diagnostics.
|
|
||||||
try:
|
|
||||||
_preview = full_text.replace("\n", " ")
|
|
||||||
if len(_preview) > 500:
|
|
||||||
_preview = _preview[:500] + "…"
|
|
||||||
except Exception:
|
|
||||||
_preview = str(full_text)[:500]
|
|
||||||
app_logging.logger.info(
|
|
||||||
"[APIPILOT] REASONING PASS-2 (WS realtime): response.done DIAGNOSTIC fullLen=%d markerFound=%s markerName=%s argsKeys=%s defaultsAutoFill=%s acc3Remaining=%d fullTextPreview=%r",
|
|
||||||
len(full_text or ""),
|
|
||||||
("Y" if isinstance(marker_name, str) and marker_name else "N"),
|
|
||||||
(marker_name if isinstance(marker_name, str) and marker_name else ""),
|
|
||||||
(list(marker_args.keys()) if isinstance(marker_args, dict) else []),
|
|
||||||
(list(_p1_defaults.keys()) if isinstance(_p1_defaults, dict) and _p1_defaults else "none"),
|
|
||||||
(len(acc3) if isinstance(acc3, dict) else -1),
|
|
||||||
_preview,
|
|
||||||
)
|
|
||||||
elif isinstance(full_text, str):
|
|
||||||
app_logging.logger.info(
|
|
||||||
"[APIPILOT] REASONING PASS-2 (WS realtime): response.done DIAGNOSTIC fullLen=0 EMPTY markerFound=N response_id=%r acc3Remaining=%d",
|
|
||||||
rid_val,
|
|
||||||
(len(acc3) if isinstance(acc3, dict) else -1),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
app_logging.logger.info(
|
|
||||||
"[APIPILOT] REASONING PASS-2 (WS realtime): response.done DIAGNOSTIC fullText is not str type=%r acc3=%r",
|
|
||||||
type(full_text).__name__,
|
|
||||||
(list(acc3.keys()) if isinstance(acc3, dict) else "?"),
|
|
||||||
)
|
|
||||||
needs_pass2 = (
|
|
||||||
isinstance(marker_name, str)
|
|
||||||
and bool(marker_name)
|
|
||||||
and not bool(session_ctx.get("_ws_apipilot_pass2_active"))
|
|
||||||
and (int(session_ctx.get("_ws_apipilot_pass2_count") or 0) < 3)
|
|
||||||
)
|
|
||||||
if needs_pass2:
|
|
||||||
# Clear active audio-drop gates before pass 2: its response
|
|
||||||
# contains actual user data and must be played normally.
|
|
||||||
drop_ids_clean = session_ctx.get("_ws_drop_audio_for_response_ids")
|
|
||||||
if isinstance(drop_ids_clean, dict) and drop_ids_clean:
|
|
||||||
drop_ids_clean.clear()
|
|
||||||
app_logging.logger.info(
|
|
||||||
"[APIPILOT-AUDIO-DROP] PASS-2 start: clear audio-drop gate → forward pass-2 briefing audio normally"
|
|
||||||
)
|
|
||||||
args = marker_args if isinstance(marker_args, dict) else {}
|
|
||||||
# Fill missing arguments or replace fictional prompt example
|
|
||||||
# values with actual defaults. When defaults are empty, use
|
|
||||||
# session_ctx["inject_phone"] from the query string.
|
|
||||||
_p1_defaults_use = (
|
|
||||||
session_ctx.get("apipilot_defaults")
|
|
||||||
if isinstance(session_ctx.get("apipilot_defaults"), dict)
|
|
||||||
else {}
|
|
||||||
)
|
|
||||||
_fallback_inject_phone = (
|
|
||||||
session_ctx.get("inject_phone") if isinstance(session_ctx.get("inject_phone"), str) else ""
|
|
||||||
)
|
|
||||||
# Known fictional example values appearing in prompts.
|
|
||||||
_FAKE_PHONE_PLACEHOLDERS: set[str] = {
|
|
||||||
"+56912345678", # Prompt example from rule 7.
|
|
||||||
"56912345678", # Variant without +.
|
|
||||||
"+5612345678",
|
|
||||||
"+1234567890",
|
|
||||||
"+12345678",
|
|
||||||
"+56XXXXXXX",
|
|
||||||
"+56xxxxxxx",
|
|
||||||
"",
|
|
||||||
}
|
|
||||||
|
|
||||||
def _is_phone_fake(v: str) -> bool:
|
|
||||||
c = str(v or "").strip()
|
|
||||||
if c in _FAKE_PHONE_PLACEHOLDERS: # noqa: B023
|
|
||||||
return True
|
|
||||||
if len(c) < 7:
|
|
||||||
return True
|
|
||||||
if c.endswith("00000"):
|
|
||||||
return True
|
|
||||||
if "123456" in c or "xxxxxx" in c.lower() or "xxxx" in c.lower():
|
|
||||||
return True
|
|
||||||
if c.startswith("+56") and len(c) <= 9:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Apply defaults and fallback to all available keys.
|
|
||||||
if isinstance(_p1_defaults_use, dict) and _p1_defaults_use:
|
|
||||||
for k, default_v in _p1_defaults_use.items():
|
|
||||||
if default_v in (None, "", []):
|
|
||||||
continue
|
|
||||||
if k not in args or args[k] in (None, "", []):
|
|
||||||
args[k] = default_v
|
|
||||||
elif k == "phone" and isinstance(args.get("phone"), str):
|
|
||||||
if _is_phone_fake(str(args.get("phone"))):
|
|
||||||
args[k] = default_v
|
|
||||||
# Replace fictional phone values even when defaults lacks phone.
|
|
||||||
if (
|
|
||||||
isinstance(args.get("phone"), str)
|
|
||||||
and _is_phone_fake(str(args.get("phone")))
|
|
||||||
and isinstance(_fallback_inject_phone, str)
|
|
||||||
and _fallback_inject_phone.strip()
|
|
||||||
):
|
|
||||||
args["phone"] = _fallback_inject_phone.strip()
|
|
||||||
# Refresh _p1_defaults for subsequent logging.
|
|
||||||
_p1_defaults = _p1_defaults_use
|
|
||||||
session_ctx["_ws_apipilot_pass2_count"] = int(session_ctx.get("_ws_apipilot_pass2_count") or 0) + 1
|
|
||||||
session_ctx["_ws_apipilot_pass2_active"] = True
|
|
||||||
# Reuse exact discovery auth/demo values: AquaPortal binds
|
|
||||||
# authorized tools to the x-demo-session used for GET /tools.
|
|
||||||
_exec_auth = (
|
|
||||||
session_ctx.get("apipilot_exec_auth")
|
|
||||||
if isinstance(session_ctx.get("apipilot_exec_auth"), str)
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
_exec_demo = (
|
|
||||||
session_ctx.get("apipilot_exec_demo")
|
|
||||||
if isinstance(session_ctx.get("apipilot_exec_demo"), str)
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
# Compatibility fallback for missing values:
|
|
||||||
if not isinstance(_exec_auth, str) or not _exec_auth.strip():
|
|
||||||
login_tok = session_ctx.get("login_token")
|
|
||||||
_exec_auth = f"Bearer {login_tok}" if isinstance(login_tok, str) and login_tok.strip() else None
|
|
||||||
if not isinstance(_exec_demo, str) or not _exec_demo.strip():
|
|
||||||
_exec_demo = f"ws-pass2-{client_ip}-{id(session_ctx)}"
|
|
||||||
# The SUPER API KEY overrides even SERVICE_TOKEN. Force bearer
|
|
||||||
# and demo-session values to match the discovery bypass identity.
|
|
||||||
if settings.S2S_APIPILOT_API_KEY:
|
|
||||||
_exec_auth = f"Bearer {settings.S2S_APIPILOT_API_KEY}"
|
|
||||||
_exec_demo = settings.S2S_APIPILOT_API_KEY
|
|
||||||
# Apply the service-token override only without a SUPER API KEY.
|
|
||||||
elif settings.S2S_APIPILOT_SERVICE_TOKEN:
|
|
||||||
_svc = settings.S2S_APIPILOT_SERVICE_TOKEN.strip()
|
|
||||||
if _svc:
|
|
||||||
_exec_auth = f"Bearer {_svc}" if not _svc.lower().startswith("bearer ") else _svc
|
|
||||||
# For authenticated tools with an empty/short bearer, force
|
|
||||||
# phone login again so AquaPortal receives a valid user token.
|
|
||||||
# The length heuristic treats fewer than 80 characters as short.
|
|
||||||
_phone_lfb = (
|
|
||||||
session_ctx.get("inject_phone") if isinstance(session_ctx.get("inject_phone"), str) else ""
|
|
||||||
) or (inject_phone if isinstance(inject_phone, str) else "") # type: ignore[name-defined] # noqa: F821 - preserve the legacy fallback failure.
|
|
||||||
_phone_lfb = _phone_lfb.strip() if isinstance(_phone_lfb, str) else ""
|
|
||||||
_current_bearer_token = ""
|
|
||||||
if isinstance(_exec_auth, str) and _exec_auth.lower().startswith("bearer "):
|
|
||||||
_current_bearer_token = _exec_auth[len("bearer ") :].strip()
|
|
||||||
_is_jwt_like = (
|
|
||||||
bool(_current_bearer_token)
|
|
||||||
and len(_current_bearer_token) >= 120
|
|
||||||
and _current_bearer_token.count(".") >= 2
|
|
||||||
)
|
|
||||||
# Resolve requires_auth from recent session/cache tools; default true.
|
|
||||||
_tools_recent: list[dict] = (
|
|
||||||
cast(list[dict], session_ctx.get("apipilot_tools_pass1"))
|
|
||||||
if isinstance(session_ctx.get("apipilot_tools_pass1"), list)
|
|
||||||
else []
|
|
||||||
)
|
|
||||||
if not _tools_recent:
|
|
||||||
try:
|
|
||||||
_cached = (
|
|
||||||
providers_apipilot._APIPILOT_CACHE.get("tools")
|
|
||||||
if providers_apipilot._APIPILOT_CACHE
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
if isinstance(_cached, tuple) and _cached and isinstance(_cached[0], list):
|
|
||||||
_tools_recent = _cached[0]
|
|
||||||
except Exception: # noqa: BLE001
|
|
||||||
_tools_recent = []
|
|
||||||
_mtool = next(
|
|
||||||
(t for t in _tools_recent if isinstance(t, dict) and t.get("name") == marker_name), None
|
|
||||||
)
|
|
||||||
_requires_auth = True if (_mtool is None) else bool(_mtool.get("requires_auth"))
|
|
||||||
if (
|
|
||||||
(not settings.S2S_APIPILOT_API_KEY)
|
|
||||||
and settings.S2S_LOGIN_BY_PHONE
|
|
||||||
and _phone_lfb
|
|
||||||
and _requires_auth
|
|
||||||
and (not _is_jwt_like)
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
_l_tok, _l_obj = await providers_caller._login_by_phone(_phone_lfb)
|
|
||||||
if isinstance(_l_tok, str) and _l_tok.strip():
|
|
||||||
_exec_auth = f"Bearer {_l_tok.strip()}"
|
|
||||||
_tok_len = len(_l_tok.strip())
|
|
||||||
_is_new_jwt = _tok_len >= 120 and _l_tok.strip().count(".") >= 2
|
|
||||||
app_logging.logger.info(
|
|
||||||
"[APIPILOT] REASONING PASS-2 (WS realtime): FORCED PASS-2 LOGIN (tool requires_auth) phoneLen=%d token=%s%s",
|
|
||||||
len(_phone_lfb),
|
|
||||||
f"YES len={_tok_len}",
|
|
||||||
(" JWT?" if _is_new_jwt else (" SHORT?" if _tok_len < 80 else " OK")),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
app_logging.logger.info(
|
|
||||||
"[APIPILOT] REASONING PASS-2 (WS realtime): FORCED PASS-2 LOGIN token EXTRACTION FAILED phoneLen=%d (tool requires_auth → likely 403)",
|
|
||||||
len(_phone_lfb),
|
|
||||||
)
|
|
||||||
except Exception as _lfb_exc: # noqa: BLE001
|
|
||||||
app_logging.logger.info(
|
|
||||||
"[APIPILOT] REASONING PASS-2 (WS realtime): FORCED PASS-2 LOGIN failed phoneLen=%d err=%r",
|
|
||||||
len(_phone_lfb),
|
|
||||||
_lfb_exc,
|
|
||||||
)
|
|
||||||
bearer = _exec_auth
|
|
||||||
demo_key = _exec_demo
|
|
||||||
try:
|
|
||||||
app_logging.logger.info(
|
|
||||||
"[APIPILOT-CALL-NAME] USE=WS_REALTIME_PASS2_TRIGGER name=%r argsKeys=%s argsPhone=%r",
|
|
||||||
marker_name,
|
|
||||||
list(args.keys()) if args else [],
|
|
||||||
(args.get("phone") if isinstance(args.get("phone"), str) else ""),
|
|
||||||
)
|
|
||||||
app_logging.logger.info(
|
|
||||||
"[APIPILOT] REASONING PASS-2 (WS realtime): trigger marker name=%s argsKeys=%s argsPhone=%s auth=%s defaultsAutoFill=%s demoSessionKey=%.40s",
|
|
||||||
marker_name,
|
|
||||||
list(args.keys()) if args else [],
|
|
||||||
(args.get("phone") if isinstance(args.get("phone"), str) else ""),
|
|
||||||
"Y" if bearer else "anon",
|
|
||||||
list(_p1_defaults.keys()) if isinstance(_p1_defaults, dict) and _p1_defaults else "none",
|
|
||||||
demo_key if isinstance(demo_key, str) else "?",
|
|
||||||
)
|
|
||||||
t0 = providers_apipilot._monotonic_now()
|
|
||||||
app_logging.logger.info(
|
|
||||||
"[APIPILOT-CALL-NAME] USE=WS_REALTIME_PASS2_EXEC_HELPER name=%r → forward a _apipilot_execute_tool",
|
|
||||||
marker_name,
|
|
||||||
)
|
|
||||||
env_exec = providers_apipilot._apipilot_execute_tool(
|
|
||||||
auth_header=bearer,
|
|
||||||
demo_session=demo_key,
|
|
||||||
name=cast(str, marker_name),
|
|
||||||
arguments=args,
|
|
||||||
)
|
|
||||||
if hasattr(asyncio, "create_task"):
|
|
||||||
envelope = await asyncio.wait_for(env_exec, timeout=60.0)
|
|
||||||
else:
|
|
||||||
envelope = await env_exec
|
|
||||||
t1 = providers_apipilot._monotonic_now() - t0
|
|
||||||
ok = bool(isinstance(envelope, dict) and envelope.get("success"))
|
|
||||||
data = envelope.get("data") if isinstance(envelope, dict) else None
|
|
||||||
err_obj = (
|
|
||||||
envelope.get("error")
|
|
||||||
if isinstance(envelope, dict) and isinstance(envelope.get("error"), dict)
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
if isinstance(data, str):
|
|
||||||
tool_text = data
|
|
||||||
elif isinstance(data, (dict, list)):
|
|
||||||
try:
|
|
||||||
tool_text = json.dumps(data, indent=2, ensure_ascii=False)
|
|
||||||
except Exception: # noqa: BLE001
|
|
||||||
tool_text = str(data)
|
|
||||||
else:
|
|
||||||
tool_text = ""
|
|
||||||
if not ok:
|
|
||||||
code = (
|
|
||||||
err_obj.get("code")
|
|
||||||
if isinstance(err_obj, dict) and err_obj.get("code") is not None
|
|
||||||
else (envelope.get("code") if isinstance(envelope, dict) else None)
|
|
||||||
)
|
|
||||||
msg = (
|
|
||||||
err_obj.get("message")
|
|
||||||
if isinstance(err_obj, dict) and isinstance(err_obj.get("message"), str)
|
|
||||||
else ""
|
|
||||||
)
|
|
||||||
prefix = ""
|
|
||||||
if code is not None or msg:
|
|
||||||
prefix = f"[{code if code is not None else 'ERROR'}] {msg}".strip() + "\n"
|
|
||||||
tool_text = f"{prefix}{tool_text}".strip()
|
|
||||||
app_logging.logger.info(
|
|
||||||
"[APIPILOT] REASONING PASS-2 (WS realtime): execute DONE name=%s success=%s len=%d t=%.0fms",
|
|
||||||
marker_name,
|
|
||||||
"ok" if ok else "error",
|
|
||||||
len(tool_text),
|
|
||||||
t1,
|
|
||||||
)
|
|
||||||
call_result_dict: dict[str, Any] = {
|
|
||||||
"name": marker_name,
|
|
||||||
"text": tool_text or "",
|
|
||||||
"error": (not ok),
|
|
||||||
}
|
|
||||||
# Use the light internal sanitizer for pass 2. The user-output
|
|
||||||
# sanitizer would remove the RESULT wrapper and prevent the
|
|
||||||
# upstream model from receiving the briefing result.
|
|
||||||
len_raw_tool_text = len(call_result_dict.get("text") or "")
|
|
||||||
if isinstance(call_result_dict.get("text"), str):
|
|
||||||
call_result_dict["text"] = services_tool_context._sanitize_pass2_internal_context(
|
|
||||||
call_result_dict["text"]
|
|
||||||
)
|
|
||||||
len_sanitized_tool_text = len(call_result_dict.get("text") or "")
|
|
||||||
blk_pass2 = (
|
|
||||||
services_tool_context._resolve_apipilot_reasoning_context_block(
|
|
||||||
None,
|
|
||||||
call_result=call_result_dict,
|
|
||||||
)
|
|
||||||
or ""
|
|
||||||
)
|
|
||||||
if not blk_pass2.strip():
|
|
||||||
blk_pass2 = (tool_text or "").strip()
|
|
||||||
len_raw_wrapper = len(blk_pass2 or "")
|
|
||||||
# Light sanitation preserves the pass-2 RESULT wrapper.
|
|
||||||
if blk_pass2.strip():
|
|
||||||
blk_pass2 = services_tool_context._sanitize_pass2_internal_context(blk_pass2)
|
|
||||||
len_sanitized_wrapper = len(blk_pass2 or "")
|
|
||||||
app_logging.logger.info(
|
|
||||||
"[APIPILOT] REASONING PASS-2 (WS realtime): block SIZES len_raw_tool_text=%d len_sanitized_tool_text=%d len_raw_wrapper=%d len_sanitized_wrapper=%d success=%s",
|
|
||||||
len_raw_tool_text,
|
|
||||||
len_sanitized_tool_text,
|
|
||||||
len_raw_wrapper,
|
|
||||||
len_sanitized_wrapper,
|
|
||||||
("ok" if ok else "error"),
|
|
||||||
)
|
|
||||||
if blk_pass2.strip():
|
|
||||||
# Reset _ws_current_response_id before pass-2 injection so
|
|
||||||
# valid briefing audio cannot match the previous CALL response.
|
|
||||||
session_ctx["_ws_current_response_id"] = None
|
|
||||||
app_logging.logger.info(
|
|
||||||
"[APIPILOT] REASONING PASS-2 (WS realtime): inject PASS-2 block len=%d -> send conversation.item.create + response.create upstream",
|
|
||||||
len(blk_pass2),
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
await upstream.send(
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"type": "conversation.item.create",
|
|
||||||
"item": {
|
|
||||||
"type": "message",
|
|
||||||
"role": "user",
|
|
||||||
"content": [{"type": "input_text", "text": blk_pass2}],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
ensure_ascii=False,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
await upstream.send(json.dumps({"type": "response.create"}, ensure_ascii=False))
|
|
||||||
app_logging.logger.info(
|
|
||||||
"[APIPILOT] REASONING PASS-2 (WS realtime): injection OK. Awaiting model response (pass2_active=Y until next response.done)",
|
|
||||||
)
|
|
||||||
except Exception as exc2: # noqa: BLE001
|
|
||||||
session_ctx["_ws_apipilot_pass2_active"] = False
|
|
||||||
app_logging.logger.info(
|
|
||||||
"[APIPILOT] REASONING PASS-2 (WS realtime): inject FAIL upstream send err=%r. pass2_active=N.",
|
|
||||||
exc2,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
session_ctx["_ws_apipilot_pass2_active"] = False
|
|
||||||
app_logging.logger.info(
|
|
||||||
"[APIPILOT] REASONING PASS-2 (WS realtime): EMPTY result block -> skip injection. pass2_active=N.",
|
|
||||||
)
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
session_ctx["_ws_apipilot_pass2_active"] = False
|
|
||||||
app_logging.logger.info(
|
|
||||||
"[APIPILOT] REASONING PASS-2 (WS realtime): ABORT marker=%s err=%r. pass2_active=N (safety).",
|
|
||||||
marker_name,
|
|
||||||
exc,
|
|
||||||
)
|
|
||||||
elif isinstance(session_ctx.get("_ws_apipilot_pass2_active"), bool) and bool(
|
|
||||||
session_ctx.get("_ws_apipilot_pass2_active")
|
|
||||||
):
|
|
||||||
session_ctx["_ws_apipilot_pass2_active"] = False
|
|
||||||
app_logging.logger.info(
|
|
||||||
"[APIPILOT] REASONING PASS-2 (WS realtime): response.done PASS-2 FINALE -> pass2_active=N safety counter=%s",
|
|
||||||
int(session_ctx.get("_ws_apipilot_pass2_count") or 0),
|
|
||||||
)
|
|
||||||
# Layer 7 post-processing: after marker handling, safely remove
|
|
||||||
# internal markers before forwarding text to the client.
|
|
||||||
message, was_sanitized = realtime_events._sanitize_realtime_event_raw(message, session_ctx)
|
message, was_sanitized = realtime_events._sanitize_realtime_event_raw(message, session_ctx)
|
||||||
# #region debug-point B:upstream-text-forward
|
# #region debug-point B:upstream-text-forward
|
||||||
summary = realtime_events._realtime_event_summary(message)
|
summary = realtime_events._realtime_event_summary(message)
|
||||||
@@ -1184,8 +405,7 @@ async def _upstream_to_client(
|
|||||||
# Agent output, including all event variants.
|
# Agent output, including all event variants.
|
||||||
agent_text = ""
|
agent_text = ""
|
||||||
if event_type in {"response.audio_transcript.done", "response.output_audio_transcript.done"}:
|
if event_type in {"response.audio_transcript.done", "response.output_audio_transcript.done"}:
|
||||||
# Pass-2 accumulation already retained the original text.
|
# Use the sanitized agent text for UI and logs.
|
||||||
# This sanitized agent_text is only for UI and logs.
|
|
||||||
agent_text = (summary.get("text") or "").strip()
|
agent_text = (summary.get("text") or "").strip()
|
||||||
if agent_text:
|
if agent_text:
|
||||||
app_logging.logger.info(
|
app_logging.logger.info(
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ from typing import Any, cast
|
|||||||
|
|
||||||
from vocero_api.core import config as settings
|
from vocero_api.core import config as settings
|
||||||
from vocero_api.core import logging as app_logging
|
from vocero_api.core import logging as app_logging
|
||||||
from vocero_api.services import tool_context as services_tool_context
|
|
||||||
|
|
||||||
VOCERO_USER_CONTEXT_HINT = " Caller profile resolved from the page phoneNumber: "
|
VOCERO_USER_CONTEXT_HINT = " Caller profile resolved from the page phoneNumber: "
|
||||||
REPORTS_LIST_HINT = (
|
REPORTS_LIST_HINT = (
|
||||||
@@ -334,8 +333,6 @@ _CONTEXT_DEDUPE_MARKERS: tuple = (
|
|||||||
("summary_full", SUMMARY_FULL_MARKER),
|
("summary_full", SUMMARY_FULL_MARKER),
|
||||||
("today_fixed", TODAY_BLOCK_MARKER),
|
("today_fixed", TODAY_BLOCK_MARKER),
|
||||||
("reports_minimal", MINIMAL_REPORTS_MARKER),
|
("reports_minimal", MINIMAL_REPORTS_MARKER),
|
||||||
("apipilot_tools_list", services_tool_context.APIPILOT_PASS1_LIST_MARKER),
|
|
||||||
("apipilot_result", services_tool_context.APIPILOT_PASS2_RESULT_MARKER),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -971,25 +968,19 @@ def _patch_client_session_update_with_profile(
|
|||||||
summary_context_text: str | None = None,
|
summary_context_text: str | None = None,
|
||||||
episode_context_text: str | None = None,
|
episode_context_text: str | None = None,
|
||||||
profile_obj: Any = None,
|
profile_obj: Any = None,
|
||||||
apipilot_context_text_pass1: str | None = None,
|
|
||||||
) -> str:
|
) -> str:
|
||||||
"""If raw_text is a client session.update JSON and S2S_INJECT_PROFILE is
|
"""If raw_text is a client session.update JSON and S2S_INJECT_PROFILE is
|
||||||
true, append episode_context_text + user_context_text + (optional) reports_context_text +
|
true, append episode_context_text + user_context_text + (optional) reports_context_text +
|
||||||
(optional) summary_context_text + (optional) apipilot_context_text_pass1 to session.instructions.
|
(optional) summary_context_text to session.instructions.
|
||||||
Returns the modified JSON string, or the original string unchanged if:
|
Returns the modified JSON string, or the original string unchanged if:
|
||||||
- it is not session.update JSON (the VAST majority of frames -> silent passthrough, NO LOG)
|
- it is not session.update JSON (the VAST majority of frames -> silent passthrough, NO LOG)
|
||||||
- episode_context_text AND user_context_text AND reports_context_text AND summary_context_text AND apipilot_context_text_pass1 are all empty
|
- episode_context_text AND user_context_text AND reports_context_text AND summary_context_text are all empty
|
||||||
- S2S_INJECT_PROFILE is False (but if we have apipilot_context_text_pass1 -> PATCH even if S2S_INJECT_PROFILE=false, tools injection is independent.)
|
- S2S_INJECT_PROFILE is False
|
||||||
- parse fails
|
- parse fails
|
||||||
|
|
||||||
2026-09-11: NEW keyword profile_obj. Forwarded to
|
2026-09-11: NEW keyword profile_obj. Forwarded to
|
||||||
_augment_instructions_with_profile so the prompt lock can emit the proper-name
|
_augment_instructions_with_profile so the prompt lock can emit the proper-name
|
||||||
anti-anglicization rule for this caller (e.g. Alessandro never Alexander).
|
anti-anglicization rule for this caller (e.g. Alessandro never Alexander).
|
||||||
|
|
||||||
2026-09-25: NEW keyword apipilot_context_text_pass1. Injected IN ADDITION so WS
|
|
||||||
Realtime LLM sees the tools list match description / marker rules (same as the
|
|
||||||
/api/reasoning HTTP endpoint). Independent gate: even S2S_INJECT_PROFILE=false,
|
|
||||||
if apipilot_context_text_pass1 has content -> PATCH instructions.
|
|
||||||
"""
|
"""
|
||||||
# Step 1: only consider session.update frames. All other frame types
|
# Step 1: only consider session.update frames. All other frame types
|
||||||
# (response.create, conversation.item.*, audio_* etc.) pass through
|
# (response.create, conversation.item.*, audio_* etc.) pass through
|
||||||
@@ -1005,10 +996,9 @@ def _patch_client_session_update_with_profile(
|
|||||||
# From here on: we know this is a real session.update (typically emitted
|
# From here on: we know this is a real session.update (typically emitted
|
||||||
# just once or twice per realtime session). We can log safely — 1 line,
|
# just once or twice per realtime session). We can log safely — 1 line,
|
||||||
# not 20 repeated lines.
|
# not 20 repeated lines.
|
||||||
has_apipilot_pass1 = bool((apipilot_context_text_pass1 or "").strip())
|
if not settings.S2S_INJECT_PROFILE:
|
||||||
if not settings.S2S_INJECT_PROFILE and not has_apipilot_pass1:
|
|
||||||
app_logging.logger.info(
|
app_logging.logger.info(
|
||||||
"[INJECT-PROFILE] ⏭️ WS patch SKIPPED on session.update (S2S_INJECT_PROFILE=false and no apipilot list pass1). "
|
"[INJECT-PROFILE] ⏭️ WS patch SKIPPED on session.update (S2S_INJECT_PROFILE=false). "
|
||||||
"WS stays pure passthrough."
|
"WS stays pure passthrough."
|
||||||
)
|
)
|
||||||
return raw_text
|
return raw_text
|
||||||
@@ -1016,9 +1006,9 @@ def _patch_client_session_update_with_profile(
|
|||||||
has_profile = bool((user_context_text or "").strip())
|
has_profile = bool((user_context_text or "").strip())
|
||||||
has_reports = bool((reports_context_text or "").strip())
|
has_reports = bool((reports_context_text or "").strip())
|
||||||
has_summary = bool((summary_context_text or "").strip())
|
has_summary = bool((summary_context_text or "").strip())
|
||||||
if not (has_episode or has_profile or has_reports or has_summary or has_apipilot_pass1):
|
if not (has_episode or has_profile or has_reports or has_summary):
|
||||||
app_logging.logger.info(
|
app_logging.logger.info(
|
||||||
"[INJECT-PROFILE] ⏭️ WS patch SKIPPED on session.update (no episode, no profile context, no reports list, no latest summary, no apipilot tools list pass1). "
|
"[INJECT-PROFILE] ⏭️ WS patch SKIPPED on session.update (no episode, no profile context, no reports list, no latest summary). "
|
||||||
"Probably phoneNumber/episode_id query param missing / fetch failed upstream / "
|
"Probably phoneNumber/episode_id query param missing / fetch failed upstream / "
|
||||||
"endpoints or S2S_LATEST_SUMMARY / S2S_REPORT_LIST returned empty result."
|
"endpoints or S2S_LATEST_SUMMARY / S2S_REPORT_LIST returned empty result."
|
||||||
)
|
)
|
||||||
@@ -1038,12 +1028,6 @@ def _patch_client_session_update_with_profile(
|
|||||||
"[INJECT-REPORTS] profile missing, but REPORTS LIST block loaded. "
|
"[INJECT-REPORTS] profile missing, but REPORTS LIST block loaded. "
|
||||||
"Will inject reports alone into session.update."
|
"Will inject reports alone into session.update."
|
||||||
)
|
)
|
||||||
if has_apipilot_pass1 and not has_profile and not has_episode and not has_reports and not has_summary:
|
|
||||||
app_logging.logger.info(
|
|
||||||
"[INJECT-APIPILOT] profile/episode/reports/summary missing, but APIPILOT TOOLS list PASS-1 block loaded (%d chars). "
|
|
||||||
"Will inject tools list alone into session.update.",
|
|
||||||
len(apipilot_context_text_pass1) if isinstance(apipilot_context_text_pass1, str) else 0,
|
|
||||||
)
|
|
||||||
top_session = payload.get("session")
|
top_session = payload.get("session")
|
||||||
if not isinstance(top_session, dict):
|
if not isinstance(top_session, dict):
|
||||||
return raw_text
|
return raw_text
|
||||||
@@ -1064,22 +1048,10 @@ def _patch_client_session_update_with_profile(
|
|||||||
len(current_instructions),
|
len(current_instructions),
|
||||||
current_instructions,
|
current_instructions,
|
||||||
)
|
)
|
||||||
# Append ApiPilot pass-1 tools and marker rules after profile/report/episode/
|
if augmented == current_instructions:
|
||||||
# summary context, matching /api/reasoning. If a second client session.update
|
|
||||||
# overwrites instructions without the tool-list marker, append the list again.
|
|
||||||
already_has_apipilot_pass1 = (
|
|
||||||
isinstance(current_instructions, str)
|
|
||||||
and services_tool_context.APIPILOT_PASS1_LIST_MARKER in current_instructions
|
|
||||||
)
|
|
||||||
if has_apipilot_pass1 and not already_has_apipilot_pass1:
|
|
||||||
augmented = (augmented + "\n\n" if augmented.strip() else "") + (
|
|
||||||
apipilot_context_text_pass1 if isinstance(apipilot_context_text_pass1, str) else ""
|
|
||||||
)
|
|
||||||
# Do not skip a tool-list repair even if profile augmentation was unchanged.
|
|
||||||
if augmented == current_instructions and not (has_apipilot_pass1 and not already_has_apipilot_pass1):
|
|
||||||
app_logging.logger.info(
|
app_logging.logger.info(
|
||||||
"[INJECT-PROFILE] ⏭️ WS patch SKIPPED on session.update (augmented == current; "
|
"[INJECT-PROFILE] ⏭️ WS patch SKIPPED on session.update (augmented == current; "
|
||||||
"episode/profile already injected or context empty or apipilot same)"
|
"episode/profile already injected or context empty)"
|
||||||
)
|
)
|
||||||
if settings.S2S_DEBUG_PROMPT and augmented.strip():
|
if settings.S2S_DEBUG_PROMPT and augmented.strip():
|
||||||
app_logging.logger.info(
|
app_logging.logger.info(
|
||||||
@@ -1091,7 +1063,7 @@ def _patch_client_session_update_with_profile(
|
|||||||
top_session["instructions"] = augmented
|
top_session["instructions"] = augmented
|
||||||
app_logging.logger.info(
|
app_logging.logger.info(
|
||||||
"[INJECT-PROFILE] ✅ WS session.update PATCHED -> upstream instructions. "
|
"[INJECT-PROFILE] ✅ WS session.update PATCHED -> upstream instructions. "
|
||||||
"origLen=%d newLen=%d injectedLen=%d episode=%s profile=%s reports=%s summary=%s apipilotPass1=%s(len=%d, wasAlreadyPresent=%s)",
|
"origLen=%d newLen=%d injectedLen=%d episode=%s profile=%s reports=%s summary=%s",
|
||||||
len(current_instructions),
|
len(current_instructions),
|
||||||
len(augmented),
|
len(augmented),
|
||||||
(len(augmented) - len(current_instructions)),
|
(len(augmented) - len(current_instructions)),
|
||||||
@@ -1099,9 +1071,6 @@ def _patch_client_session_update_with_profile(
|
|||||||
has_profile,
|
has_profile,
|
||||||
has_reports,
|
has_reports,
|
||||||
has_summary,
|
has_summary,
|
||||||
has_apipilot_pass1,
|
|
||||||
(len(apipilot_context_text_pass1) if isinstance(apipilot_context_text_pass1, str) else 0),
|
|
||||||
("true" if already_has_apipilot_pass1 else "false"),
|
|
||||||
)
|
)
|
||||||
if settings.S2S_DEBUG_PROMPT:
|
if settings.S2S_DEBUG_PROMPT:
|
||||||
app_logging.logger.info(
|
app_logging.logger.info(
|
||||||
@@ -1121,25 +1090,6 @@ def _patch_client_session_update_with_profile(
|
|||||||
len(reports_context_text.strip()),
|
len(reports_context_text.strip()),
|
||||||
reports_context_text.strip(),
|
reports_context_text.strip(),
|
||||||
)
|
)
|
||||||
if (
|
|
||||||
settings.S2S_DEBUG_PROMPT
|
|
||||||
and isinstance(apipilot_context_text_pass1, str)
|
|
||||||
and apipilot_context_text_pass1.strip()
|
|
||||||
):
|
|
||||||
app_logging.logger.info(
|
|
||||||
"[DEBUG-APIPILOT][WS session.update] FULL ApiPilot tools list PASS-1 block (len=%d):\n%s\n",
|
|
||||||
len(apipilot_context_text_pass1.strip()),
|
|
||||||
_strip_internal_markers(apipilot_context_text_pass1),
|
|
||||||
)
|
|
||||||
# Also log the FINAL upstream instructions when S2S_DEBUG_PROMPT is on and
|
|
||||||
# this is a session.update (so we see the EXACT text reaching the model even
|
|
||||||
# when S2S_INJECT_PROFILE is off and no patch happened.)
|
|
||||||
if (not settings.S2S_INJECT_PROFILE) and settings.S2S_DEBUG_PROMPT and current_instructions.strip():
|
|
||||||
app_logging.logger.info(
|
|
||||||
"[DEBUG-PROMPT][WS session.update] FINAL upstream instructions (injectProfile=false, len=%d):\n%s\n",
|
|
||||||
len(current_instructions),
|
|
||||||
current_instructions,
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
return json.dumps(payload)
|
return json.dumps(payload)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ _CALL = re.compile(r"<<<VOCERO_MCP_CALL:NAME=([A-Za-z0-9_]+),ARGUMENTS=(.*)>>>",
|
|||||||
|
|
||||||
|
|
||||||
def selection(text: str) -> tuple[str, dict[str, Any]] | None:
|
def selection(text: str) -> tuple[str, dict[str, Any]] | None:
|
||||||
"""Use a marker that survives the speech pipeline's ApiPilot sanitizer."""
|
"""Parse the internal tool selection marker returned by the speech model."""
|
||||||
if "VOCERO_MCP" not in text.upper() and not text.lstrip().startswith("<<<"):
|
if "VOCERO_MCP" not in text.upper() and not text.lstrip().startswith("<<<"):
|
||||||
return None
|
return None
|
||||||
match = _CALL.fullmatch(text.strip())
|
match = _CALL.fullmatch(text.strip())
|
||||||
|
|||||||
@@ -11,11 +11,9 @@ from vocero_api.core import config as settings
|
|||||||
from vocero_api.core import logging as app_logging
|
from vocero_api.core import logging as app_logging
|
||||||
from vocero_api.models import requests as models_requests
|
from vocero_api.models import requests as models_requests
|
||||||
from vocero_api.models.responses import ReasoningResponse
|
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.providers import caller as providers_caller
|
||||||
from vocero_api.services import context as services_context
|
from vocero_api.services import context as services_context
|
||||||
from vocero_api.services import mcp_reasoning
|
from vocero_api.services import mcp_reasoning
|
||||||
from vocero_api.services import tool_context as services_tool_context
|
|
||||||
|
|
||||||
|
|
||||||
def _openai_headers() -> dict[str, str]:
|
def _openai_headers() -> dict[str, str]:
|
||||||
@@ -182,45 +180,6 @@ async def reasoning(req: models_requests.ReasoningRequest, request: Request) ->
|
|||||||
app_logging.logger.info(
|
app_logging.logger.info(
|
||||||
"[INJECT-SUMMARY] ⏭️ REASONING: latest summary skipped (context empty / upstream returned empty summary / fetch failed.)"
|
"[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_context_parts: list[str] = []
|
||||||
# ─── USER FIX BUG 3: TODAY fixed context block → PREPEND to user_context too
|
# ─── USER FIX BUG 3: TODAY fixed context block → PREPEND to user_context too
|
||||||
# (the same anti-hallucination block gets prefixed in the SYSTEM instructions
|
# (the same anti-hallucination block gets prefixed in the SYSTEM instructions
|
||||||
@@ -242,8 +201,6 @@ async def reasoning(req: models_requests.ReasoningRequest, request: Request) ->
|
|||||||
user_context_parts.append(inject_reports_text)
|
user_context_parts.append(inject_reports_text)
|
||||||
if inject_summary_text:
|
if inject_summary_text:
|
||||||
user_context_parts.append(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()
|
original_user_context = (req.user_context or "").strip()
|
||||||
if original_user_context:
|
if original_user_context:
|
||||||
user_context_parts.append(original_user_context)
|
user_context_parts.append(original_user_context)
|
||||||
@@ -334,18 +291,6 @@ async def reasoning(req: models_requests.ReasoningRequest, request: Request) ->
|
|||||||
len(inject_profile_text),
|
len(inject_profile_text),
|
||||||
services_context._strip_internal_markers(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(
|
effective_instructions = services_context._augment_instructions_with_profile(
|
||||||
req.instructions or "",
|
req.instructions or "",
|
||||||
inject_profile_text,
|
inject_profile_text,
|
||||||
@@ -354,28 +299,8 @@ async def reasoning(req: models_requests.ReasoningRequest, request: Request) ->
|
|||||||
inject_episode_text,
|
inject_episode_text,
|
||||||
profile_obj=profile_obj,
|
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:
|
else:
|
||||||
effective_instructions = (req.instructions or "").strip()
|
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.
|
# Call the OpenAI-compatible Chat Completions provider.
|
||||||
if not settings.OPENAI_API_KEY:
|
if not settings.OPENAI_API_KEY:
|
||||||
raise HTTPException(status_code=503, detail="OPENAI_API_KEY no está configurado.")
|
raise HTTPException(status_code=503, detail="OPENAI_API_KEY no está configurado.")
|
||||||
@@ -385,8 +310,6 @@ async def reasoning(req: models_requests.ReasoningRequest, request: Request) ->
|
|||||||
system_parts.append(instructions)
|
system_parts.append(instructions)
|
||||||
if effective_user_context.strip():
|
if effective_user_context.strip():
|
||||||
system_parts.append(f"User context:\n{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():
|
if req.language.strip():
|
||||||
system_parts.append(
|
system_parts.append(
|
||||||
"Reply only with the final assistant answer text. "
|
"Reply only with the final assistant answer text. "
|
||||||
@@ -474,183 +397,4 @@ async def reasoning(req: models_requests.ReasoningRequest, request: Request) ->
|
|||||||
)
|
)
|
||||||
raise HTTPException(status_code=502, detail="El servicio de razonamiento de OpenAI no devolvió una respuesta.")
|
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])
|
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)
|
return validated_json_response({"answer": answer}, ReasoningResponse)
|
||||||
|
|||||||
@@ -1,629 +0,0 @@
|
|||||||
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()
|
|
||||||
@@ -144,10 +144,11 @@ Markdown replies, JSON/TXT exports, optional browser wake word, TALK/TALKFIRST
|
|||||||
startup modes, and optional backend features are retained. Camera controls and
|
startup modes, and optional backend features are retained. Camera controls and
|
||||||
quick actions remain absent, matching their disabled state in the old UI.
|
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 speech service
|
||||||
continues to own VAD, transcription, model inference, synthesis, upstream
|
owns VAD, transcription, model inference, and synthesis. The Vocero API owns
|
||||||
credentials, and ApiPilot execution. Browser `SpeechSynthesis` is used only
|
AquaPortal credentials and authenticated MCP discovery/execution. Browser
|
||||||
when the configured external reasoning service returns an answer.
|
`SpeechSynthesis` is used only when the configured external reasoning service
|
||||||
|
returns an answer.
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
import App from "./App";
|
import App from "./App";
|
||||||
import { VoiceController } from "./realtime/controller";
|
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" };
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
window.history.replaceState({}, "", "/?debug=true&phoneNumber=1234");
|
window.history.replaceState({}, "", "/?debug=true&phoneNumber=1234");
|
||||||
vi.spyOn(HTMLDialogElement.prototype, "showModal").mockImplementation(function (this: HTMLDialogElement) {
|
vi.spyOn(HTMLDialogElement.prototype, "showModal").mockImplementation(function (this: HTMLDialogElement) {
|
||||||
@@ -29,9 +29,13 @@ beforeEach(() => {
|
|||||||
truncated: true,
|
truncated: true,
|
||||||
note: "Lista abreviada",
|
note: "Lista abreviada",
|
||||||
},
|
},
|
||||||
"/api/apipilot/tools": {
|
"/api/conversations": {
|
||||||
success: true,
|
conversationId: "conversation-ana",
|
||||||
data: { tools: [{ name: "get_reports", description: "Consulta tus reportes", parameters: {} }] },
|
expiresIn: 3600,
|
||||||
|
profile: { id: "ana", name: "Ana Vargas" },
|
||||||
|
},
|
||||||
|
"/api/mcp/tools": {
|
||||||
|
tools: [{ name: "list_reports", description: "Consulta tus reportes", parameters: {}, requires_auth: true }],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
return Promise.resolve(new Response(JSON.stringify(data[path] ?? {})));
|
return Promise.resolve(new Response(JSON.stringify(data[path] ?? {})));
|
||||||
@@ -150,7 +154,7 @@ describe("React application", () => {
|
|||||||
await user.click(screen.getByRole("button", { name: "Vaciar" }));
|
await user.click(screen.getByRole("button", { name: "Vaciar" }));
|
||||||
expect(screen.getByRole("textbox")).toHaveValue("");
|
expect(screen.getByRole("textbox")).toHaveValue("");
|
||||||
});
|
});
|
||||||
it("shows nested profile fields and discovers remote tools after bootstrap", async () => {
|
it("shows nested profile fields", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<App />);
|
render(<App />);
|
||||||
await user.click(await screen.findByRole("button", { name: "Ver datos de Ana Vargas" }));
|
await user.click(await screen.findByRole("button", { name: "Ver datos de Ana Vargas" }));
|
||||||
@@ -159,11 +163,23 @@ describe("React application", () => {
|
|||||||
"mailto:ana@example.test",
|
"mailto:ana@example.test",
|
||||||
);
|
);
|
||||||
await user.click(screen.getByRole("button", { name: "Cerrar" }));
|
await user.click(screen.getByRole("button", { name: "Cerrar" }));
|
||||||
await user.click(screen.getByRole("button", { name: "Herramientas" }));
|
});
|
||||||
|
it("discovers and displays MCP tools for the authenticated conversation", async () => {
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce(new Response(JSON.stringify({ ...bootstrap, mcp: true })));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<App />);
|
||||||
|
await user.click(await screen.findByRole("button", { name: "Herramientas" }));
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("get_reports")).toBeInTheDocument();
|
expect(screen.getByText("list_reports")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
expect(screen.getByText("Consulta tus reportes")).toBeInTheDocument();
|
expect(screen.getByText("Consulta tus reportes")).toBeInTheDocument();
|
||||||
|
expect(vi.mocked(fetch).mock.calls.map(([path]) => path)).toEqual([
|
||||||
|
"/api/config",
|
||||||
|
"/api/conversations",
|
||||||
|
"/api/mcp/tools",
|
||||||
|
]);
|
||||||
|
const discovery = vi.mocked(fetch).mock.calls.find(([path]) => path === "/api/mcp/tools");
|
||||||
|
expect(new Headers(discovery?.[1]?.headers).get("X-Vocero-Conversation")).toBe("conversation-ana");
|
||||||
});
|
});
|
||||||
it("keeps debug controls out of the normal user screen", async () => {
|
it("keeps debug controls out of the normal user screen", async () => {
|
||||||
window.history.replaceState({}, "", "/");
|
window.history.replaceState({}, "", "/");
|
||||||
|
|||||||
@@ -282,17 +282,16 @@ export function ToolsDialog({ state, controller, onClose }: Props) {
|
|||||||
Herramientas MCP cargadas
|
Herramientas MCP cargadas
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[13.5px] text-muted compact:text-[12.5px]">
|
<span className="text-[13.5px] text-muted compact:text-[12.5px]">
|
||||||
Herramientas publicadas por el perfil remoto del agente.
|
Herramientas disponibles para esta conversación.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="block text-[13px] leading-[1.4] text-faint compact:text-[12px]" role="status">
|
<p className="block text-[13px] leading-[1.4] text-faint compact:text-[12px]" role="status">
|
||||||
{state.toolsLoading
|
{state.toolsLoading
|
||||||
? "Cargando herramientas…"
|
? "Cargando herramientas…"
|
||||||
: state.toolsError ||
|
: state.tools.length
|
||||||
(state.tools.length
|
|
||||||
? `${String(state.tools.length)} herramientas disponibles.`
|
? `${String(state.tools.length)} herramientas disponibles.`
|
||||||
: "No hay herramientas remotas disponibles.")}
|
: "No hay herramientas remotas disponibles."}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{state.tools.map((tool) => (
|
{state.tools.map((tool) => (
|
||||||
|
|||||||
@@ -1,13 +1,6 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import {
|
import { configSchema, conversationSchema, mcpToolsSchema, recordSchema, reportsSchema } from "./contracts";
|
||||||
configSchema,
|
|
||||||
conversationSchema,
|
|
||||||
mcpToolsSchema,
|
|
||||||
recordSchema,
|
|
||||||
reportsSchema,
|
|
||||||
toolsEnvelopeSchema,
|
|
||||||
} from "./contracts";
|
|
||||||
import type { Caller } from "./settings";
|
import type { Caller } from "./settings";
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
@@ -108,18 +101,6 @@ export function getReports(phone: string, signal: AbortSignal) {
|
|||||||
const query = phone ? `?${new URLSearchParams({ phoneNumber: phone })}` : "";
|
const query = phone ? `?${new URLSearchParams({ phoneNumber: phone })}` : "";
|
||||||
return optionalRequest(`/api/user/reports${query}`, reportsSchema, signal);
|
return optionalRequest(`/api/user/reports${query}`, reportsSchema, signal);
|
||||||
}
|
}
|
||||||
export async function getTools(signal: AbortSignal, refresh = false) {
|
|
||||||
const result = await request(
|
|
||||||
`/api/apipilot/tools?include_unauthorized=true&refresh=${String(refresh)}`,
|
|
||||||
toolsEnvelopeSchema,
|
|
||||||
{ signal },
|
|
||||||
10000,
|
|
||||||
);
|
|
||||||
if (!result.success || !result.data) {
|
|
||||||
throw new Error("No se pudieron cargar las herramientas.");
|
|
||||||
}
|
|
||||||
return result.data.tools;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function conversationHeaders(id: string, turn?: number): Record<string, string> {
|
export function conversationHeaders(id: string, turn?: number): Record<string, string> {
|
||||||
return { "X-Vocero-Conversation": id, ...(turn === undefined ? {} : { "X-Vocero-Turn": String(turn) }) };
|
return { "X-Vocero-Conversation": id, ...(turn === undefined ? {} : { "X-Vocero-Turn": String(turn) }) };
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { recordSchema, type DynamicTool, type FunctionTool, type Profile, type ServerConfig } from "./contracts";
|
import { recordSchema, type FunctionTool, type Profile, type ServerConfig } from "./contracts";
|
||||||
import type { Settings } from "./settings";
|
import type { Settings } from "./settings";
|
||||||
|
|
||||||
export function profileName(profile: Profile | null): string {
|
export function profileName(profile: Profile | null): string {
|
||||||
@@ -43,35 +43,9 @@ export function profileContext(profile: Profile | null) {
|
|||||||
export function ragContext(text: string) {
|
export function ragContext(text: string) {
|
||||||
return text.trim() ? `<<<CUSTOM_RAG_BEGIN>>>\n${text.trim()}\n<<<CUSTOM_RAG_END>>>` : "";
|
return text.trim() ? `<<<CUSTOM_RAG_BEGIN>>>\n${text.trim()}\n<<<CUSTOM_RAG_END>>>` : "";
|
||||||
}
|
}
|
||||||
export const TOOL_MARKER = "<<<APIPILOT_TOOLS_LIST_BEGIN>>>";
|
export function buildInstructions(config: ServerConfig, settings: Settings, profile: Profile | null, rag: string) {
|
||||||
export function toolContext(tools: DynamicTool[]) {
|
|
||||||
if (!tools.length) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
const entries = tools.slice(0, 100).map((tool) => {
|
|
||||||
const required = Array.isArray(tool.parameters.required)
|
|
||||||
? tool.parameters.required.filter((key): key is string => typeof key === "string")
|
|
||||||
: [];
|
|
||||||
return `- name: ${tool.name}\n requires_auth: ${String(tool.requires_auth)}${required.length ? `\n required_parameters: [${required.slice(0, 40).join(", ")}]` : ""}\n description: ${tool.description.slice(0, 500)}`;
|
|
||||||
});
|
|
||||||
return `${TOOL_MARKER}\n### Available dynamic tools (ApiPilot AquaPortal)\nEach tool name is a unique identifier: DO NOT change, camelCase or translate it.\nMANDATORY EXECUTION RULES:\n1. If the user request matches a tool description, output ONLY one line as the FIRST line of your response:\n<<<APIPILOT_CALL:NAME=tool_name,ARGUMENTS={"parameter1":"value1"}>>>\n2. ARGUMENTS must be valid JSON. Include ONLY fields listed in required_parameters, if any.\n3. Do NOT add text after the marker. The engine executes the tool and supplies its result in a second call.\n4. If no description matches, respond normally in Spanish without a marker.\n5. If multiple descriptions match, choose the most specific.\n${entries.join("\n")}\n<<<APIPILOT_TOOLS_LIST_END>>>`;
|
|
||||||
}
|
|
||||||
export function buildInstructions(
|
|
||||||
config: ServerConfig,
|
|
||||||
settings: Settings,
|
|
||||||
profile: Profile | null,
|
|
||||||
rag: string,
|
|
||||||
tools: DynamicTool[],
|
|
||||||
) {
|
|
||||||
const base = config.primaryPrompt || settings.instructions;
|
const base = config.primaryPrompt || settings.instructions;
|
||||||
return [
|
return [base, config.injectProfile ? profileContext(profile) : "", ragContext(rag)].filter(Boolean).join("\n\n");
|
||||||
base,
|
|
||||||
config.injectProfile ? profileContext(profile) : "",
|
|
||||||
ragContext(rag),
|
|
||||||
config.mcp || base.includes(TOOL_MARKER) ? "" : toolContext(tools),
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join("\n\n");
|
|
||||||
}
|
}
|
||||||
export function speechInstructions(instructions: string, config: ServerConfig) {
|
export function speechInstructions(instructions: string, config: ServerConfig) {
|
||||||
return config.reasoningType === "OPENAI"
|
return config.reasoningType === "OPENAI"
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ export const configSchema = z.object({
|
|||||||
debugPrompt: z.boolean().default(false),
|
debugPrompt: z.boolean().default(false),
|
||||||
startSessionMode: z.string().default(""),
|
startSessionMode: z.string().default(""),
|
||||||
theme: z.string().default(""),
|
theme: z.string().default(""),
|
||||||
apipilot: z.boolean().default(false),
|
|
||||||
mcp: z.boolean().default(false),
|
mcp: z.boolean().default(false),
|
||||||
});
|
});
|
||||||
export type ServerConfig = z.infer<typeof configSchema>;
|
export type ServerConfig = z.infer<typeof configSchema>;
|
||||||
@@ -50,11 +49,6 @@ export interface FunctionTool {
|
|||||||
description: string;
|
description: string;
|
||||||
parameters: Record<string, unknown>;
|
parameters: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
export const toolsEnvelopeSchema = z.object({
|
|
||||||
success: z.boolean(),
|
|
||||||
data: z.object({ tools: z.array(toolSchema), count: z.number().optional() }).nullish(),
|
|
||||||
error: z.unknown().optional(),
|
|
||||||
});
|
|
||||||
export const conversationSchema = z.object({
|
export const conversationSchema = z.object({
|
||||||
conversationId: z.string().min(1),
|
conversationId: z.string().min(1),
|
||||||
expiresIn: z.number(),
|
expiresIn: z.number(),
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
getMcpTools,
|
getMcpTools,
|
||||||
getProfile,
|
getProfile,
|
||||||
getReports,
|
getReports,
|
||||||
getTools,
|
|
||||||
interruptConversation,
|
interruptConversation,
|
||||||
post,
|
post,
|
||||||
reasoningSchema,
|
reasoningSchema,
|
||||||
@@ -53,7 +52,6 @@ export interface AppState {
|
|||||||
reports: Reports | null;
|
reports: Reports | null;
|
||||||
tools: DynamicTool[];
|
tools: DynamicTool[];
|
||||||
toolsLoading: boolean;
|
toolsLoading: boolean;
|
||||||
toolsError: string;
|
|
||||||
status: ConnectionStatus;
|
status: ConnectionStatus;
|
||||||
error: string;
|
error: string;
|
||||||
notice: string;
|
notice: string;
|
||||||
@@ -102,7 +100,6 @@ export class VoiceController {
|
|||||||
reports: null,
|
reports: null,
|
||||||
tools: [],
|
tools: [],
|
||||||
toolsLoading: false,
|
toolsLoading: false,
|
||||||
toolsError: "",
|
|
||||||
status: "idle",
|
status: "idle",
|
||||||
error: "",
|
error: "",
|
||||||
notice: "",
|
notice: "",
|
||||||
@@ -194,12 +191,7 @@ export class VoiceController {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
const tools = profile.then(async () => {
|
await Promise.all([profile, reports]);
|
||||||
if (config.apipilot && !signal.aborted) {
|
|
||||||
await this.refreshTools(signal, !!this.state.profile);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
await Promise.all([profile, reports, tools]);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!signal.aborted) {
|
if (!signal.aborted) {
|
||||||
this.update({ loading: false, configError: `No se pudo cargar la configuración. ${errorMessage(error)}` });
|
this.update({ loading: false, configError: `No se pudo cargar la configuración. ${errorMessage(error)}` });
|
||||||
@@ -212,7 +204,7 @@ export class VoiceController {
|
|||||||
const version = ++this.conversationVersion;
|
const version = ++this.conversationVersion;
|
||||||
let createdId: string | undefined;
|
let createdId: string | undefined;
|
||||||
this.releaseConversation();
|
this.releaseConversation();
|
||||||
this.update({ contextLoading: true, profile: null, reports: null, tools: [], toolsLoading: true, toolsError: "" });
|
this.update({ contextLoading: true, profile: null, reports: null, tools: [], toolsLoading: true });
|
||||||
try {
|
try {
|
||||||
if (!this.caller.phoneNumber) {
|
if (!this.caller.phoneNumber) {
|
||||||
throw new Error("Abre Vocero desde un enlace que incluya tu número de teléfono.");
|
throw new Error("Abre Vocero desde un enlace que incluya tu número de teléfono.");
|
||||||
@@ -247,30 +239,8 @@ export class VoiceController {
|
|||||||
this.initialization?.abort();
|
this.initialization?.abort();
|
||||||
void this.stop();
|
void this.stop();
|
||||||
}
|
}
|
||||||
async refreshTools(signal: AbortSignal, refresh = false) {
|
|
||||||
this.update({ toolsLoading: true, toolsError: "" });
|
|
||||||
try {
|
|
||||||
const tools = this.state.config.mcp
|
|
||||||
? await getMcpTools(this.conversationId ?? "", signal)
|
|
||||||
: await getTools(signal, refresh);
|
|
||||||
if (!signal.aborted) {
|
|
||||||
this.update({ tools, toolsLoading: false });
|
|
||||||
this.pushSettings();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (!signal.aborted) {
|
|
||||||
this.update({ toolsError: errorMessage(error), toolsLoading: false });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
private instructions() {
|
private instructions() {
|
||||||
return buildInstructions(
|
return buildInstructions(this.state.config, this.state.settings, this.state.profile, this.state.rag);
|
||||||
this.state.config,
|
|
||||||
this.state.settings,
|
|
||||||
this.state.profile,
|
|
||||||
this.state.rag,
|
|
||||||
this.state.tools,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
private tools() {
|
private tools() {
|
||||||
return this.state.settings.webSearch && (this.state.config.search || this.state.settings.searchKey)
|
return this.state.settings.webSearch && (this.state.config.search || this.state.settings.searchKey)
|
||||||
|
|||||||
Reference in New Issue
Block a user