@nytka/plugin-typesafe
Version:
TypeSafe connector for nytka projects. Asks Jev typed questions (noul, choice, score) about a file or an existing dataset and registers the judgements in datasets/ with the model, the questions hash and the usage.
130 lines (119 loc) • 5.91 kB
JavaScript
// Programmatic API. The CLI is a thin wrapper over exactly these.
export {
client, credentials, modelFromEnv, ask, classify, plan as planItems, prepare, validateQuestions, questionShape,
estimateTokens, estimateCost, detectKeyField, datasetId, questionsKey, subjectKey, slugify, shortHash, canonical,
explain, backoffMs,
API_URL, API_KEY_ENV, MODEL_ENV, DEFAULT_MODEL, RATE_USD_PER_MTOK, RATE_AS_OF, STATE_TOKEN_LIMIT, QUESTION_TYPES,
RETRIABLE_STATUS, UNRETRIABLE_CODES, MAX_RETRIES, MAX_RETRY_AFTER_MS, DEFAULT_CONCURRENCY, MAX_CONCURRENCY,
TypeSafeError, TypeSafeHttpError,
} from './typesafe.mjs'
export { loadQuestions, loadState, resolveInput, ITEMS_KEYS } from './inputs.mjs'
// Re-exported from @nytka/core so a caller that has this package installed does not need to
// add core to reach the plumbing. Same courtesy every connector extends.
export { findProjectRoot, loadEnv, writePayload, registerDataset, isoDate } from '@nytka/core'
import { client, classify, plan as planItems, modelFromEnv, datasetId, shortHash, canonical, questionShape } from './typesafe.mjs'
import { loadQuestions, resolveInput } from './inputs.mjs'
import { findProjectRoot, loadEnv, writePayload, registerDataset, isoDate } from '@nytka/core'
function projectRootOf (root) {
const projectRoot = root || findProjectRoot()
if (!projectRoot) throw new Error('no project.yaml found walking up from cwd — run inside a nytka project')
return projectRoot
}
/** What run() would do, priced, with the input and questions resolved the same way run()
* resolves them. Sends nothing, constructs no client, needs no credential — the plan is
* what an agent reads instead of the payload, so it must be free. */
export async function plan ({ root, input, questions, field = null, limit = null, model, itemsKey = null } = {}) {
const projectRoot = projectRootOf(root)
loadEnv(projectRoot)
const requested = model ?? modelFromEnv()
const q = await loadQuestions(projectRoot, questions)
const src = await resolveInput(projectRoot, input, { itemsKey })
const p = planItems({ items: src.items, questions: q.questions, model: requested, field, limit })
return {
...p,
id: datasetId({ input: src, questions: q.questions, questionsName: q.name, model: requested }),
input: { kind: src.kind, ref: src.ref, rawPath: src.rawPath, itemsKey: src.itemsKey, available: src.items.length },
questions: { name: q.name, path: q.path, hash: shortHash(canonical(q.questions)), ...questionShape(q.questions) },
}
}
/** Classify and store in one call: writes the payload into the project's datasets/ and
* registers it in datasets/index.json.
*
* Returns { id, model, items, rowCount, calls, usage, estimatedCost, rawPath, registered }
* — never the rows. Payloads must not enter agent context; that this connector read one
* to produce its own is the whole reason it exists.
*
* run() never plans and never prompts. The plan step is the CLI's, because "show what
* you would spend and stop" is a conversation with whoever typed the command, and a
* programmatic caller has already had it. */
export async function run ({
root, input, questions, field = null, keyField = null, limit = null, concurrency,
model, itemsKey = null, snapshot = false, register = true, ts, at = new Date(),
} = {}) {
const projectRoot = projectRootOf(root)
// Before client(), which reads the credential straight out of process.env.
loadEnv(projectRoot)
const requested = model ?? modelFromEnv()
const q = await loadQuestions(projectRoot, questions)
const src = await resolveInput(projectRoot, input, { itemsKey })
const result = await classify({
ts: ts ?? client(),
items: src.items,
questions: q.questions,
model: requested,
field,
keyField,
limit,
concurrency,
input: { ref: src.ref, kind: src.kind, rawPath: src.rawPath, itemsKey: src.itemsKey },
questionsMeta: { path: q.path, name: q.name },
at,
})
if (snapshot) result.id = datasetId({ input: src, questions: q.questions, questionsName: q.name, model: requested, collectedOn: isoDate(at) })
const rawPath = await writePayload(projectRoot, result.id, result)
const base = {
id: result.id,
model: result.model,
items: result.input.items,
rowCount: result.rowCount,
calls: result.apiMetadata.calls,
usage: result.apiMetadata.usage,
estimatedCost: result.apiMetadata.estimatedCost,
rate: result.apiMetadata.rate,
rawPath,
}
if (!register) return { ...base, registered: false }
await registerDataset(projectRoot, {
id: result.id,
source: 'typesafe',
operation: 'classify',
// `subject`, as DataForSEO: the thing judged is not a property the project owns.
subject: truncate(`${result.questions.name} over ${result.input.ref}`, 120),
// The RESOLVED model — the provenance of the answers. The requested alias is in the id.
model: result.model.resolved,
// The first connector whose input is another dataset. Lineage is a PLG-001 item; until
// the contract names the field, it is this one.
inputs: [result.input.ref],
inputHash: result.input.inputHash,
questionsPath: result.questions.path,
questionsHash: result.questions.hash,
questionIds: result.questions.ids,
questionTypes: result.questions.types,
// Explicitly null, not absent: a judgement covers no date range. There is no `period`
// either — staleness here is "the model changed", which `model` shows.
dateRange: null,
collectedAt: isoDate(at),
validUntil: null,
rawPath,
rows: result.rowCount,
schema: result.schema,
summary: result.summary,
status: 'current',
producedBy: '@nytka/plugin-typesafe',
})
return { ...base, registered: true }
}
function truncate (s, n) {
const str = String(s ?? '')
return str.length > n ? `${str.slice(0, n - 1)}…` : str
}