From 7740ad4d2dc0aa58d3a840618b76de48bc71376a Mon Sep 17 00:00:00 2001 From: zouantchaw <44246692+zouantchaw@users.noreply.github.com> Date: Fri, 27 Feb 2026 11:12:32 -0500 Subject: [PATCH] feat(core-api): add rapid order transcribe and parse endpoints --- .../src/contracts/core/rapid-order-parse.js | 17 + .../contracts/core/rapid-order-transcribe.js | 23 + backend/core-api/src/routes/core.js | 99 ++++- backend/core-api/src/services/llm.js | 7 + backend/core-api/src/services/rapid-order.js | 410 ++++++++++++++++++ backend/core-api/test/app.test.js | 124 ++++++ .../M4/planning/m4-core-api-frontend-guide.md | 140 +++++- 7 files changed, 808 insertions(+), 12 deletions(-) create mode 100644 backend/core-api/src/contracts/core/rapid-order-parse.js create mode 100644 backend/core-api/src/contracts/core/rapid-order-transcribe.js create mode 100644 backend/core-api/src/services/rapid-order.js diff --git a/backend/core-api/src/contracts/core/rapid-order-parse.js b/backend/core-api/src/contracts/core/rapid-order-parse.js new file mode 100644 index 00000000..86168ef5 --- /dev/null +++ b/backend/core-api/src/contracts/core/rapid-order-parse.js @@ -0,0 +1,17 @@ +import { z } from 'zod'; + +const localePattern = /^[a-zA-Z]{2,3}(?:-[a-zA-Z0-9]{2,8}){0,2}$/; + +export const rapidOrderParseSchema = z + .object({ + text: z.string().trim().min(1).max(4000), + locale: z + .string() + .trim() + .regex(localePattern, 'locale must be a valid BCP-47 language tag') + .optional() + .default('en-US'), + timezone: z.string().trim().min(1).max(80).optional(), + now: z.string().datetime({ offset: true }).optional(), + }) + .strict(); diff --git a/backend/core-api/src/contracts/core/rapid-order-transcribe.js b/backend/core-api/src/contracts/core/rapid-order-transcribe.js new file mode 100644 index 00000000..c85f2512 --- /dev/null +++ b/backend/core-api/src/contracts/core/rapid-order-transcribe.js @@ -0,0 +1,23 @@ +import { z } from 'zod'; + +const localePattern = /^[a-zA-Z]{2,3}(?:-[a-zA-Z0-9]{2,8}){0,2}$/; + +export const rapidOrderTranscribeSchema = z + .object({ + audioFileUri: z + .string() + .startsWith('gs://', 'audioFileUri must start with gs://') + .max(2048), + locale: z + .string() + .trim() + .regex(localePattern, 'locale must be a valid BCP-47 language tag') + .optional() + .default('en-US'), + promptHints: z + .array(z.string().trim().min(1).max(80)) + .max(20) + .optional() + .default([]), + }) + .strict(); diff --git a/backend/core-api/src/routes/core.js b/backend/core-api/src/routes/core.js index f9d22ef7..6c905278 100644 --- a/backend/core-api/src/routes/core.js +++ b/backend/core-api/src/routes/core.js @@ -6,9 +6,12 @@ import { requireAuth, requirePolicy } from '../middleware/auth.js'; import { createSignedUrlSchema } from '../contracts/core/create-signed-url.js'; import { createVerificationSchema } from '../contracts/core/create-verification.js'; import { invokeLlmSchema } from '../contracts/core/invoke-llm.js'; +import { rapidOrderParseSchema } from '../contracts/core/rapid-order-parse.js'; +import { rapidOrderTranscribeSchema } from '../contracts/core/rapid-order-transcribe.js'; import { reviewVerificationSchema } from '../contracts/core/review-verification.js'; import { invokeVertexModel } from '../services/llm.js'; import { checkLlmRateLimit } from '../services/llm-rate-limit.js'; +import { parseRapidOrderText, transcribeRapidOrderAudio } from '../services/rapid-order.js'; import { ensureFileExistsForActor, generateReadSignedUrl, @@ -24,7 +27,22 @@ import { const DEFAULT_MAX_FILE_BYTES = 10 * 1024 * 1024; const DEFAULT_MAX_SIGNED_URL_SECONDS = 900; -const ALLOWED_FILE_TYPES = new Set(['application/pdf', 'image/jpeg', 'image/jpg', 'image/png']); +const ALLOWED_FILE_TYPES = new Set([ + 'application/pdf', + 'image/jpeg', + 'image/jpg', + 'image/png', + 'audio/webm', + 'audio/wav', + 'audio/x-wav', + 'audio/mpeg', + 'audio/mp3', + 'audio/mp4', + 'audio/m4a', + 'audio/aac', + 'audio/ogg', + 'audio/flac', +]); const upload = multer({ storage: multer.memoryStorage(), @@ -59,6 +77,10 @@ function requireVerificationFileExists() { return process.env.VERIFICATION_REQUIRE_FILE_EXISTS !== 'false'; } +function requireRapidAudioFileExists() { + return process.env.RAPID_AUDIO_REQUIRE_FILE_EXISTS !== 'false'; +} + function parseBody(schema, body) { const parsed = schema.safeParse(body); if (!parsed.success) { @@ -69,6 +91,15 @@ function parseBody(schema, body) { return parsed.data; } +function enforceLlmRateLimit(uid) { + const rateLimit = checkLlmRateLimit({ uid }); + if (!rateLimit.allowed) { + throw new AppError('RATE_LIMITED', 'Too many model requests. Please retry shortly.', 429, { + retryAfterSeconds: rateLimit.retryAfterSeconds, + }); + } +} + async function handleUploadFile(req, res, next) { try { const file = req.file; @@ -158,12 +189,7 @@ async function handleCreateSignedUrl(req, res, next) { async function handleInvokeLlm(req, res, next) { try { const payload = parseBody(invokeLlmSchema, req.body || {}); - const rateLimit = checkLlmRateLimit({ uid: req.actor.uid }); - if (!rateLimit.allowed) { - throw new AppError('RATE_LIMITED', 'Too many model requests. Please retry shortly.', 429, { - retryAfterSeconds: rateLimit.retryAfterSeconds, - }); - } + enforceLlmRateLimit(req.actor.uid); const startedAt = Date.now(); if (process.env.LLM_MOCK === 'false') { @@ -194,6 +220,63 @@ async function handleInvokeLlm(req, res, next) { } } +async function handleRapidOrderTranscribe(req, res, next) { + try { + const payload = parseBody(rapidOrderTranscribeSchema, req.body || {}); + validateFileUriAccess({ + fileUri: payload.audioFileUri, + actorUid: req.actor.uid, + }); + + if (requireRapidAudioFileExists() && !useMockUpload()) { + await ensureFileExistsForActor({ + fileUri: payload.audioFileUri, + actorUid: req.actor.uid, + }); + } + + enforceLlmRateLimit(req.actor.uid); + + const startedAt = Date.now(); + const result = await transcribeRapidOrderAudio({ + audioFileUri: payload.audioFileUri, + locale: payload.locale, + promptHints: payload.promptHints, + }); + + return res.status(200).json({ + ...result, + latencyMs: Date.now() - startedAt, + requestId: req.requestId, + }); + } catch (error) { + return next(error); + } +} + +async function handleRapidOrderParse(req, res, next) { + try { + const payload = parseBody(rapidOrderParseSchema, req.body || {}); + enforceLlmRateLimit(req.actor.uid); + + const startedAt = Date.now(); + const result = await parseRapidOrderText({ + text: payload.text, + locale: payload.locale, + timezone: payload.timezone, + now: payload.now, + }); + + return res.status(200).json({ + ...result, + latencyMs: Date.now() - startedAt, + requestId: req.requestId, + }); + } catch (error) { + return next(error); + } +} + async function handleCreateVerification(req, res, next) { try { const payload = parseBody(createVerificationSchema, req.body || {}); @@ -268,6 +351,8 @@ export function createCoreRouter() { router.post('/upload-file', requireAuth, requirePolicy('core.upload', 'file'), upload.single('file'), handleUploadFile); router.post('/create-signed-url', requireAuth, requirePolicy('core.sign-url', 'file'), handleCreateSignedUrl); router.post('/invoke-llm', requireAuth, requirePolicy('core.invoke-llm', 'model'), handleInvokeLlm); + router.post('/rapid-orders/transcribe', requireAuth, requirePolicy('core.rapid-order.transcribe', 'model'), handleRapidOrderTranscribe); + router.post('/rapid-orders/parse', requireAuth, requirePolicy('core.rapid-order.parse', 'model'), handleRapidOrderParse); router.post('/verifications', requireAuth, requirePolicy('core.verification.create', 'verification'), handleCreateVerification); router.get('/verifications/:verificationId', requireAuth, requirePolicy('core.verification.read', 'verification'), handleGetVerification); router.post('/verifications/:verificationId/review', requireAuth, requirePolicy('core.verification.review', 'verification'), handleReviewVerification); diff --git a/backend/core-api/src/services/llm.js b/backend/core-api/src/services/llm.js index 19c90862..0e8b6ed7 100644 --- a/backend/core-api/src/services/llm.js +++ b/backend/core-api/src/services/llm.js @@ -45,6 +45,13 @@ function guessMimeTypeFromUri(fileUri) { if (path.endsWith('.jpg') || path.endsWith('.jpeg')) return 'image/jpeg'; if (path.endsWith('.png')) return 'image/png'; if (path.endsWith('.pdf')) return 'application/pdf'; + if (path.endsWith('.webm')) return 'audio/webm'; + if (path.endsWith('.mp3')) return 'audio/mpeg'; + if (path.endsWith('.wav')) return 'audio/wav'; + if (path.endsWith('.m4a')) return 'audio/m4a'; + if (path.endsWith('.aac')) return 'audio/aac'; + if (path.endsWith('.ogg')) return 'audio/ogg'; + if (path.endsWith('.flac')) return 'audio/flac'; return 'application/octet-stream'; } diff --git a/backend/core-api/src/services/rapid-order.js b/backend/core-api/src/services/rapid-order.js new file mode 100644 index 00000000..8b061907 --- /dev/null +++ b/backend/core-api/src/services/rapid-order.js @@ -0,0 +1,410 @@ +import { z } from 'zod'; +import { AppError } from '../lib/errors.js'; +import { invokeVertexModel, invokeVertexMultimodalModel } from './llm.js'; + +const rapidOrderTranscriptionSchema = z.object({ + transcript: z.string().trim().min(1).max(4000), + confidence: z.number().min(0).max(1).default(0.7), + language: z.string().trim().min(2).max(35).default('en-US'), + warnings: z.array(z.string().trim().min(1).max(200)).max(10).default([]), +}); + +const rapidOrderPositionSchema = z.object({ + role: z.string().trim().min(1).max(100), + count: z.number().int().min(1).max(200), +}); + +const rapidOrderParseResultSchema = z.object({ + parsed: z.object({ + orderType: z.literal('ONE_TIME'), + isRapid: z.literal(true), + positions: z.array(rapidOrderPositionSchema).max(20).default([]), + startAt: z.string().datetime({ offset: true }).nullable().default(null), + endAt: z.string().datetime({ offset: true }).nullable().default(null), + durationMinutes: z.number().int().min(15).max(1440).nullable().default(null), + locationHint: z.string().trim().max(200).nullable().default(null), + notes: z.string().trim().max(1200).nullable().default(null), + sourceText: z.string().trim().min(1).max(4000), + }), + missingFields: z.array(z.string().trim().min(1).max(60)).max(20).default([]), + warnings: z.array(z.string().trim().min(1).max(200)).max(20).default([]), + confidence: z.object({ + overall: z.number().min(0).max(1), + fields: z.record(z.number().min(0).max(1)).default({}), + }), +}); + +const RAPID_ORDER_TRANSCRIPTION_JSON_SCHEMA = { + type: 'object', + additionalProperties: false, + required: ['transcript', 'confidence', 'language', 'warnings'], + properties: { + transcript: { type: 'string' }, + confidence: { type: 'number', minimum: 0, maximum: 1 }, + language: { type: 'string' }, + warnings: { + type: 'array', + items: { type: 'string' }, + }, + }, +}; + +const RAPID_ORDER_PARSE_JSON_SCHEMA = { + type: 'object', + additionalProperties: false, + required: ['parsed', 'missingFields', 'warnings', 'confidence'], + properties: { + parsed: { + type: 'object', + additionalProperties: false, + required: [ + 'orderType', + 'isRapid', + 'positions', + 'startAt', + 'endAt', + 'durationMinutes', + 'locationHint', + 'notes', + 'sourceText', + ], + properties: { + orderType: { type: 'string', enum: ['ONE_TIME'] }, + isRapid: { type: 'boolean', enum: [true] }, + positions: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: ['role', 'count'], + properties: { + role: { type: 'string' }, + count: { type: 'integer', minimum: 1, maximum: 200 }, + }, + }, + }, + startAt: { + anyOf: [ + { type: 'string', format: 'date-time' }, + { type: 'null' }, + ], + }, + endAt: { + anyOf: [ + { type: 'string', format: 'date-time' }, + { type: 'null' }, + ], + }, + durationMinutes: { + anyOf: [ + { type: 'integer', minimum: 15, maximum: 1440 }, + { type: 'null' }, + ], + }, + locationHint: { + anyOf: [ + { type: 'string' }, + { type: 'null' }, + ], + }, + notes: { + anyOf: [ + { type: 'string' }, + { type: 'null' }, + ], + }, + sourceText: { type: 'string' }, + }, + }, + missingFields: { + type: 'array', + items: { type: 'string' }, + }, + warnings: { + type: 'array', + items: { type: 'string' }, + }, + confidence: { + type: 'object', + additionalProperties: false, + required: ['overall', 'fields'], + properties: { + overall: { type: 'number', minimum: 0, maximum: 1 }, + fields: { + type: 'object', + additionalProperties: { type: 'number', minimum: 0, maximum: 1 }, + }, + }, + }, + }, +}; + +function parseModelResult(schema, value, errorMessage) { + const parsed = schema.safeParse(value); + if (!parsed.success) { + throw new AppError('MODEL_FAILED', errorMessage, 502, { + issues: parsed.error.issues, + }); + } + + return parsed.data; +} + +function isMockLlmEnabled() { + return process.env.LLM_MOCK !== 'false'; +} + +function validateTimezoneOrThrow(timezone) { + if (!timezone) { + return; + } + + try { + new Intl.DateTimeFormat('en-US', { timeZone: timezone }).format(new Date()); + } catch { + throw new AppError('VALIDATION_ERROR', 'timezone must be a valid IANA timezone', 400, { + timezone, + }); + } +} + +function detectRoleFromText(text) { + const rolePatterns = [ + { role: 'server', regex: /\bserver(s)?\b/i }, + { role: 'bartender', regex: /\bbartender(s)?\b/i }, + { role: 'cook', regex: /\bcook(s)?\b/i }, + { role: 'chef', regex: /\bchef(s)?\b/i }, + { role: 'dishwasher', regex: /\bdishwasher(s)?\b/i }, + { role: 'host', regex: /\bhost(ess)?(es)?\b/i }, + { role: 'cashier', regex: /\bcashier(s)?\b/i }, + { role: 'barista', regex: /\bbarista(s)?\b/i }, + ]; + + for (const item of rolePatterns) { + if (item.regex.test(text)) { + return item.role; + } + } + + return 'general_staff'; +} + +function detectCountFromText(text) { + const match = text.match(/\b(\d{1,3})\b/); + if (!match) { + return 1; + } + const parsed = Number.parseInt(match[1], 10); + if (Number.isNaN(parsed) || parsed < 1) { + return 1; + } + return Math.min(parsed, 200); +} + +function detectDurationMinutesFromText(text) { + const hoursMatch = text.match(/\b(\d{1,2})\s*(hour|hours|hr|hrs)\b/i); + if (hoursMatch) { + const hours = Number.parseInt(hoursMatch[1], 10); + if (!Number.isNaN(hours) && hours > 0) { + return Math.min(hours * 60, 1440); + } + } + + const minutesMatch = text.match(/\b(\d{1,3})\s*(minute|minutes|min|mins)\b/i); + if (minutesMatch) { + const minutes = Number.parseInt(minutesMatch[1], 10); + if (!Number.isNaN(minutes) && minutes >= 15) { + return Math.min(minutes, 1440); + } + } + + return null; +} + +function detectAsap(text) { + return /\b(asap|right now|immediately|urgent|emergency|now)\b/i.test(text); +} + +function buildMockParseResult({ text, now }) { + const normalizedNow = now || new Date().toISOString(); + const role = detectRoleFromText(text); + const count = detectCountFromText(text); + const durationMinutes = detectDurationMinutesFromText(text); + const isAsap = detectAsap(text); + const startAt = isAsap ? normalizedNow : null; + + const missingFields = []; + if (!startAt) { + missingFields.push('startAt'); + } + if (!durationMinutes) { + missingFields.push('durationMinutes'); + } + if (!role) { + missingFields.push('positions'); + } + + const warnings = []; + if (!startAt) { + warnings.push('Missing explicit start time. Prompt user to confirm date and time.'); + } + if (!durationMinutes) { + warnings.push('Missing duration. Prompt user for shift length.'); + } + + return { + parsed: { + orderType: 'ONE_TIME', + isRapid: true, + positions: [ + { + role, + count, + }, + ], + startAt, + endAt: null, + durationMinutes, + locationHint: null, + notes: null, + sourceText: text, + }, + missingFields, + warnings, + confidence: { + overall: 0.72, + fields: { + positions: 0.86, + startAt: startAt ? 0.9 : 0.2, + durationMinutes: durationMinutes ? 0.88 : 0.2, + }, + }, + }; +} + +function normalizeRapidOrderParseResult(result) { + const normalized = { + parsed: { + ...result.parsed, + positions: result.parsed.positions, + sourceText: result.parsed.sourceText, + }, + missingFields: Array.from(new Set(result.missingFields)), + warnings: Array.from(new Set(result.warnings)), + confidence: { + overall: result.confidence.overall, + fields: result.confidence.fields, + }, + }; + + if (normalized.parsed.positions.length === 0 && !normalized.missingFields.includes('positions')) { + normalized.missingFields.push('positions'); + } + + if (!normalized.parsed.startAt && !normalized.missingFields.includes('startAt')) { + normalized.missingFields.push('startAt'); + } + + if (!normalized.parsed.durationMinutes && !normalized.missingFields.includes('durationMinutes')) { + normalized.missingFields.push('durationMinutes'); + } + + return normalized; +} + +function buildTranscriptionPrompt({ locale, promptHints }) { + const hints = promptHints.length > 0 ? `Domain hints: ${promptHints.join(', ')}` : 'Domain hints: none'; + return [ + 'You transcribe urgent staffing request audio for a workforce scheduling app.', + `Locale hint: ${locale}`, + hints, + 'Return only what was spoken in transcript form.', + 'Do not infer roles, counts, durations, dates, or locations that were not spoken.', + 'If audio quality is poor, still provide best-effort transcript and add warnings.', + ].join('\n'); +} + +function buildParsePrompt({ text, locale, timezone, now }) { + return [ + 'You parse urgent staffing request text into a strict one-time order draft.', + `Locale hint: ${locale}`, + `Timezone hint: ${timezone || 'UTC'}`, + `Current time (ISO): ${now}`, + 'Interpret phrases like ASAP/today/tonight into ISO datetimes when confidence is high.', + 'Do not invent uncertain data. Put unknown required values into missingFields.', + 'Use warnings for ambiguities that need user confirmation.', + `Input text: ${text}`, + ].join('\n'); +} + +export async function transcribeRapidOrderAudio({ + audioFileUri, + locale = 'en-US', + promptHints = [], +}) { + if (isMockLlmEnabled()) { + return { + transcript: 'Need 2 servers ASAP for 4 hours.', + confidence: 0.87, + language: locale, + warnings: [], + model: process.env.LLM_MODEL || 'vertexai/gemini-mock', + }; + } + + const llmResult = await invokeVertexMultimodalModel({ + prompt: buildTranscriptionPrompt({ locale, promptHints }), + responseJsonSchema: RAPID_ORDER_TRANSCRIPTION_JSON_SCHEMA, + fileUris: [audioFileUri], + }); + + const parsed = parseModelResult( + rapidOrderTranscriptionSchema, + llmResult.result, + 'Rapid order transcription failed' + ); + + return { + ...parsed, + model: llmResult.model, + }; +} + +export async function parseRapidOrderText({ + text, + locale = 'en-US', + timezone, + now, +}) { + validateTimezoneOrThrow(timezone); + + const normalizedNow = now || new Date().toISOString(); + + if (isMockLlmEnabled()) { + const mock = buildMockParseResult({ + text, + now: normalizedNow, + }); + + return { + ...mock, + model: process.env.LLM_MODEL || 'vertexai/gemini-mock', + }; + } + + const llmResult = await invokeVertexModel({ + prompt: buildParsePrompt({ text, locale, timezone, now: normalizedNow }), + responseJsonSchema: RAPID_ORDER_PARSE_JSON_SCHEMA, + }); + + const parsed = parseModelResult( + rapidOrderParseResultSchema, + llmResult.result, + 'Rapid order parsing failed' + ); + + return { + ...normalizeRapidOrderParseResult(parsed), + model: llmResult.model, + }; +} diff --git a/backend/core-api/test/app.test.js b/backend/core-api/test/app.test.js index e12f9005..c3e50de5 100644 --- a/backend/core-api/test/app.test.js +++ b/backend/core-api/test/app.test.js @@ -150,6 +150,130 @@ test('POST /core/invoke-llm enforces per-user rate limit', async () => { assert.equal(typeof second.headers['retry-after'], 'string'); }); +test('POST /core/upload-file accepts audio/webm for rapid transcription', async () => { + const app = createApp(); + const res = await request(app) + .post('/core/upload-file') + .set('Authorization', 'Bearer test-token') + .field('visibility', 'private') + .attach('file', Buffer.from('fake-audio-data'), { + filename: 'rapid-request.webm', + contentType: 'audio/webm', + }); + + assert.equal(res.status, 200); + assert.equal(res.body.contentType, 'audio/webm'); + assert.equal(typeof res.body.fileUri, 'string'); +}); + +test('POST /core/rapid-orders/transcribe returns transcript in mock mode', async () => { + const app = createApp(); + const res = await request(app) + .post('/core/rapid-orders/transcribe') + .set('Authorization', 'Bearer test-token') + .send({ + audioFileUri: 'gs://krow-workforce-dev-private/uploads/test-user/request.webm', + locale: 'en-US', + promptHints: ['server', 'urgent'], + }); + + assert.equal(res.status, 200); + assert.equal(typeof res.body.transcript, 'string'); + assert.ok(res.body.transcript.length > 0); + assert.equal(typeof res.body.confidence, 'number'); + assert.equal(typeof res.body.model, 'string'); + assert.equal(typeof res.body.requestId, 'string'); +}); + +test('POST /core/rapid-orders/transcribe rejects non-owned file URI', async () => { + const app = createApp(); + const res = await request(app) + .post('/core/rapid-orders/transcribe') + .set('Authorization', 'Bearer test-token') + .send({ + audioFileUri: 'gs://krow-workforce-dev-private/uploads/other-user/request.webm', + locale: 'en-US', + }); + + assert.equal(res.status, 403); + assert.equal(res.body.code, 'FORBIDDEN'); +}); + +test('POST /core/rapid-orders/parse returns structured rapid order draft', async () => { + const app = createApp(); + const res = await request(app) + .post('/core/rapid-orders/parse') + .set('Authorization', 'Bearer test-token') + .send({ + text: 'Need 2 servers ASAP for 4 hours', + locale: 'en-US', + timezone: 'America/New_York', + now: '2026-02-27T12:00:00.000Z', + }); + + assert.equal(res.status, 200); + assert.equal(res.body.parsed.orderType, 'ONE_TIME'); + assert.equal(res.body.parsed.isRapid, true); + assert.equal(Array.isArray(res.body.parsed.positions), true); + assert.equal(res.body.parsed.positions[0].role, 'server'); + assert.equal(res.body.parsed.positions[0].count, 2); + assert.equal(res.body.parsed.durationMinutes, 240); + assert.equal(typeof res.body.confidence.overall, 'number'); + assert.equal(typeof res.body.requestId, 'string'); +}); + +test('POST /core/rapid-orders/parse validates timezone', async () => { + const app = createApp(); + const res = await request(app) + .post('/core/rapid-orders/parse') + .set('Authorization', 'Bearer test-token') + .send({ + text: 'Need 2 servers ASAP', + timezone: 'Mars/OlympusMons', + }); + + assert.equal(res.status, 400); + assert.equal(res.body.code, 'VALIDATION_ERROR'); +}); + +test('POST /core/rapid-orders/parse rejects unknown fields', async () => { + const app = createApp(); + const res = await request(app) + .post('/core/rapid-orders/parse') + .set('Authorization', 'Bearer test-token') + .send({ + text: 'Need 2 servers ASAP', + unexpected: 'not-allowed', + }); + + assert.equal(res.status, 400); + assert.equal(res.body.code, 'VALIDATION_ERROR'); +}); + +test('POST /core/rapid-orders/parse enforces per-user model rate limit', async () => { + process.env.LLM_RATE_LIMIT_PER_MINUTE = '1'; + const app = createApp(); + + const first = await request(app) + .post('/core/rapid-orders/parse') + .set('Authorization', 'Bearer test-token') + .send({ + text: 'Need 2 servers ASAP for 4 hours', + }); + + const second = await request(app) + .post('/core/rapid-orders/parse') + .set('Authorization', 'Bearer test-token') + .send({ + text: 'Need 3 bartenders tonight', + }); + + assert.equal(first.status, 200); + assert.equal(second.status, 429); + assert.equal(second.body.code, 'RATE_LIMITED'); + assert.equal(typeof second.headers['retry-after'], 'string'); +}); + test('POST /core/verifications creates async job and GET returns status', async () => { const app = createApp(); const created = await request(app) diff --git a/docs/MILESTONES/M4/planning/m4-core-api-frontend-guide.md b/docs/MILESTONES/M4/planning/m4-core-api-frontend-guide.md index 64f8a5c2..ef3e18dd 100644 --- a/docs/MILESTONES/M4/planning/m4-core-api-frontend-guide.md +++ b/docs/MILESTONES/M4/planning/m4-core-api-frontend-guide.md @@ -1,7 +1,7 @@ # M4 Core API Frontend Guide (Dev) Status: Active -Last updated: 2026-02-24 +Last updated: 2026-02-27 Audience: Web and mobile frontend developers ## 1) Base URLs (dev) @@ -41,6 +41,16 @@ Authorization: Bearer - `image/jpeg` - `image/jpg` - `image/png` +- `audio/webm` +- `audio/wav` +- `audio/x-wav` +- `audio/mpeg` +- `audio/mp3` +- `audio/mp4` +- `audio/m4a` +- `audio/aac` +- `audio/ogg` +- `audio/flac` 6. Max upload size: `10 MB` (default) 7. Current behavior: real upload to Cloud Storage (not mock) 8. Success `200` example: @@ -118,7 +128,91 @@ Authorization: Bearer } ``` -## 4.4 Create verification job +## 4.4 Rapid order transcribe (audio to text) +1. Route: `POST /core/rapid-orders/transcribe` +2. Auth: required +3. Purpose: transcribe uploaded RAPID voice note into text for the RAPID input box. +4. Request body: +```json +{ + "audioFileUri": "gs://krow-workforce-dev-private/uploads//rapid-request.webm", + "locale": "en-US", + "promptHints": ["server", "urgent"] +} +``` +5. Security checks: +- `audioFileUri` must be in allowed bucket +- `audioFileUri` path must be owned by caller (`uploads//...`) +- file existence is required in non-mock upload mode +6. Success `200` example: +```json +{ + "transcript": "Need 2 servers ASAP for 4 hours.", + "confidence": 0.87, + "language": "en-US", + "warnings": [], + "model": "gemini-2.0-flash-001", + "latencyMs": 412, + "requestId": "uuid" +} +``` +7. Typical errors: +- `400 VALIDATION_ERROR` (invalid payload) +- `401 UNAUTHENTICATED` (missing/invalid bearer token) +- `403 FORBIDDEN` (audio path not owned by caller) +- `429 RATE_LIMITED` (model quota per user) +- `502 MODEL_FAILED` (upstream model output/availability) + +## 4.5 Rapid order parse (text to structured draft) +1. Route: `POST /core/rapid-orders/parse` +2. Auth: required +3. Purpose: convert RAPID text into structured one-time order draft JSON for form prefill. +4. Request body: +```json +{ + "text": "Need 2 servers ASAP for 4 hours", + "locale": "en-US", + "timezone": "America/New_York", + "now": "2026-02-27T12:00:00.000Z" +} +``` +5. Success `200` example: +```json +{ + "parsed": { + "orderType": "ONE_TIME", + "isRapid": true, + "positions": [ + { "role": "server", "count": 2 } + ], + "startAt": "2026-02-27T12:00:00.000Z", + "endAt": null, + "durationMinutes": 240, + "locationHint": null, + "notes": null, + "sourceText": "Need 2 servers ASAP for 4 hours" + }, + "missingFields": [], + "warnings": [], + "confidence": { + "overall": 0.72, + "fields": { + "positions": 0.86, + "startAt": 0.9, + "durationMinutes": 0.88 + } + }, + "model": "gemini-2.0-flash-001", + "latencyMs": 531, + "requestId": "uuid" +} +``` +6. Contract notes: +- unknown request keys are rejected (`400 VALIDATION_ERROR`) +- when information is missing/ambiguous, backend returns `missingFields` and `warnings` +- frontend should use output to prefill one-time order and request user confirmation where needed + +## 4.6 Create verification job 1. Route: `POST /core/verifications` 2. Auth: required 3. Purpose: enqueue an async verification job for an uploaded file. @@ -148,7 +242,7 @@ Authorization: Bearer - `government_id`: third-party adapter path (falls back to `NEEDS_REVIEW` if provider is not configured). - `certification`: third-party adapter path (falls back to `NEEDS_REVIEW` if provider is not configured). -## 4.5 Get verification status +## 4.7 Get verification status 1. Route: `GET /core/verifications/{verificationId}` 2. Auth: required 3. Purpose: polling status from frontend. @@ -163,7 +257,7 @@ Authorization: Bearer } ``` -## 4.6 Review verification +## 4.8 Review verification 1. Route: `POST /core/verifications/{verificationId}/review` 2. Auth: required 3. Purpose: final human decision for the verification. @@ -188,7 +282,7 @@ Authorization: Bearer } ``` -## 4.7 Retry verification +## 4.9 Retry verification 1. Route: `POST /core/verifications/{verificationId}/retry` 2. Auth: required 3. Purpose: requeue verification to run again. @@ -234,6 +328,42 @@ const res = await fetch('https://krow-core-api-e3g6witsvq-uc.a.run.app/core/invo const data = await res.json(); ``` +## 5.3 Rapid audio transcribe request +```ts +const token = await firebaseAuth.currentUser?.getIdToken(); +const res = await fetch('https://krow-core-api-e3g6witsvq-uc.a.run.app/core/rapid-orders/transcribe', { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + audioFileUri: 'gs://krow-workforce-dev-private/uploads//rapid-request.webm', + locale: 'en-US', + promptHints: ['server', 'urgent'], + }), +}); +const data = await res.json(); +``` + +## 5.4 Rapid text parse request +```ts +const token = await firebaseAuth.currentUser?.getIdToken(); +const res = await fetch('https://krow-core-api-e3g6witsvq-uc.a.run.app/core/rapid-orders/parse', { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + text: 'Need 2 servers ASAP for 4 hours', + locale: 'en-US', + timezone: 'America/New_York', + }), +}); +const data = await res.json(); +``` + ## 6) Notes for frontend team 1. Use canonical `/core/*` routes for new work. 2. Aliases exist only for migration compatibility.