diff --git a/CHANGELOG.md b/CHANGELOG.md index d1ce090..0258e56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,13 @@ All notable changes to this project are documented here. The format follows for an account, and `resubscribeWebhook` puts a lapsed subscription back. - `inbox().passThreadControl`, `takeThreadControl` and `handover` move a Messenger thread between Meta apps (`inbox` scope, plus `publish`). +- `knowledge()`: the workspace knowledge base — `list`, `create` (plus the + `createText`, `createUrl` and `createFile` shorthands), `update`, `delete`, + `sync` and `search`, with the `KnowledgeSource` and `KnowledgeMatch` records. + A source is an FAQ, a note, a URL on your own site or a plain-text/CSV media + item; `search` returns the passages closest to a question, and is what grounds + a drafted inbox reply in your own answers. Needs the `inbox` scope. + - `accounts()`: `listSlackChannels`, `listSlackMembers`, `getSlackIdentity` and `updateSlackIdentity` (`UpdateSlackIdentityParams`) for a Slack account. A webhook-connected account answers 409 `webhook_connection`. diff --git a/README.md b/README.md index a4c54ca..799b5fc 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,8 @@ long failed = client.posts().stream(PostListParams.create().workspaceId(workspac | `media()` | `list`, `upload`, `presign`, `complete`, `uploadDirect`, `delete` | | `ai()` | `credits`, `generateCaption`, `rewrite`, `repurposeUrl` | | `inbox()` | `list`, `threads`, `conversations`, `unreadCount`, `accounts`, `platforms`, `markThreadRead`, `markConversationRead`, `refresh`, `update`, `editComment`, `reply`, `hide`, `unhide`, `delete`, `like`, `unlike`, `pin`, `unpin`, `react`, `startConversation`, `setTyping`, `passThreadControl`, `takeThreadControl`, `handover`, `listApprovals`, `approveReply`, `rejectReply` | +| `inbox()` | `list`, `threads`, `conversations`, `unreadCount`, `accounts`, `platforms`, `markThreadRead`, `markConversationRead`, `refresh`, `update`, `editComment`, `reply`, `hide`, `unhide`, `delete`, `like`, `unlike`, `pin`, `unpin`, `react`, `startConversation`, `setTyping`, `listApprovals`, `approveReply`, `rejectReply` | +| `knowledge()` | `list`, `create`, `createText`, `createUrl`, `createFile`, `update`, `delete`, `sync`, `search` | | `ads()` | `list`, `external`, `boostable`, `connections`, `sources`, `authorizeMeta`, `deleteConnection`, `boost`, `create`, `refresh`, `setStatus`, `delete`, `accountTree`, `createCampaign`, `campaign`, `updateCampaign`, `deleteCampaign`, `duplicateCampaign`, `createAdSet`, `adSet`, `updateAdSet`, `deleteAdSet`, `duplicateAdSet`, `createNetworkAd`, `networkAd`, `updateNetworkAd`, `deleteNetworkAd`, `duplicateNetworkAd`, `bulkSetStatus`, `creatives`, `createCreative`, `creative`, `deleteCreative`, `estimateReach`, `insights`, `adInsights`, `audiences`, `createAudience`, `audience`, `updateAudience`, `deleteAudience`, `addAudienceUsers`, `searchTargeting`, `leadForms`, `createLeadForm`, `leadForm`, `archiveLeadForm`, `leads`, `leadsFeed`, `leadPages`, `subscribeLeadPage`, `unsubscribeLeadPage` | | `validate()` | `post`, `length`, `media` | diff --git a/src/main/java/com/fopost/sdk/FoPost.java b/src/main/java/com/fopost/sdk/FoPost.java index 9094803..c640497 100644 --- a/src/main/java/com/fopost/sdk/FoPost.java +++ b/src/main/java/com/fopost/sdk/FoPost.java @@ -13,6 +13,7 @@ import com.fopost.sdk.resource.AnalyticsResource; import com.fopost.sdk.resource.AutomationsResource; import com.fopost.sdk.resource.InboxResource; +import com.fopost.sdk.resource.KnowledgeResource; import com.fopost.sdk.resource.LabelsResource; import com.fopost.sdk.resource.MediaResource; import com.fopost.sdk.resource.PostsResource; @@ -60,6 +61,7 @@ public final class FoPost { private final AccountsResource accounts; private final AccountGroupsResource accountGroups; private final WorkspacesResource workspaces; + private final KnowledgeResource knowledge; private final LabelsResource labels; private final WebhooksResource webhooks; private final AnalyticsResource analytics; @@ -76,6 +78,7 @@ private FoPost(ApiClient http) { this.accounts = new AccountsResource(http); this.accountGroups = new AccountGroupsResource(http); this.workspaces = new WorkspacesResource(http); + this.knowledge = new KnowledgeResource(http); this.labels = new LabelsResource(http); this.webhooks = new WebhooksResource(http); this.analytics = new AnalyticsResource(http); @@ -116,6 +119,11 @@ public WorkspacesResource workspaces() { return workspaces; } + /** The workspace knowledge base, which grounds drafted replies. */ + public KnowledgeResource knowledge() { + return knowledge; + } + public LabelsResource labels() { return labels; } diff --git a/src/main/java/com/fopost/sdk/internal/ApiClient.java b/src/main/java/com/fopost/sdk/internal/ApiClient.java index 2cc76b8..57f16dc 100644 --- a/src/main/java/com/fopost/sdk/internal/ApiClient.java +++ b/src/main/java/com/fopost/sdk/internal/ApiClient.java @@ -76,6 +76,10 @@ public JsonNode put(String path, Object body) { return request("PUT", path, body, null); } + public JsonNode patch(String path, Object body) { + return request("PATCH", path, body, null); + } + public JsonNode delete(String path) { return request("DELETE", path, null, null); } diff --git a/src/main/java/com/fopost/sdk/model/KnowledgeMatch.java b/src/main/java/com/fopost/sdk/model/KnowledgeMatch.java new file mode 100644 index 0000000..7bb8bd3 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/KnowledgeMatch.java @@ -0,0 +1,14 @@ +package com.fopost.sdk.model; + +/** + * One retrieved passage, with the source it came from so a reply can cite it. + * + * @param score similarity to the question, 0-1 + */ +public record KnowledgeMatch( + String sourceId, + String sourceTitle, + String sourceKind, + String sourceUrl, + String text, + Double score) {} diff --git a/src/main/java/com/fopost/sdk/model/KnowledgeSource.java b/src/main/java/com/fopost/sdk/model/KnowledgeSource.java new file mode 100644 index 0000000..cb72c73 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/KnowledgeSource.java @@ -0,0 +1,32 @@ +package com.fopost.sdk.model; + +import java.time.Instant; + +/** + * One thing the workspace has told FoPost about itself: an FAQ, a note, a page + * on its own site, or a plain-text/CSV file from the media library. + * + * @param kind {@code faq}, {@code text}, {@code url} or {@code file} + * @param status {@code pending}, {@code syncing}, {@code ready} or {@code failed}; + * only a ready source is searched + * @param statusMessage why the last sync failed, in plain words + * @param url set for {@code url} sources + * @param mediaId set for {@code file} sources: the media library item read + * @param brandVoiceId null means the source serves the whole workspace + * @param chunkCount searchable passages the last sync produced + * @param content the typed text, for {@code faq} and {@code text} sources only + */ +public record KnowledgeSource( + String id, + String kind, + String title, + String status, + String statusMessage, + String url, + String mediaId, + String brandVoiceId, + Integer chunkCount, + String content, + Instant lastSyncedAt, + Instant createdAt, + Instant updatedAt) {} diff --git a/src/main/java/com/fopost/sdk/resource/KnowledgeResource.java b/src/main/java/com/fopost/sdk/resource/KnowledgeResource.java new file mode 100644 index 0000000..98f6b19 --- /dev/null +++ b/src/main/java/com/fopost/sdk/resource/KnowledgeResource.java @@ -0,0 +1,137 @@ +package com.fopost.sdk.resource; + +import com.fopost.sdk.internal.ApiClient; +import com.fopost.sdk.model.KnowledgeMatch; +import com.fopost.sdk.model.KnowledgeSource; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * The workspace knowledge base: what the workspace has told FoPost about + * itself. + * + *

A source is an FAQ, a note, a page on your own site, or a plain-text/CSV + * item from the media library. Retrieval over these is what grounds a drafted + * inbox reply in your own answers instead of an invented one. Needs the {@code + * inbox} scope. + */ +public final class KnowledgeResource { + + private final ApiClient http; + + public KnowledgeResource(ApiClient http) { + this.http = http; + } + + public List list() { + return list(null); + } + + /** Every source in the workspace. Only a {@code ready} one is searched. */ + public List list(String workspaceId) { + Map query = new LinkedHashMap<>(); + if (workspaceId != null) { + query.put("workspace_id", workspaceId); + } + return http.convertList( + ApiClient.unwrap(http.get("/v1/knowledge/sources", query)), KnowledgeSource.class); + } + + /** + * Adds a source and queues it for indexing, so it comes back {@code pending}. + * + *

{@code kind} is {@code faq}, {@code text}, {@code url} or {@code file}. An {@code faq} or + * {@code text} source needs {@code content}, a {@code url} source needs {@code url}, and a + * {@code file} source needs {@code mediaId} pointing at a plain-text or CSV item in the same + * workspace. Pass null for whatever the kind does not use. + */ + public KnowledgeSource create( + String kind, + String title, + String content, + String url, + String mediaId, + String brandVoiceId, + String workspaceId) { + Map body = new LinkedHashMap<>(); + body.put("kind", kind); + body.put("title", title); + putIfPresent(body, "content", content); + putIfPresent(body, "url", url); + putIfPresent(body, "media_id", mediaId); + putIfPresent(body, "brand_voice_id", brandVoiceId); + putIfPresent(body, "workspace_id", workspaceId); + return http.convert( + ApiClient.unwrap(http.post("/v1/knowledge/sources", body)), KnowledgeSource.class); + } + + /** An FAQ or a note, whose text you type here. */ + public KnowledgeSource createText(String kind, String title, String content) { + return create(kind, title, content, null, null, null, null); + } + + /** A page on your own site, re-read whenever you sync it. */ + public KnowledgeSource createUrl(String title, String url) { + return create("url", title, null, url, null, null, null); + } + + /** A plain-text or CSV item already in the media library. */ + public KnowledgeSource createFile(String title, String mediaId) { + return create("file", title, null, null, mediaId, null, null); + } + + /** + * Partial update: only the non-null fields are sent. Changing the content or the URL returns + * the source to {@code pending} and re-indexes it. + */ + public KnowledgeSource update( + String sourceId, String title, String content, String url, String brandVoiceId) { + Map body = new LinkedHashMap<>(); + putIfPresent(body, "title", title); + putIfPresent(body, "content", content); + putIfPresent(body, "url", url); + putIfPresent(body, "brand_voice_id", brandVoiceId); + return http.convert( + ApiClient.unwrap(http.patch("/v1/knowledge/sources/" + sourceId, body)), + KnowledgeSource.class); + } + + /** Removes the source and every passage indexed from it. */ + public void delete(String sourceId) { + http.delete("/v1/knowledge/sources/" + sourceId); + } + + /** + * Reads the source again — a {@code url} source is re-fetched. Returns once the re-index is + * queued, not once it has finished. + */ + public void sync(String sourceId) { + http.post("/v1/knowledge/sources/" + sourceId + "/sync", new LinkedHashMap()); + } + + public List search(String q) { + return search(q, null, null, null); + } + + /** + * The passages closest to a question, best first. An empty list is the honest answer when + * nothing stored answers it. {@code topK} defaults to 5 and caps at 20. + */ + public List search( + String q, Integer topK, String brandVoiceId, String workspaceId) { + Map query = new LinkedHashMap<>(); + query.put("q", q); + putIfPresent(query, "top_k", topK); + putIfPresent(query, "brand_voice_id", brandVoiceId); + putIfPresent(query, "workspace_id", workspaceId); + return http.convertList( + ApiClient.unwrap(http.get("/v1/knowledge/search", query)), KnowledgeMatch.class); + } + + private static void putIfPresent(Map target, String key, Object value) { + if (value != null) { + target.put(key, value); + } + } +} diff --git a/src/test/java/com/fopost/sdk/KnowledgeTest.java b/src/test/java/com/fopost/sdk/KnowledgeTest.java new file mode 100644 index 0000000..b140f8c --- /dev/null +++ b/src/test/java/com/fopost/sdk/KnowledgeTest.java @@ -0,0 +1,90 @@ +package com.fopost.sdk; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +/** + * The knowledge base: the path, the query casing, the snake_case request body, + * and that a camelCase response reads back into the record. + */ +class KnowledgeTest { + + private static final String SOURCE = "{\"id\":\"know_1\",\"kind\":\"url\",\"title\":\"Refund policy\"," + + "\"status\":\"ready\",\"statusMessage\":null,\"url\":\"https://yourbrand.com/help/refunds\"," + + "\"mediaId\":null,\"brandVoiceId\":null,\"chunkCount\":3,\"content\":null," + + "\"lastSyncedAt\":\"2026-09-20T00:00:00.000Z\",\"createdAt\":\"2026-09-19T00:00:00.000Z\"," + + "\"updatedAt\":\"2026-09-20T00:00:00.000Z\"}"; + + @Test + void listSendsTheWorkspaceFilterAndReadsCamelCaseFields() { + FakeTransport transport = new FakeTransport().enqueue(200, "{\"data\":[" + SOURCE + "]}"); + + var sources = TestSupport.client(transport).knowledge().list("w1"); + + assertEquals( + "https://api.fopost.test/v1/knowledge/sources?workspace_id=w1", transport.last().url()); + assertEquals(1, sources.size()); + assertEquals("ready", sources.get(0).status()); + assertEquals(3, sources.get(0).chunkCount()); + } + + @Test + void createSendsASnakeCaseBodyAndOmitsWhatTheKindDoesNotUse() { + FakeTransport transport = new FakeTransport().enqueue(200, "{\"data\":" + SOURCE + "}"); + + TestSupport.client(transport).knowledge().createFile("Price list", "media_1"); + + assertEquals("POST", transport.last().method()); + assertEquals("https://api.fopost.test/v1/knowledge/sources", transport.last().url()); + String body = transport.lastBody(); + assertTrue(body.contains("\"media_id\":\"media_1\""), body); + assertTrue(body.contains("\"kind\":\"file\""), body); + // Nothing the kind does not use reaches the wire. + assertFalse(body.contains("\"url\""), body); + assertFalse(body.contains("\"content\""), body); + } + + @Test + void updateSendsOnlyTheFieldsThatWereGiven() { + FakeTransport transport = new FakeTransport().enqueue(200, "{\"data\":" + SOURCE + "}"); + + TestSupport.client(transport).knowledge().update("know_1", "Refunds", null, null, null); + + assertEquals("PATCH", transport.last().method()); + assertEquals("https://api.fopost.test/v1/knowledge/sources/know_1", transport.last().url()); + assertEquals("{\"title\":\"Refunds\"}", transport.lastBody()); + } + + @Test + void searchSendsTopKAndReadsTheMatches() { + FakeTransport transport = new FakeTransport() + .enqueue( + 200, + "{\"data\":[{\"sourceId\":\"know_1\",\"sourceTitle\":\"Refund policy\"," + + "\"sourceKind\":\"url\",\"sourceUrl\":null," + + "\"text\":\"We refund within 30 days.\",\"score\":0.82}]}"); + + var matches = TestSupport.client(transport).knowledge().search("refunds", 3, null, null); + + assertTrue(transport.last().url().contains("q=refunds"), transport.last().url()); + assertTrue(transport.last().url().contains("top_k=3"), transport.last().url()); + assertEquals(1, matches.size()); + assertEquals("Refund policy", matches.get(0).sourceTitle()); + assertEquals(0.82, matches.get(0).score(), 0.0001); + } + + @Test + void syncPostsToTheSourcesSyncPath() { + FakeTransport transport = + new FakeTransport().enqueue(200, "{\"data\":{\"id\":\"know_1\",\"status\":\"pending\"}}"); + + TestSupport.client(transport).knowledge().sync("know_1"); + + assertEquals("POST", transport.last().method()); + assertEquals( + "https://api.fopost.test/v1/knowledge/sources/know_1/sync", transport.last().url()); + } +}