Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
f08e175
docs: design real entity enrichment producers
jeevanpillay Jun 7, 2026
ca660a7
docs: clarify entity graph people migration path
jeevanpillay Jun 7, 2026
011b44c
docs: scope entity enrichment to signals
jeevanpillay Jun 7, 2026
fbf7370
docs: clarify signal enrichment visibility
jeevanpillay Jun 7, 2026
e9628dc
docs: plan signal scoped entity enrichment
jeevanpillay Jun 7, 2026
477f562
feat: add entity graph people source
jeevanpillay Jun 7, 2026
8ff6794
feat: derive signal enrichment targets
jeevanpillay Jun 7, 2026
49173fb
feat: add signal entity enrichment events
jeevanpillay Jun 7, 2026
f1a6dcd
feat: queue signal entity enrichment
jeevanpillay Jun 7, 2026
7c84a5a
feat: add entity enrichment adapters
jeevanpillay Jun 7, 2026
08abadf
feat: fetch signal enrichment profiles
jeevanpillay Jun 7, 2026
65f6359
feat: add signal entity enrichment workflow
jeevanpillay Jun 7, 2026
2705627
feat: project graph people to people bridge
jeevanpillay Jun 7, 2026
22d7d73
feat: reconcile signal links after entity graph persistence
jeevanpillay Jun 7, 2026
bf1a6ef
feat: return rich x user profiles
jeevanpillay Jun 7, 2026
db0c9b3
feat: emulate github user profiles
jeevanpillay Jun 7, 2026
24b939c
feat: add signal enrichment retry harness
jeevanpillay Jun 7, 2026
4a887c2
fix: stabilize signal entity enrichment
jeevanpillay Jun 7, 2026
3bfee3b
feat: enable local signal entity enrichment
jeevanpillay Jun 7, 2026
9ff7bc6
fix: address entity enrichment review feedback
jeevanpillay Jun 7, 2026
2d1339c
fix: stabilize graph person projection aliases
jeevanpillay Jun 7, 2026
ac87b5b
fix: stabilize detail sheet cross-links
jeevanpillay Jun 7, 2026
d43d2fc
fix: apply CodeRabbit auto-fixes
jeevanpillay Jun 7, 2026
3e8447c
Merge remote-tracking branch 'origin/main' into feat/real-entity-enri…
jeevanpillay Jun 7, 2026
00b0d24
ci: make turbo summary artifacts best-effort
jeevanpillay Jun 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/actions/turbo-summary/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ runs:
fi
- name: Upload .turbo/runs artifact
if: always()
continue-on-error: true
uses: actions/upload-artifact@v4
with:
name: ${{ inputs.artifact-name }}
Expand Down
68 changes: 68 additions & 0 deletions ai/src/__tests__/signal-classifier/classify.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import {
buildSignalClassificationRequest,
classifySignalInput,
classifySignalInputLocally,
getSignalClassificationFailure,
SIGNAL_CLASSIFICATION_FAILED_ERROR_CODE,
SIGNAL_CLASSIFICATION_INVALID_OUTPUT_ERROR_CODE,
Expand Down Expand Up @@ -139,6 +140,73 @@ describe("classifySignalInput", () => {
expect(request).not.toHaveProperty("organizationIdentitySystemSection");
});

it("builds a deterministic local team classification for explicit person identities", () => {
expect(
classifySignalInputLocally({
input:
"Met Ava Chen today. Follow up with @ava_ai and review https://github.com/avachen.",
signalId,
})
).toEqual(
expect.objectContaining({
schemaVersion: "signal.classification.v2",
disposition: "actionable",
kind: "follow_up",
priority: "normal",
routing: {
visibility: {
scope: "team",
rationale:
"Local development classifier found explicit person identity handles or profile URLs.",
},
review: {
required: false,
reason: null,
rationale: null,
},
routes: {
people: {
shouldRun: true,
confidence: 0.8,
rationale:
"Local development classifier found deterministic person identity candidates.",
},
},
},
})
);
});

it("keeps deterministic local classifications user-visible without explicit identities", () => {
expect(
classifySignalInputLocally({
input: "Remember to update the onboarding notes.",
signalId,
})
).toEqual(
expect.objectContaining({
schemaVersion: "signal.classification.v2",
disposition: "actionable",
kind: "remember",
routing: expect.objectContaining({
visibility: {
scope: "user",
rationale:
"Local development classifier did not find explicit durable person identities.",
},
routes: {
people: {
shouldRun: false,
confidence: 0,
rationale:
"Local development classifier only routes explicit durable person identities.",
},
},
}),
})
);
});

it("uses AI SDK structured output with metadata-only telemetry", async () => {
const model = createClassifierModel(
JSON.stringify(modelOwnedClassification)
Expand Down
1 change: 1 addition & 0 deletions ai/src/signal-classifier/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from "./classify";
export * from "./constants";
export * from "./errors";
export * from "./local";
export * from "./prompt";
export * from "./schema";
143 changes: 143 additions & 0 deletions ai/src/signal-classifier/local.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import {
type SignalClassification,
signalClassificationSchema,
type signalKindSchema,
type signalPrioritySchema,
} from "@repo/api-contract";
import type { z } from "zod";

import { extractDeterministicSignalEntityLinks } from "../signal-entity-linker/extract";
import { SIGNAL_CLASSIFICATION_SCHEMA_VERSION } from "./constants";

type SignalKind = z.infer<typeof signalKindSchema>;
type SignalPriority = z.infer<typeof signalPrioritySchema>;

export interface LocalSignalClassificationInput {
input: string;
signalId: string;
}

export function classifySignalInputLocally(
input: LocalSignalClassificationInput
): SignalClassification {
const normalizedInput = normalizeInput(input.input);
const deterministicCandidates = extractDeterministicSignalEntityLinks({
input: input.input,
});
const hasExplicitPersonIdentity = deterministicCandidates.length > 0;
const kind = inferSignalKind(normalizedInput);
const priority = inferSignalPriority(normalizedInput);

return signalClassificationSchema.parse({
schemaVersion: SIGNAL_CLASSIFICATION_SCHEMA_VERSION,
disposition: "actionable",
title: buildTitle(normalizedInput),
summary: hasExplicitPersonIdentity
? "Local development classifier found explicit person identities in the signal."
: "Local development classifier routed the signal without external AI.",
kind,
nextAction: inferNextAction(kind, hasExplicitPersonIdentity),
priority,
rationale: hasExplicitPersonIdentity
? "Local development classifier uses deterministic emails, handles, and profile URLs when AI Gateway credentials are unavailable."
: "Local development classifier keeps keyless local dev usable while preserving production AI classification.",
confidence: hasExplicitPersonIdentity ? 0.8 : 0.55,
routing: {
visibility: {
scope: hasExplicitPersonIdentity ? "team" : "user",
rationale: hasExplicitPersonIdentity
? "Local development classifier found explicit person identity handles or profile URLs."
: "Local development classifier did not find explicit durable person identities.",
},
review: {
required: false,
reason: null,
rationale: null,
},
routes: {
people: {
shouldRun: hasExplicitPersonIdentity,
confidence: hasExplicitPersonIdentity ? 0.8 : 0,
rationale: hasExplicitPersonIdentity
? "Local development classifier found deterministic person identity candidates."
: "Local development classifier only routes explicit durable person identities.",
},
},
},
});
}

function normalizeInput(input: string): string {
return input.trim().replace(/\s+/g, " ");
}

function buildTitle(input: string): string {
const firstSentence = input.split(/[.!?]/u)[0]?.trim() ?? "";
const title = firstSentence.length > 0 ? firstSentence : "Local signal";

return title.length <= 80 ? title : `${title.slice(0, 77).trimEnd()}...`;
}

function inferSignalKind(input: string): SignalKind {
const lower = input.toLowerCase();

if (lower.includes("follow up") || lower.includes("follow-up")) {
return "follow_up";
}

if (lower.includes("review")) {
return "review";
}

if (lower.includes("fix")) {
return "fix";
}

if (lower.includes("investigate") || lower.includes("debug")) {
return "investigate";
}

if (lower.includes("remember") || lower.includes("note")) {
return "remember";
}

if (
lower.includes("engage") ||
lower.includes("reply") ||
lower.includes("message") ||
lower.includes("dm ")
) {
return "engage";
}

return "other";
}

function inferSignalPriority(input: string): SignalPriority {
const lower = input.toLowerCase();

if (lower.includes("urgent") || lower.includes("asap")) {
return "urgent";
}

if (lower.includes("high priority") || lower.includes("important")) {
return "high";
}

return "normal";
}

function inferNextAction(
kind: SignalKind,
hasExplicitPersonIdentity: boolean
): string {
if (hasExplicitPersonIdentity) {
return "Review the referenced person identities and decide the next outreach step.";
}

if (kind === "remember") {
return "Keep the note available to the creator.";
}

return "Review the signal when local AI credentials are available.";
}
31 changes: 28 additions & 3 deletions api/app/src/__tests__/connectors-x-mcp-bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,8 @@ vi.mock("../env", () => ({ env: envMock }));
const { issueConnectorMcpToken } = await import(
"../services/connectors/mcp-auth"
);
const { handleXConnectorMcpRequest } = await import(
"../services/connectors/x-mcp-bridge"
);
const { getFreshXConnectorAccessToken, handleXConnectorMcpRequest } =
await import("../services/connectors/x-mcp-bridge");
const { X_OAUTH_SCOPES } = await import("@repo/x-app-node");

function connection(
Expand Down Expand Up @@ -310,6 +309,32 @@ describe("X MCP bridge service", () => {
);
});

it("exposes the shared fresh access-token helper", async () => {
await expect(
getFreshXConnectorAccessToken({
config: {
appOrigin: "https://app.lightfast.localhost",
clientId: "x_client_test",
clientSecret: "x_secret_test",
endpoints: {
apiOrigin: "https://x.test",
mcpEndpoint: "https://app.lightfast.localhost/api/connectors/x/mcp",
oauthAuthorizeUrl: "https://x.test/i/oauth2/authorize",
oauthRevokeUrl: "https://x.test/2/oauth2/revoke",
oauthTokenUrl: "https://x.test/2/oauth2/token",
viewerUrl: "https://x.test/2/users/me",
},
},
connection: connection(),
})
).resolves.toBe("x_access_token");

expect(decryptMock).toHaveBeenCalledWith(
"encrypted_x_access",
envMock.ENCRYPTION_KEY
);
});

it("rejects write tool calls missing granted scopes", async () => {
getCurrentOrgConnectorConnectionMock.mockResolvedValueOnce(
connection({
Expand Down
Loading
Loading