-
Notifications
You must be signed in to change notification settings - Fork 10
feat(ts-sdk): Knowledge Graph client layer #97
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
base: main
Are you sure you want to change the base?
Changes from all commits
0d48496
27f3d02
8418e6a
c3bbdfb
ee3bf26
ae7ca9c
c185b70
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
| 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}`); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We have caching! Nice!
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P1] validate not empty. |
||
| 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; | ||
| } | ||
| } | ||
| 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'; |
Uh oh!
There was an error while loading. Please reload this page.