Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

H-1755, H-3349, H-3351: Enable setting and using plural and inverse type names #5579

Merged
merged 8 commits into from
Nov 5, 2024
Merged
1 change: 1 addition & 0 deletions apps/hash-api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ The HASH Backend API service is configured using the following environment varia
over an HTTPS connection.
- `INTERNAL_API_KEY`: The API key used to authenticate with HASH (the company)'s internal API, required for some functionality specific to hosted HASH (the app)
- `INTERNAL_API_HOST`: The host for the internal API, required if the internal API is not running locally
- `OPENAI_API_KEY`: The API key used to authenticate with OpenAI's API, used for some non-essential generation functionality (e.g. suggesting the pluralized form of type names)
- `STATSD_ENABLED`: (optional) set to "1" if the service should report metrics to a
StatsD server. If enabled, the following variables must be set:
- `STATSD_HOST`: the hostname of the StatsD server.
Expand Down
2 changes: 2 additions & 0 deletions apps/hash-api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@
"cors": "2.8.5",
"cross-env": "7.0.3",
"dedent": "0.7.0",
"exponential-backoff": "3.1.1",
"express": "4.21.1",
"express-handlebars": "7.1.3",
"express-http-proxy": "2.1.1",
Expand All @@ -96,6 +97,7 @@
"nanoid": "3.3.7",
"nodemailer": "6.9.16",
"oembed-providers": "1.0.20241022",
"openai": "4.68.4",
"ts-json-schema-generator": "1.5.1",
"tsx": "4.19.2",
"typescript": "5.6.3",
Expand Down
91 changes: 91 additions & 0 deletions apps/hash-api/src/graphql/resolvers/generation/generate-inverse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { stringifyError } from "@local/hash-isomorphic-utils/stringify-error";
import { ApolloError } from "apollo-server-errors";
import { ForbiddenError } from "apollo-server-express";
import { backOff } from "exponential-backoff";

import type { QueryGenerateInverseArgs, ResolverFn } from "../../api-types.gen";
import type { GraphQLContext } from "../../context";
import { getOpenAiClient } from "./shared/openai-client";

const generatePrompt = (relationship: string): string => `
You are building the ontology for a knowledge graph. You have a directed relationship between two nodes called "${relationship}".

We're looking for a description of the relationship in the other direction, i.e. if 'X ${relationship} Y', then 'Y [inverse] X'.

Examples:
- "Parent Of" -> "Child Of"
- "Employee Of" -> "Employer Of"
- "Owner Of" -> "Owned By"
- "Succeeded At" -> "Was Success Of"
- "Majored In" -> "Was Majored In By"

Please provide a name for the inverse relationship, without quotation marks. Do not provide any other information – your response will be fed directly into the system you're building.

We're NOT looking for a description of the opposite concept.

For example,
The inverse of "Succeeded At" could be "Was Success Of", because if 'X succeeded at Y', then 'Y was a success of X'.
It's NOT "Failed At", which does not describe the relationship in the opposite direction, but instead the opposite concept.

Pay attention to the tense of the relationship. The tense of the inverse should match the tense of the original.
For example, if someone 'Majored In' something the inverse is 'Was Majored In By', but if someone 'Majors In' something the inverse could be 'Majored In By'
– they are still majoring in that thing.

Match the words in the original as much as possible – don't replace key words with synonyms unless necessary.

Given those requirements, what is the inverse of ${relationship}? Or in other words, fill in the blank: 'If X ${relationship} Y, then Y [inverse] X.'

Don't append 'X'!
`;

export const generateInverseResolver: ResolverFn<
Promise<string>,
Record<string, never>,
GraphQLContext,
QueryGenerateInverseArgs
> = async (_, params, graphQLContext) => {
if (!graphQLContext.user?.isAccountSignupComplete) {
throw new ForbiddenError("No user found");
}

const { relationship } = params;

const openAiClient = getOpenAiClient();

try {
const responseMessage = await backOff(
async () => {
const response = await openAiClient.chat.completions.create({
model: "gpt-4o-mini",
messages: [
{
role: "user",
content: generatePrompt(relationship),
},
],
});

const message = response.choices[0]?.message.content;

if (!message) {
throw new Error("Empty response from AI model");
}

return message;
},
{
maxDelay: 300,
numOfAttempts: 3,
},
);

return responseMessage;
} catch (err) {
graphQLContext.logger.error(
`Failed to generate inverse relationship for '${relationship}': ${stringifyError(err)}`,
);
throw new ApolloError(
`Failed to generate inverse relationship for ${relationship}`,
);
}
};
81 changes: 81 additions & 0 deletions apps/hash-api/src/graphql/resolvers/generation/generate-plural.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { stringifyError } from "@local/hash-isomorphic-utils/stringify-error";
import { ApolloError } from "apollo-server-errors";
import { ForbiddenError } from "apollo-server-express";
import { backOff } from "exponential-backoff";

import type { QueryGeneratePluralArgs, ResolverFn } from "../../api-types.gen";
import type { GraphQLContext } from "../../context";
import { getOpenAiClient } from "./shared/openai-client";

const generatePrompt = (type: string): string => `
You are building the ontology for a knowledge graph.

You need to come up with a name for the plural form for the type named "${type}".

This will be used to describe multiple instances of the type, for example "View all [plural form]"

Examples:
- "Company" -> "Companies"
- "Person" -> "People"
- "Child" -> "Children"
- "Data" -> "Data"

If the type represents a link between two entities, it may be called something like "Is Child Of".

In this case, the plural should be "Is Child Ofs", because we're talking about multiple of the "Is Child Of" link,
NOT multiple children.

Please provide the plural form, without quotation marks. Do not provide any other information – your response will be fed directly into the system you're building.

What is the plural of ${type}, that we can use when saying 'View all ${type}'?
`;

export const generatePluralResolver: ResolverFn<
Promise<string>,
Record<string, never>,
GraphQLContext,
QueryGeneratePluralArgs
> = async (_, params, graphQLContext) => {
if (!graphQLContext.user?.isAccountSignupComplete) {
throw new ForbiddenError("No user found");
}

const { singular } = params;

const openAiClient = getOpenAiClient();

try {
const responseMessage = await backOff(
async () => {
const response = await openAiClient.chat.completions.create({
model: "gpt-4o-mini",
messages: [
{
role: "user",
content: generatePrompt(singular),
},
],
});

const message = response.choices[0]?.message.content;

if (!message) {
throw new Error("Empty response from AI model");
}

return message;
},
{
maxDelay: 300,
numOfAttempts: 3,
},
);

return responseMessage;
} catch (err) {
graphQLContext.logger.error(
`Failed to generate plural for '${singular}': ${stringifyError(err)}`,
);
throw new ApolloError(`Failed to generate plural for '${singular}'`);
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type OpenAI from "openai";

import type {
IsGenerationAvailableResponse,
ResolverFn,
} from "../../api-types.gen";
import type { GraphQLContext } from "../../context";
import { getOpenAiClient } from "./shared/openai-client";

export const isGenerationAvailableResolver: ResolverFn<
Promise<IsGenerationAvailableResponse>,
Record<string, never>,
GraphQLContext,
Record<string, never>
> = async (_parent, _params, graphQLContext) => {
if (!graphQLContext.user?.isAccountSignupComplete) {
return {
available: false,
reason: "No authenticated user",
};
}

let openAiClient: OpenAI | undefined;
try {
openAiClient = getOpenAiClient();
} catch {
return {
available: false,
reason: "No OpenAI API key available",
};
}

try {
await openAiClient.models.list();

return {
available: true,
};
} catch (err) {
return {
available: false,
reason: "Invalid OpenAI API key or API error",
};
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import OpenAI from "openai";

let openAiClient: OpenAI | undefined;

export const getOpenAiClient = (): OpenAI => {
if (!process.env.OPENAI_API_KEY) {
throw new Error("OPENAI_API_KEY environment variable not set.");
}

if (!openAiClient) {
openAiClient = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
}

return openAiClient;
};
7 changes: 7 additions & 0 deletions apps/hash-api/src/graphql/resolvers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ import { getFlowRunsResolver } from "./flows/get-flow-runs";
import { resetFlow } from "./flows/reset-flow";
import { startFlow } from "./flows/start-flow";
import { submitExternalInputResponse } from "./flows/submit-external-input-response";
import { generateInverseResolver } from "./generation/generate-inverse";
import { generatePluralResolver } from "./generation/generate-plural";
import { isGenerationAvailableResolver } from "./generation/is-generation-available";
import { getLinearOrganizationResolver } from "./integrations/linear/linear-organization";
import { syncLinearIntegrationWithWorkspacesMutation } from "./integrations/linear/sync-workspaces-with-teams";
import { blocksResolver } from "./knowledge/block/block";
Expand Down Expand Up @@ -138,6 +141,10 @@ export const resolvers: Omit<Resolvers, "Query" | "Mutation"> & {
checkUserPermissionsOnEntity({ metadata }, _, context, info),
checkUserPermissionsOnEntityType: checkUserPermissionsOnEntityTypeResolver,
hasAccessToHash: loggedInMiddleware(hasAccessToHashResolver),
// Generation
generateInverse: loggedInMiddleware(generateInverseResolver),
generatePlural: loggedInMiddleware(generatePluralResolver),
isGenerationAvailable: isGenerationAvailableResolver,
},

Mutation: {
Expand Down
22 changes: 22 additions & 0 deletions apps/hash-frontend/src/graphql/queries/generation.queries.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { gql } from "@apollo/client";

export const generateInverseQuery = gql`
query generateInverse($relationship: String!) {
generateInverse(relationship: $relationship)
}
`;

export const generatePluralQuery = gql`
query generatePlural($singular: String!) {
generatePlural(singular: $singular)
}
`;

export const isGenerationAvailableQuery = gql`
query isGenerationAvailable {
isGenerationAvailable {
available
reason
}
}
`;
9 changes: 6 additions & 3 deletions apps/hash-frontend/src/pages/[shortname].page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -211,9 +211,12 @@ const ProfilePage: NextPageWithLayout = () => {
({ schema }) => extractBaseUrl(schema.$id),
);

const title = entityType?.schema.title;

const pluralTitle = title ? pluralize(title) : undefined;
const pluralTitle =
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- we don't want an empty string
entityType?.schema.titlePlural ||
(entityType?.schema.title
? pluralize(entityType.schema.title)
: undefined);

return {
...tab,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,8 @@ const TableRow = memo(({ row }: { row: IncomingLinkRow }) => {
mr: 1,
}}
>
{linkEntityType.title}
{/* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- we don't want an empty string */}
{linkEntityType.inverse?.title || linkEntityType.title}
</ValueChip>
))}
<Typography
Expand Down Expand Up @@ -514,11 +515,17 @@ export const IncomingLinksTable = memo(

switch (field) {
case "linkTypes": {
return (
a.data.linkEntityTypes[0]!.title.localeCompare(
b.data.linkEntityTypes[0]!.title,
) * direction
);
const aValue =
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- we don't want an empty string
a.data.linkEntityTypes[0]!.inverse?.title ||
a.data.linkEntityTypes[0]!.title;

const bValue =
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- we don't want an empty string
b.data.linkEntityTypes[0]!.inverse?.title ||
b.data.linkEntityTypes[0]!.title;

return aValue.localeCompare(bValue) * direction;
}
case "linkedFromTypes": {
return (
Expand Down
Loading
Loading