Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
328 changes: 312 additions & 16 deletions sdks/typescript/package-lock.json

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion sdks/typescript/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
],
"scripts": {
"generate:schemas": "tsx scripts/generate-schema.ts ../../evals/prompts/purpose/config.json",
"generate:kg-types": "openapi-typescript https://docs.learningcommons.org/api-reference/knowledge-graph-api/openapi.yaml -o src/knowledge-graph/kg-api.d.ts",
"generate:schemas:check": "tsx scripts/generate-schema.ts --check ../../evals/prompts/purpose/config.json",
"build": "tsup",
"dev": "tsup --watch",
Expand Down Expand Up @@ -93,16 +94,17 @@
"@ai-sdk/anthropic": "^3.0.12",
"@ai-sdk/google": "^3.0.7",
"@ai-sdk/openai": "^3.0.9",
"@eslint/js": "^10.0.1",
"@types/node": "^25.6.0",
"@types/prompts": "^2.4.9",
"@eslint/js": "^10.0.1",
"@typescript-eslint/eslint-plugin": "^8.59.4",
"@typescript-eslint/parser": "^8.59.4",
"@vitest/coverage-v8": "^4.0.17",
"ai": "^6.0.30",
"eslint": "^10.4.0",
"globals": "^17.6.0",
"json-schema-to-zod": "^2.8.1",
"openapi-typescript": "^7.13.0",
"tsup": "^8.0.1",
"tsx": "^4.21.0",
"typescript": "^5.3.3",
Expand Down
10 changes: 10 additions & 0 deletions sdks/typescript/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,16 @@ export class NetworkError extends APIError {
}
}

/**
* Knowledge Graph error - thrown when KG API calls fail
*/
export class KnowledgeGraphError extends EvaluatorError {
constructor(message: string, public readonly statusCode?: number) {
super(message, 'KNOWLEDGE_GRAPH_ERROR');
this.name = 'KnowledgeGraphError';
}
}
Comment thread
adnanrhussain marked this conversation as resolved.

/**
* Timeout error - thrown when requests exceed timeout limits
* Should be retried with caution
Expand Down
3 changes: 3 additions & 0 deletions sdks/typescript/src/evaluators/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ export interface BaseEvaluatorConfig {
/** Learning Commons partner key for authenticated telemetry (optional) */
partnerKey?: string;

/** Learning Commons platform API key — used for Knowledge Graph API access */
platformApiKey?: string;

/**
* Override the provider and model used by this evaluator.
* When set, all LLM calls use this provider and model instead of the defaults.
Expand Down
1 change: 1 addition & 0 deletions sdks/typescript/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export {
RateLimitError,
NetworkError,
TimeoutError,
KnowledgeGraphError,
} from './errors.js';

// Logger
Expand Down
12 changes: 12 additions & 0 deletions sdks/typescript/src/knowledge-graph/README.md

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice! 🙌

Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Knowledge Graph client

HTTP client for the [Learning Commons Knowledge Graph API](https://api.learningcommons.org/knowledge-graph/v0).

## Regenerate types

```bash
npm run generate:kg-types
```

Pulls from `https://docs.learningcommons.org/api-reference/knowledge-graph-api/openapi.yaml`.
`kg-api.d.ts` is generated — do not edit by hand.
176 changes: 176 additions & 0 deletions sdks/typescript/src/knowledge-graph/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import pLimit from 'p-limit';
import {
KnowledgeGraphError,
AuthenticationError,
RateLimitError,
NetworkError,
} from '../errors.js';
import type { AcademicStandard, LearningComponent, StandardInfo } from './types.js';
import type { components } from './kg-api.js';

const KG_BASE_URL = 'https://api.learningcommons.org/knowledge-graph/v0';
const CCSS_FRAMEWORK_UUID = 'c6496676-d7cb-11e8-824f-0242ac160002';
const KG_TIMEOUT_MS = 30_000;

type SpecLearningComponent = components['schemas']['LearningComponent'];

async function kgFetch(url: string, apiKey: string): Promise<unknown> {
let response: Response;
try {
response = await fetch(url, {
headers: { 'x-api-key': apiKey },
signal: AbortSignal.timeout(KG_TIMEOUT_MS),
});
} catch (err) {
if (err instanceof Error && err.name === 'TimeoutError') {
throw new NetworkError(`Knowledge Graph request timed out after ${KG_TIMEOUT_MS}ms`);
}
throw new NetworkError(`Knowledge Graph request failed: ${err instanceof Error ? err.message : String(err)}`);
}

if (response.ok) {
return response.json().catch((err: unknown) => {
throw new KnowledgeGraphError(
`Knowledge Graph returned invalid JSON: ${err instanceof Error ? err.message : String(err)}`,
);
});
}

const body = await response.text().catch(() => '');

if (response.status === 401 || response.status === 403) {
throw new AuthenticationError(`Knowledge Graph authentication failed: ${body}`, response.status);
}
if (response.status === 429) {
throw new RateLimitError(`Knowledge Graph rate limit exceeded: ${body}`);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Is this retryable?

}
throw new KnowledgeGraphError(
`Knowledge Graph request failed (${response.status}): ${body}`,
response.status,
);
}

/**
* HTTP client for the Learning Commons Knowledge Graph API.
*
* Handles standard and learning component lookups with concurrency limiting
* and promise caching. Rejected promises are evicted so transient errors
* (429, network blips) are retryable on the next call.
*/
export class KnowledgeGraphClient {
private readonly apiKey: string;
private readonly limit: ReturnType<typeof pLimit>;

private readonly standardInfoCache = new Map<string, Promise<StandardInfo>>();
private readonly lcCache = new Map<string, Promise<LearningComponent[]>>();
Comment on lines +64 to +65

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have caching! Nice!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] AI says these are unbounded. Valid concern. But I think we can fit all the data these have in memory, right?


constructor(apiKey: string, concurrency = 20) {
this.apiKey = apiKey;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] validate not empty.
I think you check the config elsewhere for this, right?

this.limit = pLimit(concurrency);
}

getStandardInfo(statementCode: string): Promise<StandardInfo> {
let p = this.standardInfoCache.get(statementCode);
if (!p) {
p = this.limit(() => this._fetchStandardInfo(statementCode));
p = p.catch((err) => { this.standardInfoCache.delete(statementCode); throw err; });
this.standardInfoCache.set(statementCode, p);
}
return p;
}

getLearningComponents(caseIdentifierUUID: string): Promise<LearningComponent[]> {
let p = this.lcCache.get(caseIdentifierUUID);
if (!p) {
p = this.limit(() => this._fetchLearningComponents(caseIdentifierUUID));
p = p.catch((err) => { this.lcCache.delete(caseIdentifierUUID); throw err; });
this.lcCache.set(caseIdentifierUUID, p);
}
return p;
}

async getStandardsByGrade(grade: string): Promise<AcademicStandard[]> {
const url =
`${KG_BASE_URL}/academic-standards?limit=500` +
`&standardsFrameworkCaseIdentifierUUID=${CCSS_FRAMEWORK_UUID}` +
`&academicSubject=Mathematics` +
`&gradeLevel=${encodeURIComponent(grade)}` +
`&normalizedStatementType=Standard`;

const data = (await kgFetch(url, this.apiKey)) as {
data: Array<AcademicStandard>;
pagination?: { hasMore: boolean; nextCursor: string | null };
};

if (data.pagination?.hasMore) {
throw new KnowledgeGraphError(
`getStandardsByGrade returned a paginated result for grade "${grade}" — ` +
`increase limit or implement cursor pagination to retrieve all standards.`,
);
}

return (data.data ?? []).map((item) => ({
caseIdentifierUUID: item.caseIdentifierUUID,
statementCode: item.statementCode,
description: item.description,
statementType: item.statementType,
normalizedStatementType: item.normalizedStatementType,
gradeLevel: item.gradeLevel ?? [],
}));
}

async getLearningComponentsByCode(
statementCode: string,
): Promise<{ uuid: string; description?: string; components: LearningComponent[] }> {
const { uuid, description } = await this.getStandardInfo(statementCode);
const components = await this.getLearningComponents(uuid);
return { uuid, description, components };
}

// ---------------------------------------------------------------------------

private async _fetchStandardInfo(statementCode: string): Promise<StandardInfo> {
const url = `${KG_BASE_URL}/academic-standards/search?jurisdiction=Multi-State&statementCode=${encodeURIComponent(statementCode)}`;
const data = (await kgFetch(url, this.apiKey)) as Array<{ caseIdentifierUUID: string; description?: string }>;

if (!Array.isArray(data) || data.length === 0) {
throw new KnowledgeGraphError(`Standard not found: "${statementCode}"`);
}
if (data.length > 1) {
throw new KnowledgeGraphError(`Ambiguous standard code: "${statementCode}", ${data.length} results returned`);
}
return { uuid: data[0].caseIdentifierUUID, description: data[0].description };
}

private async _fetchLearningComponents(caseIdentifierUUID: string): Promise<LearningComponent[]> {
const results: LearningComponent[] = [];
let cursor: string | null = null;

do {
const url =
`${KG_BASE_URL}/academic-standards/${encodeURIComponent(caseIdentifierUUID)}/learning-components?limit=100` +
(cursor ? `&cursor=${encodeURIComponent(cursor)}` : '');

const page = (await kgFetch(url, this.apiKey)) as {
data: SpecLearningComponent[];
pagination: { hasMore: boolean; nextCursor: string | null };
};

for (const item of page.data ?? []) {
if (item.description != null) {
results.push({ identifier: item.identifier, description: item.description });
}
}

const { hasMore, nextCursor } = page.pagination ?? {};
if (hasMore && !nextCursor) {
throw new KnowledgeGraphError(
`Knowledge Graph pagination error: hasMore=true but nextCursor is null for UUID ${caseIdentifierUUID}`,
);
}
cursor = hasMore ? (nextCursor ?? null) : null;
} while (cursor !== null);

return results;
}
}
2 changes: 2 additions & 0 deletions sdks/typescript/src/knowledge-graph/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export type { AcademicStandard, LearningComponent, StandardInfo } from './types.js';
export { KnowledgeGraphClient } from './client.js';
Loading