Initial commit (unpacked platform)

This commit is contained in:
Sven (AAMOS AI)
2026-08-05 19:21:11 +07:00
commit ac5340195a
314 changed files with 57584 additions and 0 deletions
+281
View File
@@ -0,0 +1,281 @@
import { randomUUID } from "node:crypto";
import {
TASK_CONTRACTS,
aamosResponseEnvelopeSchema,
type AamosRequestEnvelope,
type AamosTaskType,
type TaskInput,
type TaskOutput,
} from "./tasks.js";
import { mockOutputFor } from "./mock.js";
import type { LocaleContext } from "@app/shared-types";
export interface AamosCallOptions {
correlationId?: string;
localeContext?: LocaleContext;
subjectRef?: string | null;
priority?: "low" | "normal" | "high";
consentFlags?: {
personalization: boolean;
anonymizedImprovement: boolean;
imageTraining: boolean;
};
}
export interface AamosResult<T extends AamosTaskType> {
status: "ok" | "uncertain" | "failed";
output: TaskOutput<T> | null;
error?: string | undefined;
modelVersion?: string | undefined;
promptVersion?: string | undefined;
latencyMs?: number | undefined;
costUsd?: number | undefined;
}
/**
* Klientinterface mot AAMOS. Mobilappen anropar ALDRIG detta bara
* Food API/worker (spec §31, §61.13).
*/
export interface AamosClient {
runTask<T extends AamosTaskType>(
taskType: T,
input: TaskInput<T>,
options?: AamosCallOptions,
): Promise<AamosResult<T>>;
healthCheck(): Promise<{ ok: boolean; detail?: string }>;
}
export interface HttpAamosClientConfig {
baseUrl: string;
apiKey: string;
timeoutMs?: number;
maxRetries?: number;
fetchImpl?: typeof fetch;
}
/**
* HTTP-klient mot den befintliga AAMOS-plattformen.
*
* Antaget API (justeras mot AAMOS faktiska dokumentation endast denna fil
* och endpointkonstanterna behöver ändras, inga kontrakt läcker vidare):
* POST {baseUrl}/v1/tasks body: AamosRequestEnvelope → AamosResponseEnvelope
* GET {baseUrl}/v1/health
* Auth: Authorization: Bearer <AAMOS_API_KEY>
*/
export class HttpAamosClient implements AamosClient {
private readonly cfg: Required<Omit<HttpAamosClientConfig, "fetchImpl">>;
private readonly fetchImpl: typeof fetch;
constructor(config: HttpAamosClientConfig) {
this.cfg = {
baseUrl: config.baseUrl.replace(/\/$/, ""),
apiKey: config.apiKey,
timeoutMs: config.timeoutMs ?? 60_000,
maxRetries: config.maxRetries ?? 2,
};
this.fetchImpl = config.fetchImpl ?? fetch;
}
async runTask<T extends AamosTaskType>(
taskType: T,
input: TaskInput<T>,
options: AamosCallOptions = {},
): Promise<AamosResult<T>> {
// Validera input mot kontraktet INNAN nätverksanropet fail fast.
const contract = TASK_CONTRACTS[taskType];
const parsedInput = contract.input.safeParse(input);
if (!parsedInput.success) {
return {
status: "failed",
output: null,
error: `Kontraktsfel (input) för ${taskType}: ${parsedInput.error.message}`,
};
}
const envelope: AamosRequestEnvelope = {
taskId: randomUUID(),
taskType,
contractVersion: "1.0.0",
input: parsedInput.data,
metadata: {
correlationId: options.correlationId ?? randomUUID(),
localeContext: options.localeContext ?? {
languageTag: "sv-SE",
regionCode: "SE",
timeZone: "Europe/Stockholm",
measurementSystem: "METRIC" as const,
temperatureUnit: "CELSIUS" as const,
currencyCode: "SEK",
},
subjectRef: options.subjectRef ?? null,
priority: options.priority ?? "normal",
consentFlags: options.consentFlags ?? {
personalization: true,
anonymizedImprovement: false,
imageTraining: false,
},
},
};
let lastError = "";
for (let attempt = 0; attempt <= this.cfg.maxRetries; attempt++) {
if (attempt > 0) await sleep(500 * 2 ** attempt);
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), this.cfg.timeoutMs);
const res = await this.fetchImpl(`${this.cfg.baseUrl}/v1/tasks`, {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${this.cfg.apiKey}`,
"x-correlation-id": envelope.metadata.correlationId,
"x-contract-version": envelope.contractVersion,
},
body: JSON.stringify(envelope),
signal: controller.signal,
});
clearTimeout(timer);
if (res.status === 429 || res.status >= 500) {
lastError = `AAMOS svarade ${res.status}`;
continue; // retry
}
if (!res.ok) {
const body = await safeText(res);
return { status: "failed", output: null, error: `AAMOS ${res.status}: ${body}` };
}
const json: unknown = await res.json();
const parsed = aamosResponseEnvelopeSchema.safeParse(json);
if (!parsed.success) {
return {
status: "failed",
output: null,
error: `Ogiltigt svarskuvert från AAMOS: ${parsed.error.message}`,
};
}
const env = parsed.data;
if (env.status === "failed" || env.output == null) {
return {
status: env.status === "failed" ? "failed" : "uncertain",
output: null,
error: env.error ?? undefined,
modelVersion: env.modelVersion ?? undefined,
promptVersion: env.promptVersion ?? undefined,
latencyMs: env.latencyMs ?? undefined,
costUsd: env.costUsd ?? undefined,
};
}
// Validera output mot kontraktet ett brutet kontrakt är ett fel,
// aldrig något som tyst släpps vidare till användardata.
const parsedOutput = contract.output.safeParse(env.output);
if (!parsedOutput.success) {
return {
status: "failed",
output: null,
error: `Kontraktsfel (output) för ${taskType}: ${parsedOutput.error.message}`,
modelVersion: env.modelVersion ?? undefined,
};
}
return {
status: env.status,
output: parsedOutput.data as TaskOutput<T>,
modelVersion: env.modelVersion ?? undefined,
promptVersion: env.promptVersion ?? undefined,
latencyMs: env.latencyMs ?? undefined,
costUsd: env.costUsd ?? undefined,
};
} catch (err) {
lastError = err instanceof Error ? err.message : String(err);
}
}
return { status: "failed", output: null, error: `AAMOS onåbart: ${lastError}` };
}
async healthCheck(): Promise<{ ok: boolean; detail?: string }> {
try {
const res = await this.fetchImpl(`${this.cfg.baseUrl}/v1/health`, {
headers: { authorization: `Bearer ${this.cfg.apiKey}` },
signal: AbortSignal.timeout(5_000),
});
return { ok: res.ok, detail: `status ${res.status}` };
} catch (err) {
return { ok: false, detail: err instanceof Error ? err.message : String(err) };
}
}
}
/**
* Deterministisk mock för lokal utveckling och tester (AAMOS_MODE=mock).
* Produktionsmiljöer ska ALLTID köra AAMOS_MODE=http.
*/
export class MockAamosClient implements AamosClient {
async runTask<T extends AamosTaskType>(
taskType: T,
input: TaskInput<T>,
): Promise<AamosResult<T>> {
const contract = TASK_CONTRACTS[taskType];
const parsedInput = contract.input.safeParse(input);
if (!parsedInput.success) {
return {
status: "failed",
output: null,
error: `Kontraktsfel (input) för ${taskType}: ${parsedInput.error.message}`,
};
}
const output = mockOutputFor(taskType, parsedInput.data);
const validated = contract.output.parse(output) as TaskOutput<T>;
return {
status: "ok",
output: validated,
modelVersion: "mock-1.0",
promptVersion: "mock",
latencyMs: 42,
costUsd: 0,
};
}
async healthCheck(): Promise<{ ok: boolean; detail?: string }> {
return { ok: true, detail: "mock" };
}
}
export interface AamosEnv {
AAMOS_MODE?: string | undefined;
AAMOS_API_URL?: string | undefined;
AAMOS_API_KEY?: string | undefined;
AAMOS_TIMEOUT_MS?: string | undefined;
}
/** Fabrik: http mot riktiga AAMOS (default), mock för dev/test. */
export function createAamosClient(env: AamosEnv = process.env): AamosClient {
const mode = env.AAMOS_MODE ?? "http";
if (mode === "mock") return new MockAamosClient();
const baseUrl = env.AAMOS_API_URL;
const apiKey = env.AAMOS_API_KEY;
if (!baseUrl || !apiKey) {
throw new Error(
"AAMOS_MODE=http kräver AAMOS_API_URL och AAMOS_API_KEY. " +
"Sätt AAMOS_MODE=mock för lokal utveckling utan AAMOS-åtkomst.",
);
}
return new HttpAamosClient({
baseUrl,
apiKey,
timeoutMs: env.AAMOS_TIMEOUT_MS ? Number(env.AAMOS_TIMEOUT_MS) : undefined,
});
}
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
async function safeText(res: Response): Promise<string> {
try {
return (await res.text()).slice(0, 500);
} catch {
return "<no body>";
}
}