diff --git a/AGENTS.md b/AGENTS.md index b8811af..d51897c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,37 @@ - Backend: `packages/convex` - OpenSpec source of truth: `openspec/changes//` +## General Workflow Guardrails + +- Start every non-trivial task by grounding in current repo state before changing files: + 1. identify the active scope + 2. read the relevant files/specs/tests + 3. verify whether the work is already partly done + 4. choose a narrow verification plan +- If working from an existing OpenSpec change, always read: + - `openspec status --change "" --json` + - `openspec instructions apply --change "" --json` + - the current `proposal.md`, `design.md`, `specs/**/*.md`, and `tasks.md` +- Never assume unchecked boxes in `tasks.md` mean the code is still missing. Verify the current implementation first, then update artifacts or tasks to match reality. +- Before creating a new OpenSpec change, quickly check for overlapping active changes or existing specs so you do not create duplicates or split ownership accidentally. +- For multi-step work, keep an explicit plan/todo and update it as tasks complete. Prefer one active task at a time. +- When changing course mid-task, record the new scope and the reason in the active change artifacts if they are affected. +- Before marking work complete, verify both code and artifacts: + - code/tests/typechecks reflect the final state + - `tasks.md` checkboxes match what is actually done + - any follow-up work is written down explicitly instead of left implicit + +## Existing Proposal Discipline + +- If you did not create the current proposal/change, treat the artifacts as hypotheses until verified against the codebase. +- Separate findings into three buckets before editing artifacts: + - already implemented + - still unfinished + - intentionally out of scope or accepted exception +- Only put unfinished work into active proposal/spec/task artifacts. +- If code and artifacts disagree, prefer fixing the artifact first unless the user explicitly asked for implementation. +- When leaving partial progress, record exact remaining file clusters, blockers, and verification still needed so a later pass can continue without re-auditing the whole repo. + ## High-Value Commands (copy/paste) ### Typecheck @@ -48,6 +79,140 @@ - `apps/widget/src/test/refHardeningGuard.test.ts` - `packages/react-native-sdk/tests/hookBoundaryGuard.test.ts` +## Convex Hardening Audit Triage + +- Before treating an audit item as open work, verify whether it is already implemented and only the guard/proposal text is stale. +- Default classification for current repo state: + - `packages/sdk-core/src/api/*.ts` manual fixed refs are generally **approved TS2589 hotspots**, not automatic cleanup targets. + - `packages/sdk-core/src/api/aiAgent.ts` already routes `getRelevantKnowledge` through `client.action(...)`; do not reopen the old query-path migration unless you find a current regression. + - `packages/convex/convex/embeddings.ts` batching/backfill concurrency work is already in place; do not create new perf tasks for `generateBatch`, `backfillExisting`, or `generateBatchInternal` unless the current code regressed. + - `packages/convex/convex/testAdmin.ts` is an explicit dynamic exception because it intentionally dispatches caller-selected internal test mutations. +- Treat these patterns differently: + - **Remaining cleanup target:** generic `name: string` ref helpers such as `makeInternalQueryRef(name)` / `getQueryRef(name)` in covered runtime files. + - **Usually acceptable hotspot:** fixed module-scope `makeFunctionReference("module:function")` constants with a narrow comment or guard-railed `TS2589` justification. + - **Accepted exception:** intentionally dynamic dispatch that is security-constrained and documented (currently `testAdmin.ts`). +- When cleaning backend Convex boundaries, prefer this order: + 1. Generated `api` / `internal` refs + 2. Named shallow runner helper at the hot spot + 3. Fixed `makeFunctionReference("module:function")` constant + 4. Only if intentionally dynamic and documented, a narrow exception +- Do not add new generic helper factories to shared ref modules. If a module exists to share refs, export fixed named refs from it. + +## Testing Best Practices + +### Do + +- Create isolated test data using helpers +- Clean up after tests +- Use descriptive test names +- Test both success and error cases +- Use `data-testid` attributes for E2E selectors +- Keep tests focused and independent + +### Don't + +- Share state between tests +- Rely on specific database IDs +- Skip cleanup in afterAll +- Hard-code timeouts (use Playwright's auto-wait) + + +## Code Style and Comments + +### Comment Tags + +Use these tags to highlight important information in code comments: + +- `IMPORTANT:` - Critical information that must not be overlooked +- `NOTE:` - Helpful context or clarification +- `WARNING:` - Potential pitfalls or dangerous operations +- `TODO:` - Future work that should be done +- `FIXME:` - Known issues that need fixing + +### Code Patterns + +- Use `MUST` / `MUST NOT` for hard requirements +- Use `NEVER` / `ALWAYS` for absolute rules +- Use `AVOID` for anti-patterns to stay away from +- Use `DO NOT` for explicit prohibitions + +### Example + +```typescript +// IMPORTANT: This function must be called before any Convex operations +// NOTE: The widget uses Shadow DOM, so overlays must portal into the shadow root +// WARNING: Never fall back to wildcard "*" for CORS +// TODO: Add rate limiting to this endpoint +// FIXME: This cast should be removed after TS2589 is resolved +``` + +## Modularity Patterns + +### Module Organization + +- Separate orchestration from rendering +- Extract helper logic from page components +- Use explicit domain modules instead of co-locating all logic +- Preserve existing behavior when refactoring + +### Key Principles + +1. **Single Responsibility**: Each module should have one clear purpose +2. **Explicit Contracts**: Modules must expose typed internal contracts +3. **Preserve Semantics**: Refactoring must preserve existing behavior +4. **Shared Utilities**: Common logic should be extracted to shared modules + +### Common Patterns + +- **Controller/View Separation**: Separate orchestration from rendering +- **Domain Modules**: Group related functionality by domain +- **Adapter Pattern**: Use adapters for external dependencies +- **Wrapper Hooks**: Wrap external hooks with local adapters + +## Error Handling Patterns + +### Standard Error Functions + +Use the standardized error functions from `packages/convex/convex/utils/errors.ts`: + +- `throwNotFound(resourceType)` - Resource not found +- `throwNotAuthenticated()` - Authentication required +- `throwPermissionDenied(permission?)` - Permission denied + +### Error Feedback + +- Use standardized non-blocking error feedback for frontend paths +- Provide actionable user messaging +- Centralize unknown error mapping for covered paths + +## Documentation Standards + +### Source of Truth + +- OpenSpec specs are the source of truth for requirements +- `docs/` contains reference documentation +- `AGENTS.md` contains AI agent guardrails +- Code comments provide inline guidance + +### When to Update Docs + +- When adding new features or changing behavior +- When fixing bugs that affect user-facing behavior +- When refactoring that changes module boundaries +- When adding new patterns or conventions + +## Agent Handoff Notes + +- When converting a repo audit into OpenSpec artifacts, put **only unfinished work** into `proposal.md`, spec deltas, and `tasks.md`. +- Explicitly call out already-finished adjacent work so a follow-up agent does not reopen it by mistake. +- For the current Convex hardening area, the default out-of-scope items are: + - sdk-core `getRelevantKnowledge` action routing + - embedding batching/backfill concurrency in `packages/convex/convex/embeddings.ts` +- If you change the covered hardening inventory or accepted exceptions, update the matching guard in the same change. Common files: + - `packages/convex/tests/runtimeTypeHardeningGuard.test.ts` + - `packages/sdk-core/tests/refHardeningGuard.test.ts` +- When leaving work half-finished, record the remaining file clusters explicitly in `openspec/changes//tasks.md` so the next agent can resume without re-auditing the repo. + ### Tests - Convex targeted file: @@ -107,3 +272,379 @@ Use these when working within OpenSpec-driven requests to reduce setup time in fresh chats. Warning: Running scripts inline causes the terminal to hang and crash. Create files and run them that way. Avoid running commmands like `... node - <<"NODE ..."` or `python3 - <<'PY' ...` + + +# Convex Type Safety Playbook + +This is the canonical guide for adding or changing Convex-backed code in this repo. + +Use it when you are: + +- adding a new Convex `query`, `mutation`, or `action` +- calling one Convex function from another +- wiring a new frontend feature to Convex +- hitting `TS2589` (`Type instantiation is excessively deep and possibly infinite`) +- deciding where a cast or `makeFunctionReference(...)` is acceptable + +Historical hardening notes still exist in `openspec/archive/refactor-*` and `runtime-type-hardening-2026-03-05.md`, but this file is the current source of truth for new code. + +## Goals + +- Keep runtime and UI code on explicit, local types. +- Keep unavoidable Convex typing escape hatches small and centralized. +- Prevent new generic string-ref factories, broad casts, and component-local ref creation. +- Make it obvious which pattern to use for each call site. + +## Decision Table + +| Situation | Preferred approach | Where | Why | +| --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | --------------------------------------- | ------------------------------------------------------------- | +| Define a new public Convex query or mutation | Export a normal Convex function with narrow `v.*` args and a narrow return shape | `packages/convex/convex/**` | Keeps the source contract explicit and reusable | +| Call Convex from web or widget UI/runtime code | Use the local surface adapter plus a feature-local wrapper hook or fixed ref constant | `apps/web/src/**`, `apps/widget/src/**` | Keeps `convex/react` and ref typing out of runtime/UI modules | +| Call one Convex function from another and generated refs typecheck normally | Use generated `api.*` / `internal.*` refs | `packages/convex/convex/**` | This is the default, simplest path | +| Call one Convex function from another and generated refs hit `TS2589` | Add a local shallow `runQuery` / `runMutation` / `runAction` / `runAfter` helper | the hotspot file only | Shrinks type instantiation at the call boundary | +| The generated ref itself still triggers `TS2589` | Replace only that hot ref with a fixed, typed `makeFunctionReference("module:function")` constant | the hotspot file only | Avoids broad weakening of the entire module | +| Convex React hook tuple typing still needs help | Keep a tiny adapter-local helper/cast in the surface adapter | adapter file only | Localizes the last unavoidable boundary | + +## Non-Negotiable Rules + +### 1. Do not import `convex/react` in runtime or feature UI files + +Use the surface adapter layer instead: + +- web: `apps/web/src/lib/convex/hooks.ts` +- widget: `apps/widget/src/lib/convex/hooks.ts` +- mobile: follow the same local-wrapper pattern; do not add new direct screen/context-level `convex/react` usage + +Direct `convex/react` imports are only acceptable in: + +- explicit adapter files +- bootstrap/provider wiring +- targeted tests that intentionally mock the adapter boundary + +The current hardening guards freeze these boundaries: + +- `apps/web/src/app/typeHardeningGuard.test.ts` +- `apps/widget/src/test/refHardeningGuard.test.ts` +- `apps/mobile/src/typeHardeningGuard.test.ts` +- `packages/react-native-sdk/tests/hookBoundaryGuard.test.ts` + +### 2. Do not create refs inside React components or hooks + +Bad: + +```ts +function WidgetPane() { + const listRef = makeFunctionReference<"query", Args, Result>("messages:list"); + const data = useQuery(listRef, args); +} +``` + +Good: + +```ts +const LIST_REF = widgetQueryRef("messages:list"); + +function WidgetPane() { + const data = useWidgetQuery(LIST_REF, args); +} +``` + +All refs must be module-scope constants. + +### 3. Do not add new generic string-ref factories + +Do not introduce helpers like: + +- `getQueryRef(name: string)` +- `getMutationRef(name: string)` +- `getActionRef(name: string)` + +Those patterns weaken the type boundary and make review harder. Some older code still has them, but they are legacy, not the standard for new work. + +Use named fixed refs instead: + +```ts +const LIST_MESSAGES_REF = webQueryRef("messages:list"); +const SEND_MESSAGE_REF = webMutationRef>("messages:send"); +``` + +### 4. Keep casts local, named, and justified + +Allowed: + +- a tiny adapter-local cast needed to satisfy a Convex hook tuple type +- a hotspot-local shallow helper for `ctx.runQuery`, `ctx.runMutation`, `ctx.runAction`, or `ctx.scheduler.runAfter` +- a hotspot-local typed `makeFunctionReference("module:function")` when generated refs trigger `TS2589` + +Not allowed for new code: + +- `as any` +- broad `unsafeApi` / `unsafeInternal` object aliases in runtime code +- repeated `as unknown as` across multiple call sites +- hiding transport typing inside UI/controller modules + +### 5. Update guard tests when you add or move a boundary + +If you intentionally add a new approved boundary, document it in the relevant guard test at the same time. + +## Standard Patterns + +## A. Defining a new Convex query or mutation + +Default backend pattern: + +```ts +import { query, mutation } from "./_generated/server"; +import { v } from "convex/values"; +import type { Id } from "./_generated/dataModel"; + +type VisitorSummary = { + _id: Id<"visitors">; + name?: string; + email?: string; +}; + +export const listByWorkspace = query({ + args: { + workspaceId: v.id("workspaces"), + }, + handler: async (ctx, args): Promise => { + const visitors = await ctx.db + .query("visitors") + .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId)) + .collect(); + + return visitors.map((visitor) => ({ + _id: visitor._id, + name: visitor.name, + email: visitor.email, + })); + }, +}); +``` + +Rules: + +- Use narrow `v.*` validators. +- Prefer explicit local return types for shared, frontend-facing, or cross-function contracts. +- Convert untyped or broad data to a narrow shape before returning it. +- If you need `v.any()`, document it in `security/convex-v-any-arg-exceptions.json`. + +## B. Consuming Convex from web or widget code + +Runtime/UI files should consume feature-local wrappers or fixed refs through the local adapter. + +Widget example: + +```ts +import type { Id } from "@opencom/convex/dataModel"; +import { useWidgetQuery, widgetQueryRef } from "../lib/convex/hooks"; + +type TicketRecord = { + _id: Id<"tickets">; + subject: string; +}; + +const VISITOR_TICKETS_REF = widgetQueryRef< + { workspaceId: Id<"workspaces">; visitorId: Id<"visitors">; sessionToken: string }, + TicketRecord[] +>("tickets:listByVisitor"); + +export function useVisitorTickets( + workspaceId: Id<"workspaces"> | undefined, + visitorId: Id<"visitors"> | null, + sessionToken: string | null +) { + return useWidgetQuery( + VISITOR_TICKETS_REF, + workspaceId && visitorId && sessionToken ? { workspaceId, visitorId, sessionToken } : "skip" + ); +} +``` + +Use the same structure in web with `webQueryRef`, `webMutationRef`, `webActionRef`, `useWebQuery`, `useWebMutation`, and `useWebAction`. + +Rules: + +- Define refs once at module scope. +- Keep `skip` / gating logic in the wrapper where practical. +- Export narrow feature-local result types instead of leaking giant inferred shapes. +- Do not import `convex/react` directly in feature components, screens, contexts, or controller hooks. + +## C. Calling one Convex function from another + +### Preferred default: generated refs + +Start here when the types are normal: + +```ts +import { internal } from "./_generated/api"; + +await ctx.runMutation(internal.notifications.deliver, { + conversationId, +}); +``` + +This is the standard path until it hits a real `TS2589` problem. + +### TS2589 fallback: shallow helper first + +If `ctx.runQuery(...)`, `ctx.runMutation(...)`, `ctx.runAction(...)`, or `ctx.scheduler.runAfter(...)` causes deep-instantiation errors, add a local helper: + +```ts +import { type FunctionReference } from "convex/server"; + +type ConvexRef< + Type extends "query" | "mutation" | "action", + Visibility extends "internal" | "public", + Args extends Record, + Return = unknown, +> = FunctionReference; + +function getShallowRunMutation(ctx: { runMutation: unknown }) { + return ctx.runMutation as unknown as < + Visibility extends "internal" | "public", + Args extends Record, + Return, + >( + mutationRef: ConvexRef<"mutation", Visibility, Args, Return>, + mutationArgs: Args + ) => Promise; +} +``` + +Then call through the helper: + +```ts +const runMutation = getShallowRunMutation(ctx); +await runMutation(internal.notifications.deliver, { conversationId }); +``` + +### TS2589 fallback: fixed typed ref when the generated ref itself is the problem + +If simply referencing `api.foo.bar` or `internal.foo.bar` still triggers `TS2589`, switch only that hot call site to a fixed typed ref: + +```ts +import { makeFunctionReference, type FunctionReference } from "convex/server"; + +type DeliverArgs = { conversationId: Id<"conversations"> }; +type DeliverResult = null; + +type ConvexRef< + Type extends "query" | "mutation" | "action", + Visibility extends "internal" | "public", + Args extends Record, + Return = unknown, +> = FunctionReference; + +const DELIVER_NOTIFICATION_REF = makeFunctionReference<"mutation", DeliverArgs, DeliverResult>( + "notifications:deliver" +) as unknown as ConvexRef<"mutation", "internal", DeliverArgs, DeliverResult>; +``` + +Use this only after the generated ref path proved pathological. + +## Which approach to choose + +### Use generated `api` / `internal` refs when + +- the call is backend-to-backend +- the generated ref typechecks normally +- you are not in a known `TS2589` hotspot + +### Use fixed typed `makeFunctionReference(...)` constants when + +- you are in a surface adapter or feature-local wrapper file +- you need a stable local ref for a frontend wrapper +- a backend hotspot still blows up after trying generated refs + +### Use local wrapper hooks when + +- the consumer is React UI, runtime, controller, screen, or context code +- the feature needs gating or `skip` behavior +- you want to normalize the result shape once for multiple consumers + +### Use a shallow backend helper when + +- the problem is `ctx.runQuery` / `ctx.runMutation` / `ctx.runAction` / `runAfter` +- the ref type is okay, but the invocation is too deep + +### Use an adapter-local cast only when + +- Convex’s React hook typing still needs an exact tuple or helper shape +- the cast can stay in the adapter file and nowhere else + +## Current Surface Standards + +### Backend (`packages/convex`) + +- Default to generated refs. +- Localize `TS2589` workarounds with named `getShallowRun*` helpers. +- If needed, use fixed typed refs at the hotspot only. +- Keep guard coverage in `packages/convex/tests/runtimeTypeHardeningGuard.test.ts`. + +### Web (`apps/web`) + +- Feature/runtime code should not import `convex/react` directly. +- Use feature-local wrapper hooks and the web adapter in `apps/web/src/lib/convex/hooks.ts`. +- Keep refs at module scope. +- Guard coverage lives in `apps/web/src/app/typeHardeningGuard.test.ts`. + +### Widget (`apps/widget`) + +- Feature/runtime code should not import `convex/react` directly. +- Use the widget adapter in `apps/widget/src/lib/convex/hooks.ts`. +- The only remaining adapter escape hatch is the query-args tuple helper required by Convex’s hook typing. +- Guard coverage lives in `apps/widget/src/test/refHardeningGuard.test.ts`. + +### Mobile (`apps/mobile`) + +- Target the same pattern as web/widget: local wrapper hooks plus module-scope typed refs. +- Do not add new direct `convex/react` usage to screens, contexts, or controller-style hooks. +- If a local adapter/wrapper does not exist for the feature yet, create one instead of importing hooks directly into runtime UI. +- Guard coverage lives in `apps/mobile/src/typeHardeningGuard.test.ts`. + +## Anti-Patterns To Avoid + +- `function getQueryRef(name: string) { ... }` +- `function getMutationRef(name: string) { ... }` +- `function getActionRef(name: string) { ... }` +- component-local `makeFunctionReference(...)` +- `as any` +- broad `unsafeApi` / `unsafeInternal` aliases +- scattering the same `as unknown as` across many call sites +- returning `unknown` or `string` when a branded `Id<"...">` or explicit object type is the real contract + +## Verification Checklist + +After changing Convex typing boundaries: + +1. Run the touched package typecheck first. +2. Run the touched package tests. +3. Run the relevant hardening guard tests. +4. If this work is OpenSpec-driven, run strict OpenSpec validation. + +Useful commands: + +```bash +pnpm --filter @opencom/convex typecheck +pnpm --filter @opencom/web typecheck +pnpm --filter @opencom/widget typecheck + +pnpm --filter @opencom/convex test -- --run tests/runtimeTypeHardeningGuard.test.ts +pnpm --filter @opencom/web test -- --run src/app/typeHardeningGuard.test.ts +pnpm --filter @opencom/widget test -- --run src/test/refHardeningGuard.test.ts +pnpm exec vitest run --config apps/mobile/vitest.config.ts apps/mobile/src/typeHardeningGuard.test.ts +``` + +## Review Rule of Thumb + +If you are about to: + +- add a new direct `convex/react` import in feature code +- add a new `get*Ref(name: string)` factory +- add `unsafeApi`, `unsafeInternal`, `as any`, or repeated `as unknown as` +- create a ref inside a React component + +stop and use one of the standard patterns above instead. diff --git a/ROADMAP.md b/ROADMAP.md index 7ba8148..5090218 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -49,6 +49,7 @@ Goal: ship a professional open-source customer messaging platform with strong de - [ ] allow customising the agent's system prompt? - [p] a CI AI agent to check for any doc drift and update docs based on the latest code - [ ] convert supportAttachments.finalizeUpload into an action + internal mutation pipeline so we can add real signature checks too. The current finalizeUpload boundary is a Convex mutation and ctx.storage.get() is only available in actions. Doing true magic-byte validation would need a larger refactor of that finalize flow. +- [ ] add URL param deep links for the widget - Go to a url like ?open-widget-tab=home to open the widget to that tab, etc. apps/web/src/app/outbound/[id]/OutboundTriggerPanel.tsx @@ -210,3 +211,80 @@ Legend: `[x]` complete, `[~]` present but still maturing, `[ ]` not yet implemen - [ ] Should OTP be the only default path for new users, with password optional? - [ ] How strict should hosted-tier limits be for email campaigns and push usage? - [ ] What is required for Production Readiness, vs nice-to-haves? + + + + +I followed the AGENTS.md memory/instructions (focused, repo-scoped audit with PNPM-era conventions) and used jcodemunch + repo grep to map this. + +1) Manual makeFunctionReference hotspots + +RAG-follow-up critical + +packages/convex/convex/aiAgentActionsKnowledge.ts:45 (GET_CONTENT_BY_ID_REF) +packages/convex/convex/aiAgentActionsKnowledge.ts:54 (GET_EMBEDDING_BY_ID_REF) +packages/convex/convex/aiAgentActions.ts:162 (GET_RELEVANT_KNOWLEDGE_FOR_RUNTIME_ACTION_REF) +packages/convex/convex/embeddings.ts:172 (GENERATE_BATCH_INTERNAL_REF) +packages/convex/convex/embeddings.ts:137 (LIST_BY_CONTENT_REF) and adjacent refs used in embedding pipeline +Broader repo hotspots (same pattern) + +packages/convex/convex/notifications/functionRefs.ts +packages/convex/convex/push/functionRefs.ts +packages/convex/convex/series/scheduler.ts +packages/convex/convex/pushCampaigns.ts +packages/convex/convex/testing/helpers/notifications.ts +packages/convex/convex/emailChannel.ts +packages/convex/convex/embeddings/functionRefs.ts +packages/convex/convex/carousels/triggering.ts +packages/convex/convex/events.ts +packages/convex/convex/http.ts +packages/convex/convex/outboundMessages.ts +packages/convex/convex/snippets.ts +packages/convex/convex/testAdmin.ts +packages/convex/convex/visitors/mutations.ts +packages/convex/convex/widgetSessions.ts +packages/convex/convex/workspaceMembers.ts +packages/convex/convex/tickets.ts +2) as unknown as reduction targets + +Immediate (RAG path) + +packages/convex/convex/aiAgentActionsKnowledge.ts:39 +packages/convex/convex/aiAgentActionsKnowledge.ts:49 +packages/convex/convex/aiAgentActionsKnowledge.ts:58 +packages/convex/convex/aiAgentActions.ts:123 +packages/convex/convex/aiAgentActions.ts:155 +packages/convex/convex/aiAgentActions.ts:172 +packages/convex/convex/aiAgentActions.ts:189 +packages/convex/convex/embeddings.ts:30 +packages/convex/convex/embeddings.ts:37 +packages/convex/convex/embeddings.ts:44 +packages/convex/convex/embeddings.ts:123 +packages/convex/convex/embeddings.ts:176 +Full broader set + +Same file list as section 1 (19 Convex files matched for makeFunctionReference ... as unknown as). +3) Batching/perf refactor sites + +packages/convex/convex/embeddings.ts:371 (generateBatch loops serially over args.items) +packages/convex/convex/embeddings.ts:465 (backfillExisting runs batch chunks sequentially) +packages/convex/convex/embeddings.ts:509 (generateBatchInternal loops serially over args.items) +These are the concrete places to introduce concurrency-limited parallelism / true batched embedding work. + +4) SDK route migration sites (getRelevantKnowledge old query path) + +Source + +packages/sdk-core/src/api/aiAgent.ts:12 (aiAgent:getRelevantKnowledge ref) +packages/sdk-core/src/api/aiAgent.ts:76 (getRelevantKnowledge exported function) +packages/sdk-core/src/api/aiAgent.ts:83 (client.query(GET_RELEVANT_KNOWLEDGE_REF, ...)) +Tests/contracts expecting old route + +packages/sdk-core/tests/contracts.test.ts:478 +packages/sdk-core/tests/api.test.ts:56 +packages/sdk-core/tests/api.test.ts:150 +Backend fallback currently kept for compatibility + +packages/convex/convex/aiAgent.ts:318 (getRelevantKnowledge public query) +If you want, I can turn this into a prioritized migration checklist (P0/P1/P2) with exact replacement strategy per file. + diff --git a/apps/widget/src/components/conversationView/Footer.tsx b/apps/widget/src/components/conversationView/Footer.tsx index 6fe3bfe..2d5cac8 100644 --- a/apps/widget/src/components/conversationView/Footer.tsx +++ b/apps/widget/src/components/conversationView/Footer.tsx @@ -204,7 +204,7 @@ export function ConversationFooter({ type="button" > {suggestion.title} - {suggestion.snippet} + {/* {suggestion.snippet} */} ))} diff --git a/opencode.json b/opencode.json new file mode 100644 index 0000000..1911ada --- /dev/null +++ b/opencode.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "jcodemunch-mcp": { + "type":"local", + "command": ["uvx", "jcodemunch-mcp"], + "enabled": true + }, + "jdocmunch-mcp": { + "type":"local", + "command": ["uvx", "jdocmunch-mcp"], + "enabled": true + } + } +} \ No newline at end of file diff --git a/openspec/changes/archive/2026-03-12-use-convex-vector-search/.openspec.yaml b/openspec/changes/archive/2026-03-12-use-convex-vector-search/.openspec.yaml new file mode 100644 index 0000000..6dfce10 --- /dev/null +++ b/openspec/changes/archive/2026-03-12-use-convex-vector-search/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-12 diff --git a/openspec/changes/archive/2026-03-12-use-convex-vector-search/design.md b/openspec/changes/archive/2026-03-12-use-convex-vector-search/design.md new file mode 100644 index 0000000..c51573f --- /dev/null +++ b/openspec/changes/archive/2026-03-12-use-convex-vector-search/design.md @@ -0,0 +1,27 @@ +## Context + +Currently, the `aiAgent.ts` inside the `packages/convex` workspace relies on an in-memory string-matching approach (`calculateRelevanceScore`) to find relevant articles and snippets for a given query. It loads all articles and snippets in a workspace and loops through them, which is a bottleneck for larger workspaces. + +However, we already have a `contentEmbeddings` table and `embeddings:generateBatch` in the backend which creates embeddings for articles and snippets using `text-embedding-3-small` and stores them. The widget and suggestions are already using `ctx.vectorSearch` against `contentEmbeddings`. The AI agent should use this same infrastructure. + +## Goals / Non-Goals + +**Goals:** +- Replace the inefficient in-memory string-matching search with Convex Vector similarity search (`ctx.vectorSearch`). +- Refactor `getRelevantKnowledgeForRuntime` to use the new vector search method. +- Keep the system prompt and the number of returned results (limit: 5) the same to preserve the current AI behavior. +- Reduce latency for AI responses. + +**Non-Goals:** +- We are not changing the AI models used (e.g., OpenAI/Anthropic). +- We are not changing how the UI renders responses. +- We are not modifying the fallback human handoff logic. + +## Decisions + +1. **Use Convex Vector Search**: We will update the AI agent to query the vector search index `by_embedding` on the `contentEmbeddings` table, similar to how `searchForWidget` in `suggestions.ts` works. +2. **Move embedding generation logic to AI Action**: Since vector search requires embedding the query first, we will change `getRelevantKnowledgeForRuntime` from an `internalQuery` to an `internalAction` so it can call the `embed` AI action before running the vector search query. + +## Risks / Trade-offs + +- **Risk: Embedding Latency.** Generating an embedding for the query adds a small latency (via OpenAI API call), but it will be much faster than loading all articles and string matching in memory. diff --git a/openspec/changes/archive/2026-03-12-use-convex-vector-search/proposal.md b/openspec/changes/archive/2026-03-12-use-convex-vector-search/proposal.md new file mode 100644 index 0000000..bb601a7 --- /dev/null +++ b/openspec/changes/archive/2026-03-12-use-convex-vector-search/proposal.md @@ -0,0 +1,18 @@ +## Why + +Currently, the AI agent retrieves knowledge by loading all articles in a workspace into memory and performing a basic string-matching relevance score (`calculateRelevanceScore`) on every query. While it doesn't pass the entire knowledge base into the prompt (it limits to the top 5 results), the process of loading and scoring every article in memory on every request is highly inefficient and causes significant latency, especially as the knowledge base grows. We need a proper Vector RAG solution to improve response times and relevance. + +## What Changes + +- Refactor `aiAgentActions.ts` and `aiAgent.ts` to utilize the existing `contentEmbeddings` table and Convex Vector Search instead of the legacy in-memory matching. +- Remove the legacy string-matching algorithms. + +## Capabilities + +### Modified Capabilities +- `ai-agent-knowledge-retrieval`: Changing the underlying retrieval mechanism from in-memory string matching to vector search. + +## Impact + +- **Convex Backend**: `packages/convex/convex/aiAgent.ts` and `packages/convex/convex/aiAgentActions.ts` will be updated to use Convex Vector Search. +- **Performance**: Significant reduction in AI agent response latency. diff --git a/openspec/changes/archive/2026-03-12-use-convex-vector-search/specs/ai-agent-knowledge-retrieval/spec.md b/openspec/changes/archive/2026-03-12-use-convex-vector-search/specs/ai-agent-knowledge-retrieval/spec.md new file mode 100644 index 0000000..5eade16 --- /dev/null +++ b/openspec/changes/archive/2026-03-12-use-convex-vector-search/specs/ai-agent-knowledge-retrieval/spec.md @@ -0,0 +1,14 @@ +## MODIFIED Requirements + +### Requirement: AI Agent retrieves knowledge context +The system SHALL retrieve relevant knowledge context to answer customer questions using vector similarity search. + +#### Scenario: AI Agent answers a question +- **WHEN** a customer asks a question +- **THEN** the AI Agent retrieves the top 5 most semantically similar knowledge items (articles/snippets) using vector search and uses them as context to generate an answer + +## REMOVED Requirements + +### Requirement: In-memory string matching for relevance score +**Reason**: Replaced by vector similarity search for improved performance and scalability. +**Migration**: Use the new vector search capability provided natively by Convex (`ctx.vectorSearch`). diff --git a/openspec/changes/archive/2026-03-12-use-convex-vector-search/tasks.md b/openspec/changes/archive/2026-03-12-use-convex-vector-search/tasks.md new file mode 100644 index 0000000..ced035c --- /dev/null +++ b/openspec/changes/archive/2026-03-12-use-convex-vector-search/tasks.md @@ -0,0 +1,10 @@ +## 1. Setup Vector Search for AI Agent + +- [x] 1.1 Update `getRelevantKnowledgeForRuntime` to be an internal action (or handle embedding in `aiAgentActions.ts`) since it needs to call OpenAI `embed`. +- [x] 1.2 Implement Convex vector search logic in `aiAgentActions.ts` or `aiAgent.ts` querying the `contentEmbeddings` table `by_embedding` index. +- [x] 1.3 Ensure the results are returned in the exact format expected by the `buildSystemPrompt` function. + +## 2. Cleanup Legacy Matching + +- [x] 2.1 Remove the `calculateRelevanceScore` function and associated `escapeRegex` from `packages/convex/convex/aiAgent.ts`. +- [x] 2.2 Remove the legacy `collectRelevantKnowledge` string matching loop logic. diff --git a/openspec/changes/harden-convex-function-references/.openspec.yaml b/openspec/changes/harden-convex-function-references/.openspec.yaml new file mode 100644 index 0000000..219e2a0 --- /dev/null +++ b/openspec/changes/harden-convex-function-references/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-13 diff --git a/openspec/changes/harden-convex-function-references/design.md b/openspec/changes/harden-convex-function-references/design.md new file mode 100644 index 0000000..6f91f6c --- /dev/null +++ b/openspec/changes/harden-convex-function-references/design.md @@ -0,0 +1,69 @@ +## Context + +The repo has already completed several earlier Convex ref-hardening slices, but the current backend inventory still contains residual `makeFunctionReference(...)` helpers that accept `name: string` and a small set of repeated `as unknown as` runner/ref casts. The remaining work is concentrated in RAG-critical files (`aiAgent.ts`, `aiAgentActions.ts`, `aiAgentActionsKnowledge.ts`, `embeddings.ts`), shared helper modules (`notifications/functionRefs.ts`, `push/functionRefs.ts`, `embeddings/functionRefs.ts`), and a set of domain-specific runtime files such as `http.ts`, `tickets.ts`, `emailChannel.ts`, and `series/scheduler.ts`. + +The active constraints are already documented in `docs/convex-type-safety-playbook.md`: generated refs remain the default, shallow runner helpers are the first `TS2589` escape hatch, fixed `makeFunctionReference("module:function")` refs are allowed only for confirmed hotspots, and new generic string-ref factories are not allowed. The repo also already finished two adjacent slices that this design must not reopen by default: sdk-core `getRelevantKnowledge` now routes through an action, and embedding batch/backfill flows already use concurrency helpers. + +## Goals / Non-Goals + +**Goals:** + +- Remove remaining generic `name: string` Convex ref helper patterns from the covered backend inventory. +- Keep any unavoidable `TS2589` workaround localized to fixed refs or named shallow runner helpers. +- Preserve existing runtime behavior while tightening the boundary shapes in `packages/convex`. +- Update guardrails and ownership records so the remaining accepted exceptions are explicit and minimal. + +**Non-Goals:** + +- Changing AI retrieval semantics, ranking, or public API behavior. +- Reopening sdk-core route migration work that is already verified. +- Reworking embedding throughput or batching behavior beyond boundary cleanup. +- Removing intentionally dynamic admin/test dispatch unless requirements explicitly change. + +## Decisions + +### Use explicit module-scope refs instead of reusable `name: string` factories + +Covered backend files will replace generic helpers such as `makeInternalQueryRef(name)` with explicit named refs per target. For shared clusters that already centralize refs, the module stays shared, but it exports only fixed refs and any required named runner helpers. + +- **Why:** this matches the playbook, makes each remaining `TS2589` workaround auditable, and prevents the helper surface from silently expanding. +- **Alternative considered:** keep the current helper factories and document them as accepted legacy. Rejected because it preserves the broad pattern the repo is trying to eliminate. + +### Keep shallow runner casts only where generated refs still force them + +The implementation will continue to use named `getShallowRunQuery`, `getShallowRunMutation`, `getShallowRunAction`, or `getShallowRunAfter` helpers only at confirmed hotspot boundaries. Inline runner casts or repeated double-cast patterns inside feature logic will not be expanded. + +- **Why:** shallow helpers are the approved first-line `TS2589` workaround and are smaller than broad object casts. +- **Alternative considered:** switch every covered call site back to generated refs and raw `ctx.run*` invocations immediately. Rejected because some existing hotspots were introduced specifically to avoid deep-instantiation regressions. + +### Treat RAG-critical files as the first implementation batch + +`aiAgent.ts`, `aiAgentActions.ts`, `aiAgentActionsKnowledge.ts`, and `embeddings.ts` form the highest-value cleanup cluster because they still contain the most visible residual helpers and are adjacent to already-finished AI route and embedding performance work. Shared AI/embedding ref modules may be introduced or expanded if that reduces duplication without reintroducing a generic selector helper. + +- **Why:** this cluster closes the remaining audit items around the active AI runtime path first and reduces duplicate helper patterns before wider backend cleanup. +- **Alternative considered:** start with lower-risk leaf files such as `widgetSessions.ts` and `workspaceMembers.ts`. Rejected because it leaves the largest remaining hotspot cluster unresolved. + +### Update guardrails with each file-cluster migration + +Guard tests will move in lockstep with each cleanup batch so the approved inventory reflects the new steady state. Positive assertions that currently depend on helper names or legacy hotspot lists will be rewritten to validate the new fixed-ref or exception shape. + +- **Why:** the repo already uses guard tests as the enforcement mechanism for these boundaries, so the inventory cannot lag behind implementation. +- **Alternative considered:** defer all guard updates to the end. Rejected because intermediate batches would either fail verification or leave the wrong exceptions approved. + +## Risks / Trade-offs + +- **[TS2589 resurfaces in previously stable files]** -> Migrate in small clusters and rerun `pnpm --filter @opencom/convex typecheck` after each cluster before broadening the slice. +- **[Boundary cleanup accidentally changes runtime targets or argument shapes]** -> Preserve existing ref strings and call signatures exactly, then cover the touched cluster with focused tests. +- **[Guard tests become too coupled to temporary implementation details]** -> Prefer assertions about approved patterns and explicit exception inventory rather than helper naming alone. +- **[Scope expands back into already-finished sdk-core or performance work]** -> Keep proposal/tasks explicit that route migration and embedding concurrency items are already satisfied unless new evidence appears. + +## Migration Plan + +1. Migrate the RAG-critical cluster and update `packages/convex/tests/runtimeTypeHardeningGuard.test.ts` to match the new boundary shape. +2. Migrate shared helper modules and adjacent domain-specific runtime files in verification-gated micro-batches. +3. Preserve explicitly approved dynamic exceptions, validate the change with focused Convex checks, and then continue into implementation from the generated task list. + +## Open Questions + +- Should `supportAttachmentFunctionRefs.ts` remain a tiny fixed-ref module or be folded into the caller once the residual cast is removed? +- Which shared helper clusters still truly require shallow runner helpers after fixed refs replace the remaining generic factories? diff --git a/openspec/changes/harden-convex-function-references/proposal.md b/openspec/changes/harden-convex-function-references/proposal.md new file mode 100644 index 0000000..d6fcf69 --- /dev/null +++ b/openspec/changes/harden-convex-function-references/proposal.md @@ -0,0 +1,30 @@ +## Why + +Residual Convex type-hardening work still leaves manual string-based function-ref helpers and repeated `as unknown as` escape hatches in backend hotspots, especially around AI knowledge retrieval and shared helper modules. A focused follow-up is needed now so the remaining exceptions match the repo playbook, stay explicitly guard-railed, and do not expand again. + +## What Changes + +- Replace remaining generic `name: string` `makeFunctionReference(...)` helpers in covered `packages/convex/convex/**` hotspots with fixed module-scope refs or narrowly scoped shared ref modules. +- Reduce remaining repeated `as unknown as` ref and runner casts in runtime-critical Convex files by keeping unavoidable `TS2589` workarounds behind named local helpers only. +- Close the residual backend inventory across RAG-critical files, shared helper modules, and domain-specific runtime boundaries, while preserving accepted dynamic exceptions such as `testAdmin.ts` unless requirements change. +- Update guardrails and change ownership records so the repo-wide hardening inventory reflects the remaining covered files and the new steady-state exception list. +- Keep already completed work, including sdk-core `getRelevantKnowledge` action routing and embedding batching concurrency, out of scope except where guard or proposal text must acknowledge it as already satisfied. + +## Capabilities + +### New Capabilities + +- None. + +### Modified Capabilities + +- `convex-function-ref-boundaries`: extend the covered backend inventory to the remaining shared helper modules and runtime hotspots that still rely on generic string-based ref helpers. +- `runtime-type-safety-hardening`: tighten the remaining runtime-critical Convex boundaries so ref and runner casts stay localized, named, and minimal. +- `cross-surface-convex-ref-boundary-hardening`: record this change as the owner for the remaining backend inventory and anti-regression guard updates, while keeping approved exceptions explicit. + +## Impact + +- Affected code: `packages/convex/convex/aiAgent.ts`, `packages/convex/convex/aiAgentActions.ts`, `packages/convex/convex/aiAgentActionsKnowledge.ts`, `packages/convex/convex/embeddings.ts`, `packages/convex/convex/notifications/functionRefs.ts`, `packages/convex/convex/push/functionRefs.ts`, `packages/convex/convex/embeddings/functionRefs.ts`, `packages/convex/convex/http.ts`, `packages/convex/convex/tickets.ts`, and the remaining runtime helper files identified by the repo audit. +- Guardrails: `packages/convex/tests/runtimeTypeHardeningGuard.test.ts`, plus any supporting spec deltas and inventory notes that define approved exceptions. +- Docs/process: `docs/convex-type-safety-playbook.md` may need minor follow-up if the accepted hotspot inventory changes. +- Runtime/API impact: no intended product behavior change; this is a hardening and maintainability cleanup of Convex call boundaries. diff --git a/openspec/changes/harden-convex-function-references/specs/convex-function-ref-boundaries/spec.md b/openspec/changes/harden-convex-function-references/specs/convex-function-ref-boundaries/spec.md new file mode 100644 index 0000000..0f24196 --- /dev/null +++ b/openspec/changes/harden-convex-function-references/specs/convex-function-ref-boundaries/spec.md @@ -0,0 +1,21 @@ +## ADDED Requirements + +### Requirement: Shared backend ref modules MUST export fixed refs instead of generic selector helpers + +The system MUST implement covered shared backend Convex ref modules with explicit module-scope function-ref constants and named runner helpers instead of reusable `name: string` selector helpers. + +#### Scenario: Shared helper module exposes reusable backend refs + +- **WHEN** a covered helper module under `packages/convex/convex/**` publishes refs for other runtime files +- **THEN** each supported target SHALL be exported as an explicit named ref constant +- **AND** the module SHALL NOT expose a reusable `makeInternalQueryRef(name)`, `makeInternalMutationRef(name)`, or `makeInternalActionRef(name)` style selector helper + +### Requirement: Accepted dynamic backend exceptions MUST stay explicitly inventoried + +The system MUST record any intentionally dynamic backend Convex dispatch as an explicit accepted exception with a narrow scope instead of leaving it as an ambiguous unfinished migration. + +#### Scenario: Backend file intentionally dispatches by caller-supplied function name + +- **WHEN** a backend path cannot use fixed typed refs because the function target is intentionally selected at runtime +- **THEN** the file SHALL be documented as an approved exception with its allowed scope +- **AND** anti-regression guardrails SHALL prevent the same dynamic pattern from spreading to additional covered runtime files diff --git a/openspec/changes/harden-convex-function-references/specs/cross-surface-convex-ref-boundary-hardening/spec.md b/openspec/changes/harden-convex-function-references/specs/cross-surface-convex-ref-boundary-hardening/spec.md new file mode 100644 index 0000000..f31768c --- /dev/null +++ b/openspec/changes/harden-convex-function-references/specs/cross-surface-convex-ref-boundary-hardening/spec.md @@ -0,0 +1,11 @@ +## ADDED Requirements + +### Requirement: Active residual-hardening proposals MUST distinguish unfinished work from completed slices + +The system MUST track only unfinished Convex ref-boundary cleanup in the active owning change and MUST explicitly mark already completed audit items as satisfied instead of reopening them as default implementation work. + +#### Scenario: Repo-wide audit seeds a follow-up cleanup change + +- **WHEN** the team converts a repo-wide hardening audit into an owning follow-up change +- **THEN** the proposal and task list SHALL enumerate only the remaining unfinished file clusters and explicit accepted exceptions +- **AND** already completed slices, such as verified sdk-core route migrations or completed embedding concurrency refactors, SHALL remain out of scope unless new evidence shows a remaining gap diff --git a/openspec/changes/harden-convex-function-references/specs/runtime-type-safety-hardening/spec.md b/openspec/changes/harden-convex-function-references/specs/runtime-type-safety-hardening/spec.md new file mode 100644 index 0000000..1af397d --- /dev/null +++ b/openspec/changes/harden-convex-function-references/specs/runtime-type-safety-hardening/spec.md @@ -0,0 +1,21 @@ +## ADDED Requirements + +### Requirement: RAG-critical runtime boundaries MUST minimize residual ref and runner casts + +The system MUST keep RAG-critical AI retrieval and embedding runtime files on fixed typed refs or shared typed ref modules, with any remaining runner cast localized to one named helper per boundary. + +#### Scenario: RAG runtime file invokes another Convex function + +- **WHEN** a covered AI retrieval or embedding runtime file calls an internal or public Convex query, mutation, or action +- **THEN** the file SHALL use a fixed typed ref or a shared typed ref module for the target function +- **AND** any remaining `ctx.runQuery`, `ctx.runMutation`, or `ctx.runAction` workaround SHALL live in a named helper rather than repeated inline double casts + +### Requirement: Runtime hardening guardrails MUST track covered backend hotspot inventory changes + +The system MUST update runtime hardening guardrails in the same change that adds, removes, or narrows a covered backend hotspot or accepted exception. + +#### Scenario: Backend hotspot inventory changes + +- **WHEN** a hardening change moves a file into or out of the covered backend hotspot inventory +- **THEN** the runtime guard test SHALL be updated in the same change +- **AND** the guard SHALL distinguish approved exceptions from regressions in uncovered files diff --git a/openspec/changes/harden-convex-function-references/tasks.md b/openspec/changes/harden-convex-function-references/tasks.md new file mode 100644 index 0000000..8e09258 --- /dev/null +++ b/openspec/changes/harden-convex-function-references/tasks.md @@ -0,0 +1,30 @@ +## 1. Scope Locks And Inventory + +- [x] 1.1 Keep the change scoped to unfinished backend hardening work and preserve the existing out-of-scope decisions for sdk-core `getRelevantKnowledge` action routing, embedding concurrency, and the accepted `testAdmin.ts` dynamic exception. +- [x] 1.2 Update the active hardening inventory so the remaining covered file clusters and approved exceptions are explicit before code cleanup expands. + +## 2. RAG-Critical Backend Boundaries + +- [x] 2.1 Replace the remaining generic query-ref helper and narrow the residual runner cast in `packages/convex/convex/aiAgentActionsKnowledge.ts`. +- [x] 2.2 Replace generic ref helpers in `packages/convex/convex/aiAgentActions.ts` and `packages/convex/convex/aiAgent.ts` with fixed named refs or a shared typed ref module, keeping only hotspot-local shallow runners if `TS2589` still requires them. +- [x] 2.3 Replace generic ref helpers in `packages/convex/convex/embeddings.ts` and `packages/convex/convex/embeddings/functionRefs.ts` with fixed named refs while preserving the already-finished batching/backfill concurrency behavior. +- [x] 2.4 Run `pnpm --filter @opencom/convex typecheck` and the focused guard/test coverage needed for the AI retrieval and embedding hardening batch. + +## 3. Shared Ref Helper Modules + +- [x] 3.1 Replace generic selector helpers in `packages/convex/convex/notifications/functionRefs.ts` with explicit exported refs and keep any shallow runner helpers named and minimal. +- [x] 3.2 Replace generic selector helpers in `packages/convex/convex/push/functionRefs.ts` with explicit exported refs and keep any shallow runner helpers named and minimal. +- [x] 3.3 Remove residual manual cast-only helper patterns in `packages/convex/convex/supportAttachmentFunctionRefs.ts` and align any affected callers with the fixed-ref shape. + +## 4. Domain-Specific Runtime Micro-Batches + +- [x] 4.1 Harden the messaging and ingestion files `packages/convex/convex/http.ts`, `packages/convex/convex/emailChannel.ts`, `packages/convex/convex/outboundMessages.ts`, and `packages/convex/convex/snippets.ts`. +- [x] 4.2 Harden the workflow and scheduling files `packages/convex/convex/events.ts`, `packages/convex/convex/series/scheduler.ts`, `packages/convex/convex/pushCampaigns.ts`, and `packages/convex/convex/tickets.ts`. +- [x] 4.3 Harden the remaining mutation/helper files `packages/convex/convex/carousels/triggering.ts`, `packages/convex/convex/widgetSessions.ts`, `packages/convex/convex/workspaceMembers.ts`, `packages/convex/convex/visitors/mutations.ts`, and `packages/convex/convex/testing/helpers/notifications.ts`. +- [x] 4.4 Re-run `pnpm --filter @opencom/convex typecheck` and focused tests after each micro-batch before moving to the next cluster. + +## 5. Guardrails And Final Verification + +- [x] 5.1 Update `packages/convex/tests/runtimeTypeHardeningGuard.test.ts` to match the new fixed-ref inventory and keep accepted exceptions explicit. +- [x] 5.2 Update `packages/sdk-core/tests/refHardeningGuard.test.ts`, `docs/convex-type-safety-playbook.md`, and `AGENTS.md` only if the approved hotspot inventory or exception guidance changes during implementation. +- [x] 5.3 Run `openspec validate harden-convex-function-references --strict --no-interactive` and finish the change by checking off completed tasks. diff --git a/openspec/specs/ai-agent-knowledge-retrieval/spec.md b/openspec/specs/ai-agent-knowledge-retrieval/spec.md new file mode 100644 index 0000000..09a8c6a --- /dev/null +++ b/openspec/specs/ai-agent-knowledge-retrieval/spec.md @@ -0,0 +1,13 @@ +# AI Agent Knowledge Retrieval + +## Purpose +Defines how the AI Agent searches for and retrieves relevant knowledge context from the workspace to answer customer questions. + +## Requirements + +### Requirement: AI Agent retrieves knowledge context +The system SHALL retrieve relevant knowledge context to answer customer questions using vector similarity search. + +#### Scenario: AI Agent answers a question +- **WHEN** a customer asks a question +- **THEN** the AI Agent retrieves the top 5 most semantically similar knowledge items (articles/snippets) using vector search and uses them as context to generate an answer diff --git a/packages/convex/convex/_generated/api.d.ts b/packages/convex/convex/_generated/api.d.ts index ee164bd..7c39510 100644 --- a/packages/convex/convex/_generated/api.d.ts +++ b/packages/convex/convex/_generated/api.d.ts @@ -10,6 +10,7 @@ import type * as aiAgent from "../aiAgent.js"; import type * as aiAgentActions from "../aiAgentActions.js"; +import type * as aiAgentActionsKnowledge from "../aiAgentActionsKnowledge.js"; import type * as articles from "../articles.js"; import type * as assignmentRules from "../assignmentRules.js"; import type * as audienceRules from "../audienceRules.js"; @@ -195,6 +196,7 @@ import type { declare const fullApi: ApiFromModules<{ aiAgent: typeof aiAgent; aiAgentActions: typeof aiAgentActions; + aiAgentActionsKnowledge: typeof aiAgentActionsKnowledge; articles: typeof articles; assignmentRules: typeof assignmentRules; audienceRules: typeof audienceRules; diff --git a/packages/convex/convex/aiAgent.ts b/packages/convex/convex/aiAgent.ts index a58d049..3b95393 100644 --- a/packages/convex/convex/aiAgent.ts +++ b/packages/convex/convex/aiAgent.ts @@ -1,4 +1,5 @@ import { v } from "convex/values"; +import { makeFunctionReference, type FunctionReference } from "convex/server"; import { mutation, query, @@ -10,13 +11,8 @@ import { import { Doc, Id } from "./_generated/dataModel"; import { getAuthenticatedUserFromSession } from "./auth"; import { getWorkspaceMembership, requirePermission } from "./permissions"; -import { authMutation, authQuery } from "./lib/authWrappers"; +import { authAction, authMutation, authQuery } from "./lib/authWrappers"; import { getShallowRunAfter, routeEventRef } from "./notifications/functionRefs"; -import { - isInternalArticle, - isPublicArticle, - listUnifiedArticlesWithLegacyFallback, -} from "./lib/unifiedArticles"; import { resolveVisitorFromSession } from "./widgetSessions"; const knowledgeSourceValidator = v.union( @@ -27,7 +23,7 @@ const knowledgeSourceValidator = v.union( type KnowledgeSource = "articles" | "internalArticles" | "snippets"; -type KnowledgeResult = { +type RuntimeKnowledgeResult = { id: string; type: "article" | "internalArticle" | "snippet"; title: string; @@ -35,6 +31,36 @@ type KnowledgeResult = { relevanceScore: number; }; +type GetRelevantKnowledgeForRuntimeActionArgs = { + workspaceId: Id<"workspaces">; + query: string; + knowledgeSources?: KnowledgeSource[]; + limit?: number; +}; + +type InternalActionRef, Return = unknown> = FunctionReference< + "action", + "internal", + Args, + Return +>; + +function getShallowRunAction(ctx: { runAction: unknown }) { + return ctx.runAction as , Return>( + actionRef: InternalActionRef, + actionArgs: Args + ) => Promise; +} + +const GET_RELEVANT_KNOWLEDGE_FOR_RUNTIME_ACTION_REF = makeFunctionReference< + "action", + GetRelevantKnowledgeForRuntimeActionArgs, + RuntimeKnowledgeResult[] +>("aiAgentActionsKnowledge:getRelevantKnowledgeForRuntimeAction") as unknown as InternalActionRef< + GetRelevantKnowledgeForRuntimeActionArgs, + RuntimeKnowledgeResult[] +>; + const aiResponseSourceValidator = v.object({ type: v.string(), id: v.string(), @@ -103,109 +129,6 @@ async function getWorkspaceAISettings( .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId)) .first(); } - -const escapeRegex = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - -const calculateRelevanceScore = (title: string, content: string, term: string): number => { - let score = 0; - const lowerTitle = title.toLowerCase(); - const lowerContent = content.toLowerCase(); - - if (lowerTitle.includes(term)) { - score += 10; - if (lowerTitle.startsWith(term)) score += 5; - if (lowerTitle === term) score += 10; - } - - const contentMatches = (lowerContent.match(new RegExp(escapeRegex(term), "g")) || []).length; - score += Math.min(contentMatches, 5); - - const words = term.split(/\s+/).filter((w) => w.length > 2); - for (const word of words) { - if (lowerTitle.includes(word)) score += 2; - if (lowerContent.includes(word)) score += 1; - } - - return score; -}; - -async function collectRelevantKnowledge( - ctx: QueryCtx, - args: { - workspaceId: Id<"workspaces">; - query: string; - knowledgeSources?: KnowledgeSource[]; - limit?: number; - } -): Promise { - const searchTerm = args.query.trim().toLowerCase(); - if (!searchTerm) { - return []; - } - - const limit = args.limit ?? 5; - const sources = args.knowledgeSources ?? ["articles", "internalArticles", "snippets"]; - const results: KnowledgeResult[] = []; - const articles = await listUnifiedArticlesWithLegacyFallback(ctx.db, args.workspaceId); - - if (sources.includes("articles")) { - for (const article of articles) { - if (!isPublicArticle(article) || article.status !== "published") continue; - - const score = calculateRelevanceScore(article.title, article.content, searchTerm); - if (score > 0) { - results.push({ - id: article._id, - type: "article", - title: article.title, - content: article.content, - relevanceScore: score, - }); - } - } - } - - if (sources.includes("internalArticles")) { - for (const article of articles) { - if (!isInternalArticle(article) || article.status !== "published") continue; - - const score = calculateRelevanceScore(article.title, article.content, searchTerm); - if (score > 0) { - results.push({ - id: article._id, - type: "internalArticle", - title: article.title, - content: article.content, - relevanceScore: score, - }); - } - } - } - - if (sources.includes("snippets")) { - const snippets = await ctx.db - .query("snippets") - .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId)) - .collect(); - - for (const snippet of snippets) { - const score = calculateRelevanceScore(snippet.name, snippet.content, searchTerm); - if (score > 0) { - results.push({ - id: snippet._id, - type: "snippet", - title: snippet.name, - content: snippet.content, - relevanceScore: score, - }); - } - } - } - - results.sort((a, b) => b.relevanceScore - a.relevanceScore); - return results.slice(0, limit); -} - async function requireConversationAccess( ctx: QueryCtx | MutationCtx, args: { @@ -429,7 +352,7 @@ export const clearRuntimeDiagnostic = internalMutation({ }); // Get relevant knowledge for a query -export const getRelevantKnowledge = authQuery({ +export const getRelevantKnowledge = authAction({ args: { workspaceId: v.id("workspaces"), query: v.string(), @@ -437,12 +360,17 @@ export const getRelevantKnowledge = authQuery({ limit: v.optional(v.number()), }, permission: "articles.read", - handler: async (ctx, args) => { - return await collectRelevantKnowledge(ctx, { + handler: async (ctx, args): Promise => { + if (!args.query.trim()) { + return []; + } + const runAction = getShallowRunAction(ctx); + const limit = Math.max(1, Math.min(args.limit ?? 5, 20)); + return await runAction(GET_RELEVANT_KNOWLEDGE_FOR_RUNTIME_ACTION_REF, { workspaceId: args.workspaceId, query: args.query, - knowledgeSources: args.knowledgeSources as KnowledgeSource[] | undefined, - limit: args.limit, + knowledgeSources: args.knowledgeSources, + limit, }); }, }); @@ -454,13 +382,8 @@ export const getRelevantKnowledgeForRuntime = internalQuery({ knowledgeSources: v.optional(v.array(knowledgeSourceValidator)), limit: v.optional(v.number()), }, - handler: async (ctx, args) => { - return await collectRelevantKnowledge(ctx, { - workspaceId: args.workspaceId, - query: args.query, - knowledgeSources: args.knowledgeSources as KnowledgeSource[] | undefined, - limit: args.limit, - }); + handler: async () => { + return []; }, }); @@ -474,9 +397,7 @@ export const storeResponse = mutation({ query: v.string(), response: v.string(), generatedCandidateResponse: v.optional(v.string()), - generatedCandidateSources: v.optional( - v.array(aiResponseSourceValidator) - ), + generatedCandidateSources: v.optional(v.array(aiResponseSourceValidator)), generatedCandidateConfidence: v.optional(v.number()), sources: v.array(aiResponseSourceValidator), confidence: v.number(), diff --git a/packages/convex/convex/aiAgentActions.ts b/packages/convex/convex/aiAgentActions.ts index 69faed2..2830050 100644 --- a/packages/convex/convex/aiAgentActions.ts +++ b/packages/convex/convex/aiAgentActions.ts @@ -15,7 +15,7 @@ type AIConfigurationDiagnostic = { }; type ConvexRef< - Type extends "query" | "mutation", + Type extends "query" | "mutation" | "action", Visibility extends "internal" | "public", Args extends Record, Return = unknown, @@ -41,6 +41,7 @@ type RuntimeSettings = { enabled: boolean; model: string; knowledgeSources?: KnowledgeSource[]; + embeddingModel?: string; personality?: string | null; confidenceThreshold?: number; }; @@ -53,6 +54,10 @@ type RuntimeDiagnosticArgs = { model?: string; }; +type WorkspaceIdArgs = { + workspaceId: Id<"workspaces">; +}; + type RelevantKnowledgeResult = { type: string; id: string; @@ -61,6 +66,14 @@ type RelevantKnowledgeResult = { relevanceScore: number; }; +type GetRelevantKnowledgeForRuntimeActionArgs = { + workspaceId: Id<"workspaces">; + query: string; + knowledgeSources?: KnowledgeSource[]; + limit?: number; + embeddingModel?: string; +}; + type HandoffToHumanArgs = { conversationId: Id<"conversations">; visitorId?: Id<"visitors">; @@ -128,12 +141,12 @@ const AUTHORIZE_CONVERSATION_ACCESS_REF = makeFunctionReference< const GET_RUNTIME_SETTINGS_FOR_WORKSPACE_REF = makeFunctionReference< "query", - { workspaceId: Id<"workspaces"> }, + WorkspaceIdArgs, RuntimeSettings >("aiAgent:getRuntimeSettingsForWorkspace") as unknown as ConvexRef< "query", "internal", - { workspaceId: Id<"workspaces"> }, + WorkspaceIdArgs, RuntimeSettings >; @@ -144,38 +157,29 @@ const RECORD_RUNTIME_DIAGNOSTIC_REF = makeFunctionReference< >("aiAgent:recordRuntimeDiagnostic") as unknown as ConvexRef< "mutation", "internal", - RuntimeDiagnosticArgs + RuntimeDiagnosticArgs, + unknown >; const CLEAR_RUNTIME_DIAGNOSTIC_REF = makeFunctionReference< "mutation", - { workspaceId: Id<"workspaces"> }, + WorkspaceIdArgs, Id<"aiAgentSettings"> | null >("aiAgent:clearRuntimeDiagnostic") as unknown as ConvexRef< "mutation", "internal", - { workspaceId: Id<"workspaces"> }, + WorkspaceIdArgs, Id<"aiAgentSettings"> | null >; -const GET_RELEVANT_KNOWLEDGE_FOR_RUNTIME_REF = makeFunctionReference< - "query", - { - workspaceId: Id<"workspaces">; - query: string; - knowledgeSources?: KnowledgeSource[]; - limit?: number; - }, +const GET_RELEVANT_KNOWLEDGE_FOR_RUNTIME_ACTION_REF = makeFunctionReference< + "action", + GetRelevantKnowledgeForRuntimeActionArgs, RelevantKnowledgeResult[] ->("aiAgent:getRelevantKnowledgeForRuntime") as unknown as ConvexRef< - "query", +>("aiAgentActionsKnowledge:getRelevantKnowledgeForRuntimeAction") as unknown as ConvexRef< + "action", "internal", - { - workspaceId: Id<"workspaces">; - query: string; - knowledgeSources?: KnowledgeSource[]; - limit?: number; - }, + GetRelevantKnowledgeForRuntimeActionArgs, RelevantKnowledgeResult[] >; @@ -206,7 +210,7 @@ const INTERNAL_SEND_BOT_MESSAGE_REF = makeFunctionReference< >; function getShallowRunQuery(ctx: { runQuery: unknown }) { - return ctx.runQuery as unknown as < + return ctx.runQuery as < Visibility extends "internal" | "public", Args extends Record, Return, @@ -217,7 +221,7 @@ function getShallowRunQuery(ctx: { runQuery: unknown }) { } function getShallowRunMutation(ctx: { runMutation: unknown }) { - return ctx.runMutation as unknown as < + return ctx.runMutation as < Visibility extends "internal" | "public", Args extends Record, Return = unknown, @@ -227,6 +231,17 @@ function getShallowRunMutation(ctx: { runMutation: unknown }) { ) => Promise; } +function getShallowRunAction(ctx: { runAction: unknown }) { + return ctx.runAction as < + Visibility extends "internal" | "public", + Args extends Record, + Return, + >( + actionRef: ConvexRef<"action", Visibility, Args, Return>, + actionArgs: Args + ) => Promise; +} + const SUPPORTED_AI_PROVIDERS = new Set(["openai"]); const GENERATION_FAILURE_FALLBACK_RESPONSE = "I'm having trouble processing your request right now. Let me connect you with a human agent."; @@ -453,15 +468,12 @@ export const generateResponse = action({ ) ), }, - handler: async ( - ctx, - args - ): Promise => { + handler: async (ctx, args): Promise => { const startTime = Date.now(); const runQuery = getShallowRunQuery(ctx); const runMutation = getShallowRunMutation(ctx); - + const runAction = getShallowRunAction(ctx); const access = await runQuery(AUTHORIZE_CONVERSATION_ACCESS_REF, { conversationId: args.conversationId, visitorId: args.visitorId, @@ -563,12 +575,21 @@ export const generateResponse = action({ }); // Get relevant knowledge - const knowledgeResults = await runQuery(GET_RELEVANT_KNOWLEDGE_FOR_RUNTIME_REF, { - workspaceId: args.workspaceId, - query: args.query, - knowledgeSources: settings.knowledgeSources, - limit: 5, - }); + let knowledgeResults: RelevantKnowledgeResult[] = []; + try { + knowledgeResults = await runAction(GET_RELEVANT_KNOWLEDGE_FOR_RUNTIME_ACTION_REF, { + workspaceId: args.workspaceId, + query: args.query, + knowledgeSources: settings.knowledgeSources, + limit: 5, + embeddingModel: settings.embeddingModel, + }); + } catch (retrievalError) { + console.error( + "Knowledge retrieval failed; continuing without knowledge context:", + retrievalError + ); + } // Build knowledge context for prompt const knowledgeContext = knowledgeResults diff --git a/packages/convex/convex/aiAgentActionsKnowledge.ts b/packages/convex/convex/aiAgentActionsKnowledge.ts new file mode 100644 index 0000000..67debcb --- /dev/null +++ b/packages/convex/convex/aiAgentActionsKnowledge.ts @@ -0,0 +1,159 @@ +import { internalAction } from "./_generated/server"; +import { v } from "convex/values"; +import { embed } from "ai"; +import { createAIClient } from "./lib/aiGateway"; +import { makeFunctionReference, type FunctionReference } from "convex/server"; +import type { Id } from "./_generated/dataModel"; + +const DEFAULT_EMBEDDING_MODEL = "text-embedding-3-small"; +const RUNTIME_KNOWLEDGE_DEFAULT_LIMIT = 5; +const RUNTIME_KNOWLEDGE_MAX_LIMIT = 20; + +export const knowledgeSourceValidator = v.union( + v.literal("articles"), + v.literal("internalArticles"), + v.literal("snippets") +); + +type SuggestionContentType = "article" | "internalArticle" | "snippet"; +type SuggestionContentRecord = { content: string; title: string } | null; +type EmbeddingRecord = { + contentType: SuggestionContentType; + contentId: string; +} | null; +type RuntimeKnowledgeResult = { + id: string; + type: SuggestionContentType; + title: string; + content: string; + relevanceScore: number; +}; +type InternalQueryRef, Return = unknown> = FunctionReference< + "query", + "internal" | "public", + Args, + Return +>; + +type GetContentByIdArgs = { + contentType: SuggestionContentType; + contentId: string; +}; + +type GetEmbeddingByIdArgs = { + id: Id<"contentEmbeddings">; +}; + +function getShallowRunQuery(ctx: { runQuery: unknown }) { + return ctx.runQuery as , Return>( + queryRef: InternalQueryRef, + queryArgs: Args + ) => Promise; +} + +const GET_CONTENT_BY_ID_REF = makeFunctionReference< + "query", + GetContentByIdArgs, + SuggestionContentRecord +>("suggestions:getContentById") as InternalQueryRef; + +const GET_EMBEDDING_BY_ID_REF = makeFunctionReference< + "query", + GetEmbeddingByIdArgs, + EmbeddingRecord +>("suggestions:getEmbeddingById") as InternalQueryRef; + +export const getRelevantKnowledgeForRuntimeAction = internalAction({ + args: { + workspaceId: v.id("workspaces"), + query: v.string(), + knowledgeSources: v.optional(v.array(knowledgeSourceValidator)), + limit: v.optional(v.number()), + embeddingModel: v.optional(v.string()), + }, + handler: async (ctx, args): Promise => { + const aiClient = createAIClient(); + const runQuery = getShallowRunQuery(ctx); + + const embeddingModel = args.embeddingModel?.trim() || DEFAULT_EMBEDDING_MODEL; + + // 1. Embed the query + const { embedding } = await embed({ + model: aiClient.embedding(embeddingModel), + value: args.query, + }); + + const limit = Math.max( + 1, + Math.min(args.limit ?? RUNTIME_KNOWLEDGE_DEFAULT_LIMIT, RUNTIME_KNOWLEDGE_MAX_LIMIT) + ); + + // 2. Vector search + const results = await ctx.vectorSearch("contentEmbeddings", "by_embedding", { + vector: embedding, + limit: limit * 8, + filter: (q) => q.eq("workspaceId", args.workspaceId), + }); + + // 3. Fetch full embedding docs to get contentType and filter them + const docs = await Promise.all( + results.map(async (r) => { + const doc = await runQuery(GET_EMBEDDING_BY_ID_REF, { id: r._id }); + if (!doc) return null; + return { ...doc, _score: r._score }; + }) + ); + + let filteredDocs = docs.filter((d): d is NonNullable<(typeof docs)[number]> => d !== null); + + if (args.knowledgeSources && args.knowledgeSources.length > 0) { + const sourceSet = new Set( + args.knowledgeSources.map((s) => + s === "articles" ? "article" : s === "internalArticles" ? "internalArticle" : "snippet" + ) + ); + filteredDocs = filteredDocs.filter((d) => sourceSet.has(d.contentType)); + } + + const dedupedDocs: typeof filteredDocs = []; + const seen = new Set(); + for (const doc of filteredDocs) { + const key = `${doc.contentType}:${doc.contentId}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + dedupedDocs.push(doc); + if (dedupedDocs.length >= limit) { + break; + } + } + + // 4. Fetch the actual content + const enrichedResults = await Promise.all( + dedupedDocs.map(async (doc) => { + const content = await runQuery(GET_CONTENT_BY_ID_REF, { + contentType: doc.contentType, + contentId: doc.contentId, + }); + + if (!content) return null; + + return { + id: doc.contentId, + type: + doc.contentType === "article" + ? "article" + : doc.contentType === "internalArticle" + ? "internalArticle" + : "snippet", + title: content.title, + content: content.content, + relevanceScore: doc._score * 100, // Roughly map vector search score to legacy 0-100 scale + }; + }) + ); + + return enrichedResults.filter((r): r is RuntimeKnowledgeResult => r !== null); + }, +}); diff --git a/packages/convex/convex/carousels/triggering.ts b/packages/convex/convex/carousels/triggering.ts index f417166..d7b17fa 100644 --- a/packages/convex/convex/carousels/triggering.ts +++ b/packages/convex/convex/carousels/triggering.ts @@ -50,7 +50,7 @@ const GET_ELIGIBLE_VISITORS_WITH_PUSH_TOKENS_REF = makeFunctionReference< >; function getShallowRunQuery(ctx: { runQuery: unknown }) { - return ctx.runQuery as unknown as < + return ctx.runQuery as < Visibility extends "internal" | "public", Args extends Record, Return, @@ -61,7 +61,7 @@ function getShallowRunQuery(ctx: { runQuery: unknown }) { } function getShallowRunMutation(ctx: { runMutation: unknown }) { - return ctx.runMutation as unknown as < + return ctx.runMutation as < Visibility extends "internal" | "public", Args extends Record, Return = unknown, diff --git a/packages/convex/convex/emailChannel.ts b/packages/convex/convex/emailChannel.ts index d33a1db..b030eb8 100644 --- a/packages/convex/convex/emailChannel.ts +++ b/packages/convex/convex/emailChannel.ts @@ -23,11 +23,17 @@ const WEBHOOK_INTERNAL_SECRET = process.env.EMAIL_WEBHOOK_INTERNAL_SECRET ?? process.env.RESEND_WEBHOOK_SECRET ?? ""; const ENFORCE_WEBHOOK_INTERNAL_SECRET = process.env.ENFORCE_WEBHOOK_SIGNATURES !== "false"; -type InternalMutationRef, Return = unknown> = - FunctionReference<"mutation", "internal", Args, Return>; +type InternalMutationRef< + Args extends Record, + Return = unknown, +> = FunctionReference<"mutation", "internal", Args, Return>; -type InternalActionRef, Return = unknown> = - FunctionReference<"action", "internal", Args, Return>; +type InternalActionRef, Return = unknown> = FunctionReference< + "action", + "internal", + Args, + Return +>; type SendEmailViaProviderArgs = { messageId: Id<"messages">; diff --git a/packages/convex/convex/embeddings.ts b/packages/convex/convex/embeddings.ts index 48662c1..fc812af 100644 --- a/packages/convex/convex/embeddings.ts +++ b/packages/convex/convex/embeddings.ts @@ -2,7 +2,7 @@ import { v } from "convex/values"; import { makeFunctionReference, type FunctionReference } from "convex/server"; import { internalAction, internalMutation, internalQuery } from "./_generated/server"; import { Doc, Id } from "./_generated/dataModel"; -import { embed, embedMany } from "ai"; +import { embedMany } from "ai"; import { authAction } from "./lib/authWrappers"; import { createAIClient } from "./lib/aiGateway"; import { @@ -12,6 +12,8 @@ import { } from "./lib/unifiedArticles"; const DEFAULT_EMBEDDING_MODEL = "text-embedding-3-small"; +const EMBEDDING_GENERATE_CONCURRENCY = 4; +const EMBEDDING_BACKFILL_BATCH_CONCURRENCY = 2; type InternalQueryRef, Return = unknown> = FunctionReference< "query", @@ -20,28 +22,34 @@ type InternalQueryRef, Return = unknown> = Return >; -type InternalMutationRef, Return = unknown> = - FunctionReference<"mutation", "internal", Args, Return>; +type InternalMutationRef< + Args extends Record, + Return = unknown, +> = FunctionReference<"mutation", "internal", Args, Return>; -type InternalActionRef, Return = unknown> = - FunctionReference<"action", "internal", Args, Return>; +type InternalActionRef, Return = unknown> = FunctionReference< + "action", + "internal", + Args, + Return +>; function getShallowRunQuery(ctx: { runQuery: unknown }) { - return ctx.runQuery as unknown as , Return>( + return ctx.runQuery as , Return>( queryRef: InternalQueryRef, queryArgs: Args ) => Promise; } function getShallowRunMutation(ctx: { runMutation: unknown }) { - return ctx.runMutation as unknown as , Return>( + return ctx.runMutation as , Return>( mutationRef: InternalMutationRef, mutationArgs: Args ) => Promise; } function getShallowRunAction(ctx: { runAction: unknown }) { - return ctx.runAction as unknown as , Return>( + return ctx.runAction as , Return>( actionRef: InternalActionRef, actionArgs: Args ) => Promise; @@ -55,12 +63,6 @@ type BatchItem = { content: string; }; -type BatchItemWithHash = BatchItem & { - textToEmbed: string; - textHash: string; - snippet: string; -}; - type GenerateEmbeddingArgs = { workspaceId: Id<"workspaces">; contentType: BatchItem["contentType"]; @@ -100,14 +102,6 @@ type InsertEmbeddingArgs = { snippet: string; }; -type UpdateEmbeddingArgs = { - id: Id<"contentEmbeddings">; - embedding: number[]; - textHash: string; - title: string; - snippet: string; -}; - type PermissionForActionArgs = { userId: Id<"users">; workspaceId: Id<"workspaces">; @@ -130,19 +124,19 @@ type ListedSnippet = { content: string; }; -const GET_BY_CONTENT_REF = makeFunctionReference< +type RemoveEmbeddingsByIdsArgs = { + ids: Id<"contentEmbeddings">[]; +}; + +const LIST_BY_CONTENT_REF = makeFunctionReference< "query", GetByContentArgs, - Doc<"contentEmbeddings"> | null ->("embeddings:getByContent") as unknown as InternalQueryRef< + Doc<"contentEmbeddings">[] +>("embeddings:listByContent") as unknown as InternalQueryRef< GetByContentArgs, - Doc<"contentEmbeddings"> | null + Doc<"contentEmbeddings">[] >; -const UPDATE_EMBEDDING_REF = makeFunctionReference<"mutation", UpdateEmbeddingArgs, unknown>( - "embeddings:update" -) as unknown as InternalMutationRef; - const INSERT_EMBEDDING_REF = makeFunctionReference< "mutation", InsertEmbeddingArgs, @@ -165,7 +159,10 @@ const REQUIRE_PERMISSION_FOR_ACTION_REF = makeFunctionReference< "query", PermissionForActionArgs, unknown ->("permissions:requirePermissionForAction") as unknown as InternalQueryRef; +>("permissions:requirePermissionForAction") as unknown as InternalQueryRef< + PermissionForActionArgs, + unknown +>; const LIST_ARTICLES_REF = makeFunctionReference<"query", WorkspaceArgs, ListedArticle[]>( "embeddings:listArticles" @@ -179,6 +176,12 @@ const LIST_SNIPPETS_REF = makeFunctionReference<"query", WorkspaceArgs, ListedSn "embeddings:listSnippets" ) as unknown as InternalQueryRef; +const REMOVE_EMBEDDINGS_BY_IDS_REF = makeFunctionReference< + "mutation", + RemoveEmbeddingsByIdsArgs, + unknown +>("embeddings:removeByIds") as unknown as InternalMutationRef; + const GENERATE_BATCH_INTERNAL_REF = makeFunctionReference< "action", GenerateBatchArgs, @@ -196,6 +199,59 @@ async function createTextHash(text: string): Promise { .join(""); } +const EMBEDDING_CHUNK_MAX_CHARS = 3000; +const EMBEDDING_CHUNK_OVERLAP_CHARS = 300; + +function splitContentIntoChunks( + content: string, + maxChars: number = EMBEDDING_CHUNK_MAX_CHARS, + overlapChars: number = EMBEDDING_CHUNK_OVERLAP_CHARS +): string[] { + const normalized = content.replace(/\r\n/g, "\n").trim(); + if (normalized.length === 0) { + return [""]; + } + if (normalized.length <= maxChars) { + return [normalized]; + } + + const chunks: string[] = []; + let start = 0; + + while (start < normalized.length) { + let end = Math.min(start + maxChars, normalized.length); + + if (end < normalized.length) { + const window = normalized.slice(start, end); + const minBreakIndex = Math.floor(maxChars * 0.6); + const paragraphBreak = window.lastIndexOf("\n\n"); + const lineBreak = window.lastIndexOf("\n"); + const sentenceBreak = Math.max( + window.lastIndexOf(". "), + window.lastIndexOf("? "), + window.lastIndexOf("! ") + ); + const bestBreak = [paragraphBreak, lineBreak, sentenceBreak].find( + (index) => index >= minBreakIndex + ); + if (bestBreak !== undefined) { + end = start + bestBreak + 1; + } + } + + const chunk = normalized.slice(start, end).trim(); + if (chunk.length > 0) { + chunks.push(chunk); + } + if (end >= normalized.length) { + break; + } + start = Math.max(end - overlapChars, start + 1); + } + + return chunks.length > 0 ? chunks : [normalized]; +} + function createSnippet(content: string, maxLength: number = 200): string { const cleaned = content .replace(/<[^>]*>/g, "") @@ -205,6 +261,32 @@ function createSnippet(content: string, maxLength: number = 200): string { return cleaned.slice(0, maxLength - 3) + "..."; } +async function runWithConcurrency( + items: T[], + concurrency: number, + worker: (item: T, index: number) => Promise +): Promise { + if (items.length === 0) { + return []; + } + + const maxWorkers = Math.max(1, Math.min(concurrency, items.length)); + const results = new Array(items.length); + let nextIndex = 0; + + await Promise.all( + Array.from({ length: maxWorkers }, async () => { + let currentIndex = nextIndex++; + while (currentIndex < items.length) { + results[currentIndex] = await worker(items[currentIndex], currentIndex); + currentIndex = nextIndex++; + } + }) + ); + + return results; +} + export const generateInternal = internalAction({ args: { workspaceId: v.id("workspaces"), @@ -215,51 +297,60 @@ export const generateInternal = internalAction({ model: v.optional(v.string()), }, handler: async (ctx, args): Promise => { - const textToEmbed = `${args.title}\n\n${args.content}`; - const textHash = await createTextHash(textToEmbed); + const fullText = `${args.title}\n\n${args.content}`; + const textHash = await createTextHash(fullText); + const chunks = splitContentIntoChunks(args.content); const runQuery = getShallowRunQuery(ctx); - const existing = await runQuery(GET_BY_CONTENT_REF, { + const existing = await runQuery(LIST_BY_CONTENT_REF, { contentType: args.contentType, contentId: args.contentId, }); - if (existing && existing.textHash === textHash) { - return { id: existing._id, skipped: true }; + if ( + existing.length === chunks.length && + existing.length > 0 && + existing.every((embeddingDoc) => embeddingDoc.textHash === textHash) + ) { + return { id: existing[0]._id, skipped: true }; } const modelName = args.model || DEFAULT_EMBEDDING_MODEL; const aiClient = createAIClient(); - const { embedding } = await embed({ + const chunkTexts = chunks.map((chunk) => `${args.title}\n\n${chunk}`); + const { embeddings } = await embedMany({ model: aiClient.embedding(modelName), - value: textToEmbed, + values: chunkTexts, }); - const snippetText = createSnippet(args.content); const runMutation = getShallowRunMutation(ctx); - - if (existing) { - await runMutation(UPDATE_EMBEDDING_REF, { - id: existing._id, - embedding: embedding, + const insertedIds: Id<"contentEmbeddings">[] = []; + for (let index = 0; index < embeddings.length; index += 1) { + const embedding = embeddings[index]; + const chunk = chunks[index]; + const id = await runMutation(INSERT_EMBEDDING_REF, { + workspaceId: args.workspaceId, + contentType: args.contentType, + contentId: args.contentId, + embedding, textHash, title: args.title, - snippet: snippetText, + snippet: createSnippet(chunk), }); - return { id: existing._id, skipped: false }; + insertedIds.push(id); } - const id = await runMutation(INSERT_EMBEDDING_REF, { - workspaceId: args.workspaceId, - contentType: args.contentType, - contentId: args.contentId, - embedding: embedding, - textHash, - title: args.title, - snippet: snippetText, - }); + if (insertedIds.length === 0) { + throw new Error("Failed to generate embeddings: no chunks were inserted"); + } - return { id, skipped: false }; + if (existing.length > 0) { + await runMutation(REMOVE_EMBEDDINGS_BY_IDS_REF, { + ids: existing.map((embeddingDoc) => embeddingDoc._id), + }); + } + + return { id: insertedIds[0], skipped: false }; }, }); @@ -318,77 +409,24 @@ export const generateBatch = authAction({ }); } - const itemsWithHash: BatchItemWithHash[] = await Promise.all( - args.items.map(async (item: BatchItem) => ({ - ...item, - textToEmbed: `${item.title}\n\n${item.content}`, - textHash: await createTextHash(`${item.title}\n\n${item.content}`), - snippet: createSnippet(item.content), - })) - ); - - const existingEmbeddings: (Doc<"contentEmbeddings"> | null)[] = await Promise.all( - itemsWithHash.map((item: BatchItemWithHash) => - runQuery(GET_BY_CONTENT_REF, { - contentType: item.contentType, - contentId: item.contentId, - }) - ) - ); - - const itemsToProcess: BatchItemWithHash[] = itemsWithHash.filter( - (item: BatchItemWithHash, index: number) => { - const existing: Doc<"contentEmbeddings"> | null = existingEmbeddings[index]; - return !existing || existing.textHash !== item.textHash; - } - ); - - if (itemsToProcess.length === 0) { - return { processed: 0, skipped: args.items.length }; - } - - const modelName = args.model || DEFAULT_EMBEDDING_MODEL; - const aiClient = createAIClient(); - const { embeddings } = await embedMany({ - model: aiClient.embedding(modelName), - values: itemsToProcess.map((item: BatchItemWithHash) => item.textToEmbed), - }); - - const runMutation = getShallowRunMutation(ctx); - - for (let i = 0; i < itemsToProcess.length; i++) { - const item = itemsToProcess[i]; - const embedding = embeddings[i]; - const existingIndex = itemsWithHash.findIndex( - (ih) => ih.contentType === item.contentType && ih.contentId === item.contentId - ); - const existing = existingEmbeddings[existingIndex]; - - if (existing) { - await runMutation(UPDATE_EMBEDDING_REF, { - id: existing._id, - embedding, - textHash: item.textHash, - title: item.title, - snippet: item.snippet, - }); - } else { - await runMutation(INSERT_EMBEDDING_REF, { + const runAction = getShallowRunAction(ctx); + const results = await runWithConcurrency( + args.items, + EMBEDDING_GENERATE_CONCURRENCY, + async (item) => + runAction(GENERATE_INTERNAL_REF, { workspaceId: item.workspaceId, contentType: item.contentType, contentId: item.contentId, - embedding, - textHash: item.textHash, title: item.title, - snippet: item.snippet, - }); - } - } + content: item.content, + model: args.model, + }) + ); - return { - processed: itemsToProcess.length, - skipped: args.items.length - itemsToProcess.length, - }; + const skipped = results.filter((result) => result.skipped).length; + const processed = results.length - skipped; + return { processed, skipped }; }, }); @@ -466,12 +504,22 @@ export const backfillExisting = authAction({ let totalProcessed = 0; let totalSkipped = 0; + const batches: BatchItem[][] = []; for (let i = 0; i < items.length; i += batchSize) { - const batch = items.slice(i, i + batchSize); - const result = await runAction(GENERATE_BATCH_INTERNAL_REF, { - items: batch, - model: args.model, - }); + batches.push(items.slice(i, i + batchSize)); + } + + const batchResults = await runWithConcurrency( + batches, + EMBEDDING_BACKFILL_BATCH_CONCURRENCY, + async (batch) => + runAction(GENERATE_BATCH_INTERNAL_REF, { + items: batch, + model: args.model, + }) + ); + + for (const result of batchResults) { totalProcessed += result.processed; totalSkipped += result.skipped; } @@ -506,78 +554,24 @@ export const generateBatchInternal = internalAction({ return { processed: 0, skipped: 0 }; } - const runQuery = getShallowRunQuery(ctx); - const itemsWithHash: BatchItemWithHash[] = await Promise.all( - args.items.map(async (item: BatchItem) => ({ - ...item, - textToEmbed: `${item.title}\n\n${item.content}`, - textHash: await createTextHash(`${item.title}\n\n${item.content}`), - snippet: createSnippet(item.content), - })) - ); - - const existingEmbeddings: (Doc<"contentEmbeddings"> | null)[] = await Promise.all( - itemsWithHash.map((item: BatchItemWithHash) => - runQuery(GET_BY_CONTENT_REF, { - contentType: item.contentType, - contentId: item.contentId, - }) - ) - ); - - const itemsToProcess: BatchItemWithHash[] = itemsWithHash.filter( - (item: BatchItemWithHash, index: number) => { - const existing: Doc<"contentEmbeddings"> | null = existingEmbeddings[index]; - return !existing || existing.textHash !== item.textHash; - } - ); - - if (itemsToProcess.length === 0) { - return { processed: 0, skipped: args.items.length }; - } - - const modelName = args.model || DEFAULT_EMBEDDING_MODEL; - const aiClient = createAIClient(); - const { embeddings } = await embedMany({ - model: aiClient.embedding(modelName), - values: itemsToProcess.map((item: BatchItemWithHash) => item.textToEmbed), - }); - - const runMutation = getShallowRunMutation(ctx); - - for (let i = 0; i < itemsToProcess.length; i++) { - const item = itemsToProcess[i]; - const embedding = embeddings[i]; - const existingIndex = itemsWithHash.findIndex( - (ih) => ih.contentType === item.contentType && ih.contentId === item.contentId - ); - const existing = existingEmbeddings[existingIndex]; - - if (existing) { - await runMutation(UPDATE_EMBEDDING_REF, { - id: existing._id, - embedding, - textHash: item.textHash, - title: item.title, - snippet: item.snippet, - }); - } else { - await runMutation(INSERT_EMBEDDING_REF, { + const runAction = getShallowRunAction(ctx); + const results = await runWithConcurrency( + args.items, + EMBEDDING_GENERATE_CONCURRENCY, + async (item) => + runAction(GENERATE_INTERNAL_REF, { workspaceId: item.workspaceId, contentType: item.contentType, contentId: item.contentId, - embedding, - textHash: item.textHash, title: item.title, - snippet: item.snippet, - }); - } - } + content: item.content, + model: args.model, + }) + ); - return { - processed: itemsToProcess.length, - skipped: args.items.length - itemsToProcess.length, - }; + const skipped = results.filter((result) => result.skipped).length; + const processed = results.length - skipped; + return { processed, skipped }; }, }); @@ -592,15 +586,42 @@ export const remove = internalMutation({ .withIndex("by_content", (q) => q.eq("contentType", args.contentType).eq("contentId", args.contentId) ) - .first(); + .collect(); - if (existing) { - await ctx.db.delete(existing._id); + for (const embeddingDoc of existing) { + await ctx.db.delete(embeddingDoc._id); + } + }, +}); + +export const removeByIds = internalMutation({ + args: { + ids: v.array(v.id("contentEmbeddings")), + }, + handler: async (ctx, args) => { + for (const id of args.ids) { + await ctx.db.delete(id); } }, }); export const getByContent = internalQuery({ + args: { + contentType: v.union(v.literal("article"), v.literal("internalArticle"), v.literal("snippet")), + contentId: v.string(), + }, + handler: async (ctx, args) => { + const docs = await ctx.db + .query("contentEmbeddings") + .withIndex("by_content", (q) => + q.eq("contentType", args.contentType).eq("contentId", args.contentId) + ) + .collect(); + return docs[0] ?? null; + }, +}); + +export const listByContent = internalQuery({ args: { contentType: v.union(v.literal("article"), v.literal("internalArticle"), v.literal("snippet")), contentId: v.string(), @@ -611,7 +632,7 @@ export const getByContent = internalQuery({ .withIndex("by_content", (q) => q.eq("contentType", args.contentType).eq("contentId", args.contentId) ) - .first(); + .collect(); }, }); diff --git a/packages/convex/convex/embeddings/functionRefs.ts b/packages/convex/convex/embeddings/functionRefs.ts index 03a9cd7..e28acdd 100644 --- a/packages/convex/convex/embeddings/functionRefs.ts +++ b/packages/convex/convex/embeddings/functionRefs.ts @@ -37,7 +37,7 @@ export const removeEmbeddingRef = makeFunctionReference<"mutation", RemoveEmbedd ) as unknown as InternalSchedulableRef<"mutation", RemoveEmbeddingArgs>; export function getShallowRunAfter(ctx: { scheduler: { runAfter: unknown } }) { - return ctx.scheduler.runAfter as unknown as < + return ctx.scheduler.runAfter as < Type extends "action" | "mutation", Args extends Record, Return = unknown, diff --git a/packages/convex/convex/events.ts b/packages/convex/convex/events.ts index 339f883..8d7206d 100644 --- a/packages/convex/convex/events.ts +++ b/packages/convex/convex/events.ts @@ -17,8 +17,10 @@ type AutoEventType = (typeof AUTO_EVENT_TYPES)[number]; const RATE_LIMIT_WINDOW_MS = 60 * 1000; const RATE_LIMIT_MAX_EVENTS = 100; -type InternalMutationRef, Return = unknown> = - FunctionReference<"mutation", "internal", Args, Return>; +type InternalMutationRef< + Args extends Record, + Return = unknown, +> = FunctionReference<"mutation", "internal", Args, Return>; type CheckAutoCompletionArgs = { visitorId: Id<"visitors">; @@ -30,12 +32,10 @@ const CHECK_AUTO_COMPLETION_REF = makeFunctionReference< "mutation", CheckAutoCompletionArgs, unknown ->("checklists:checkAutoCompletion") as unknown as InternalMutationRef< - CheckAutoCompletionArgs ->; +>("checklists:checkAutoCompletion") as unknown as InternalMutationRef; function getShallowRunAfter(ctx: MutationCtx) { - return ctx.scheduler.runAfter as unknown as , Return = unknown>( + return ctx.scheduler.runAfter as , Return = unknown>( delayMs: number, functionRef: InternalMutationRef, runArgs: Args diff --git a/packages/convex/convex/http.ts b/packages/convex/convex/http.ts index 6165724..66f780c 100644 --- a/packages/convex/convex/http.ts +++ b/packages/convex/convex/http.ts @@ -296,14 +296,14 @@ const UPDATE_DELIVERY_STATUS_BY_EXTERNAL_ID_REF = makeFunctionReference< >; function getShallowRunQuery(ctx: { runQuery: unknown }) { - return ctx.runQuery as unknown as , Return>( + return ctx.runQuery as , Return>( query: PublicQueryRef, args: Args ) => Promise; } function getShallowRunMutation(ctx: { runMutation: unknown }) { - return ctx.runMutation as unknown as < + return ctx.runMutation as < Visibility extends "public" | "internal", Args extends Record, Return, diff --git a/packages/convex/convex/notifications/functionRefs.ts b/packages/convex/convex/notifications/functionRefs.ts index 5b5626e..8a5ba4e 100644 --- a/packages/convex/convex/notifications/functionRefs.ts +++ b/packages/convex/convex/notifications/functionRefs.ts @@ -1,6 +1,10 @@ import { makeFunctionReference, type FunctionReference } from "convex/server"; import type { Id } from "../_generated/dataModel"; -import type { NotificationPushAttempt, NotificationRecipientType, NotifyNewMessageMode } from "./contracts"; +import type { + NotificationPushAttempt, + NotificationRecipientType, + NotifyNewMessageMode, +} from "./contracts"; type InternalFunctionRef< Type extends "query" | "mutation" | "action", @@ -140,15 +144,25 @@ type PushTokenDeliveryFailureArgs = { removeToken: boolean; }; -export const getMemberRecipientsForNewVisitorMessageRef = - makeFunctionReference<"query", MemberRecipientArgs, MemberRecipientResult>( - "notifications:getMemberRecipientsForNewVisitorMessage" - ) as unknown as InternalFunctionRef<"query", MemberRecipientArgs, MemberRecipientResult>; +export const getMemberRecipientsForNewVisitorMessageRef = makeFunctionReference< + "query", + MemberRecipientArgs, + MemberRecipientResult +>("notifications:getMemberRecipientsForNewVisitorMessage") as unknown as InternalFunctionRef< + "query", + MemberRecipientArgs, + MemberRecipientResult +>; -export const getVisitorRecipientsForSupportReplyRef = - makeFunctionReference<"query", VisitorReplyRecipientArgs, VisitorReplyRecipientResult>( - "notifications:getVisitorRecipientsForSupportReply" - ) as unknown as InternalFunctionRef<"query", VisitorReplyRecipientArgs, VisitorReplyRecipientResult>; +export const getVisitorRecipientsForSupportReplyRef = makeFunctionReference< + "query", + VisitorReplyRecipientArgs, + VisitorReplyRecipientResult +>("notifications:getVisitorRecipientsForSupportReply") as unknown as InternalFunctionRef< + "query", + VisitorReplyRecipientArgs, + VisitorReplyRecipientResult +>; export const routeEventRef = makeFunctionReference<"mutation", RouteEventArgs, unknown>( "notifications:routeEvent" @@ -167,14 +181,9 @@ export const notifyNewConversationRef = makeFunctionReference< NotifyNewConversationArgs >; -export const notifyAssignmentRef = makeFunctionReference< - "mutation", - NotifyAssignmentArgs, - unknown ->("notifications:notifyAssignment") as unknown as InternalFunctionRef< - "mutation", - NotifyAssignmentArgs ->; +export const notifyAssignmentRef = makeFunctionReference<"mutation", NotifyAssignmentArgs, unknown>( + "notifications:notifyAssignment" +) as unknown as InternalFunctionRef<"mutation", NotifyAssignmentArgs>; export const sendNotificationEmailRef = makeFunctionReference< "action", @@ -182,7 +191,8 @@ export const sendNotificationEmailRef = makeFunctionReference< unknown >("notifications:sendNotificationEmail") as unknown as InternalFunctionRef< "action", - SendNotificationEmailArgs + SendNotificationEmailArgs, + unknown >; export const dispatchPushAttemptsRef = makeFunctionReference< @@ -191,7 +201,8 @@ export const dispatchPushAttemptsRef = makeFunctionReference< unknown >("notifications:dispatchPushAttempts") as unknown as InternalFunctionRef< "action", - DispatchPushAttemptsArgs + DispatchPushAttemptsArgs, + unknown >; export const logDeliveryOutcomeRef = makeFunctionReference< @@ -226,28 +237,28 @@ export const recordVisitorPushTokenDeliveryFailureRef = makeFunctionReference< >; export function getShallowRunQuery(ctx: { runQuery: unknown }) { - return ctx.runQuery as unknown as , Return>( + return ctx.runQuery as , Return>( queryRef: InternalFunctionRef<"query", Args, Return>, queryArgs: Args ) => Promise; } export function getShallowRunMutation(ctx: { runMutation: unknown }) { - return ctx.runMutation as unknown as , Return = unknown>( + return ctx.runMutation as , Return = unknown>( mutationRef: InternalFunctionRef<"mutation", Args, Return>, mutationArgs: Args ) => Promise; } export function getShallowRunAction(ctx: { runAction: unknown }) { - return ctx.runAction as unknown as , Return>( + return ctx.runAction as , Return>( actionRef: InternalFunctionRef<"action", Args, Return>, actionArgs: Args ) => Promise; } export function getShallowRunAfter(ctx: { scheduler: { runAfter: unknown } }) { - return ctx.scheduler.runAfter as unknown as < + return ctx.scheduler.runAfter as < Type extends "mutation" | "action", Args extends Record, Return = unknown, diff --git a/packages/convex/convex/outboundMessages.ts b/packages/convex/convex/outboundMessages.ts index 6087e80..3d9b9b3 100644 --- a/packages/convex/convex/outboundMessages.ts +++ b/packages/convex/convex/outboundMessages.ts @@ -63,7 +63,7 @@ const GET_ELIGIBLE_VISITORS_FOR_PUSH_REF = makeFunctionReference< >; function getShallowRunQuery(ctx: { runQuery: unknown }) { - return ctx.runQuery as unknown as < + return ctx.runQuery as < Visibility extends "internal" | "public", Args extends Record, Return, @@ -74,7 +74,7 @@ function getShallowRunQuery(ctx: { runQuery: unknown }) { } function getShallowRunMutation(ctx: { runMutation: unknown }) { - return ctx.runMutation as unknown as < + return ctx.runMutation as < Visibility extends "internal" | "public", Args extends Record, Return = unknown, diff --git a/packages/convex/convex/push/functionRefs.ts b/packages/convex/convex/push/functionRefs.ts index 31a71f3..5af7019 100644 --- a/packages/convex/convex/push/functionRefs.ts +++ b/packages/convex/convex/push/functionRefs.ts @@ -139,21 +139,21 @@ export const GET_ELIGIBLE_VISITORS_REF = makeFunctionReference< >; export function getShallowRunQuery(ctx: { runQuery: unknown }) { - return ctx.runQuery as unknown as , Return>( + return ctx.runQuery as , Return>( queryRef: InternalFunctionRef<"query", Args, Return>, queryArgs: Args ) => Promise; } export function getShallowRunAction(ctx: { runAction: unknown }) { - return ctx.runAction as unknown as , Return>( + return ctx.runAction as , Return>( actionRef: InternalFunctionRef<"action", Args, Return>, actionArgs: Args ) => Promise; } export function getShallowRunMutation(ctx: { runMutation: unknown }) { - return ctx.runMutation as unknown as , Return = unknown>( + return ctx.runMutation as , Return = unknown>( mutationRef: InternalFunctionRef<"mutation", Args, Return>, mutationArgs: Args ) => Promise; diff --git a/packages/convex/convex/pushCampaigns.ts b/packages/convex/convex/pushCampaigns.ts index 3ff0435..7c319e7 100644 --- a/packages/convex/convex/pushCampaigns.ts +++ b/packages/convex/convex/pushCampaigns.ts @@ -32,8 +32,10 @@ type InternalQueryRef, Return = unknown> = Return >; -type InternalMutationRef, Return = unknown> = - FunctionReference<"mutation", "internal", Args, Return>; +type InternalMutationRef< + Args extends Record, + Return = unknown, +> = FunctionReference<"mutation", "internal", Args, Return>; type GetInternalArgs = { id: Id<"pushCampaigns">; @@ -56,9 +58,14 @@ type UpdateStatsArgs = { failed: number; }; -const GET_INTERNAL_REF = makeFunctionReference<"query", GetInternalArgs, Doc<"pushCampaigns"> | null>( - "pushCampaigns:getInternal" -) as unknown as InternalQueryRef | null>; +const GET_INTERNAL_REF = makeFunctionReference< + "query", + GetInternalArgs, + Doc<"pushCampaigns"> | null +>("pushCampaigns:getInternal") as unknown as InternalQueryRef< + GetInternalArgs, + Doc<"pushCampaigns"> | null +>; const GET_PENDING_RECIPIENTS_REF = makeFunctionReference< "query", @@ -73,9 +80,9 @@ const UPDATE_RECIPIENT_STATUS_REF = makeFunctionReference< "mutation", UpdateRecipientStatusArgs, unknown ->("pushCampaigns:updateRecipientStatus") as unknown as InternalMutationRef< - UpdateRecipientStatusArgs ->; +>( + "pushCampaigns:updateRecipientStatus" +) as unknown as InternalMutationRef; const UPDATE_STATS_REF = makeFunctionReference<"mutation", UpdateStatsArgs, unknown>( "pushCampaigns:updateStats" diff --git a/packages/convex/convex/series/scheduler.ts b/packages/convex/convex/series/scheduler.ts index 6662f31..0d52db6 100644 --- a/packages/convex/convex/series/scheduler.ts +++ b/packages/convex/convex/series/scheduler.ts @@ -11,8 +11,10 @@ export type SeriesEvaluateEntryResult = { progressId?: Id<"seriesProgress">; }; -type InternalMutationRef, Return = unknown> = - FunctionReference<"mutation", "internal", Args, Return>; +type InternalMutationRef< + Args extends Record, + Return = unknown, +> = FunctionReference<"mutation", "internal", Args, Return>; type SeriesEvaluateEnrollmentArgs = { workspaceId: Id<"workspaces">; @@ -46,17 +48,17 @@ const EVALUATE_ENROLLMENT_FOR_VISITOR_REF = makeFunctionReference< "mutation", SeriesEvaluateEnrollmentArgs, unknown ->("series:evaluateEnrollmentForVisitor") as unknown as InternalMutationRef< - SeriesEvaluateEnrollmentArgs ->; +>( + "series:evaluateEnrollmentForVisitor" +) as unknown as InternalMutationRef; const RESUME_WAITING_FOR_EVENT_REF = makeFunctionReference< "mutation", SeriesResumeWaitingForEventArgs, unknown ->("series:resumeWaitingForEvent") as unknown as InternalMutationRef< - SeriesResumeWaitingForEventArgs ->; +>( + "series:resumeWaitingForEvent" +) as unknown as InternalMutationRef; const PROCESS_PROGRESS_REF = makeFunctionReference<"mutation", SeriesProcessProgressArgs, unknown>( "series:processProgress" @@ -75,12 +77,12 @@ const PROCESS_WAITING_PROGRESS_REF = makeFunctionReference< "mutation", SeriesProcessWaitingProgressArgs, unknown ->("series:processWaitingProgress") as unknown as InternalMutationRef< - SeriesProcessWaitingProgressArgs ->; +>( + "series:processWaitingProgress" +) as unknown as InternalMutationRef; function getShallowRunAfter(ctx: MutationCtx) { - return ctx.scheduler.runAfter as unknown as , Return = unknown>( + return ctx.scheduler.runAfter as , Return = unknown>( delayMs: number, functionRef: InternalMutationRef, runArgs: Args @@ -88,7 +90,7 @@ function getShallowRunAfter(ctx: MutationCtx) { } function getShallowRunMutation(ctx: MutationCtx) { - return ctx.runMutation as unknown as , Return = unknown>( + return ctx.runMutation as , Return = unknown>( mutationRef: InternalMutationRef, mutationArgs: Args ) => Promise; diff --git a/packages/convex/convex/snippets.ts b/packages/convex/convex/snippets.ts index 78cd75b..4630078 100644 --- a/packages/convex/convex/snippets.ts +++ b/packages/convex/convex/snippets.ts @@ -3,8 +3,10 @@ import { v } from "convex/values"; import type { Id } from "./_generated/dataModel"; import { authMutation, authQuery } from "./lib/authWrappers"; -type InternalSchedulableRef, Return = unknown> = - FunctionReference<"action" | "mutation", "internal", Args, Return>; +type InternalSchedulableRef< + Args extends Record, + Return = unknown, +> = FunctionReference<"action" | "mutation", "internal", Args, Return>; type GenerateSnippetEmbeddingArgs = { workspaceId: Id<"workspaces">; @@ -21,7 +23,7 @@ const GENERATE_SNIPPET_EMBEDDINGS_REF = makeFunctionReference< >("embeddings:generateInternal") as unknown as InternalSchedulableRef; function getShallowRunAfter(ctx: { scheduler: { runAfter: unknown } }) { - return ctx.scheduler.runAfter as unknown as , Return = unknown>( + return ctx.scheduler.runAfter as , Return = unknown>( delayMs: number, functionRef: InternalSchedulableRef, args: Args diff --git a/packages/convex/convex/suggestions.ts b/packages/convex/convex/suggestions.ts index 1d3b0d8..a40065b 100644 --- a/packages/convex/convex/suggestions.ts +++ b/packages/convex/convex/suggestions.ts @@ -11,6 +11,8 @@ import type { Permission } from "./permissions"; const DEFAULT_EMBEDDING_MODEL = "text-embedding-3-small"; const FEEDBACK_STATS_DEFAULT_LIMIT = 5000; const FEEDBACK_STATS_MAX_LIMIT = 20000; +const SUGGESTIONS_DEFAULT_LIMIT = 10; +const SUGGESTIONS_MAX_LIMIT = 20; const WIDGET_QUERY_MAX_LENGTH = 200; function getShallowRunQuery(ctx: { runQuery: unknown }) { @@ -244,7 +246,10 @@ export const searchSimilar = authAction({ }, permission: "articles.read", handler: async (ctx, args): Promise => { - const limit = args.limit || 10; + const limit = Math.max( + 1, + Math.min(args.limit ?? SUGGESTIONS_DEFAULT_LIMIT, SUGGESTIONS_MAX_LIMIT) + ); const modelName = args.model || DEFAULT_EMBEDDING_MODEL; const aiClient = createAIClient(); const runQuery = getShallowRunQuery(ctx); @@ -256,23 +261,15 @@ export const searchSimilar = authAction({ const results = await ctx.vectorSearch("contentEmbeddings", "by_embedding", { vector: embedding, - limit: limit * 2, + limit: limit * 8, filter: (q) => q.eq("workspaceId", args.workspaceId), }); - let filteredResults = results; - if (args.contentTypes && args.contentTypes.length > 0) { - const contentTypeSet = new Set(args.contentTypes); - filteredResults = results.filter((r: { _id: Id<"contentEmbeddings">; _score: number }) => { - const doc = r as unknown as { contentType: string }; - return contentTypeSet.has(doc.contentType as "article" | "internalArticle" | "snippet"); - }); - } - - const topResults = filteredResults.slice(0, limit); + const contentTypeSet = + args.contentTypes && args.contentTypes.length > 0 ? new Set(args.contentTypes) : null; const enrichedResults: (SuggestionResult | null)[] = await Promise.all( - topResults.map( + results.map( async (result: { _id: Id<"contentEmbeddings">; _score: number; @@ -281,6 +278,7 @@ export const searchSimilar = authAction({ id: result._id, }); if (!doc) return null; + if (contentTypeSet && !contentTypeSet.has(doc.contentType)) return null; return { id: doc.contentId, type: doc.contentType, @@ -292,7 +290,22 @@ export const searchSimilar = authAction({ ) ); - return enrichedResults.filter((r): r is SuggestionResult => r !== null); + const filtered = enrichedResults.filter((r): r is SuggestionResult => r !== null); + const deduped: SuggestionResult[] = []; + const seen = new Set(); + for (const result of filtered) { + const key = `${result.type}:${result.id}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + deduped.push(result); + if (deduped.length >= limit) { + break; + } + } + + return deduped; }, }); @@ -378,7 +391,10 @@ export const searchSimilarInternal = internalAction({ score: number; }> > => { - const limit = args.limit || 10; + const limit = Math.max( + 1, + Math.min(args.limit ?? SUGGESTIONS_DEFAULT_LIMIT, SUGGESTIONS_MAX_LIMIT) + ); const modelName = args.model || DEFAULT_EMBEDDING_MODEL; const aiClient = createAIClient(); const runQuery = getShallowRunQuery(ctx); @@ -390,20 +406,12 @@ export const searchSimilarInternal = internalAction({ const results = await ctx.vectorSearch("contentEmbeddings", "by_embedding", { vector: embedding, - limit: limit * 2, + limit: limit * 8, filter: (q) => q.eq("workspaceId", args.workspaceId), }); - let filteredResults = results; - if (args.contentTypes && args.contentTypes.length > 0) { - const contentTypeSet = new Set(args.contentTypes); - filteredResults = results.filter((r: { _id: Id<"contentEmbeddings">; _score: number }) => { - const doc = r as unknown as { contentType: string }; - return contentTypeSet.has(doc.contentType as "article" | "internalArticle" | "snippet"); - }); - } - - const topResults = filteredResults.slice(0, limit); + const contentTypeSet = + args.contentTypes && args.contentTypes.length > 0 ? new Set(args.contentTypes) : null; type EnrichedResult = { id: string; @@ -415,7 +423,7 @@ export const searchSimilarInternal = internalAction({ } | null; const enrichedResults: EnrichedResult[] = await Promise.all( - topResults.map( + results.map( async (result: { _id: Id<"contentEmbeddings">; _score: number; @@ -424,6 +432,7 @@ export const searchSimilarInternal = internalAction({ id: result._id, }); if (!doc) return null; + if (contentTypeSet && !contentTypeSet.has(doc.contentType)) return null; const content = await runQuery(GET_CONTENT_BY_ID_REF, { contentType: doc.contentType, @@ -442,7 +451,22 @@ export const searchSimilarInternal = internalAction({ ) ); - return enrichedResults.filter((r): r is NonNullable => r !== null); + const filtered = enrichedResults.filter((r): r is NonNullable => r !== null); + const deduped: NonNullable[] = []; + const seen = new Set(); + for (const result of filtered) { + const key = `${result.type}:${result.id}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + deduped.push(result); + if (deduped.length >= limit) { + break; + } + } + + return deduped; }, }); @@ -689,19 +713,12 @@ export const searchForWidget = action({ const results = await ctx.vectorSearch("contentEmbeddings", "by_embedding", { vector: embedding, - limit: limit * 2, + limit: limit * 8, filter: (q) => q.eq("workspaceId", args.workspaceId), }); - const articleResults = results.filter((r: { _id: Id<"contentEmbeddings">; _score: number }) => { - const doc = r as unknown as { contentType: string }; - return doc.contentType === "article"; - }); - - const topResults = articleResults.slice(0, limit); - const enrichedResults: (WidgetSuggestionResult | null)[] = await Promise.all( - topResults.map( + results.map( async (result: { _id: Id<"contentEmbeddings">; _score: number; @@ -710,6 +727,7 @@ export const searchForWidget = action({ id: result._id, }); if (!doc) return null; + if (doc.contentType !== "article") return null; return { id: doc.contentId, @@ -720,8 +738,21 @@ export const searchForWidget = action({ } ) ); + const filtered = enrichedResults.filter((r): r is WidgetSuggestionResult => r !== null); + const deduped: WidgetSuggestionResult[] = []; + const seen = new Set(); + for (const result of filtered) { + if (seen.has(result.id)) { + continue; + } + seen.add(result.id); + deduped.push(result); + if (deduped.length >= limit) { + break; + } + } - return enrichedResults.filter((r): r is WidgetSuggestionResult => r !== null); + return deduped; }, }); diff --git a/packages/convex/convex/supportAttachmentFunctionRefs.ts b/packages/convex/convex/supportAttachmentFunctionRefs.ts index 26a2abb..4ced969 100644 --- a/packages/convex/convex/supportAttachmentFunctionRefs.ts +++ b/packages/convex/convex/supportAttachmentFunctionRefs.ts @@ -1,8 +1,10 @@ import { makeFunctionReference, type FunctionReference } from "convex/server"; import type { Id } from "./_generated/dataModel"; -type InternalMutationRef, Return = unknown> = - FunctionReference<"mutation", "internal", Args, Return>; +type InternalMutationRef< + Args extends Record, + Return = unknown, +> = FunctionReference<"mutation", "internal", Args, Return>; export type CleanupExpiredStagedUploadsArgs = { workspaceId: Id<"workspaces">; diff --git a/packages/convex/convex/testing/helpers/notifications.ts b/packages/convex/convex/testing/helpers/notifications.ts index f6b2165..1c4768d 100644 --- a/packages/convex/convex/testing/helpers/notifications.ts +++ b/packages/convex/convex/testing/helpers/notifications.ts @@ -3,8 +3,10 @@ import { v } from "convex/values"; import { makeFunctionReference, type FunctionReference } from "convex/server"; import type { Id } from "../../_generated/dataModel"; -type InternalMutationRef, Return = unknown> = - FunctionReference<"mutation", "internal", Args, Return>; +type InternalMutationRef< + Args extends Record, + Return = unknown, +> = FunctionReference<"mutation", "internal", Args, Return>; type InternalQueryRef, Return = unknown> = FunctionReference< "query", @@ -44,37 +46,35 @@ const SEND_FOR_TESTING_REF = makeFunctionReference< SendForTestingResult >; -const GET_PENDING_RECIPIENTS_REF = makeFunctionReference< - "query", - PendingRecipientsArgs, - unknown ->("pushCampaigns:getPendingRecipients") as unknown as InternalQueryRef; +const GET_PENDING_RECIPIENTS_REF = makeFunctionReference<"query", PendingRecipientsArgs, unknown>( + "pushCampaigns:getPendingRecipients" +) as unknown as InternalQueryRef; const GET_MEMBER_RECIPIENTS_FOR_NEW_VISITOR_MESSAGE_REF = makeFunctionReference< "query", MemberRecipientsArgs, unknown ->("notifications:getMemberRecipientsForNewVisitorMessage") as unknown as InternalQueryRef< - MemberRecipientsArgs ->; +>( + "notifications:getMemberRecipientsForNewVisitorMessage" +) as unknown as InternalQueryRef; const GET_VISITOR_RECIPIENTS_FOR_SUPPORT_REPLY_REF = makeFunctionReference< "query", VisitorRecipientsArgs, unknown ->("notifications:getVisitorRecipientsForSupportReply") as unknown as InternalQueryRef< - VisitorRecipientsArgs ->; +>( + "notifications:getVisitorRecipientsForSupportReply" +) as unknown as InternalQueryRef; function getShallowRunMutation(ctx: { runMutation: unknown }) { - return ctx.runMutation as unknown as , Return>( + return ctx.runMutation as , Return>( mutationRef: InternalMutationRef, mutationArgs: Args ) => Promise; } function getShallowRunQuery(ctx: { runQuery: unknown }) { - return ctx.runQuery as unknown as , Return>( + return ctx.runQuery as , Return>( queryRef: InternalQueryRef, queryArgs: Args ) => Promise; @@ -130,20 +130,19 @@ const sendTestPushCampaign: ReturnType = internalMutati }, }); -const getTestPendingPushCampaignRecipients: ReturnType = - internalMutation({ - args: { - campaignId: v.id("pushCampaigns"), - limit: v.optional(v.number()), - }, - handler: async (ctx, args): Promise => { - const runQuery = getShallowRunQuery(ctx); - return await runQuery(GET_PENDING_RECIPIENTS_REF, { - campaignId: args.campaignId, - limit: args.limit, - }); - }, - }); +const getTestPendingPushCampaignRecipients: ReturnType = internalMutation({ + args: { + campaignId: v.id("pushCampaigns"), + limit: v.optional(v.number()), + }, + handler: async (ctx, args): Promise => { + const runQuery = getShallowRunQuery(ctx); + return await runQuery(GET_PENDING_RECIPIENTS_REF, { + campaignId: args.campaignId, + limit: args.limit, + }); + }, +}); /** * Creates a test message in the specified conversation. diff --git a/packages/convex/convex/tickets.ts b/packages/convex/convex/tickets.ts index ae74ba4..c996c94 100644 --- a/packages/convex/convex/tickets.ts +++ b/packages/convex/convex/tickets.ts @@ -58,33 +58,43 @@ type TicketNotificationRef> = FunctionRefer unknown >; -const NOTIFY_TICKET_CREATED_REF = - makeFunctionReference<"mutation", TicketCreatedNotificationArgs, unknown>( - "notifications:notifyTicketCreated" - ) as TicketNotificationRef; -const NOTIFY_TICKET_STATUS_CHANGED_REF = - makeFunctionReference<"mutation", TicketStatusChangedNotificationArgs, unknown>( - "notifications:notifyTicketStatusChanged" - ) as TicketNotificationRef; -const NOTIFY_TICKET_ASSIGNED_REF = - makeFunctionReference<"mutation", TicketAssignedNotificationArgs, unknown>( - "notifications:notifyTicketAssigned" - ) as TicketNotificationRef; -const NOTIFY_TICKET_COMMENT_REF = - makeFunctionReference<"mutation", TicketCommentNotificationArgs, unknown>( - "notifications:notifyTicketComment" - ) as TicketNotificationRef; -const NOTIFY_TICKET_CUSTOMER_REPLY_REF = - makeFunctionReference<"mutation", TicketCustomerReplyNotificationArgs, unknown>( - "notifications:notifyTicketCustomerReply" - ) as TicketNotificationRef; -const NOTIFY_TICKET_RESOLVED_REF = - makeFunctionReference<"mutation", TicketResolvedNotificationArgs, unknown>( - "notifications:notifyTicketResolved" - ) as TicketNotificationRef; +const NOTIFY_TICKET_CREATED_REF = makeFunctionReference< + "mutation", + TicketCreatedNotificationArgs, + unknown +>("notifications:notifyTicketCreated") as TicketNotificationRef; +const NOTIFY_TICKET_STATUS_CHANGED_REF = makeFunctionReference< + "mutation", + TicketStatusChangedNotificationArgs, + unknown +>( + "notifications:notifyTicketStatusChanged" +) as TicketNotificationRef; +const NOTIFY_TICKET_ASSIGNED_REF = makeFunctionReference< + "mutation", + TicketAssignedNotificationArgs, + unknown +>("notifications:notifyTicketAssigned") as TicketNotificationRef; +const NOTIFY_TICKET_COMMENT_REF = makeFunctionReference< + "mutation", + TicketCommentNotificationArgs, + unknown +>("notifications:notifyTicketComment") as TicketNotificationRef; +const NOTIFY_TICKET_CUSTOMER_REPLY_REF = makeFunctionReference< + "mutation", + TicketCustomerReplyNotificationArgs, + unknown +>( + "notifications:notifyTicketCustomerReply" +) as TicketNotificationRef; +const NOTIFY_TICKET_RESOLVED_REF = makeFunctionReference< + "mutation", + TicketResolvedNotificationArgs, + unknown +>("notifications:notifyTicketResolved") as TicketNotificationRef; function getShallowRunAfter(ctx: Pick) { - return ctx.scheduler.runAfter as unknown as ( + return ctx.scheduler.runAfter as ( delayMs: number, functionRef: TicketNotificationRef>, args: Record diff --git a/packages/convex/convex/visitors/mutations.ts b/packages/convex/convex/visitors/mutations.ts index 5d57765..81019f1 100644 --- a/packages/convex/convex/visitors/mutations.ts +++ b/packages/convex/convex/visitors/mutations.ts @@ -20,8 +20,10 @@ import { scheduleSeriesTriggerChanges, } from "./helpers"; -type InternalMutationRef, Return = unknown> = - FunctionReference<"mutation", "internal", Args, Return>; +type InternalMutationRef< + Args extends Record, + Return = unknown, +> = FunctionReference<"mutation", "internal", Args, Return>; type VerifyIdentityArgs = { workspaceId: Id<"workspaces">; @@ -45,7 +47,7 @@ const VERIFY_IDENTITY_REF = makeFunctionReference< >; function getShallowRunMutation(ctx: { runMutation: unknown }) { - return ctx.runMutation as unknown as , Return>( + return ctx.runMutation as , Return>( mutationRef: InternalMutationRef, mutationArgs: Args ) => Promise; diff --git a/packages/convex/convex/widgetSessions.ts b/packages/convex/convex/widgetSessions.ts index 87b8c3b..49a4f43 100644 --- a/packages/convex/convex/widgetSessions.ts +++ b/packages/convex/convex/widgetSessions.ts @@ -14,12 +14,10 @@ const MIN_SESSION_LIFETIME_MS = 1 * 60 * 60 * 1000; // 1 hour const MAX_SESSION_LIFETIME_MS = 7 * 24 * 60 * 60 * 1000; // 7 days const REFRESH_THRESHOLD = 0.25; // Refresh when <25% lifetime remains -type InternalMutationRef, Return = unknown> = FunctionReference< - "mutation", - "internal", - Args, - Return ->; +type InternalMutationRef< + Args extends Record, + Return = unknown, +> = FunctionReference<"mutation", "internal", Args, Return>; type VerifyIdentityArgs = { workspaceId: Id<"workspaces">; @@ -43,7 +41,7 @@ const VERIFY_IDENTITY_INTERNAL_REF = makeFunctionReference< >; function getShallowRunMutation(ctx: { runMutation: unknown }) { - return ctx.runMutation as unknown as , Return>( + return ctx.runMutation as , Return>( mutationRef: InternalMutationRef, mutationArgs: Args ) => Promise; diff --git a/packages/convex/convex/workspaceMembers.ts b/packages/convex/convex/workspaceMembers.ts index c668a96..4b2d1b7 100644 --- a/packages/convex/convex/workspaceMembers.ts +++ b/packages/convex/convex/workspaceMembers.ts @@ -15,8 +15,10 @@ import { } from "./permissions"; import { logAudit } from "./auditLogs"; -type InternalMutationRef, Return = unknown> = - FunctionReference<"mutation", "internal", Args, Return>; +type InternalMutationRef< + Args extends Record, + Return = unknown, +> = FunctionReference<"mutation", "internal", Args, Return>; type CreateInvitationArgs = { inviterId: Id<"users">; @@ -42,7 +44,7 @@ const CREATE_INVITATION_REF = makeFunctionReference< >; function getShallowRunMutation(ctx: { runMutation: unknown }) { - return ctx.runMutation as unknown as , Return>( + return ctx.runMutation as , Return>( mutationRef: InternalMutationRef, mutationArgs: Args ) => Promise; diff --git a/packages/convex/tests/runtimeTypeHardeningGuard.test.ts b/packages/convex/tests/runtimeTypeHardeningGuard.test.ts index a059707..43a78a7 100644 --- a/packages/convex/tests/runtimeTypeHardeningGuard.test.ts +++ b/packages/convex/tests/runtimeTypeHardeningGuard.test.ts @@ -90,6 +90,48 @@ describe("runtime type hardening guards", () => { } }); + it("keeps covered backend ref modules off generic selector helpers", () => { + const coveredFiles = [ + "../convex/aiAgent.ts", + "../convex/aiAgentActions.ts", + "../convex/aiAgentActionsKnowledge.ts", + "../convex/embeddings.ts", + "../convex/embeddings/functionRefs.ts", + "../convex/notifications/functionRefs.ts", + "../convex/push/functionRefs.ts", + "../convex/supportAttachmentFunctionRefs.ts", + "../convex/http.ts", + "../convex/emailChannel.ts", + "../convex/outboundMessages.ts", + "../convex/snippets.ts", + "../convex/events.ts", + "../convex/series/scheduler.ts", + "../convex/pushCampaigns.ts", + "../convex/carousels/triggering.ts", + "../convex/widgetSessions.ts", + "../convex/workspaceMembers.ts", + "../convex/visitors/mutations.ts", + "../convex/testing/helpers/notifications.ts", + "../convex/tickets.ts", + ]; + const forbiddenHelperPatterns = [ + "function makeQueryRef", + "function makeInternalQueryRef", + "function makeInternalMutationRef", + "function makeInternalActionRef", + "function makePublicQueryRef", + "function makePublicMutationRef", + "function makeTicketNotificationRef", + ]; + + for (const relativePath of coveredFiles) { + const source = readFileSync(new URL(relativePath, import.meta.url), "utf8"); + for (const pattern of forbiddenHelperPatterns) { + expect(source).not.toContain(pattern); + } + } + }); + it("routes series runtime internal calls through typed adapters", () => { const eventsSource = readFileSync(new URL("../convex/events.ts", import.meta.url), "utf8"); const seriesRuntimeSource = readFileSync( @@ -128,7 +170,10 @@ describe("runtime type hardening guards", () => { }); it("uses fixed typed refs for suggestion cross-function calls", () => { - const suggestionsSource = readFileSync(new URL("../convex/suggestions.ts", import.meta.url), "utf8"); + const suggestionsSource = readFileSync( + new URL("../convex/suggestions.ts", import.meta.url), + "utf8" + ); expect(suggestionsSource).not.toContain("function getApiRef(name: string)"); expect(suggestionsSource).toContain("GET_EMBEDDING_BY_ID_REF"); @@ -181,17 +226,20 @@ describe("runtime type hardening guards", () => { }); it("uses fixed typed refs for embedding self-dispatch and permission checks", () => { - const embeddingsSource = readFileSync(new URL("../convex/embeddings.ts", import.meta.url), "utf8"); + const embeddingsSource = readFileSync( + new URL("../convex/embeddings.ts", import.meta.url), + "utf8" + ); expect(embeddingsSource).not.toContain("function getInternalRef(name: string)"); - expect(embeddingsSource).toContain("GET_BY_CONTENT_REF"); - expect(embeddingsSource).toContain("UPDATE_EMBEDDING_REF"); + expect(embeddingsSource).toContain("LIST_BY_CONTENT_REF"); expect(embeddingsSource).toContain("INSERT_EMBEDDING_REF"); expect(embeddingsSource).toContain("GENERATE_INTERNAL_REF"); expect(embeddingsSource).toContain("REQUIRE_PERMISSION_FOR_ACTION_REF"); expect(embeddingsSource).toContain("LIST_ARTICLES_REF"); expect(embeddingsSource).toContain("LIST_INTERNAL_ARTICLES_REF"); expect(embeddingsSource).toContain("LIST_SNIPPETS_REF"); + expect(embeddingsSource).toContain("REMOVE_EMBEDDINGS_BY_IDS_REF"); expect(embeddingsSource).toContain("GENERATE_BATCH_INTERNAL_REF"); expect(embeddingsSource).toContain("getShallowRunQuery"); expect(embeddingsSource).toContain("getShallowRunMutation"); @@ -389,7 +437,10 @@ describe("runtime type hardening guards", () => { }); it("keeps the dynamic test admin gateway scoped to test-only modules", () => { - const testAdminSource = readFileSync(new URL("../convex/testAdmin.ts", import.meta.url), "utf8"); + const testAdminSource = readFileSync( + new URL("../convex/testAdmin.ts", import.meta.url), + "utf8" + ); expect(testAdminSource).toContain('const ALLOWED_MODULE_PREFIXES = ["testData", "testing"]'); expect(testAdminSource).toContain("if (!ALLOWED_MODULE_PREFIXES.includes(topModule))"); diff --git a/packages/sdk-core/src/api/aiAgent.ts b/packages/sdk-core/src/api/aiAgent.ts index caaa121..c0c18b6 100644 --- a/packages/sdk-core/src/api/aiAgent.ts +++ b/packages/sdk-core/src/api/aiAgent.ts @@ -9,7 +9,7 @@ import { getVisitorState } from "../state/visitor"; const GET_PUBLIC_AI_SETTINGS_REF = makeFunctionReference("aiAgent:getPublicSettings") as FunctionReference<"query">; const GET_RELEVANT_KNOWLEDGE_REF = - makeFunctionReference("aiAgent:getRelevantKnowledge") as FunctionReference<"query">; + makeFunctionReference("aiAgent:getRelevantKnowledge") as FunctionReference<"action">; const GET_CONVERSATION_AI_RESPONSES_REF = makeFunctionReference("aiAgent:getConversationResponses") as FunctionReference<"query">; const SUBMIT_AI_FEEDBACK_REF = @@ -80,7 +80,7 @@ export async function getRelevantKnowledge( const client = getClient(); const config = getConfig(); - const results = await client.query(GET_RELEVANT_KNOWLEDGE_REF, { + const results = await client.action(GET_RELEVANT_KNOWLEDGE_REF, { workspaceId: config.workspaceId as Id<"workspaces">, query, limit, diff --git a/packages/sdk-core/tests/api.test.ts b/packages/sdk-core/tests/api.test.ts index 8c16381..44f7769 100644 --- a/packages/sdk-core/tests/api.test.ts +++ b/packages/sdk-core/tests/api.test.ts @@ -5,6 +5,7 @@ vi.mock("convex/react", () => ({ ConvexReactClient: vi.fn().mockImplementation(() => ({ query: vi.fn(), mutation: vi.fn(), + action: vi.fn(), })), })); diff --git a/packages/sdk-core/tests/client.test.ts b/packages/sdk-core/tests/client.test.ts index 72541aa..b49b549 100644 --- a/packages/sdk-core/tests/client.test.ts +++ b/packages/sdk-core/tests/client.test.ts @@ -3,10 +3,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const clientMocks = vi.hoisted(() => { const query = vi.fn(); const mutation = vi.fn(); + const action = vi.fn(); const constructor = vi.fn().mockImplementation(function MockConvexReactClient() { - return { query, mutation }; + return { query, mutation, action }; }); - return { query, mutation, constructor }; + return { query, mutation, action, constructor }; }); vi.mock("convex/react", () => ({ diff --git a/packages/sdk-core/tests/contracts.test.ts b/packages/sdk-core/tests/contracts.test.ts index 1a10024..67073c6 100644 --- a/packages/sdk-core/tests/contracts.test.ts +++ b/packages/sdk-core/tests/contracts.test.ts @@ -3,10 +3,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const clientMocks = vi.hoisted(() => { const query = vi.fn(); const mutation = vi.fn(); + const action = vi.fn(); const constructor = vi.fn().mockImplementation(function MockConvexReactClient() { - return { query, mutation }; + return { query, mutation, action }; }); - return { query, mutation, constructor }; + return { query, mutation, action, constructor }; }); vi.mock("convex/react", () => ({ @@ -114,6 +115,20 @@ async function expectLatestMutationCall( expect(mutationArgs).toEqual(expectedArgs); } +async function expectLatestActionCall( + expectedPath: string, + invoke: () => Promise, + expectedArgs: unknown, + resolvedValue: unknown +): Promise { + clientMocks.action.mockResolvedValueOnce(resolvedValue); + await invoke(); + + const [actionRef, actionArgs] = clientMocks.action.mock.calls.at(-1) ?? []; + expect(resolveFunctionPath(actionRef)).toBe(expectedPath); + expect(actionArgs).toEqual(expectedArgs); +} + async function expectLatestQueryCall( expectedPath: string, invoke: () => Promise, @@ -132,6 +147,7 @@ describe("sdk-core backend contract conformance", () => { beforeEach(() => { clientMocks.mutation.mockReset(); clientMocks.query.mockReset(); + clientMocks.action.mockReset(); resetClient(); resetVisitorState(); @@ -473,7 +489,7 @@ describe("sdk-core backend contract conformance", () => { } ); - await expectLatestQueryCall( + await expectLatestActionCall( "aiAgent:getRelevantKnowledge", () => getRelevantKnowledge("refund policy", 5), {