From 6b5cc234d32125ffd817d372ac272e46a72d5663 Mon Sep 17 00:00:00 2001 From: weilun <1083432533@qq.com> Date: Sun, 19 Jul 2026 09:09:29 +0800 Subject: [PATCH] Add API docs reliability checks --- .github/workflows/docs-quality.yml | 51 ++++ .../contacts/unlock-phone-task.mdx | 2 +- api-reference-backup/contacts/unlock-task.mdx | 71 +++++ api-reference-backup/contacts/unlock.mdx | 76 ++++- api-reference-backup/openapi.json | 272 +++++++++++++++++- .../personnel/unlock-linkedin-activity.mdx | 64 +++++ docs.json | 137 +++++++++ llms-full.txt | 125 +++++++- scripts/sync-public-assets.py | 252 ++++++++++++++-- scripts/test_sync_public_assets.py | 116 ++++++++ 10 files changed, 1127 insertions(+), 39 deletions(-) create mode 100644 .github/workflows/docs-quality.yml create mode 100644 scripts/test_sync_public_assets.py diff --git a/.github/workflows/docs-quality.yml b/.github/workflows/docs-quality.yml new file mode 100644 index 0000000..255efae --- /dev/null +++ b/.github/workflows/docs-quality.yml @@ -0,0 +1,51 @@ +name: Docs Quality + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + docs-quality: + name: Docs Quality + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Run public asset tests + run: python3 -m unittest scripts/test_sync_public_assets.py + + - name: Verify generated assets are current + run: python3 scripts/sync-public-assets.py --check + + - name: Validate JSON and OpenAPI artifacts + run: | + jq empty docs.json api-catalog.json openapi.json api-reference/openapi.json api-reference-backup/openapi.json + cmp -s openapi.json api-reference/openapi.json + cmp -s openapi.json api-reference-backup/openapi.json + + - name: Reject legacy API key guidance + run: | + if rg -n 'uak_' README.md index.mdx authentication.mdx guides concepts llms.txt llms-full.txt openapi.json api-reference/openapi.json api-catalog.json scripts; then + exit 1 + fi + + - name: Lint OpenAPI + run: npx --yes @redocly/cli@2.39.0 lint openapi.json diff --git a/api-reference-backup/contacts/unlock-phone-task.mdx b/api-reference-backup/contacts/unlock-phone-task.mdx index 8e13aac..c6188b6 100644 --- a/api-reference-backup/contacts/unlock-phone-task.mdx +++ b/api-reference-backup/contacts/unlock-phone-task.mdx @@ -10,7 +10,7 @@ Use this endpoint after `POST /external/contacts/unlock-phone` returns `task_id` ## When to use this endpoint -Use `GET /external/contacts/unlock-phone-tasks/{taskId}` to poll until the job completaskStatus` is `completed` or `failed`. If some items remain failed while the task is completed, handle those item statuses individually. +Use `GET /external/contacts/unlock-phone-tasks/{taskId}` to poll until the job completes or fails. Stop polling when `taskStatus` is `completed` or `failed`, and handle item-level failures independently. ## Notes - Phone number is present only when the individual item has been unlocked successfully. diff --git a/api-reference-backup/contacts/unlock-task.mdx b/api-reference-backup/contacts/unlock-task.mdx index fdd05d5..791a0fe 100644 --- a/api-reference-backup/contacts/unlock-task.mdx +++ b/api-reference-backup/contacts/unlock-task.mdx @@ -14,6 +14,72 @@ Use `GET /external/contacts/unlock-tasks/{taskId}` to make email unlock workflow Do not assume the unlock result is ready immediately after creating the task. +## Endpoint +`GET /external/contacts/unlock-tasks/{taskId}` + +## Authentication +See [Authentication](/authentication) + +## Success status code +`200 OK` + +## Path parameters +| Name | Required | Type | Notes | +| --- | --- | --- | --- | +| `taskId` | Yes | string | The `task_id` returned by contact unlock. | + +## Request example +```bash +curl "https://platform.lensmor.com/external/contacts/unlock-tasks/321" \ + -H "Authorization: Bearer $LENSMOR_API_KEY" +``` + +## Response example +```json +{ + "taskId": "321", + "taskStatus": "processing", + "items": [ + { + "personnelId": "789", + "status": "unlocked", + "email": "jane@acme.example" + }, + { + "personnelId": "790", + "status": "processing" + } + ] +} +``` + +## Response fields + +| Field | Description | +| --- | --- | +| `taskId` | Contact unlock task identifier. | +| `taskStatus` | Overall task state. | +| `items` | Per-person unlock results. | +| `items[].personnelId` | Personnel identifier submitted in the unlock request. | +| `items[].status` | Item-level unlock state. | +| `items[].email` | Email address when the item is unlocked successfully. | +| `items[].errorCode` | Error code when an individual item fails, if provided. | + +## Status values +Task status: + +- `pending` +- `processing` +- `completed` +- `failed` + +Item status: + +- `pending` +- `processing` +- `unlocked` +- `failed` + ## Result interpretation Treat each `items[]` entry independently: @@ -33,6 +99,11 @@ Use a backoff schedule instead of polling aggressively: Stop polling when `taskStatus` is `completed` or `failed`. If some items remain failed while the task is completed, handle those item statuses individually. +## Error responses +- `401 Unauthorized` +- `404 Not Found` +- `429 Too Many Requests` + ## Notes - `email` is present only when the individual item has been unlocked successfully. - Failed items can include `errorCode`. diff --git a/api-reference-backup/contacts/unlock.mdx b/api-reference-backup/contacts/unlock.mdx index 1a6dd7e..74dbbe9 100644 --- a/api-reference-backup/contacts/unlock.mdx +++ b/api-reference-backup/contacts/unlock.mdx @@ -19,9 +19,72 @@ This endpoint is intentionally asynchronous because email unlock can involve mul - A `201 Created` response means the unlock task was accepted. It does not guaubmitted personnel record will return an email. Always inspect item-level task results before counting a contact as enriched. + A `201 Created` response means the unlock task was accepted. It does not guarantee that every submitted personnel record will return an email. Always inspect item-level task results before counting a contact as enriched. +## Endpoint +`POST /external/contacts/unlock` + +## Authentication +See [Authentication](/authentication) + +## Success status code +`201 Created` + +## Request body +| Name | Required | Type | Notes | +| --- | --- | --- | --- | +| `personnel_ids` | Yes | string[] | One or more personnel identifiers. Maximum `100` per request. | +| `event_id` | Yes | string | Supports event `id` or `eventId` returned by event responses. | + +## Headers +| Name | Required | Type | Notes | +| --- | --- | --- | --- | +| `x-call-source` | No | string | Optional usage source. Use `api` or `agent`; defaults to `api`. | + +## Request example +```bash +curl -X POST "https://platform.lensmor.com/external/contacts/unlock" \ + -H "Authorization: Bearer $LENSMOR_API_KEY" \ + -H "Content-Type: application/json" \ + -H "x-call-source: api" \ + -d '{"event_id":"139574","personnel_ids":["789","790"]}' +``` + +## Response example +```json +{ + "status": "accepted", + "task_id": "321", + "job_id": "321" +} +``` + +## Response fields + +| Field | Description | +| --- | --- | +| `status` | Initial task acceptance state. Usually `accepted` when the task was created. | +| `task_id` | Identifier used to poll task status. Store this value. | +| `job_id` | Alias for the background job identifier. It currently matches `task_id`. | + +## Polling pattern + +After receiving `task_id`, poll the task endpoint: + +```bash +curl "https://platform.lensmor.com/external/contacts/unlock-tasks/321" \ + -H "Authorization: Bearer $LENSMOR_API_KEY" +``` + +Use backoff instead of a tight polling loop. A practical pattern is: + +1. Wait a few seconds after task creation. +2. Poll every few seconds for short jobs. +3. Increase the interval if the job remains in progress. +4. Stop polling when the task completes or fails. +5. Re-fetch personnel or contact records if your UI needs the latest `email` and `contactUnlockStatus` values. + ## Credit behavior Contact email unlock currently costs `15` credits per chargeable contact. @@ -32,6 +95,15 @@ Contact email unlock currently costs `15` credits per chargeable contact. - Insufficient balance returns `402 Payment Required`. - Retry behavior should be tied to the task state, not only the create response. +## Error responses +- `400 Bad Request` +- `401 Unauthorized` +- `402 Payment Required` +- `404 Not Found` +- `409 Conflict` +- `429 Too Many Requests` + ## Notes -- This endpoint creates an asynchronous unlock job. Poll [Get contact unlock task](/api-reference/contacts/get-contact-unlock-task) with `taskot create duplicate unlock tasks while a prior task for the same selected contacts is still pending or processing. +- This endpoint creates an asynchronous unlock job. Poll [Get contact unlock task](/api-reference/contacts/get-contact-unlock-task) with `task_id`. +- Do not create duplicate unlock tasks while a prior task for the same selected contacts is still pending or processing. - See [Credits and access](/concepts/credits-and-access) for shared credit behavior. diff --git a/api-reference-backup/openapi.json b/api-reference-backup/openapi.json index 8487de4..e9253b6 100644 --- a/api-reference-backup/openapi.json +++ b/api-reference-backup/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "Lensmor API", - "version": "0.22.0", + "version": "0.23.1", "description": "Lensmor Event Intelligence API for event discovery, exhibitor research, personnel lookup, credits, and profile matching." }, "servers": [ @@ -959,6 +959,11 @@ ], "summary": "Unlock LinkedIn activity", "operationId": "unlockLinkedinActivity", + "parameters": [ + { + "$ref": "#/components/parameters/CallSource" + } + ], "requestBody": { "required": true, "content": { @@ -1277,11 +1282,11 @@ ], "responses": { "200": { - "description": "Paginated exhibitors", + "description": "Paginated recommendation state or unranked fallback exhibitor rows", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ExhibitorPage" + "$ref": "#/components/schemas/RecommendedExhibitorPage" } } } @@ -1399,6 +1404,11 @@ ], "summary": "Generate outreach messages", "operationId": "generateOutreachMessage", + "parameters": [ + { + "$ref": "#/components/parameters/CallSource" + } + ], "requestBody": { "required": true, "content": { @@ -1493,7 +1503,8 @@ "bearerAuth": { "type": "http", "scheme": "bearer", - "bearerFormat": "User API key" + "bearerFormat": "Business sk API key", + "description": "Send the full Business API key from Settings -> API Keys, for example `Authorization: Bearer sk_your_api_key`." } }, "parameters": { @@ -2179,7 +2190,10 @@ "$ref": "#/components/schemas/EventItem" }, "score": { - "type": "number" + "type": "number", + "minimum": 0, + "maximum": 10, + "description": "Overall event fit score on a 0-10 scale." }, "recommendation": { "type": "string", @@ -2187,16 +2201,226 @@ "recommended", "consider", "not_recommended" - ] + ], + "description": "Decision category derived from score: recommended for 7-10, consider for 4 to below 7, and not_recommended for below 4." }, "breakdown": { "type": "object", - "additionalProperties": { - "type": "number" - } + "required": [ + "profile_match", + "matched_exhibitor_density", + "event_scale" + ], + "properties": { + "profile_match": { + "type": "number", + "minimum": 0, + "maximum": 10, + "description": "Profile-context compatibility component on a 0-10 scale." + }, + "matched_exhibitor_density": { + "type": "number", + "minimum": 0, + "maximum": 10, + "description": "Matched-exhibitor density component on a 0-10 scale. This is not verified buyer or attendee density." + }, + "event_scale": { + "type": "number", + "minimum": 0, + "maximum": 10, + "description": "Event-scale component derived from the Lensmor exhibitor count and capped at 10." + } + }, + "additionalProperties": false } } }, + "RecommendedExhibitorItem": { + "type": "object", + "required": [ + "id", + "companyName", + "isRecommended" + ], + "properties": { + "id": { + "type": "string" + }, + "companyName": { + "type": "string" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "logo": { + "type": [ + "string", + "null" + ] + }, + "website": { + "type": [ + "string", + "null" + ] + }, + "country": { + "type": [ + "string", + "null" + ] + }, + "industry": { + "type": [ + "string", + "null" + ] + }, + "categories": { + "type": "array", + "items": { + "type": "string" + } + }, + "employeeCount": { + "type": [ + "integer", + "null" + ] + }, + "companySize": { + "type": [ + "string", + "null" + ] + }, + "fundingRound": { + "type": [ + "string", + "null" + ] + }, + "techStacks": { + "type": "array", + "items": { + "type": "string" + } + }, + "isRecommended": { + "type": "boolean", + "description": "True only when the item belongs to the active recommendation snapshot." + }, + "recommendationRank": { + "type": [ + "integer", + "null" + ], + "minimum": 1 + }, + "matchStatus": { + "type": [ + "string", + "null" + ], + "enum": [ + "pending_unlock", + "processing", + "ready", + "failed", + null + ] + }, + "matchScore": { + "type": [ + "number", + "null" + ] + }, + "matchTier": { + "type": [ + "string", + "null" + ], + "enum": [ + "top_match", + "strong_match", + "qualifying_match", + null + ] + }, + "reason": { + "type": [ + "string", + "null" + ], + "description": "Recommendation reason when matchStatus is ready. The field name is reason, not matchReason." + } + }, + "additionalProperties": true + }, + "RecommendedExhibitorPage": { + "type": "object", + "required": [ + "items", + "total", + "page", + "pageSize", + "totalPages", + "hasMore", + "recommendationProcessing" + ], + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RecommendedExhibitorItem" + } + }, + "total": { + "type": "integer", + "minimum": 0 + }, + "page": { + "type": "integer", + "minimum": 1 + }, + "pageSize": { + "type": "integer", + "minimum": 1, + "maximum": 100 + }, + "totalPages": { + "type": "integer", + "minimum": 0 + }, + "hasMore": { + "type": "boolean" + }, + "recommendationProcessing": { + "type": "boolean", + "description": "True when recommendation detail generation is still processing for the event." + }, + "code": { + "type": [ + "string", + "null" + ], + "enum": [ + "AI_SEARCH_RESULT_MISMATCH", + null + ], + "description": "Present on unranked fallback results." + }, + "show_refresh_hint": { + "type": "boolean", + "description": "Present on fallback results. This flag does not identify the cause or prescribe a required action." + } + }, + "additionalProperties": true + }, "EventRankItem": { "type": "object", "required": [ @@ -3272,6 +3496,13 @@ } } } + }, + "taskCenterId": { + "type": [ + "string", + "null" + ], + "description": "TaskCenter identifier returned when at least one outreach generation task was created." } } }, @@ -3332,6 +3563,29 @@ "string", "null" ] + }, + "status": { + "type": [ + "string", + "null" + ], + "description": "Current outreach message status, such as processing, ready, or failed. Null when no record exists." + }, + "create_time": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "description": "Record creation timestamp in milliseconds. Null when no record exists." + }, + "update_time": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "description": "Record update timestamp in milliseconds. Null when no record exists." } } } diff --git a/api-reference-backup/personnel/unlock-linkedin-activity.mdx b/api-reference-backup/personnel/unlock-linkedin-activity.mdx index 9644459..20ca1fe 100644 --- a/api-reference-backup/personnel/unlock-linkedin-activity.mdx +++ b/api-reference-backup/personnel/unlock-linkedin-activity.mdx @@ -8,9 +8,73 @@ Unlock LinkedIn activity visibility for one or more personnel records in an even Use this endpoint when your integration already has personnel IDs and wants to expose LinkedIn activity metadata such as `linkedinActivity` and `linkedinActivityStatus`. +## Endpoint +`POST /external/personnel/unlock-linkedin-activity` + +## Authentication +See [Authentication](/authentication) + +## Success status code +`201 Created` + +## Request body +| Name | Required | Type | Notes | +| --- | --- | --- | --- | +| `personnel_ids` | Yes | string[] | One to 50 personnel IDs. | +| `event_id` | Yes | string | Event identifier. Supports `eventId` or `id` returned by event responses. | + +## Request example +```bash +curl -X POST "https://platform.lensmor.com/external/personnel/unlock-linkedin-activity" \ + -H "Authorization: Bearer $LENSMOR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "event_id": "26855", + "personnel_ids": ["789", "790"] + }' +``` + +## Response example +```json +{ + "items": [ + { + "personnel_id": "789", + "success": true, + "alreadyUnlocked": false, + "taskId": "901", + "error": null + }, + { + "personnel_id": "790", + "success": false, + "alreadyUnlocked": false, + "taskId": null, + "error": "Personnel has no LinkedIn URL" + } + ] +} +``` + +## Response fields +| Field | Description | +| --- | --- | +| `items` | Per-personnel unlock or task result. | +| `personnel_id` | The personnel ID from the request. | +| `success` | Whether this personnel item was accepted or already available. | +| `alreadyUnlocked` | `true` when the caller already has an active or completed LinkedIn activity unlock for this personnel record. | +| `taskId` | Analysis task ID when a new LinkedIn activity analysis task is created. `null` when data already exists, the item is already unlocked, or the item failed validation. | +| `error` | Item-level failure message. `null` on success. | + ## Notes - This endpoint is idempotent for personnel records with active or completed LinkedIn activity unlocks. - If LinkedIn activity data already exists, the API can mark the record as unlocked without creating a new task. - If activity data does not exist, the API creates an asynchronous LinkedIn activity analysis task and returns `taskId`. - This endpoint does not unlock contact emails. Use [Unlock contact emails](/api-reference/contacts/unlock-contact-emails) for email unlock workflows. - Poll [Personnel list](/api-reference/personnel/list-event-personnel) or [Personnel profile](/api-reference/personnel/get-personnel-profile) to observe `linkedinActivityStatus` and `linkedinActivity` after processing. + +## Error responses +- `400 Bad Request` +- `401 Unauthorized` +- `404 Not Found` +- `429 Too Many Requests` diff --git a/docs.json b/docs.json index f947517..206f9e0 100644 --- a/docs.json +++ b/docs.json @@ -135,6 +135,143 @@ } ] }, + "redirects": [ + { + "source": "/api-reference/actions/precheck", + "destination": "/api-reference/actions/precheck-an-external-action", + "permanent": true + }, + { + "source": "/api-reference/contacts/search", + "destination": "/api-reference/contacts/search-contacts", + "permanent": true + }, + { + "source": "/api-reference/contacts/unlock", + "destination": "/api-reference/contacts/unlock-contact-emails", + "permanent": true + }, + { + "source": "/api-reference/contacts/unlock-phone", + "destination": "/api-reference/contacts/unlock-contact-phone-numbers", + "permanent": true + }, + { + "source": "/api-reference/contacts/unlock-phone-task", + "destination": "/api-reference/contacts/get-phone-unlock-task", + "permanent": true + }, + { + "source": "/api-reference/contacts/unlock-task", + "destination": "/api-reference/contacts/get-contact-unlock-task", + "permanent": true + }, + { + "source": "/api-reference/credits/balance", + "destination": "/api-reference/credits/get-credits-balance", + "permanent": true + }, + { + "source": "/api-reference/events/brief", + "destination": "/api-reference/events/get-event-brief", + "permanent": true + }, + { + "source": "/api-reference/events/detail", + "destination": "/api-reference/events/get-event-detail", + "permanent": true + }, + { + "source": "/api-reference/events/fit-score", + "destination": "/api-reference/events/score-one-event", + "permanent": true + }, + { + "source": "/api-reference/events/list", + "destination": "/api-reference/events/list-events", + "permanent": true + }, + { + "source": "/api-reference/events/rank", + "destination": "/api-reference/events/rank-events", + "permanent": true + }, + { + "source": "/api-reference/events/unlock", + "destination": "/api-reference/events/unlock-event-access", + "permanent": true + }, + { + "source": "/api-reference/exhibitors/events", + "destination": "/api-reference/exhibitors/list-exhibitor-related-events", + "permanent": true + }, + { + "source": "/api-reference/exhibitors/list", + "destination": "/api-reference/exhibitors/list-event-exhibitors", + "permanent": true + }, + { + "source": "/api-reference/exhibitors/profile", + "destination": "/api-reference/exhibitors/get-exhibitor-profile", + "permanent": true + }, + { + "source": "/api-reference/exhibitors/search", + "destination": "/api-reference/exhibitors/search-exhibitors-by-company-context", + "permanent": true + }, + { + "source": "/api-reference/exhibitors/search-by-company-name", + "destination": "/api-reference/exhibitors/search-exhibitors-by-company-name", + "permanent": true + }, + { + "source": "/api-reference/exhibitors/search-events", + "destination": "/api-reference/exhibitors/search-events-by-exhibitor-company-name", + "permanent": true + }, + { + "source": "/api-reference/personnel/events", + "destination": "/api-reference/personnel/list-personnel-related-events", + "permanent": true + }, + { + "source": "/api-reference/personnel/events-by-linkedin", + "destination": "/api-reference/personnel/list-personnel-related-events-by-linkedin-url", + "permanent": true + }, + { + "source": "/api-reference/personnel/generate-outreach-message", + "destination": "/api-reference/personnel/generate-outreach-messages", + "permanent": true + }, + { + "source": "/api-reference/personnel/list", + "destination": "/api-reference/personnel/list-event-personnel", + "permanent": true + }, + { + "source": "/api-reference/personnel/outreach", + "destination": "/api-reference/personnel/get-outreach-message-detail", + "permanent": true + }, + { + "source": "/api-reference/personnel/profile", + "destination": "/api-reference/personnel/get-personnel-profile", + "permanent": true + }, + { + "source": "/api-reference/profile-matching/actions-apply-recommended-events-paged", + "destination": "/api-reference/profile-matching/apply-profile-and-get-recommended-events", + "permanent": true + }, + { + "source": "/api-reference/profile-matching/recommendations-exhibitors", + "destination": "/api-reference/profile-matching/get-recommended-exhibitors-for-event", + "permanent": true + } + ], "description": "Lensmor API documentation for event intelligence, trade show discovery, exhibitor research, profile matching, credits, and contact email unlock workflows.", "seo": { "indexing": "navigable", diff --git a/llms-full.txt b/llms-full.txt index 9ec82fb..d24b506 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -235,8 +235,13 @@ Released June 18, 2026. ### Added -- Added documentation for phone number unlock workflows and AI-powered outreach message generation. -- Converted existing contact and outreach endpoint pages to OpenAPI-driven format with interactive playground support. +- Added documentation for phone number unlock workflow: + - `POST /external/contacts/unlock-phone` — start an asynchronous phone number unlock job. + - `GET /external/contacts/unlock-phone-tasks/{taskId}` — poll phone unlock task status and retrieve results. +- Added documentation for AI-powered outreach message generation: + - `POST /external/personnel/generate-outreach-message` — generate personalized outreach messages for personnel. + - `GET /external/personnel/outreach` — retrieve generated outreach message content. +- Converted existing endpoint pages (contacts unlock, unlock task, LinkedIn activity unlock) to OpenAPI-driven format with interactive playground support. - Updated OpenAPI metadata to version `0.22.0`. ## v0.21.0 @@ -2969,6 +2974,63 @@ curl -X POST "https://platform.lensmor.com/external/personnel/unlock-linkedin-ac --- +## Generate outreach messages + +Source: /api-reference/personnel/generate-outreach-messages + +Generate AI-powered personalized outreach messages for one or more personnel in an event context. + +Use this endpoint when your integration needs AI-generated cold outreach content tailored to each contact's role, company, and event participation. + +## When to use this endpoint + +Use `POST /external/personnel/generate-outreach-message` when you want to: + +- Generate personalized cold emails for event contacts +- Create LinkedIn connection messages referencing shared event context +- Batch-produce outreach content for multiple contacts at once + +This endpoint is asynchronous. Each personnel item may create a background task. Use [Get outreach message detail](/api-reference/personnel/get-outreach-message-detail) to retrieve the generated content once processing completes. + + + Channels must be one or more of: `email`, `linkedin_message`. You can request both simultaneously. + + +## Credit behavior + +Outreach message generation consumes credits per personnel record processed. + +- Maximum 50 personnel IDs per request. +- Insufficient balance returns `402 Payment Required`. + +## Notes +- Poll [Get outreach message detail](/api-reference/personnel/get-outreach-message-detail) with `personnel_id` and `event_id` to retrieve generated messages. +- Messages are generated using AI based on the contact's profile, company context, and event participation data. + +--- + +## Get outreach message detail + +Source: /api-reference/personnel/get-outreach-message-detail + +Retrieve the generated outreach message for a specific personnel record in an event context. + +Use this endpoint after [Generate outreach messages](/api-reference/personnel/generate-outreach-messages) to fetch the AI-generated content. + +## When to use this endpoint + +Use `GET /external/personnel/outreach` when: + +- Polling for outreach generation completion after calling the generate endpoint +- Retrieving previously generated messages for display or export +- Checking whether outreach content already exists before triggering a new generation + +## Notes +- Returns the most recent outreach generation result for the given personnel and event combination. +- Re-generating outreach for the same personnel + event will overwrite previous content. + +--- + ## Contacts search Source: /api-reference/contacts/search-contacts @@ -3200,12 +3262,12 @@ Use this endpoint after `POST /external/contacts/unlock` returns `task_id`. ## When to use this endpoint -Use `GET /external/contacts/unlock-tasks/:taskId` to make email unlock workflows resumable. The task ID can be stored in your database, background worker, or frontend state and polled until the job completes or fails. +Use `GET /external/contacts/unlock-tasks/{taskId}` to make email unlock workflows resumable. The task ID can be stored in your database, background worker, or frontend state and polled until the job completes or fails. Do not assume the unlock result is ready immediately after creating the task. ## Endpoint -`GET /external/contacts/unlock-tasks/:taskId` +`GET /external/contacts/unlock-tasks/{taskId}` ## Authentication See [Authentication](/authentication) @@ -3300,6 +3362,61 @@ Stop polling when `taskStatus` is `completed` or `failed`. If some items remain --- +## Unlock contact phone numbers + +Source: /api-reference/contacts/unlock-contact-phone-numbers + +Start an asynchronous job to unlock phone numbers for one or more personnel records. + +Use this endpoint after finding contacts from personnel or contact search results when you need direct phone numbers rather than email addresses. + +## When to use this endpoint + +Use `POST /external/contacts/unlock-phone` when your integration needs verified phone numbers for outbound calling or SMS workflows. + +This endpoint is asynchronous. The create call returns a task identifier; poll [Get phone unlock task](/api-reference/contacts/get-phone-unlock-task) until the job reaches a terminal state. + + + Phone unlock is a separate workflow from email unlock. Use [Unlock contact emails](/api-reference/contacts/unlock-contact-emails) for email addresses. + + + + A `201 Created` response means the task was accepted. Always inspect item-level results before counting a contact phone as delivered. + + +## Credit behavior + +Phone unlock costs credits per chargeable contact. + +- Already unlocked contacts are not charged again. +- Insufficient balance returns `402 Payment Required`. +- The API rejects batches larger than 100 personnel IDs. + +## Notes +- This endpoint creates an asynchronous unlock job. Poll [Get phone unlock task](/api-reference/contacts/get-phone-unlock-task) with `task_id`. +- Do not create duplicate unlock tasks while a prior task is still pending or processing. +- See [Credits and access](/concepts/credits-and-access) for shared credit behavior. + +--- + +## Get phone unlock task + +Source: /api-reference/contacts/get-phone-unlock-task + +Check the status of a phone number unlock job. + +Use this endpoint after `POST /external/contacts/unlock-phone` returns `task_id`. + +## When to use this endpoint + +Use `GET /external/contacts/unlock-phone-tasks/{taskId}` to poll until the job completes or fails. Stop polling when `taskStatus` is `completed` or `failed`, and handle item-level failures independently. + +## Notes +- Phone number is present only when the individual item has been unlocked successfully. +- Failed items can include `errorCode`. + +--- + ## Apply profile and get recommended events Source: /api-reference/profile-matching/apply-profile-and-get-recommended-events diff --git a/scripts/sync-public-assets.py b/scripts/sync-public-assets.py index f39d69c..57cd43a 100755 --- a/scripts/sync-public-assets.py +++ b/scripts/sync-public-assets.py @@ -3,23 +3,40 @@ from __future__ import annotations +import argparse import json import re -import shutil +from dataclasses import dataclass from pathlib import Path from typing import Any, Iterable + ROOT = Path(__file__).resolve().parents[1] DOCS_JSON = ROOT / "docs.json" OPENAPI_SOURCE = ROOT / "api-reference" / "openapi.json" OPENAPI_ROOT = ROOT / "openapi.json" +OPENAPI_BACKUP = ROOT / "api-reference-backup" / "openapi.json" +API_REFERENCE_BACKUP = ROOT / "api-reference-backup" API_CATALOG = ROOT / "api-catalog.json" LLMS_INDEX = ROOT / "llms.txt" LLMS_FULL = ROOT / "llms-full.txt" DOCS_BASE_URL = "https://api.lensmor.com" API_BASE_URL = "https://platform.lensmor.com" +HTTP_METHODS = {"get", "post", "put", "patch", "delete"} FRONTMATTER_RE = re.compile(r"\A---\n(.*?)\n---\n?", re.DOTALL) TITLE_RE = re.compile(r"^title:\s*(.+?)\s*$", re.MULTILINE) +OPENAPI_FRONTMATTER_RE = re.compile( + r'^openapi:\s*["\']openapi\.json\s+' + r"(GET|POST|PUT|PATCH|DELETE)\s+([^\"\']+)[\"\']\s*$", + re.MULTILINE, +) +OPERATION_PAGE_RE = re.compile(r"^(GET|POST|PUT|PATCH|DELETE)\s+(/\S+)$") + + +@dataclass(frozen=True) +class OperationPage: + public_path: str + source_path: Path def iter_pages(node: Any) -> Iterable[str]: @@ -32,11 +49,28 @@ def iter_pages(node: Any) -> Iterable[str]: def navigation_pages(config: dict[str, Any]) -> list[str]: - pages: list[str] = [] + indexed_groups: list[tuple[int, dict[str, Any]]] = [] + index = 0 for tab in config.get("navigation", {}).get("tabs", []): for group in tab.get("groups", []): - for page in group.get("pages", []): - pages.extend(iter_pages(page)) + indexed_groups.append((index, group)) + index += 1 + + def llms_group_order(item: tuple[int, dict[str, Any]]) -> tuple[int, int]: + original_index, group = item + name = group.get("group") + if name == "Introduction": + return 0, original_index + if name == "Guides": + return 1, original_index + if name == "Concepts": + return 3, original_index + return 2, original_index + + pages: list[str] = [] + for _, group in sorted(indexed_groups, key=llms_group_order): + for page in group.get("pages", []): + pages.extend(iter_pages(page)) return pages @@ -53,22 +87,146 @@ def parse_frontmatter(raw: str) -> tuple[str | None, str]: return title, body -def page_to_markdown(page: str) -> str: +def slugify(value: str) -> str: + slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") + if not slug: + raise ValueError(f"Unable to create a route slug from {value!r}") + return slug + + +def openapi_operation_routes() -> dict[str, str]: + spec = json.loads(OPENAPI_SOURCE.read_text(encoding="utf-8")) + operations: dict[str, str] = {} + + for path, path_item in spec.get("paths", {}).items(): + for method, operation in path_item.items(): + if method.lower() not in HTTP_METHODS: + continue + summary = operation.get("summary") + tags = operation.get("tags") or [] + if not summary or not tags: + raise ValueError( + f"{method.upper()} {path} needs a summary and tag for its Mintlify route" + ) + key = f"{method.upper()} {path}" + public_path = f"/api-reference/{slugify(tags[0])}/{slugify(summary)}" + operations[key] = public_path + + return operations + + +def operation_doc_paths() -> dict[str, Path]: + docs: dict[str, Path] = {} + for path in sorted(API_REFERENCE_BACKUP.rglob("*.mdx")): + raw = path.read_text(encoding="utf-8") + frontmatter_match = FRONTMATTER_RE.match(raw) + if not frontmatter_match: + continue + openapi_match = OPENAPI_FRONTMATTER_RE.search(frontmatter_match.group(1)) + if not openapi_match: + continue + key = f"{openapi_match.group(1)} {openapi_match.group(2).strip()}" + if key in docs: + raise ValueError(f"Duplicate OpenAPI documentation page for {key}") + docs[key] = path + return docs + + +def operation_pages() -> dict[str, OperationPage]: + routes = openapi_operation_routes() + docs = operation_doc_paths() + + missing_docs = sorted(routes.keys() - docs.keys()) + if missing_docs: + raise ValueError( + "OpenAPI operations have no backup MDX page: " + ", ".join(missing_docs) + ) + + unknown_docs = sorted(docs.keys() - routes.keys()) + if unknown_docs: + raise ValueError( + "Backup MDX pages reference unknown OpenAPI operations: " + + ", ".join(unknown_docs) + ) + + return { + key: OperationPage(public_path=routes[key], source_path=docs[key]) + for key in routes + } + + +def resolve_page( + page: str, + operations: dict[str, OperationPage], +) -> tuple[Path, str]: + if OPERATION_PAGE_RE.match(page): + if page not in operations: + raise ValueError(f"Navigation references an unknown OpenAPI operation: {page}") + operation = operations[page] + return operation.source_path, operation.public_path + path = ROOT / f"{page}.mdx" + if not path.exists(): + raise FileNotFoundError(f"Navigation page does not exist: {path}") + return path, f"/{page}" + + +def page_to_markdown( + page: str, + operations: dict[str, OperationPage], +) -> str: + path, public_path = resolve_page(page, operations) raw = path.read_text(encoding="utf-8") title, body = parse_frontmatter(raw) if title is None: title = page.replace("-", " ").replace("/", " / ").title() body = body.strip() - return f"## {title}\n\nSource: /{page}\n\n{body}\n" + return f"## {title}\n\nSource: {public_path}\n\n{body}\n" -def sync_openapi_root() -> None: - shutil.copyfile(OPENAPI_SOURCE, OPENAPI_ROOT) +def expected_legacy_redirects() -> list[dict[str, Any]]: + operations = operation_pages() + redirects: list[dict[str, Any]] = [] + for operation in operations.values(): + relative = ( + operation.source_path.relative_to(API_REFERENCE_BACKUP) + .with_suffix("") + .as_posix() + ) + source = f"/api-reference/{relative}" + destination = operation.public_path + if source != destination: + redirects.append( + {"source": source, "destination": destination, "permanent": True} + ) + + return sorted(redirects, key=lambda redirect: redirect["source"]) + + +def missing_legacy_redirects() -> list[dict[str, Any]]: + config = json.loads(DOCS_JSON.read_text(encoding="utf-8")) + configured = { + ( + redirect.get("source"), + redirect.get("destination"), + redirect.get("permanent", False), + ) + for redirect in config.get("redirects", []) + } + return [ + redirect + for redirect in expected_legacy_redirects() + if ( + redirect["source"], + redirect["destination"], + redirect["permanent"], + ) + not in configured + ] -def sync_api_catalog() -> None: - API_CATALOG.parent.mkdir(parents=True, exist_ok=True) + +def render_api_catalog() -> str: catalog = { "apis": [ { @@ -79,11 +237,11 @@ def sync_api_catalog() -> None: } ] } - API_CATALOG.write_text(json.dumps(catalog, indent=2) + "\n", encoding="utf-8") + return json.dumps(catalog, indent=2) + "\n" -def sync_llms_index() -> None: - content = f"""# Lensmor API Documentation +def render_llms_index() -> str: + return f"""# Lensmor API Documentation > Lensmor Event Intelligence API documentation for event discovery, exhibitor research, personnel lookup, credits, and profile matching. @@ -99,25 +257,73 @@ def sync_llms_index() -> None: Send requests to `{API_BASE_URL}` with `Authorization: Bearer sk_your_api_key`. """ - LLMS_INDEX.write_text(content, encoding="utf-8") -def sync_llms_full() -> None: +def render_llms_full() -> str: config = json.loads(DOCS_JSON.read_text(encoding="utf-8")) - sections = [page_to_markdown(page) for page in navigation_pages(config)] + operations = operation_pages() + sections = [ + page_to_markdown(page, operations) + for page in navigation_pages(config) + ] content = "# Lensmor API Documentation\n\n" content += "This file is generated from the public Mintlify MDX sources for LLM and agent consumption.\n" content += "It intentionally uses plain Markdown code fences without Mintlify-specific metadata.\n\n" content += "\n---\n\n".join(sections).rstrip() + "\n" - LLMS_FULL.write_text(content, encoding="utf-8") + return content + + +def build_outputs() -> dict[Path, bytes]: + openapi = OPENAPI_SOURCE.read_bytes() + return { + OPENAPI_ROOT: openapi, + OPENAPI_BACKUP: openapi, + API_CATALOG: render_api_catalog().encode("utf-8"), + LLMS_INDEX: render_llms_index().encode("utf-8"), + LLMS_FULL: render_llms_full().encode("utf-8"), + } + + +def sync_public_assets(check: bool = False) -> int: + missing_redirects = missing_legacy_redirects() + if missing_redirects: + print("Missing permanent redirects for legacy API reference routes:") + for redirect in missing_redirects: + print(f"- {redirect['source']} -> {redirect['destination']}") + return 1 + + stale: list[Path] = [] + for path, expected in build_outputs().items(): + actual = path.read_bytes() if path.exists() else None + if actual == expected: + continue + stale.append(path) + if not check: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(expected) + print(f"Updated {path.relative_to(ROOT)}") + + if check and stale: + print("Generated public assets are stale:") + for path in stale: + print(f"- {path.relative_to(ROOT)}") + return 1 + + if check: + print("Generated public assets are up to date.") + return 0 -def main() -> None: - sync_openapi_root() - sync_api_catalog() - sync_llms_index() - sync_llms_full() +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--check", + action="store_true", + help="Exit non-zero when generated assets or legacy redirects are stale.", + ) + args = parser.parse_args() + return sync_public_assets(check=args.check) if __name__ == "__main__": - main() + raise SystemExit(main()) diff --git a/scripts/test_sync_public_assets.py b/scripts/test_sync_public_assets.py new file mode 100644 index 0000000..b0e309b --- /dev/null +++ b/scripts/test_sync_public_assets.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +"""Regression tests for generated Lensmor API documentation assets.""" + +from __future__ import annotations + +import importlib.util +import json +import re +import subprocess +import sys +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts" / "sync-public-assets.py" + + +def load_sync_module(): + spec = importlib.util.spec_from_file_location("sync_public_assets", SCRIPT) + if spec is None or spec.loader is None: + raise RuntimeError(f"Unable to load {SCRIPT}") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +class PublicAssetSyncTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.sync = load_sync_module() + + def test_generated_openapi_artifacts_are_identical(self) -> None: + outputs = self.sync.build_outputs() + source = self.sync.OPENAPI_SOURCE.read_bytes() + + self.assertEqual(outputs[self.sync.OPENAPI_ROOT], source) + self.assertEqual(outputs[self.sync.OPENAPI_BACKUP], source) + + def test_llms_full_covers_every_navigation_entry(self) -> None: + outputs = self.sync.build_outputs() + llms_full = outputs[self.sync.LLMS_FULL].decode("utf-8") + sources = [ + line.removeprefix("Source: ") + for line in llms_full.splitlines() + if line.startswith("Source: ") + ] + config = json.loads(self.sync.DOCS_JSON.read_text(encoding="utf-8")) + expected_pages = self.sync.navigation_pages(config) + expected_api_pages = self.sync.openapi_operation_routes() + + self.assertEqual(len(sources), len(expected_pages)) + self.assertEqual( + len([source for source in sources if source.startswith("/api-reference/")]), + len(expected_api_pages), + ) + self.assertEqual(len(sources), len(set(sources))) + + def test_legacy_api_routes_have_permanent_redirects(self) -> None: + missing = self.sync.missing_legacy_redirects() + self.assertEqual(missing, []) + + def test_llms_full_has_no_known_truncated_fragments(self) -> None: + outputs = self.sync.build_outputs() + llms_full = outputs[self.sync.LLMS_FULL].decode("utf-8") + + for fragment in ("guaubmitted", "taskot create", "completaskStatus"): + with self.subTest(fragment=fragment): + self.assertFalse( + fragment in llms_full, + f"Generated llms-full.txt contains truncated fragment: {fragment}", + ) + + def test_internal_api_reference_links_target_generated_routes(self) -> None: + valid_routes = set(self.sync.openapi_operation_routes().values()) + + public_sources = [ + ROOT / "index.mdx", + *sorted((ROOT / "guides").rglob("*.mdx")), + *sorted((ROOT / "concepts").rglob("*.mdx")), + *sorted((ROOT / "api-reference-backup").rglob("*.mdx")), + ] + invalid: list[str] = [] + link_pattern = re.compile(r"/api-reference/[A-Za-z0-9/_-]+") + for path in public_sources: + for link in link_pattern.findall(path.read_text(encoding="utf-8")): + if link not in valid_routes: + invalid.append(f"{path.relative_to(ROOT)}: {link}") + + self.assertEqual(invalid, []) + + def test_check_mode_is_clean_and_read_only(self) -> None: + tracked_outputs = [ + self.sync.OPENAPI_ROOT, + self.sync.OPENAPI_BACKUP, + self.sync.API_CATALOG, + self.sync.LLMS_INDEX, + self.sync.LLMS_FULL, + ] + before = {path: path.read_bytes() for path in tracked_outputs} + result = subprocess.run( + [sys.executable, str(SCRIPT), "--check"], + cwd=ROOT, + check=False, + capture_output=True, + text=True, + ) + after = {path: path.read_bytes() for path in tracked_outputs} + + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(after, before) + + +if __name__ == "__main__": + unittest.main()