Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 53 additions & 56 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,64 +1,61 @@
# OmanX MVP
# OmanX

OmanX is a **compliance-first AI assistant** for Omani students studying in the United States.
Implemented architecture:

This MVP is designed for early launch, pilot usage, and Ministry-facing demos:
- Minimal UI for fast onboarding.
- Safety routing (normal vs strict compliance mode).
- Strict mode grounded in `knowledge.json` only.
- Automatic escalation language when no verified guidance exists.

## Product promise (MVP)
- **Audience:** Omani students in the US.
- **Core pain solved:** avoid visa/compliance mistakes and provide clear escalation paths.
- **Trust model:** verified content only for high-stakes topics.

## Quick start
```bash
npm start
```
Open `http://localhost:3000`.

## Required environment
Create `.env` (or set host environment variables):

```env
OPENAI_API_KEY=your_key_here
OPENAI_MODEL=gpt-4o-mini
NODE_ENV=production
RATE_LIMIT_MAX=120
ADMIN_KEY=optional_admin_key
/src
/api
routes.js
controllers.js

/core
engine.js
policy.js
validator.js
riskLabeler.js

/ai
prompts.js
responders
localResponder.js
llmResponder.js

/data
knowledge.json
sources.json
disclaimers.json

/middleware
auth.js
rateLimit.js
logging.js

/frontend
index.html
admin.html
feedback.html
kb.html
styles.css

/config
env.js
vercel.js

/tests
policy.test.js
validator.test.js
engine.test.js

server.js
app.js
package.json
README.md
```

You can use the provided example environment file as a starting point:
## Run

```bash
cp .env.example .env
# then edit .env to add your OPENAI_API_KEY and other values
npm install
npm test
npm start
```

## Deployment (Vercel)
This repository is configured for Vercel (`vercel.json`).

1. Import repository into Vercel.
2. Set environment variables (`OPENAI_API_KEY`, `OPENAI_MODEL`, `ADMIN_KEY`).
3. Deploy.
4. Smoke-check:
- `GET /health`
- `POST /chat` with a compliance question (e.g. visa/CPT/OPT)

## Key API endpoints
- `GET /health` – service + knowledge status
- `GET /ready` – readiness probe
- `POST /chat` – main assistant endpoint
- `POST /admin/cache/clear` – clear cache (admin in prod)
- `POST /admin/knowledge/reload` – reload `knowledge.json` (admin in prod)

## MVP scope decisions
To keep the project lean for launch/pitching:
- Removed non-essential test files from runtime package.
- Kept deterministic router + local fallback behavior.
- Focused UI on fast compliance-oriented usage.

---
OmanX MVP — built for Oman pilot adoption and iterative growth.
240 changes: 21 additions & 219 deletions app.js
Original file line number Diff line number Diff line change
@@ -1,220 +1,22 @@
const chatEl = document.getElementById("chat");
const formEl = document.getElementById("form");
const inputEl = document.getElementById("input");
const sendBtn = document.getElementById("send");
const clearBtn = document.getElementById("clearBtn");
const statusPill = document.getElementById("statusPill");
const statusBanner = document.getElementById("statusBanner");
const statusBannerText = statusBanner?.querySelector(".status-banner-text");
const messagesEl = document.getElementById("messages");
const yearEl = document.getElementById("year");
const starterButtons = document.querySelectorAll(".starter");

const DEMO_ERROR = "Sorry, I couldn't reach the OmanX service. Please try again.";
const DEFAULT_OFFLINE_MESSAGE =
"Service unavailable. We will not guess—please contact the relevant office.";
const API_BASE_ERROR_MESSAGE =
"Service unavailable. API endpoint not found. Set the API base or contact the relevant office.";

if (yearEl) yearEl.textContent = new Date().getFullYear();

const setStatus = (state, text) => {
if (!statusPill) return;
const el = statusPill.querySelector(".pill-text");
if (el) el.textContent = text;
statusPill.dataset.state = state;
if (statusBanner) statusBanner.hidden = state !== "offline";
};

const setStatusBanner = (text) => {
if (statusBannerText) statusBannerText.textContent = text;
};

const scrollToBottom = () => {
if (chatEl) chatEl.scrollTop = chatEl.scrollHeight;
};

const createMessage = (role, text, meta = "") => {
const wrapper = document.createElement("div");
wrapper.className = `msg ${role}`;

const avatar = document.createElement("div");
avatar.className = "avatar";
avatar.textContent = role === "me" ? "You" : "OmanX";

const bubble = document.createElement("div");
bubble.className = "bubble";

const body = document.createElement("div");
body.textContent = text;
bubble.appendChild(body);

if (meta) {
const metaEl = document.createElement("div");
metaEl.className = "message-meta";

// Render KB refs as clickable links when present: "... • Refs: ID1, ID2"
const refsMatch = /Refs:\s*(.+)$/i.exec(meta);
if (refsMatch) {
const before = meta.slice(0, refsMatch.index).trim();
if (before) metaEl.appendChild(document.createTextNode(before + " "));

const ids = refsMatch[1].split(/\s*,\s*/).filter(Boolean);
ids.forEach((id, idx) => {
const a = document.createElement('a');
a.href = apiUrl(`/kb/${encodeURIComponent(id)}`);
a.target = '_blank';
a.rel = 'noopener noreferrer';
a.className = 'kb-ref';
a.textContent = id;
metaEl.appendChild(a);
if (idx < ids.length - 1) metaEl.appendChild(document.createTextNode(', '));
});
} else {
metaEl.textContent = meta;
}

bubble.appendChild(metaEl);
}

wrapper.appendChild(avatar);
wrapper.appendChild(bubble);
return wrapper;
};

const addMessage = (role, text, meta = "") => {
const target = messagesEl || chatEl;
if (!target) return;
target.appendChild(createMessage(role, text, meta));
scrollToBottom();
};

const setLoading = (isLoading) => {
if (!sendBtn) return;
sendBtn.disabled = isLoading;
sendBtn.textContent = isLoading ? "Sending…" : "Send";
};

function getApiBase() {
try {
const url = new URL(window.location.href);
const q = url.searchParams.get("api");
if (q) return q.replace(/\/+$/, "");
} catch {}

if (typeof window !== "undefined" && window.OMANX_API_BASE) {
return String(window.OMANX_API_BASE).replace(/\/+$/, "");
}

const meta = document.querySelector('meta[name="omanx-api-base"]');
if (meta?.content) return meta.content.trim().replace(/\/+$/, "");

return "";
import express from 'express';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import apiRoutes from './src/api/routes.js';
import { requestLogger } from './src/middleware/logging.js';
import { apiRateLimit } from './src/middleware/rateLimit.js';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const frontendDir = path.join(__dirname, 'src', 'frontend');

export function createApp() {
const app = express();
app.use(express.json());
app.use(requestLogger);
app.use(apiRateLimit);

app.use(express.static(frontendDir));
app.use('/', apiRoutes);

return app;
}

const API_BASE = getApiBase();
const apiUrl = (p) => `${API_BASE}${p.startsWith("/") ? p : `/${p}`}`;

async function checkHealth() {
try {
const r = await fetch(apiUrl("/health"), { method: "GET" });
if (!r.ok) {
setStatusBanner(r.status === 404 ? API_BASE_ERROR_MESSAGE : DEFAULT_OFFLINE_MESSAGE);
throw new Error(`health ${r.status}`);
}
setStatus("online", "Online");
return true;
} catch {
setStatusBanner(DEFAULT_OFFLINE_MESSAGE);
setStatus("offline", "Offline");
return false;
}
}

const sendMessage = async (message) => {
addMessage("me", message);
setLoading(true);
setStatus("busy", "Thinking");

try {
const response = await fetch(apiUrl("/chat"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message }),
});

const payload = await response.json().catch(() => null);

if (!response.ok) {
// If the server returned a helpful `text` field, show it to the user instead
const serverText = payload?.text || payload?.error;
if (serverText) {
const laneMeta = payload?.lane === "strict" ? "Compliance mode • Verified KB only" : "Community mode";
const refs = Array.isArray(payload?.kbRefs) && payload.kbRefs.length ? ` • Refs: ${payload.kbRefs.join(", ")}` : "";
addMessage("bot", serverText, `${laneMeta}${refs}`);
setStatus("online", "Online");
return;
}

setStatusBanner(response.status === 404 ? API_BASE_ERROR_MESSAGE : DEFAULT_OFFLINE_MESSAGE);
throw new Error(payload?.error ? `${payload.error} (HTTP ${response.status})` : `Request failed: HTTP ${response.status}`);
}

const laneMeta = payload?.lane === "strict" ? "Compliance mode • Verified KB only" : "Community mode";
const refs = Array.isArray(payload?.kbRefs) && payload.kbRefs.length ? ` • Refs: ${payload.kbRefs.join(", ")}` : "";
addMessage("bot", payload?.text || "I couldn't generate a response right now.", `${laneMeta}${refs}`);
setStatus("online", "Online");
} catch (error) {
console.error(error);
const msg = error?.message?.includes("HTTP")
? `Sorry — the service returned an error. ${error.message}`
: DEMO_ERROR;

addMessage("bot", msg, "Connection issue");
setStatusBanner(DEFAULT_OFFLINE_MESSAGE);
setStatus("offline", "Offline");
} finally {
setLoading(false);
}
};

if (formEl && inputEl) {
formEl.addEventListener("submit", (event) => {
event.preventDefault();
const message = inputEl.value.trim();
if (!message) return;
inputEl.value = "";
sendMessage(message);
});

inputEl.addEventListener("keydown", (event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
formEl.requestSubmit();
}
});
}

starterButtons.forEach((btn) => {
btn.addEventListener("click", () => {
const q = btn.dataset.query;
if (!q) return;
inputEl.value = q;
formEl.requestSubmit();
});
});

if (clearBtn && chatEl) {
clearBtn.addEventListener("click", () => {
if (messagesEl) messagesEl.innerHTML = "";
});
}

addMessage(
"bot",
"Marhaban! I can help with student compliance questions (visa, CPT/OPT, legal/safety) and daily student life in the US.",
"Built for Omani students"
);

checkHealth();
if (API_BASE) console.log("[OmanX] Using API base:", API_BASE);
13 changes: 4 additions & 9 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,26 +1,21 @@
{
"name": "omanx",
"version": "2.1.0",
"version": "3.0.0",
"type": "module",
"description": "OmanX - verified AI guidance for Omani students in the US",
"description": "OmanX architecture baseline",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "NODE_ENV=development node server.js",
"test": "node test/smoke.js"
"test": "node --test src/tests/*.test.js"
},
"engines": {
"node": ">=18.0.0"
},
"dependencies": {
"compression": "^1.7.4",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"express-rate-limit": "^7.2.0",
"helmet": "^7.1.0",
"morgan": "^1.10.0",
"openai": "^4.80.0",
"winston": "^3.13.0"
"morgan": "^1.10.0"
}
}
Loading
Loading