Skip to content

Commit d963208

Browse files
Merge pull request #1 from shadowdevcode/codex-repo-update-2026-03-23
Codex repo update 2026 03 23
2 parents cf9140d + 475f79a commit d963208

35 files changed

Lines changed: 13414 additions & 2602 deletions

README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,32 @@ View your app in AI Studio: https://ai.studio/apps/3900af62-0bf5-496a-a136-d1c8a
1818
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
1919
3. Run the app:
2020
`npm run dev`
21+
22+
## Local End-to-End Check Before Vercel
23+
24+
Run the full local verification flow (type-check + production build + Firestore rules tests on emulator + end-to-end tests):
25+
26+
`npm run verify:local`
27+
28+
Firestore rules tests require Java (17 or newer) because the Firestore Emulator runs on Java.
29+
30+
You can run Firestore rules tests independently:
31+
32+
`npm run rules:test`
33+
34+
The E2E summary is written to:
35+
36+
`test/e2e/artifacts/summary.json`
37+
38+
## Localhost Firebase Auth Checklist
39+
40+
Before final deploy validation, confirm these in Firebase Console:
41+
42+
1. Authentication → Sign-in method → Google provider is enabled.
43+
2. Authentication → Settings → Authorized domains contains `localhost`.
44+
3. If you use `127.0.0.1` locally, add `127.0.0.1` to Authorized domains as well.
45+
4. Run `npm run dev` and confirm Google popup sign-in works.
46+
5. Validate role permissions:
47+
- Owner can invite/remove cook.
48+
- Invited cook gets access.
49+
- Removed cook loses access immediately.

api/ai/parse.ts

Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
import { GoogleGenAI, Type } from '@google/genai';
2+
import { validateAiParseResult } from '../../src/services/aiValidation';
3+
import { AiParseResult, InventoryItem, Language } from '../../src/types';
4+
5+
type ParseCookVoiceInputRequest = {
6+
input: string;
7+
inventory: InventoryPromptItem[];
8+
lang: Language;
9+
};
10+
11+
type InventoryPromptItem = Pick<InventoryItem, 'id' | 'name' | 'nameHi'>;
12+
13+
const AI_MODEL = 'gemini-3-flash-preview';
14+
const AI_ENDPOINT_NAME = 'ai_parse';
15+
const MAX_AI_ATTEMPTS = 3;
16+
const BASE_RETRY_DELAY_MS = 250;
17+
const EMPTY_AI_RESPONSE_MESSAGE = 'Empty response';
18+
19+
class AiParseRequestError extends Error {
20+
constructor(message: string, options?: ErrorOptions) {
21+
super(message, options);
22+
this.name = 'AiParseRequestError';
23+
}
24+
}
25+
26+
class AiParseConfigError extends Error {
27+
constructor(message: string, options?: ErrorOptions) {
28+
super(message, options);
29+
this.name = 'AiParseConfigError';
30+
}
31+
}
32+
33+
class AiParseExecutionError extends Error {
34+
constructor(message: string, options?: ErrorOptions) {
35+
super(message, options);
36+
this.name = 'AiParseExecutionError';
37+
}
38+
}
39+
40+
function createJsonResponse(body: unknown, status: number): Response {
41+
return Response.json(body, { status });
42+
}
43+
44+
function getEnvApiKey(): string {
45+
const apiKey = process.env.GEMINI_API_KEY;
46+
if (!apiKey) {
47+
throw new AiParseConfigError('GEMINI_API_KEY is not configured for the AI parse endpoint.');
48+
}
49+
return apiKey;
50+
}
51+
52+
function getAiClient(): GoogleGenAI {
53+
return new GoogleGenAI({ apiKey: getEnvApiKey() });
54+
}
55+
56+
function isLanguage(value: unknown): value is Language {
57+
return value === 'en' || value === 'hi';
58+
}
59+
60+
function isInventoryPromptItem(value: unknown): value is InventoryPromptItem {
61+
if (!value || typeof value !== 'object') {
62+
return false;
63+
}
64+
65+
const candidate = value as Record<string, unknown>;
66+
const hasValidId = typeof candidate.id === 'string' && candidate.id.trim().length > 0;
67+
const hasValidName = typeof candidate.name === 'string' && candidate.name.trim().length > 0;
68+
const hasValidHindiName = candidate.nameHi === undefined || typeof candidate.nameHi === 'string';
69+
70+
return hasValidId && hasValidName && hasValidHindiName;
71+
}
72+
73+
function parseRequestBody(raw: unknown): ParseCookVoiceInputRequest {
74+
if (!raw || typeof raw !== 'object') {
75+
throw new AiParseRequestError('AI parse request body must be an object.');
76+
}
77+
78+
const candidate = raw as Record<string, unknown>;
79+
if (typeof candidate.input !== 'string' || candidate.input.trim().length === 0) {
80+
throw new AiParseRequestError('AI parse request input must be a non-empty string.');
81+
}
82+
83+
if (!Array.isArray(candidate.inventory) || !candidate.inventory.every(isInventoryPromptItem)) {
84+
throw new AiParseRequestError('AI parse request inventory must be an array of inventory items with id and name.');
85+
}
86+
87+
if (!isLanguage(candidate.lang)) {
88+
throw new AiParseRequestError('AI parse request language must be "en" or "hi".');
89+
}
90+
91+
return {
92+
input: candidate.input,
93+
inventory: candidate.inventory,
94+
lang: candidate.lang,
95+
};
96+
}
97+
98+
function buildInventoryContext(inventory: InventoryPromptItem[]): string {
99+
return inventory
100+
.map((item) => `{ id: "${item.id}", name: "${item.name}", nameHi: "${item.nameHi ?? ''}" }`)
101+
.join(', ');
102+
}
103+
104+
function buildPrompt(input: string, inventory: InventoryPromptItem[], lang: Language): string {
105+
const inventoryContext = buildInventoryContext(inventory);
106+
107+
return `You are an AI assistant for an Indian kitchen. The cook says: "${input}".
108+
Language preference for replies: ${lang === 'hi' ? 'Hindi/Hinglish' : 'English'}.
109+
110+
Task 1: Intent Classification. Is this gibberish, chit-chat, or missing an item name? If yes, set 'understood' to false and provide a helpful 'message' asking for clarification.
111+
Task 2: Match their request to the following inventory items: [${inventoryContext}]. Determine the new status ('in-stock', 'low', 'out'). If they specify a quantity (e.g., "2 kilo", "500g", "3 packets"), extract it as 'requestedQuantity'.
112+
Task 3: If they mention an item NOT in the inventory, add it to 'unlistedItems' with a guessed status, a guessed 'category' (e.g., Vegetables, Spices, Dairy, Grains, Meat, Snacks, Cleaning), and any 'requestedQuantity'.
113+
114+
Return a JSON object matching this schema.`;
115+
}
116+
117+
function createResponseSchema() {
118+
return {
119+
type: Type.OBJECT,
120+
properties: {
121+
understood: { type: Type.BOOLEAN },
122+
message: { type: Type.STRING },
123+
updates: {
124+
type: Type.ARRAY,
125+
items: {
126+
type: Type.OBJECT,
127+
properties: {
128+
itemId: { type: Type.STRING },
129+
newStatus: { type: Type.STRING },
130+
requestedQuantity: { type: Type.STRING },
131+
},
132+
required: ['itemId', 'newStatus'],
133+
},
134+
},
135+
unlistedItems: {
136+
type: Type.ARRAY,
137+
items: {
138+
type: Type.OBJECT,
139+
properties: {
140+
name: { type: Type.STRING },
141+
status: { type: Type.STRING },
142+
category: { type: Type.STRING },
143+
requestedQuantity: { type: Type.STRING },
144+
},
145+
required: ['name', 'status', 'category'],
146+
},
147+
},
148+
},
149+
required: ['understood', 'updates', 'unlistedItems'],
150+
};
151+
}
152+
153+
function getErrorMessage(error: unknown): string {
154+
if (error instanceof Error) {
155+
return error.message;
156+
}
157+
return String(error);
158+
}
159+
160+
function createAttemptWarning(attempt: number, input: string, inventoryCount: number, lang: Language, error: unknown): Record<string, unknown> {
161+
return {
162+
endpoint: AI_ENDPOINT_NAME,
163+
attempt,
164+
maxAttempts: MAX_AI_ATTEMPTS,
165+
inputLength: input.length,
166+
inventoryCount,
167+
lang,
168+
errorMessage: getErrorMessage(error),
169+
};
170+
}
171+
172+
function getRetryDelayMs(attempt: number): number {
173+
return BASE_RETRY_DELAY_MS * attempt;
174+
}
175+
176+
async function waitForRetry(delayMs: number): Promise<void> {
177+
await new Promise((resolve) => {
178+
setTimeout(resolve, delayMs);
179+
});
180+
}
181+
182+
async function generateAiParseResult(input: string, inventory: InventoryPromptItem[], lang: Language): Promise<AiParseResult> {
183+
const aiClient = getAiClient();
184+
let lastError: unknown = null;
185+
186+
for (let attempt = 1; attempt <= MAX_AI_ATTEMPTS; attempt += 1) {
187+
try {
188+
const response = await aiClient.models.generateContent({
189+
model: AI_MODEL,
190+
contents: buildPrompt(input, inventory, lang),
191+
config: {
192+
responseMimeType: 'application/json',
193+
responseSchema: createResponseSchema(),
194+
},
195+
});
196+
197+
if (!response.text) {
198+
throw new Error(EMPTY_AI_RESPONSE_MESSAGE);
199+
}
200+
201+
const parsed = JSON.parse(response.text) as unknown;
202+
return validateAiParseResult(parsed);
203+
} catch (error) {
204+
lastError = error;
205+
console.warn('ai_parse_attempt_failed', createAttemptWarning(attempt, input, inventory.length, lang, error));
206+
207+
if (attempt < MAX_AI_ATTEMPTS) {
208+
await waitForRetry(getRetryDelayMs(attempt));
209+
}
210+
}
211+
}
212+
213+
throw new AiParseExecutionError(
214+
`AI parse failed after ${MAX_AI_ATTEMPTS} attempts. inputLength=${input.length} inventoryCount=${inventory.length} lang=${lang} error=${getErrorMessage(lastError)}`,
215+
{
216+
cause: lastError instanceof Error ? lastError : undefined,
217+
}
218+
);
219+
}
220+
221+
export const config = {
222+
runtime: 'nodejs',
223+
};
224+
225+
export default async function handler(request: Request): Promise<Response> {
226+
if (request.method !== 'POST') {
227+
return createJsonResponse({ message: 'Method not allowed.' }, 405);
228+
}
229+
230+
try {
231+
let body: unknown;
232+
233+
try {
234+
body = (await request.json()) as unknown;
235+
} catch (error) {
236+
throw new AiParseRequestError('AI parse request body must be valid JSON.', {
237+
cause: error instanceof Error ? error : undefined,
238+
});
239+
}
240+
241+
const { input, inventory, lang } = parseRequestBody(body);
242+
const result = await generateAiParseResult(input, inventory, lang);
243+
return createJsonResponse(result, 200);
244+
} catch (error) {
245+
const errorMessage = getErrorMessage(error);
246+
const status =
247+
error instanceof AiParseRequestError ? 400 :
248+
error instanceof AiParseConfigError ? 503 :
249+
500;
250+
251+
console.error('ai_parse_request_failed', {
252+
endpoint: AI_ENDPOINT_NAME,
253+
status,
254+
errorMessage,
255+
});
256+
257+
return createJsonResponse({ message: status === 400 || status === 503 ? errorMessage : 'Could not process AI response safely. Please retry with clearer input.' }, status);
258+
}
259+
}

firebase.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"firestore": {
3+
"rules": "firestore.rules"
4+
},
5+
"emulators": {
6+
"firestore": {
7+
"host": "127.0.0.1",
8+
"port": 8088
9+
},
10+
"singleProjectMode": true
11+
}
12+
}

0 commit comments

Comments
 (0)