diff --git a/.github/scripts/product_file_line_count_ratchet_baseline/backend-routers.json b/.github/scripts/product_file_line_count_ratchet_baseline/backend-routers.json index c2cae37a45d..97d8657cfa1 100644 --- a/.github/scripts/product_file_line_count_ratchet_baseline/backend-routers.json +++ b/.github/scripts/product_file_line_count_ratchet_baseline/backend-routers.json @@ -2,7 +2,7 @@ "files": { "backend/routers/apps.py": 2373, "backend/routers/chat.py": 1588, - "backend/routers/developer.py": 2263, + "backend/routers/developer.py": 2352, "backend/routers/mcp_sse.py": 1858, "backend/routers/sync.py": 2058, "backend/routers/users.py": 2046 @@ -10,7 +10,7 @@ "raise_justifications": { "backend/routers/apps.py": "The owner-migration route validates a source-bound Firebase anonymous-token proof before its existing database write and tracked background work, keeping the authorization decision at the HTTP mutation boundary.", "backend/routers/chat.py": "Merging the Windows-port chat surface with main combined the stream-error fallback (answered guard) with mains journey/attempt observability in the same SSE generators; +11 lines of combined guard flow, no new route.", - "backend/routers/developer.py": "GET /v1/dev/user/memories and /vector/search both serve the authoritative legacy memories collection for legacy-cohort accounts with no rollout state (#9892/#10203), keeping the narrow deny/legacy-fallback decision in the route that owns the read contract.", + "backend/routers/developer.py": "GET /v1/dev/user/memories and /vector/search both serve the authoritative legacy memories collection for legacy-cohort accounts with no rollout state (#9892/#10203), keeping the narrow deny/legacy-fallback decision in the route that owns the read contract. +80 for POST /v1/dev/user/ask, which grounds a natural-language answer in the caller's conversations (search + qa_rag) beside the conversation routes that own that read contract, behind the dedicated dev:ask per-key rate limit; +7 to exclude discarded conversations from retrieval and re-check is_locked on the authoritative record so neither reaches the LLM (maintainer privacy review on #10314).", "backend/routers/mcp_sse.py": "MCP SSE memory reads route their legacy-fallback decision through the shared mcp_legacy_read_authorized helper (#9892); one import line.", "backend/routers/sync.py": "Fresh Sync's daily ceiling remains beside the existing fresh-admission gates, before staged audio or durable worker work is created. Lifecycle-fenced workers are terminalized at this Cloud Tasks boundary so released WAL clients do not retry an owner they no longer own; +2 for the queued-dispatch-lost stale finalization path (#10033).", "backend/routers/users.py": "GET /v1/users/subscription serializes the new mobile plus/max plans as `unlimited` for clients whose plan enum predates them, so day-one buyers read as paid instead of Free (mirrors the existing operator remap); real limits/grandfather are computed from the true plan first." diff --git a/backend/dependencies.py b/backend/dependencies.py index 2b7637658f1..c4c9da9ed1e 100644 --- a/backend/dependencies.py +++ b/backend/dependencies.py @@ -314,6 +314,21 @@ async def get_uid_with_conversations_read(auth: ApiKeyAuth = Depends(get_api_key return auth.uid +async def get_uid_with_conversations_read_ask( + auth: ApiKeyAuth = Depends(get_api_key_auth), + request: Request = None, +) -> str: + """conversations:read plus the tighter dev:ask budget for the billable RAG endpoint. + + POST /v1/dev/user/ask invokes an LLM (qa_rag) per call, so it carries its own low + per-key hourly cap rather than riding the cheap dev:conversations_read list limit — + a leaked or overused key can't turn it into an unbounded billable endpoint. + """ + _require_conversations_read_scope(auth) + await _check_dev_api_key_rate_limit_async(request=request, auth=auth, policy_name="dev:ask") + return auth.uid + + def check_conversation_transcript_read_limit( auth: ApiKeyAuth, request: Optional[Request] = None, diff --git a/backend/route_policy_manifest.yaml b/backend/route_policy_manifest.yaml index f4629139fb6..7c87ea869c6 100644 --- a/backend/route_policy_manifest.yaml +++ b/backend/route_policy_manifest.yaml @@ -6,6 +6,31 @@ schema_version: 1 service: backend-main routes: + - route_type: http + method: POST + path: /v1/dev/user/ask + policy: + review_status: reviewed + auth: + mechanisms: + - developer_api_key + - firebase_id_token + placement: dependency + scopes: + - conversations:read + byok: not_applicable + rate_limit: + policy_name: dev:ask + key_subject: api_key + enforcement: fail_closed + placement: dependency + timeout_class: default_method + surface: developer_api + visibility: public_documented + data_domain: conversations + deprecation: + state: active + owner: backend - route_type: http method: POST path: /v1/conversations/shared/chat diff --git a/backend/routers/developer.py b/backend/routers/developer.py index bfd0325ba05..bd991662825 100644 --- a/backend/routers/developer.py +++ b/backend/routers/developer.py @@ -42,6 +42,7 @@ get_auth_with_conversation_detail_read, get_auth_with_conversations_read, get_uid_with_conversations_read, + get_uid_with_conversations_read_ask, get_uid_with_conversations_write, get_developer_memory_default_memory_batch_write_context, get_developer_memory_default_memory_read_context, @@ -58,6 +59,9 @@ from utils.conversations.process_conversation import process_conversation from utils.conversations import lifecycle as lifecycle_service from utils.conversations.location import resolve_geolocation +from utils.conversations.search import search_conversations +from utils.conversations.factory import deserialize_conversations +from utils.llm.chat import qa_rag from utils.executors import postprocess_executor from utils.request_validation import HistoryDays from utils.llm.memories import identify_category_for_memory @@ -1385,6 +1389,91 @@ def get_user_folders(uid: str = Depends(get_uid_with_conversations_read)): return folders_db.get_folders(uid) +class DeveloperAskRequest(BaseModel): + question: str = Field( + min_length=1, max_length=1000, description="A natural-language question about the user's life/conversations" + ) + limit: int = Field( + default=5, ge=1, le=10, description="How many of the most relevant conversations to ground the answer on" + ) + timezone: str = Field(default="UTC", description="IANA timezone used to resolve relative dates in the answer") + + +class DeveloperAskSource(BaseModel): + id: str + title: str + created_at: Optional[datetime] = None + + +class DeveloperAskResponse(BaseModel): + answer: str + sources: List[DeveloperAskSource] + + +_ASK_NO_CONTEXT = "I couldn't find any of your conversations relevant to that question." + + +def _ask_context_from_conversations(conversations: List[Conversation]) -> str: + blocks: List[str] = [] + for c in conversations: + title = ((c.structured.title if c.structured else None) or "Untitled").strip() + overview = ((c.structured.overview if c.structured else None) or "").strip() + transcript = " ".join((getattr(s, "text", "") or "") for s in c.transcript_segments).strip()[:3000] + date = c.created_at.date().isoformat() if c.created_at else "" + blocks.append(f'Conversation "{title}" ({date})\nSummary: {overview}\nTranscript: {transcript}') + return "\n\n---\n\n".join(blocks) + + +@router.post( + "/v1/dev/user/ask", + response_model=DeveloperAskResponse, + tags=["Conversations"], + operation_id="ask", +) +def ask_conversations(request: DeveloperAskRequest, uid: str = Depends(get_uid_with_conversations_read_ask)): + """ + Answer a natural-language question grounded in the user's own conversations. + + Semantically searches the user's conversations for the question, then synthesizes a + cited answer from the most relevant ones — the same retrieval + RAG the chat surface + uses, exposed for headless / Developer-API callers (CLI, CI, scripts). Read-only: + it never writes; discarded conversations are excluded from retrieval and locked + conversations are re-checked on the authoritative record before any go to the LLM. + """ + question = request.question.strip() + if not question: + raise HTTPException(status_code=400, detail="question must not be empty") + + # Exclude discarded conversations from retrieval (the search default includes + # them), so a deleted/discarded conversation is never fed into the LLM. + results = search_conversations(uid, question, per_page=request.limit, include_discarded=False) + items = results.get("items", []) if isinstance(results, dict) else [] + conversation_ids = [item["id"] for item in items if item.get("id")][: request.limit] + if not conversation_ids: + return DeveloperAskResponse(answer=_ASK_NO_CONTEXT, sources=[]) + + # Re-check is_locked on the authoritative Firestore records: the search index's + # is_locked can lag, so a stale hit could otherwise leak a locked conversation + # into the answer. Mirrors the post-hydration filter in /v1/conversations/search. + raw_conversations = [ + c for c in conversations_db.get_conversations_by_id(uid, conversation_ids) if not c.get('is_locked') + ] + conversations = deserialize_conversations(raw_conversations) + if not conversations: + return DeveloperAskResponse(answer=_ASK_NO_CONTEXT, sources=[]) + + answer = qa_rag(uid, question, _ask_context_from_conversations(conversations), cited=True, tz=request.timezone) + sources = [ + DeveloperAskSource( + id=c.id, + title=((c.structured.title if c.structured else None) or "Untitled").strip(), + created_at=c.created_at, + ) + for c in conversations + ] + return DeveloperAskResponse(answer=answer, sources=sources) + + @router.get( "/v1/dev/user/conversations", response_model=List[Conversation], diff --git a/backend/tests/unit/test_dev_ask_endpoint.py b/backend/tests/unit/test_dev_ask_endpoint.py new file mode 100644 index 00000000000..6a6847970a2 --- /dev/null +++ b/backend/tests/unit/test_dev_ask_endpoint.py @@ -0,0 +1,114 @@ +"""The Developer-API ask endpoint answers from the user's own conversations. + +POST /v1/dev/user/ask semantically searches the caller's conversations, then synthesizes +a cited answer from the most relevant ones via the same RAG the chat surface uses. It is +read-only, and must not invoke the LLM when no relevant conversation is found. +""" + +import os + +os.environ.setdefault("ENCRYPTION_SECRET", "omi_ZwB2ZNqB2HHpMK6wStk7sTpavJiPTFg7gXUHnc4tFABPU6pZ2c2DKgehtfgi4RZv") +os.environ.setdefault("OPENAI_API_KEY", "sk-test-not-real") + +import asyncio +from datetime import datetime, timezone +from types import SimpleNamespace +from unittest.mock import patch + +import dependencies as deps +import routers.developer as dev + + +def _conv(cid="c1", title="Pricing sync", overview="We discussed pricing.", text="Raise prices 10%."): + return SimpleNamespace( + id=cid, + structured=SimpleNamespace(title=title, overview=overview), + transcript_segments=[SimpleNamespace(text=text)], + created_at=datetime(2026, 7, 20, tzinfo=timezone.utc), + ) + + +def test_ask_returns_a_grounded_cited_answer(): + with patch.object(dev, "search_conversations", return_value={"items": [{"id": "c1"}]}), patch.object( + dev.conversations_db, "get_conversations_by_id", return_value=[{"id": "c1"}] + ), patch.object(dev, "deserialize_conversations", return_value=[_conv()]), patch.object( + dev, "qa_rag", return_value="You decided to raise prices 10%." + ) as qa: + resp = dev.ask_conversations(dev.DeveloperAskRequest(question="what did I decide about pricing?"), uid="u1") + + assert resp.answer == "You decided to raise prices 10%." + assert [s.id for s in resp.sources] == ["c1"] + assert resp.sources[0].title == "Pricing sync" + # Retrieval flows into qa_rag as grounded, citation-enabled context. + assert qa.call_args.kwargs["cited"] is True + assert "Raise prices 10%." in qa.call_args.args[2] # the context string + + +def test_ask_does_not_call_the_llm_when_no_conversation_matches(): + with patch.object(dev, "search_conversations", return_value={"items": []}), patch.object(dev, "qa_rag") as qa: + resp = dev.ask_conversations(dev.DeveloperAskRequest(question="anything?"), uid="u1") + + assert "couldn't find" in resp.answer.lower() + assert resp.sources == [] + qa.assert_not_called() # no billable RAG call on an empty retrieval + + +def test_ask_excludes_discarded_conversations_from_retrieval(): + """Discarded/deleted conversations must never reach the LLM. search_conversations + defaults include_discarded=True, so the endpoint must override it to False + (maintainer review on #10314).""" + with patch.object(dev, "search_conversations", return_value={"items": []}) as search, patch.object(dev, "qa_rag"): + dev.ask_conversations(dev.DeveloperAskRequest(question="q"), uid="u1") + + assert search.call_args.kwargs.get("include_discarded") is False + + +def test_ask_drops_a_locked_conversation_even_when_the_search_index_is_stale(): + """The search index's is_locked can lag Firestore, so a stale hit could surface a + since-locked conversation. The endpoint must re-check is_locked on the authoritative + record and never send a locked conversation into the LLM (maintainer review on + #10314).""" + locked = {"id": "c1", "is_locked": True} + unlocked = {"id": "c2"} + with patch.object(dev, "search_conversations", return_value={"items": [{"id": "c1"}, {"id": "c2"}]}), patch.object( + dev.conversations_db, "get_conversations_by_id", return_value=[locked, unlocked] + ), patch.object( + dev, "deserialize_conversations", side_effect=lambda raw: [_conv(cid=c["id"]) for c in raw] + ) as deser, patch.object( + dev, "qa_rag", return_value="answer" + ): + resp = dev.ask_conversations(dev.DeveloperAskRequest(question="q"), uid="u1") + + # The locked conversation was filtered before hydration; only the unlocked one remains. + passed_to_deserialize = deser.call_args.args[0] + assert [c["id"] for c in passed_to_deserialize] == ["c2"] + assert [s.id for s in resp.sources] == ["c2"] + + +def test_ask_endpoint_is_bound_to_the_dev_ask_rate_limited_dependency(): + """The billable LLM endpoint carries its own tight per-key budget (dev:ask), not the + cheap dev:conversations_read list limit — a leaked/overused key can't run the RAG path + unbounded. Regression for the maintainer review on #10314.""" + from utils.rate_limit_config import RATE_POLICIES + + # dev:ask exists and is no looser than the LLM conversation-create budget, and tighter + # than the cheap list-read limit the endpoint would otherwise have ridden. + assert "dev:ask" in RATE_POLICIES + assert RATE_POLICIES["dev:ask"][0] <= RATE_POLICIES["dev:conversations"][0] + assert RATE_POLICIES["dev:ask"][0] < RATE_POLICIES["dev:conversations_read"][0] + + # The endpoint's auth dependency is the rate-limited one, and it enforces exactly the + # dev:ask policy (fail-closed per-key) before returning the uid. + enforced = {} + + async def fake_enforce(*, request, auth, policy_name): + enforced["policy"] = policy_name + + auth = SimpleNamespace(uid="u1", app_id="a1", key_id="k1") + with patch.object(deps, "_require_conversations_read_scope", lambda a: None), patch.object( + deps, "_check_dev_api_key_rate_limit_async", fake_enforce + ): + uid = asyncio.run(deps.get_uid_with_conversations_read_ask(auth=auth, request=None)) + + assert uid == "u1" + assert enforced["policy"] == "dev:ask" diff --git a/backend/utils/rate_limit_config.py b/backend/utils/rate_limit_config.py index 09db1f533f1..187c819d89f 100644 --- a/backend/utils/rate_limit_config.py +++ b/backend/utils/rate_limit_config.py @@ -109,6 +109,10 @@ "dev:conversation_transcript_read": (25, 3600), "dev:goals_read": (120, 3600), "dev:conversations": (25, 3600), + # Ask (/v1/dev/user/ask): one qa_rag LLM call per request over the caller's + # conversations — billable like a conversation create, so it carries its own + # low per-key cap instead of riding the cheap dev:conversations_read list limit. + "dev:ask": (25, 3600), "dev:memories": (120, 3600), "dev:memories_batch": (15, 3600), "dev:action_items_write": (120, 3600), diff --git a/desktop/macos/Desktop/Sources/Generated/OmiApi.generated.swift b/desktop/macos/Desktop/Sources/Generated/OmiApi.generated.swift index 39c5831c46a..14c595f3897 100644 --- a/desktop/macos/Desktop/Sources/Generated/OmiApi.generated.swift +++ b/desktop/macos/Desktop/Sources/Generated/OmiApi.generated.swift @@ -7880,6 +7880,28 @@ public enum OmiAPI { return try JSONDecoder().decode(OmiAnyCodable.self, from: data) } + public static func ask(client: OmiApiClient, body: OmiAnyCodable) async throws -> OmiAnyCodable { + let _path = "/v1/dev/user/ask" + guard let components = URLComponents(string: client.baseURL + _path) else { + throw OmiApiError.invalidURL + } + guard let url = components.url else { throw OmiApiError.invalidURL } + var req = URLRequest(url: url) + req.httpMethod = "POST" + for (name, value) in client.headers { req.setValue(value, forHTTPHeaderField: name) } + if let token = client.token { + req.setValue("Bearer " + token, forHTTPHeaderField: "Authorization") + } + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.httpBody = try JSONEncoder().encode(body) + let (data, resp) = try await URLSession.shared.data(for: req) + guard let http = resp as? HTTPURLResponse else { throw OmiApiError.invalidURL } + guard (200..<300).contains(http.statusCode) else { + throw OmiApiError.httpError(status: http.statusCode, data: data) + } + return try JSONDecoder().decode(OmiAnyCodable.self, from: data) + } + public static func listConversations(client: OmiApiClient, startDate: String? = nil, endDate: String? = nil, categories: String? = nil, limit: Int? = nil, offset: Int? = nil, includeTranscript: Bool? = nil, folderId: String? = nil, starred: Bool? = nil) async throws -> [OmiAnyCodable] { let _path = "/v1/dev/user/conversations" guard var components = URLComponents(string: client.baseURL + _path) else { @@ -14390,5 +14412,5 @@ public enum OmiAPI { return try JSONDecoder().decode(OmiAnyCodable.self, from: data) } - // Total: 385 Swift client methods generated. + // Total: 386 Swift client methods generated. } diff --git a/desktop/windows/src/renderer/src/lib/omiApi.generated.ts b/desktop/windows/src/renderer/src/lib/omiApi.generated.ts index 1244a49521a..8028117669c 100644 --- a/desktop/windows/src/renderer/src/lib/omiApi.generated.ts +++ b/desktop/windows/src/renderer/src/lib/omiApi.generated.ts @@ -1414,6 +1414,23 @@ export interface DeveloperActionItem { updated_at?: string | null; } +export interface DeveloperAskRequest { + limit?: number; + question: string; + timezone?: string; +} + +export interface DeveloperAskResponse { + answer: string; + sources: Array; +} + +export interface DeveloperAskSource { + created_at?: string | null; + id: string; + title: string; +} + export interface DeveloperConversation { created_at: string; finished_at: string | null; @@ -3916,6 +3933,9 @@ export interface OmiApiSchemas { "DevApiKeyCreate": DevApiKeyCreate; "DevApiKeyCreated": DevApiKeyCreated; "DeveloperActionItem": DeveloperActionItem; + "DeveloperAskRequest": DeveloperAskRequest; + "DeveloperAskResponse": DeveloperAskResponse; + "DeveloperAskSource": DeveloperAskSource; "DeveloperConversation": DeveloperConversation; "DeveloperConversationActionItem": DeveloperConversationActionItem; "DeveloperConversationEvent": DeveloperConversationEvent; @@ -5575,6 +5595,16 @@ export interface OmiApiPaths { }; }; }; + "/v1/dev/user/ask": { + post: { + operationId: "ask"; + responses: { + "200": DeveloperAskResponse; + "401": void; + "422": HTTPValidationError; + }; + }; + }; "/v1/dev/user/conversations": { get: { operationId: "listConversations"; @@ -10746,6 +10776,23 @@ export async function deleteActionItem(path: { action_item_id: string }, init?: return _res.status === 204 ? (undefined as any) : await _res.json(); } +export async function ask(body: DeveloperAskRequest, init?: OmiApiClientInit): Promise { + const _base = init?.baseURL ?? ""; + const _path = `/v1/dev/user/ask`; + const _search = ""; + const _res = await fetch(`${_base}${_path}${_search}`, { + method: "POST", + headers: { + ...(body ? { 'Content-Type': 'application/json' } : {}), + ...(init?.token ? { Authorization: `Bearer ${init.token}` } : {}), + ...init?.headers, + }, + body: body ? JSON.stringify(body) : undefined, + }); + if (!_res.ok) throw new OmiApiError(_res.status, _res); + return _res.status === 204 ? (undefined as any) : await _res.json(); +} + export async function listConversations(query: { start_date?: string | null, end_date?: string | null, categories?: string | null, limit?: number, offset?: number, include_transcript?: boolean, folder_id?: string | null, starred?: boolean | null }, init?: OmiApiClientInit): Promise> { const _base = init?.baseURL ?? ""; const _path = `/v1/dev/user/conversations`; @@ -15695,4 +15742,4 @@ export async function get_speech_profile_v4_speech_profile_get(header: { authori return _res.status === 204 ? (undefined as any) : await _res.json(); } -// Total: 385 client methods generated. +// Total: 386 client methods generated. diff --git a/docs/api-reference/app-client-openapi.json b/docs/api-reference/app-client-openapi.json index 896092a166e..e076b8a1fed 100644 --- a/docs/api-reference/app-client-openapi.json +++ b/docs/api-reference/app-client-openapi.json @@ -8911,6 +8911,87 @@ "title": "DeveloperActionItem", "type": "object" }, + "DeveloperAskRequest": { + "properties": { + "limit": { + "default": 5, + "description": "How many of the most relevant conversations to ground the answer on", + "maximum": 10.0, + "minimum": 1.0, + "title": "Limit", + "type": "integer" + }, + "question": { + "description": "A natural-language question about the user's life/conversations", + "maxLength": 1000, + "minLength": 1, + "title": "Question", + "type": "string" + }, + "timezone": { + "default": "UTC", + "description": "IANA timezone used to resolve relative dates in the answer", + "title": "Timezone", + "type": "string" + } + }, + "required": [ + "question" + ], + "title": "DeveloperAskRequest", + "type": "object" + }, + "DeveloperAskResponse": { + "properties": { + "answer": { + "title": "Answer", + "type": "string" + }, + "sources": { + "items": { + "$ref": "#/components/schemas/DeveloperAskSource" + }, + "title": "Sources", + "type": "array" + } + }, + "required": [ + "answer", + "sources" + ], + "title": "DeveloperAskResponse", + "type": "object" + }, + "DeveloperAskSource": { + "properties": { + "created_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Created At" + }, + "id": { + "title": "Id", + "type": "string" + }, + "title": { + "title": "Title", + "type": "string" + } + }, + "required": [ + "id", + "title" + ], + "title": "DeveloperAskSource", + "type": "object" + }, "DeveloperConversation": { "properties": { "created_at": { @@ -34501,6 +34582,56 @@ ] } }, + "/v1/dev/user/ask": { + "post": { + "description": "Answer a natural-language question grounded in the user's own conversations.\n\nSemantically searches the user's conversations for the question, then synthesizes a\ncited answer from the most relevant ones — the same retrieval + RAG the chat surface\nuses, exposed for headless / Developer-API callers (CLI, CI, scripts). Read-only:\nit never writes, and locked conversations are excluded by the search layer.", + "operationId": "ask", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeveloperAskRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeveloperAskResponse" + } + } + }, + "description": "Successful Response" + }, + "401": { + "$ref": "#/components/responses/Error401" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "firebaseBearer": [] + } + ], + "summary": "Ask Conversations", + "tags": [ + "Conversations" + ] + } + }, "/v1/dev/user/conversations": { "get": { "description": "Get conversations with optional transcript inclusion.\n\n- **include_transcript**: If True, includes full transcript_segments in the response\n- **folder_id**: Filter by folder ID (must be a non-empty string if provided)\n- **starred**: Filter by starred status (true/false)", diff --git a/docs/api-reference/openapi.json b/docs/api-reference/openapi.json index c2cbe1bc70a..f1d0371814c 100644 --- a/docs/api-reference/openapi.json +++ b/docs/api-reference/openapi.json @@ -902,6 +902,87 @@ "title": "DeveloperActionItem", "type": "object" }, + "DeveloperAskRequest": { + "properties": { + "limit": { + "default": 5, + "description": "How many of the most relevant conversations to ground the answer on", + "maximum": 10.0, + "minimum": 1.0, + "title": "Limit", + "type": "integer" + }, + "question": { + "description": "A natural-language question about the user's life/conversations", + "maxLength": 1000, + "minLength": 1, + "title": "Question", + "type": "string" + }, + "timezone": { + "default": "UTC", + "description": "IANA timezone used to resolve relative dates in the answer", + "title": "Timezone", + "type": "string" + } + }, + "required": [ + "question" + ], + "title": "DeveloperAskRequest", + "type": "object" + }, + "DeveloperAskResponse": { + "properties": { + "answer": { + "title": "Answer", + "type": "string" + }, + "sources": { + "items": { + "$ref": "#/components/schemas/DeveloperAskSource" + }, + "title": "Sources", + "type": "array" + } + }, + "required": [ + "answer", + "sources" + ], + "title": "DeveloperAskResponse", + "type": "object" + }, + "DeveloperAskSource": { + "properties": { + "created_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Created At" + }, + "id": { + "title": "Id", + "type": "string" + }, + "title": { + "title": "Title", + "type": "string" + } + }, + "required": [ + "id", + "title" + ], + "title": "DeveloperAskSource", + "type": "object" + }, "DeveloperConversation": { "properties": { "created_at": { @@ -2679,6 +2760,59 @@ ] } }, + "/v1/dev/user/ask": { + "post": { + "description": "Answer a natural-language question grounded in the user's own conversations.\n\nSemantically searches the user's conversations for the question, then synthesizes a\ncited answer from the most relevant ones — the same retrieval + RAG the chat surface\nuses, exposed for headless / Developer-API callers (CLI, CI, scripts). Read-only:\nit never writes, and locked conversations are excluded by the search layer.", + "operationId": "ask", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeveloperAskRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeveloperAskResponse" + } + } + }, + "description": "Successful Response" + }, + "401": { + "$ref": "#/components/responses/Error401" + }, + "403": { + "$ref": "#/components/responses/Error403" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "developerApiKey": [] + } + ], + "summary": "Ask Conversations", + "tags": [ + "Conversations" + ] + } + }, "/v1/dev/user/conversations": { "get": { "description": "Get conversations with optional transcript inclusion.\n\n- **include_transcript**: If True, includes full transcript_segments in the response\n- **folder_id**: Filter by folder ID (must be a non-empty string if provided)\n- **starred**: Filter by starred status (true/false)", diff --git a/sdks/python-cli/omi_cli/main.py b/sdks/python-cli/omi_cli/main.py index 0386805a922..dc64ef59e50 100644 --- a/sdks/python-cli/omi_cli/main.py +++ b/sdks/python-cli/omi_cli/main.py @@ -152,6 +152,31 @@ def version() -> None: typer.echo(f"omi-cli {__version__}") +@app.command(help="Ask a natural-language question, answered from your own Omi conversations.") +def ask( + typer_ctx: typer.Context, + question: str = typer.Argument(..., help='Your question, e.g. "what did I decide about pricing last week?"'), + limit: int = typer.Option(5, "--limit", min=1, max=10, help="How many conversations to ground the answer on."), + timezone: str = typer.Option("UTC", "--timezone", help="IANA timezone for resolving relative dates."), +) -> None: + ctx: AppContext = typer_ctx.obj + with ctx.make_client() as client: + result = client.post( + "/v1/dev/user/ask", + json_body={"question": question, "limit": limit, "timezone": timezone}, + ) + if ctx.renderer.json_mode: + ctx.renderer.emit(result) + return + payload = result or {} + typer.echo(payload.get("answer", "")) + sources = payload.get("sources") or [] + if sources: + typer.echo("\nSources:") + for s in sources: + typer.echo(f" - {s.get('title') or 'Untitled'} ({s.get('created_at') or ''}) [{s.get('id')}]") + + # --------------------------------------------------------------------------- # Sub-command registration # --------------------------------------------------------------------------- diff --git a/sdks/python-cli/tests/test_ask.py b/sdks/python-cli/tests/test_ask.py new file mode 100644 index 00000000000..7f136ba1451 --- /dev/null +++ b/sdks/python-cli/tests/test_ask.py @@ -0,0 +1,34 @@ +"""Tests for the top-level ``omi ask`` command.""" + +from __future__ import annotations + +import json + +from omi_cli.main import app + + +def test_ask_posts_question_and_prints_answer(authed_profile, respx_mock, cli_runner) -> None: + route = respx_mock.post("/v1/dev/user/ask").respond( + json={ + "answer": "You decided to raise prices 10%.", + "sources": [{"id": "c1", "title": "Pricing sync", "created_at": "2026-07-20T00:00:00Z"}], + } + ) + + result = cli_runner.invoke(app, ["ask", "what did I decide about pricing?"]) + + assert result.exit_code == 0 + assert "You decided to raise prices 10%." in result.stdout + assert "Pricing sync" in result.stdout # source rendered + body = json.loads(route.calls[0].request.content) + assert body["question"] == "what did I decide about pricing?" + assert body["limit"] == 5 # default grounding size + + +def test_ask_json_mode_emits_raw_payload(authed_profile, respx_mock, cli_runner) -> None: + respx_mock.post("/v1/dev/user/ask").respond(json={"answer": "42", "sources": []}) + + result = cli_runner.invoke(app, ["--json", "ask", "meaning of life?", "--limit", "3"]) + + assert result.exit_code == 0 + assert json.loads(result.stdout) == {"answer": "42", "sources": []} diff --git a/web/admin/lib/services/omi-api/omiApi.generated.ts b/web/admin/lib/services/omi-api/omiApi.generated.ts index 1244a49521a..8028117669c 100644 --- a/web/admin/lib/services/omi-api/omiApi.generated.ts +++ b/web/admin/lib/services/omi-api/omiApi.generated.ts @@ -1414,6 +1414,23 @@ export interface DeveloperActionItem { updated_at?: string | null; } +export interface DeveloperAskRequest { + limit?: number; + question: string; + timezone?: string; +} + +export interface DeveloperAskResponse { + answer: string; + sources: Array; +} + +export interface DeveloperAskSource { + created_at?: string | null; + id: string; + title: string; +} + export interface DeveloperConversation { created_at: string; finished_at: string | null; @@ -3916,6 +3933,9 @@ export interface OmiApiSchemas { "DevApiKeyCreate": DevApiKeyCreate; "DevApiKeyCreated": DevApiKeyCreated; "DeveloperActionItem": DeveloperActionItem; + "DeveloperAskRequest": DeveloperAskRequest; + "DeveloperAskResponse": DeveloperAskResponse; + "DeveloperAskSource": DeveloperAskSource; "DeveloperConversation": DeveloperConversation; "DeveloperConversationActionItem": DeveloperConversationActionItem; "DeveloperConversationEvent": DeveloperConversationEvent; @@ -5575,6 +5595,16 @@ export interface OmiApiPaths { }; }; }; + "/v1/dev/user/ask": { + post: { + operationId: "ask"; + responses: { + "200": DeveloperAskResponse; + "401": void; + "422": HTTPValidationError; + }; + }; + }; "/v1/dev/user/conversations": { get: { operationId: "listConversations"; @@ -10746,6 +10776,23 @@ export async function deleteActionItem(path: { action_item_id: string }, init?: return _res.status === 204 ? (undefined as any) : await _res.json(); } +export async function ask(body: DeveloperAskRequest, init?: OmiApiClientInit): Promise { + const _base = init?.baseURL ?? ""; + const _path = `/v1/dev/user/ask`; + const _search = ""; + const _res = await fetch(`${_base}${_path}${_search}`, { + method: "POST", + headers: { + ...(body ? { 'Content-Type': 'application/json' } : {}), + ...(init?.token ? { Authorization: `Bearer ${init.token}` } : {}), + ...init?.headers, + }, + body: body ? JSON.stringify(body) : undefined, + }); + if (!_res.ok) throw new OmiApiError(_res.status, _res); + return _res.status === 204 ? (undefined as any) : await _res.json(); +} + export async function listConversations(query: { start_date?: string | null, end_date?: string | null, categories?: string | null, limit?: number, offset?: number, include_transcript?: boolean, folder_id?: string | null, starred?: boolean | null }, init?: OmiApiClientInit): Promise> { const _base = init?.baseURL ?? ""; const _path = `/v1/dev/user/conversations`; @@ -15695,4 +15742,4 @@ export async function get_speech_profile_v4_speech_profile_get(header: { authori return _res.status === 204 ? (undefined as any) : await _res.json(); } -// Total: 385 client methods generated. +// Total: 386 client methods generated. diff --git a/web/app/src/lib/omiApi.generated.ts b/web/app/src/lib/omiApi.generated.ts index 1244a49521a..8028117669c 100644 --- a/web/app/src/lib/omiApi.generated.ts +++ b/web/app/src/lib/omiApi.generated.ts @@ -1414,6 +1414,23 @@ export interface DeveloperActionItem { updated_at?: string | null; } +export interface DeveloperAskRequest { + limit?: number; + question: string; + timezone?: string; +} + +export interface DeveloperAskResponse { + answer: string; + sources: Array; +} + +export interface DeveloperAskSource { + created_at?: string | null; + id: string; + title: string; +} + export interface DeveloperConversation { created_at: string; finished_at: string | null; @@ -3916,6 +3933,9 @@ export interface OmiApiSchemas { "DevApiKeyCreate": DevApiKeyCreate; "DevApiKeyCreated": DevApiKeyCreated; "DeveloperActionItem": DeveloperActionItem; + "DeveloperAskRequest": DeveloperAskRequest; + "DeveloperAskResponse": DeveloperAskResponse; + "DeveloperAskSource": DeveloperAskSource; "DeveloperConversation": DeveloperConversation; "DeveloperConversationActionItem": DeveloperConversationActionItem; "DeveloperConversationEvent": DeveloperConversationEvent; @@ -5575,6 +5595,16 @@ export interface OmiApiPaths { }; }; }; + "/v1/dev/user/ask": { + post: { + operationId: "ask"; + responses: { + "200": DeveloperAskResponse; + "401": void; + "422": HTTPValidationError; + }; + }; + }; "/v1/dev/user/conversations": { get: { operationId: "listConversations"; @@ -10746,6 +10776,23 @@ export async function deleteActionItem(path: { action_item_id: string }, init?: return _res.status === 204 ? (undefined as any) : await _res.json(); } +export async function ask(body: DeveloperAskRequest, init?: OmiApiClientInit): Promise { + const _base = init?.baseURL ?? ""; + const _path = `/v1/dev/user/ask`; + const _search = ""; + const _res = await fetch(`${_base}${_path}${_search}`, { + method: "POST", + headers: { + ...(body ? { 'Content-Type': 'application/json' } : {}), + ...(init?.token ? { Authorization: `Bearer ${init.token}` } : {}), + ...init?.headers, + }, + body: body ? JSON.stringify(body) : undefined, + }); + if (!_res.ok) throw new OmiApiError(_res.status, _res); + return _res.status === 204 ? (undefined as any) : await _res.json(); +} + export async function listConversations(query: { start_date?: string | null, end_date?: string | null, categories?: string | null, limit?: number, offset?: number, include_transcript?: boolean, folder_id?: string | null, starred?: boolean | null }, init?: OmiApiClientInit): Promise> { const _base = init?.baseURL ?? ""; const _path = `/v1/dev/user/conversations`; @@ -15695,4 +15742,4 @@ export async function get_speech_profile_v4_speech_profile_get(header: { authori return _res.status === 204 ? (undefined as any) : await _res.json(); } -// Total: 385 client methods generated. +// Total: 386 client methods generated. diff --git a/web/personas-open-source/src/lib/omiApi.generated.ts b/web/personas-open-source/src/lib/omiApi.generated.ts index 1244a49521a..8028117669c 100644 --- a/web/personas-open-source/src/lib/omiApi.generated.ts +++ b/web/personas-open-source/src/lib/omiApi.generated.ts @@ -1414,6 +1414,23 @@ export interface DeveloperActionItem { updated_at?: string | null; } +export interface DeveloperAskRequest { + limit?: number; + question: string; + timezone?: string; +} + +export interface DeveloperAskResponse { + answer: string; + sources: Array; +} + +export interface DeveloperAskSource { + created_at?: string | null; + id: string; + title: string; +} + export interface DeveloperConversation { created_at: string; finished_at: string | null; @@ -3916,6 +3933,9 @@ export interface OmiApiSchemas { "DevApiKeyCreate": DevApiKeyCreate; "DevApiKeyCreated": DevApiKeyCreated; "DeveloperActionItem": DeveloperActionItem; + "DeveloperAskRequest": DeveloperAskRequest; + "DeveloperAskResponse": DeveloperAskResponse; + "DeveloperAskSource": DeveloperAskSource; "DeveloperConversation": DeveloperConversation; "DeveloperConversationActionItem": DeveloperConversationActionItem; "DeveloperConversationEvent": DeveloperConversationEvent; @@ -5575,6 +5595,16 @@ export interface OmiApiPaths { }; }; }; + "/v1/dev/user/ask": { + post: { + operationId: "ask"; + responses: { + "200": DeveloperAskResponse; + "401": void; + "422": HTTPValidationError; + }; + }; + }; "/v1/dev/user/conversations": { get: { operationId: "listConversations"; @@ -10746,6 +10776,23 @@ export async function deleteActionItem(path: { action_item_id: string }, init?: return _res.status === 204 ? (undefined as any) : await _res.json(); } +export async function ask(body: DeveloperAskRequest, init?: OmiApiClientInit): Promise { + const _base = init?.baseURL ?? ""; + const _path = `/v1/dev/user/ask`; + const _search = ""; + const _res = await fetch(`${_base}${_path}${_search}`, { + method: "POST", + headers: { + ...(body ? { 'Content-Type': 'application/json' } : {}), + ...(init?.token ? { Authorization: `Bearer ${init.token}` } : {}), + ...init?.headers, + }, + body: body ? JSON.stringify(body) : undefined, + }); + if (!_res.ok) throw new OmiApiError(_res.status, _res); + return _res.status === 204 ? (undefined as any) : await _res.json(); +} + export async function listConversations(query: { start_date?: string | null, end_date?: string | null, categories?: string | null, limit?: number, offset?: number, include_transcript?: boolean, folder_id?: string | null, starred?: boolean | null }, init?: OmiApiClientInit): Promise> { const _base = init?.baseURL ?? ""; const _path = `/v1/dev/user/conversations`; @@ -15695,4 +15742,4 @@ export async function get_speech_profile_v4_speech_profile_get(header: { authori return _res.status === 204 ? (undefined as any) : await _res.json(); } -// Total: 385 client methods generated. +// Total: 386 client methods generated.