diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..885deb4 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,28 @@ +# GitHub Copilot instructions — AgriValue Tracker + +## Project + +Sicilian agricultural value-chain terminal with **Fabric IQ**, **Foundry IQ**, **Work IQ**, and a custom **MCP server** (`agrivalue-iq`). + +Read [AGENTS.md](../AGENTS.md) and [docs/architecture.md](../docs/architecture.md) before large changes. + +## MCP + +- Server: `mcp-server.js` — registered in `.vscode/mcp.json` +- Invoke in Copilot Chat: `@agrivalue-iq get_market_intelligence crop="Olive Oil"` +- Tools: `get_market_intelligence`, `generate_supply_contract`, `evaluate_crop_quality` + +## Code patterns + +- Business logic lives in `lib/fabric-iq.js` and `lib/iq-data.js`; expose via `server.js` and MCP. +- Crop enum: `CROP_NAMES` in `lib/fabric-iq.js` — keep MCP JSON schema in sync. +- Frontend uses Tailwind CDN + `public/css/app.css`; demo teleprompter styles are inline in `demo.html`. + +## Security + +- Never suggest committing `.env` or real credentials. +- Use `.env.example` for documentation only. + +## Tests + +Run `npm test` after changing optimization, contracts, or IQ data. Tests live in `tests/`. diff --git a/.github/workflows/azure-deploy.yml b/.github/workflows/azure-deploy.yml index 702dd25..02d4237 100644 --- a/.github/workflows/azure-deploy.yml +++ b/.github/workflows/azure-deploy.yml @@ -1,38 +1,38 @@ -name: Deploy backend to Azure App Service - -on: - push: - branches: [PROD] - paths: - - 'server.js' - - 'lib/**' - - 'mcp-server.js' - - 'package.json' - - 'public/**' - - '.github/workflows/azure-deploy.yml' - workflow_dispatch: - -permissions: - contents: read - -jobs: - deploy: - runs-on: ubuntu-latest - environment: azure-production - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: '20' - cache: npm - - - run: npm ci - - run: npm run build:static - - - name: Deploy to Azure Web App - uses: azure/webapps-deploy@v3 - with: - app-name: ${{ secrets.AZURE_WEBAPP_NAME }} - publish-profile: ${{ secrets.AZURE_WEBAPP_PUBLISH_PROFILE }} - package: . +name: Deploy backend to Azure App Service + +on: + push: + branches: [PROD] + paths: + - "server.js" + - "lib/**" + - "mcp-server.js" + - "package.json" + - "public/**" + - ".github/workflows/azure-deploy.yml" + workflow_dispatch: + +permissions: + contents: read + +jobs: + deploy: + runs-on: ubuntu-latest + environment: azure-production + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + + - run: npm ci + - run: npm run build:static + + - name: Deploy to Azure Web App + uses: azure/webapps-deploy@v3 + with: + app-name: ${{ secrets.AZURE_WEBAPP_NAME }} + publish-profile: ${{ secrets.AZURE_WEBAPP_PUBLISH_PROFILE }} + package: . diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d9b9654 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,28 @@ +name: CI + +on: + push: + branches: ["PROD"] + pull_request: + branches: ["PROD"] + types: [opened, synchronize, reopened] + +permissions: + contents: read + +jobs: + quality: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + + - run: npm ci + - run: npm run lint + - run: npm run format:check + - run: npm test + - run: npm run build:static diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..97449c1 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,2 @@ +npm run lint +npm test diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..1a68d3f --- /dev/null +++ b/.prettierignore @@ -0,0 +1,7 @@ +node_modules +public/js/fabric-iq-client.js +public/config.js +public/demo.html +public/index.html +public/css/app.css +.agents/skills/higgsfield-generate diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..59e8c2d --- /dev/null +++ b/.prettierrc @@ -0,0 +1,6 @@ +{ + "semi": true, + "singleQuote": false, + "trailingComma": "es5", + "printWidth": 100 +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..eb0f27d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,67 @@ +# AgriValue Tracker — Agent Context + +Sicilian value-chain optimizer for **Agents League Battle #1** (Creative Apps + GitHub Copilot MCP). + +## Stack + +| Layer | Path | Role | +| ------------ | ----------------------------------------------------------- | ----------------------------------------------------------- | +| API | `server.js` | Express routes, Foundry agent, IQ orchestration | +| IQ data | `lib/iq-data.js` | Unified Fabric / portfolio / optimize / advisor | +| Fabric graph | `lib/fabric-iq.js`, `lib/fabric-lakehouse.js` | Semantic market graph (+ SQL fallback) | +| Work IQ | `lib/work-iq.js` | Microsoft Graph / Teams webhooks | +| MCP | `mcp-server.js`, `.vscode/mcp.json` | Copilot tools in VS Code | +| UI | `public/index.html`, `public/demo.html`, `public/js/app.js` | Terminal + judge teleprompter | +| Static sync | `scripts/sync-static-data.js` | Copies `lib/fabric-iq.js` → `public/js/fabric-iq-client.js` | + +**Default branch:** `PROD`. **Live demo:** https://cdm227.github.io/AgriValue-Tracker/demo.html + +## Commands + +```bash +npm install +npm start # http://localhost:3000 +npm run mcp # MCP server (stdio) +npm test # node:test suite +npm run lint # ESLint +npm run agent:action -- help # deterministic demo actions +npm run build:static # Pages build (sync + config inject) +``` + +## Architecture & docs + +- [docs/architecture.md](docs/architecture.md) — system overview +- [docs/MICROSOFT_IQ.md](docs/MICROSOFT_IQ.md) — Fabric / Foundry / Work IQ +- [docs/COPILOT.md](docs/COPILOT.md) — Copilot + MCP usage +- [docs/AZURE_DEPLOY.md](docs/AZURE_DEPLOY.md) — Azure App Service + Pages +- [.agents/skills/agrivalue-iq-operator/SKILL.md](.agents/skills/agrivalue-iq-operator/SKILL.md) — operator skill + +## Conventions + +- **ES modules** (`"type": "module"`) throughout; no CommonJS. +- **Secrets:** `.env` only (gitignored). Never commit keys. Use `.env.example` placeholders. +- **CORS:** restrict to GitHub Pages origin + localhost (see `server.js`). +- **IQ fallback:** app runs with in-memory Fabric graph when Azure/Fabric env vars are unset. +- **UI language:** English for all user-facing strings. +- **Minimal diffs:** match existing naming; extend `lib/iq-data.js` / `fabric-iq.js` rather than duplicating logic. +- **Generated files:** do not hand-edit `public/js/fabric-iq-client.js` or `public/config.js` — use scripts. + +## MCP tools (`@agrivalue-iq`) + +| Tool | Purpose | +| -------------------------- | ----------------------------------- | +| `get_market_intelligence` | Crop pricing, processor, compliance | +| `generate_supply_contract` | Fair-Trade contract draft | +| `evaluate_crop_quality` | Vision grade + profit multiplier | + +## Do not + +- Commit `.env`, publish profiles, or API keys. +- Add unrelated dependencies or large refactors during demo fixes. +- Break GitHub Pages static fallback (`public/mock-api.js`, `api-client.js` demo mode). + +## Validation before PR + +```bash +npm run lint && npm test && npm run build:static +``` diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..980da78 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,5 @@ +# Claude / Cursor context + +Read **[AGENTS.md](AGENTS.md)** first — it is the canonical agent context for this repo. + +Quick links: [architecture](docs/architecture.md) · [Copilot MCP](docs/COPILOT.md) · [operator skill](.agents/skills/agrivalue-iq-operator/SKILL.md) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1152896 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 AgriValue Tracker contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index c37b2e3..9fbb58c 100644 --- a/README.md +++ b/README.md @@ -20,17 +20,15 @@ If your Markdown viewer does not render video, open: ## Live Demo (for judges — start here) -**Do not rely on the default Git clone branch.** Use these URLs directly: +| Mode | URL | +| ---------------------------------------------------- | -------------------------------------------------------------------------------- | +| GitHub Pages UI | [cdm227.github.io/AgriValue-Tracker](https://cdm227.github.io/AgriValue-Tracker) | +| **2-min judge demo** (teleprompter + auto-narration) | […/demo.html](https://cdm227.github.io/AgriValue-Tracker/demo.html) | +| Full API (Azure) | Set `AZURE_API_URL` secret → see [docs/AZURE_DEPLOY.md](docs/AZURE_DEPLOY.md) | -| Mode | URL | -|------|-----| -| GitHub Pages UI | [cdm227.github.io/AgriValue-Tracker](https://cdm227.github.io/AgriValue-Tracker) | -| **2-min judge demo** (teleprompter + auto-narration) | […/demo.html](https://cdm227.github.io/AgriValue-Tracker/demo.html) | -| Full API (Azure) | Set `AZURE_API_URL` secret → see [docs/AZURE_DEPLOY.md](docs/AZURE_DEPLOY.md) | +**Source branch:** **`PROD`** (default branch — clones and Pages stay in sync). -**Source branch:** production code lives on **`PROD`**. Set GitHub **Settings → General → Default branch → PROD** so clones match Pages. - -**CI:** Pushes to `PROD` auto-deploy GitHub Pages (`.github/workflows/static.yml`) and Azure backend (`.github/workflows/azure-deploy.yml`). +**CI:** Pushes to `PROD` auto-deploy GitHub Pages (`.github/workflows/static.yml`) and Azure backend (`.github/workflows/azure-deploy.yml` when Azure secrets are configured). ## Quick Start @@ -39,8 +37,12 @@ npm install cp .env.example .env # optional: Azure, Fabric, Graph credentials npm start # http://localhost:3000 npm run mcp # Copilot MCP server for VS Code +npm test # node:test suite (Fabric IQ + agent memory) +npm run lint # ESLint ``` +**AI / agent context:** [AGENTS.md](AGENTS.md) · [docs/architecture.md](docs/architecture.md) · [.github/copilot-instructions.md](.github/copilot-instructions.md) + ### Test Locally Or In Codespaces The app works with demo data out of the box. Users only need their own Microsoft resources if they want live integrations. @@ -90,20 +92,20 @@ npm run agent:action -- contract --crop "Olive Oil" --qty 15 --buyer "Cooperativ ## Triple IQ Stack -| Layer | Technology | Purpose | -|-------|------------|---------| -| **Fabric IQ** | Fabric Lakehouse SQL + semantic graph | Market prices, processor network | -| **Foundry IQ** | Azure AI Foundry agent | Grounded compliance & bonuses | -| **Work IQ** | Microsoft Graph / Teams | Cooperative notifications | +| Layer | Technology | Purpose | +| -------------- | ------------------------------------- | -------------------------------- | +| **Fabric IQ** | Fabric Lakehouse SQL + semantic graph | Market prices, processor network | +| **Foundry IQ** | Azure AI Foundry agent | Grounded compliance & bonuses | +| **Work IQ** | Microsoft Graph / Teams | Cooperative notifications | ## Challenge Requirements -| Requirement | Status | Documentation | -|-------------|--------|---------------| -| GitHub Copilot + MCP | Done | [docs/COPILOT.md](docs/COPILOT.md) | -| Microsoft IQ (×3) | Demo + docs | [docs/MICROSOFT_IQ.md](docs/MICROSOFT_IQ.md) | -| Azure deployment | Workflow ready | [docs/AZURE_DEPLOY.md](docs/AZURE_DEPLOY.md) | -| Submission demo video | [YouTube](https://youtu.be/o8KqG8foLfM) | [docs/DEMO_SCRIPT.md](docs/DEMO_SCRIPT.md) | +| Requirement | Status | Documentation | +| --------------------- | --------------------------------------- | -------------------------------------------- | +| GitHub Copilot + MCP | Done | [docs/COPILOT.md](docs/COPILOT.md) | +| Microsoft IQ (×3) | Demo + docs | [docs/MICROSOFT_IQ.md](docs/MICROSOFT_IQ.md) | +| Azure deployment | Workflow ready | [docs/AZURE_DEPLOY.md](docs/AZURE_DEPLOY.md) | +| Submission demo video | [YouTube](https://youtu.be/o8KqG8foLfM) | [docs/DEMO_SCRIPT.md](docs/DEMO_SCRIPT.md) | ## UI Features (v3) @@ -122,34 +124,35 @@ lib/ fabric-lakehouse.js # Microsoft Fabric SQL connector work-iq.js # Microsoft Graph Teams iq-data.js # Unified data layer +tests/ # node:test — optimize, contracts, memory server.js # Express API + IQ orchestration mcp-server.js # Copilot MCP tools public/ # Terminal UI + demo recorder infra/ # Bicep + Fabric SQL schema -.github/workflows/ # Azure + GitHub Pages deploy +.github/workflows/ # CI, Azure + GitHub Pages deploy +AGENTS.md # Canonical context for AI agents +docs/architecture.md # System overview + ADRs ``` ## Agents League Submission -| Item | Link | -|------|------| -| **Demo video** | https://youtu.be/o8KqG8foLfM | -| **Live app** | https://cdm227.github.io/AgriValue-Tracker/demo.html | -| **Register** (required for badge/prizes) | https://aka.ms/agentsleague/register | -| **Submit issue** (official) | [microsoft/agentsleague — Project Submission](https://github.com/microsoft/agentsleague/issues/new?template=project.yml) | +| Item | Link | +| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| **Demo video** | https://youtu.be/o8KqG8foLfM | +| **Live app** | https://cdm227.github.io/AgriValue-Tracker/demo.html | +| **Register** (required for badge/prizes) | https://aka.ms/agentsleague/register | +| **Submit issue** (official) | [microsoft/agentsleague — Project Submission](https://github.com/microsoft/agentsleague/issues/new?template=project.yml) | Recording notes: [docs/DEMO_SCRIPT.md](docs/DEMO_SCRIPT.md) -### Fix default branch & auto-deploy (one-time) - -| Step | Where | Action | -|------|--------|--------| -| Default branch | GitHub → **Settings → General → Default branch** | Switch from `testingv1` to **`PROD`** | -| Pages deploy | Push to `PROD` | Workflow `static.yml` builds & deploys `public/` | -| Azure API | Push to `PROD` | Workflow `azure-deploy.yml` (if Azure secrets configured) | -| Manual deploy | **Actions** → *Deploy static content to Pages* → **Run workflow** | Use if you need a deploy before the next push | - +### Deploy & CI +| Trigger | Workflow | Result | +| ------------------------------ | ----------------------------------------------------------------- | ------------------------------------------- | +| PR / push to `PROD` | `ci.yml` | Lint, format check, tests, static build | +| Push to `PROD` | `static.yml` | GitHub Pages deploy from `public/` | +| Push to `PROD` (backend paths) | `azure-deploy.yml` | Azure App Service deploy (requires secrets) | +| Manual | **Actions** → _Deploy static content to Pages_ → **Run workflow** | Redeploy Pages without a new commit | ## Security diff --git a/docs/AZURE_DEPLOY.md b/docs/AZURE_DEPLOY.md index 83df4ba..95c51cf 100644 --- a/docs/AZURE_DEPLOY.md +++ b/docs/AZURE_DEPLOY.md @@ -31,33 +31,33 @@ Note the `apiUrl` output (e.g. `https://agrivalue-iq-prod.azurewebsites.net`). In Azure Portal → App Service → Configuration → Application settings: -| Setting | Purpose | -|---------|---------| -| `FOUNDRY_OPENAI_BASE_URL` | Foundry OpenAI v1 base URL, e.g. `https://...services.ai.azure.com/openai/v1` | -| `FOUNDRY_MODEL_DEPLOYMENT` | Model deployment name, e.g. `gpt-4o` | -| `FOUNDRY_RESPONSES_ENDPOINT` | Foundry project agent Responses endpoint | -| `FOUNDRY_API_KEY` | Optional key. If omitted, App Service uses managed identity / DefaultAzureCredential | -| `AZURE_AI_AGENT_ID` | Legacy Azure OpenAI agent ID fallback | -| `AZURE_OPENAI_ENDPOINT` | Legacy Azure OpenAI endpoint fallback | -| `AZURE_OPENAI_API_KEY` | Legacy Azure OpenAI API key fallback | -| `FABRIC_SQL_SERVER` | Fabric warehouse SQL endpoint | -| `FABRIC_SQL_DATABASE` | Database name | -| `GRAPH_TENANT_ID` | Work IQ — Entra tenant | -| `GRAPH_CLIENT_ID` | App registration client ID | -| `GRAPH_CLIENT_SECRET` | Client secret | -| `TEAMS_TEAM_ID` | Teams team ID | -| `TEAMS_CHANNEL_ID` | Channel ID | -| `CORS_ORIGINS` | `https://cdm227.github.io` | +| Setting | Purpose | +| ---------------------------- | ------------------------------------------------------------------------------------ | +| `FOUNDRY_OPENAI_BASE_URL` | Foundry OpenAI v1 base URL, e.g. `https://...services.ai.azure.com/openai/v1` | +| `FOUNDRY_MODEL_DEPLOYMENT` | Model deployment name, e.g. `gpt-4o` | +| `FOUNDRY_RESPONSES_ENDPOINT` | Foundry project agent Responses endpoint | +| `FOUNDRY_API_KEY` | Optional key. If omitted, App Service uses managed identity / DefaultAzureCredential | +| `AZURE_AI_AGENT_ID` | Legacy Azure OpenAI agent ID fallback | +| `AZURE_OPENAI_ENDPOINT` | Legacy Azure OpenAI endpoint fallback | +| `AZURE_OPENAI_API_KEY` | Legacy Azure OpenAI API key fallback | +| `FABRIC_SQL_SERVER` | Fabric warehouse SQL endpoint | +| `FABRIC_SQL_DATABASE` | Database name | +| `GRAPH_TENANT_ID` | Work IQ — Entra tenant | +| `GRAPH_CLIENT_ID` | App registration client ID | +| `GRAPH_CLIENT_SECRET` | Client secret | +| `TEAMS_TEAM_ID` | Teams team ID | +| `TEAMS_CHANNEL_ID` | Channel ID | +| `CORS_ORIGINS` | `https://cdm227.github.io` | ### 3. GitHub Secrets Add these repository secrets: -| Secret | Value | -|--------|-------| -| `AZURE_WEBAPP_NAME` | App name from Bicep (e.g. `agrivalue-iq-prod`) | +| Secret | Value | +| ------------------------------ | ------------------------------------------------ | +| `AZURE_WEBAPP_NAME` | App name from Bicep (e.g. `agrivalue-iq-prod`) | | `AZURE_WEBAPP_PUBLISH_PROFILE` | Download from Azure Portal → Get publish profile | -| `AZURE_API_URL` | `https://agrivalue-iq-prod.azurewebsites.net` | +| `AZURE_API_URL` | `https://agrivalue-iq-prod.azurewebsites.net` | ### 4. Enable GitHub Environments diff --git a/docs/COPILOT.md b/docs/COPILOT.md index f020736..1407a3f 100644 --- a/docs/COPILOT.md +++ b/docs/COPILOT.md @@ -1,103 +1,103 @@ -# Development with GitHub Copilot - -This document describes how GitHub Copilot was used to build AgriValue Tracker for **Battle #1: Creative Apps** in the AI Agents League. - -## How Copilot Accelerated Development - -### 1. Backend scaffolding (`server.js`) - -Copilot Chat was used to scaffold the Express API structure: - -``` -Create an Express API with routes for crop market data, quality evaluation, -and value-chain optimization. Use Azure Key Vault for secrets with env fallback. -``` - -Copilot generated the polling loop for Azure AI Foundry agent runs (`pollRun`), the thread/message/run API sequence, and telemetry logging patterns. - -### 2. Fabric IQ semantic graph (`lib/fabric-iq.js`) - -Copilot inline suggestions helped structure the crop → processor → certification ontology: - -- **Entities:** crops, processors, compliance rules, organic bonuses -- **Relationships:** each processor `inputs` a crop; each crop has `certifications` -- **Reasoning:** `optimizeValueChain()` computes Path A vs Path B with quality multipliers - -Prompt used in Copilot Chat: - -``` -Model a semantic market graph for Sicilian agriculture: 5 crops, local processors, -transport costs, DOP/DOC certifications, and Foundry compliance rules as separate -functions exportable to both Express and MCP. -``` - -### 3. MCP server for Copilot in VS Code (`mcp-server.js`) - -Copilot helped implement the Model Context Protocol server exposing three tools: - -| Tool | Purpose | -|------|---------| -| `get_market_intelligence` | Grounded pricing and compliance for a crop | -| `generate_supply_contract` | Draft Fair-Trade supply agreements | -| `evaluate_crop_quality` | Quality grade and profit multiplier | - -**Example Copilot Chat prompt (with MCP enabled):** - -``` -@agrivalue-iq generate_supply_contract crop="Olive Oil" qtyTons=15 isOrganic=true buyer="Cooperativa Valle del Belice" - -Now customize section 4 for EU DOP export requirements and add a force majeure clause. -``` - -### 4. Frontend terminal UI (`public/index.html`) - -Copilot assisted with: - -- Tailwind layout for the "institutional terminal" aesthetic -- Chart.js market trend visualization wired to Fabric IQ forecast data -- Visual quality scanner animation and console log feed -- Contract export and "Copy for Copilot MCP" workflow buttons - -### 5. Debugging with Copilot Chat - -When the market chart threw `getElementById('trendChart')` errors, Copilot Chat identified the missing `` element and suggested wrapping `initChart` in try/catch for static demo mode. - -## MCP Setup in VS Code - -1. Install [GitHub Copilot](https://marketplace.visualstudio.com/items?itemName=GitHub.copilot) and enable MCP in settings -2. Open this repo in VS Code — `.vscode/mcp.json` registers the server automatically -3. Run `npm install` then verify: `npm run mcp` (should log "running on stdio") -4. In Copilot Chat, reference tools with `@agrivalue-iq` - -## Suggested Copilot Workflows for Judges - -### Write a contract from live optimization results - -1. Run the app locally (`npm start`) -2. Optimize Olive Oil with organic certification -3. Click **Copy for Copilot MCP** in the results panel -4. Paste into Copilot Chat — MCP generates a grounded contract draft - -### Extend the semantic graph - -``` -@agrivalue-iq get_market_intelligence crop="Grapes" - -Add a new processor "Etna Volcanic Wines" in lib/fabric-iq.js with costs -and update the MCP enum in mcp-server.js -``` - -## What Was Built Manually vs. Copilot-Assisted - -| Component | Copilot role | -|-----------|-------------| -| Azure Foundry agent integration | Scaffolding + API version headers | -| Fabric IQ ontology | Data structure + helper functions | -| MCP tool schemas | JSON Schema definitions | -| UI copy and styling | Tailwind classes + animation CSS | -| Security (Key Vault, .env) | Pattern suggestions, manual review | -| Challenge documentation | Structure outline, manual editing | - -## Security Note - -Copilot was **not** given real API keys. All secrets use `.env` (gitignored) per the challenge security guidelines. See `.env.example` for required variables. +# Development with GitHub Copilot + +This document describes how GitHub Copilot was used to build AgriValue Tracker for **Battle #1: Creative Apps** in the AI Agents League. + +## How Copilot Accelerated Development + +### 1. Backend scaffolding (`server.js`) + +Copilot Chat was used to scaffold the Express API structure: + +``` +Create an Express API with routes for crop market data, quality evaluation, +and value-chain optimization. Use Azure Key Vault for secrets with env fallback. +``` + +Copilot generated the polling loop for Azure AI Foundry agent runs (`pollRun`), the thread/message/run API sequence, and telemetry logging patterns. + +### 2. Fabric IQ semantic graph (`lib/fabric-iq.js`) + +Copilot inline suggestions helped structure the crop → processor → certification ontology: + +- **Entities:** crops, processors, compliance rules, organic bonuses +- **Relationships:** each processor `inputs` a crop; each crop has `certifications` +- **Reasoning:** `optimizeValueChain()` computes Path A vs Path B with quality multipliers + +Prompt used in Copilot Chat: + +``` +Model a semantic market graph for Sicilian agriculture: 5 crops, local processors, +transport costs, DOP/DOC certifications, and Foundry compliance rules as separate +functions exportable to both Express and MCP. +``` + +### 3. MCP server for Copilot in VS Code (`mcp-server.js`) + +Copilot helped implement the Model Context Protocol server exposing three tools: + +| Tool | Purpose | +| -------------------------- | ------------------------------------------ | +| `get_market_intelligence` | Grounded pricing and compliance for a crop | +| `generate_supply_contract` | Draft Fair-Trade supply agreements | +| `evaluate_crop_quality` | Quality grade and profit multiplier | + +**Example Copilot Chat prompt (with MCP enabled):** + +``` +@agrivalue-iq generate_supply_contract crop="Olive Oil" qtyTons=15 isOrganic=true buyer="Cooperativa Valle del Belice" + +Now customize section 4 for EU DOP export requirements and add a force majeure clause. +``` + +### 4. Frontend terminal UI (`public/index.html`) + +Copilot assisted with: + +- Tailwind layout for the "institutional terminal" aesthetic +- Chart.js market trend visualization wired to Fabric IQ forecast data +- Visual quality scanner animation and console log feed +- Contract export and "Copy for Copilot MCP" workflow buttons + +### 5. Debugging with Copilot Chat + +When the market chart threw `getElementById('trendChart')` errors, Copilot Chat identified the missing `` element and suggested wrapping `initChart` in try/catch for static demo mode. + +## MCP Setup in VS Code + +1. Install [GitHub Copilot](https://marketplace.visualstudio.com/items?itemName=GitHub.copilot) and enable MCP in settings +2. Open this repo in VS Code — `.vscode/mcp.json` registers the server automatically +3. Run `npm install` then verify: `npm run mcp` (should log "running on stdio") +4. In Copilot Chat, reference tools with `@agrivalue-iq` + +## Suggested Copilot Workflows for Judges + +### Write a contract from live optimization results + +1. Run the app locally (`npm start`) +2. Optimize Olive Oil with organic certification +3. Click **Copy for Copilot MCP** in the results panel +4. Paste into Copilot Chat — MCP generates a grounded contract draft + +### Extend the semantic graph + +``` +@agrivalue-iq get_market_intelligence crop="Grapes" + +Add a new processor "Etna Volcanic Wines" in lib/fabric-iq.js with costs +and update the MCP enum in mcp-server.js +``` + +## What Was Built Manually vs. Copilot-Assisted + +| Component | Copilot role | +| ------------------------------- | ---------------------------------- | +| Azure Foundry agent integration | Scaffolding + API version headers | +| Fabric IQ ontology | Data structure + helper functions | +| MCP tool schemas | JSON Schema definitions | +| UI copy and styling | Tailwind classes + animation CSS | +| Security (Key Vault, .env) | Pattern suggestions, manual review | +| Challenge documentation | Structure outline, manual editing | + +## Security Note + +Copilot was **not** given real API keys. All secrets use `.env` (gitignored) per the challenge security guidelines. See `.env.example` for required variables. diff --git a/docs/DEMO_SCRIPT.md b/docs/DEMO_SCRIPT.md index 1a1dafa..1e22dc7 100644 --- a/docs/DEMO_SCRIPT.md +++ b/docs/DEMO_SCRIPT.md @@ -1,102 +1,102 @@ -# 2-Minute Demo Video Script - -Use this script for your **Agents League submission video**. It matches the live teleprompter on `demo.html` (Vision step, agent memory, auto-narration). - -## Before you record - -1. Open the **live judge demo** (no clone required): [demo.html](https://cdm227.github.io/AgriValue-Tracker/demo.html) -2. Open **VS Code** in this repo with GitHub Copilot + MCP (`.vscode/mcp.json` registers `agrivalue-iq`) -3. In a terminal: `npm install` then `npm run mcp` (optional — MCP also starts when Copilot connects) -4. Turn on **Audio Narration** on the demo page if you want the built-in voice-over (or narrate yourself) -5. Use **OBS**, **Xbox Game Bar (Win+G)**, or **Loom** to capture screen + mic - -## Layout (recommended) - -| Window | Content | -|--------|---------| -| Primary | `demo.html` → click **Start 2-Min Judge Demo** | -| Secondary (split or cut) | VS Code Copilot Chat with `@agrivalue-iq` | - -## Teleprompter flow (~128 seconds) - -The demo page auto-advances. Follow the green **script line** and **workflow map** (Harvest → Don → Matteo → Sofia → Vision → Copilot). - -### 0:00–0:18 — Problem & agents - -> "Sicilian farmers often sell raw olives and grapes to bulk silos at commodity prices. AgriValue is a creative app where friendly AI agents work together — Don Salvatore listens, Matteo runs the numbers, Sofia checks quality." - -**Screen:** Story cards + 6-node flow map. Intro video may auto-play muted. - -### 0:18–0:45 — Fabric IQ (Matteo) - -> "Fabric IQ maps processors, prices, and transport — Matteo compares Path A raw sale vs Path B local processing." - -**Screen:** Live app segment opens (`index.html?autoplay=1`). Watch portfolio/optimize run. Point to **added value** and Path A vs Path B. - -**Teleprompter cues:** Olive Oil, 15 MT, organic/DOP if shown. - -### 0:45–1:05 — Sofia + Foundry compliance - -> "Sofia DOP checks the serious papers — EU DOP, organic evidence — before we claim the premium." - -**Screen:** Foundry typewriter compliance text in the IQ console. - -### 1:05–1:18 — Vision scan - -> "Vision scans the crop photo and applies a quality multiplier to Path B revenue." - -**Screen:** Vision Quality Scan animation / grade badge. Brief pause after Sofia before Vision (teleprompter bridge cue ~52s). - -### 1:18–1:35 — Agent memory - -> "Every optimization is saved to agent memory — Don Salvatore can recall recent trades in the AI Advisor." - -**Screen:** Trade history / advisor memory panel (ask "show trade history" or open memory UI if visible). - -### 1:35–1:55 — Copilot MCP contract - -> "One click copies the MCP prompt. Copilot uses our AgriValue MCP server to draft a Fair-Trade supply contract from live pricing." - -**Actions in VS Code Copilot Chat:** - -``` -@agrivalue-iq generate_supply_contract crop="Olive Oil" qtyTons=15 isOrganic=true buyer="Cooperativa Valle del Belice" - -Customize for EU DOP export to the US market. -``` - -Show the generated contract markdown. - -### 1:55–2:08 — Work IQ + close - -> "Work IQ can alert the cooperative on Teams. Mamma mia — andiamo!" - -**Action:** Click **Teams Alert** in the app (or show Work IQ pill / `/api/status`). End on teleprompter finale. - ---- - -## Manual recording (without auto-demo) - -If you prefer full manual control: - -1. Open [index.html](https://cdm227.github.io/AgriValue-Tracker/) -2. Run IQ Pipeline: Olive Oil, 15 MT, DOP enabled -3. Run Vision scan → optimize → export contract → Copilot MCP → Teams -4. Cut to VS Code for the MCP contract step above - -## What judges should see - -- [ ] Six-step workflow (including **Vision** and **Copilot**) -- [ ] Meaningful **added value** number -- [ ] Foundry grounded compliance text -- [ ] **Agent memory** / trade history -- [ ] **Copilot + MCP** generating contract in IDE -- [ ] Work IQ / Teams (or configured status in API) - -## Upload - -1. Export MP4 (≤2–3 min is fine; teleprompter targets ~2 min) -2. Upload to YouTube (unlisted) or Loom -3. Paste the link in the [Agents League portal](https://aka.ms/agentsleague/aisf) with your repo URL - -**Repo URL for judges:** use the README live links — default branch should be `PROD`, not `testingv1`. +# 2-Minute Demo Video Script + +Use this script for your **Agents League submission video**. It matches the live teleprompter on `demo.html` (Vision step, agent memory, auto-narration). + +## Before you record + +1. Open the **live judge demo** (no clone required): [demo.html](https://cdm227.github.io/AgriValue-Tracker/demo.html) +2. Open **VS Code** in this repo with GitHub Copilot + MCP (`.vscode/mcp.json` registers `agrivalue-iq`) +3. In a terminal: `npm install` then `npm run mcp` (optional — MCP also starts when Copilot connects) +4. Turn on **Audio Narration** on the demo page if you want the built-in voice-over (or narrate yourself) +5. Use **OBS**, **Xbox Game Bar (Win+G)**, or **Loom** to capture screen + mic + +## Layout (recommended) + +| Window | Content | +| ------------------------ | ---------------------------------------------- | +| Primary | `demo.html` → click **Start 2-Min Judge Demo** | +| Secondary (split or cut) | VS Code Copilot Chat with `@agrivalue-iq` | + +## Teleprompter flow (~128 seconds) + +The demo page auto-advances. Follow the green **script line** and **workflow map** (Harvest → Don → Matteo → Sofia → Vision → Copilot). + +### 0:00–0:18 — Problem & agents + +> "Sicilian farmers often sell raw olives and grapes to bulk silos at commodity prices. AgriValue is a creative app where friendly AI agents work together — Don Salvatore listens, Matteo runs the numbers, Sofia checks quality." + +**Screen:** Story cards + 6-node flow map. Intro video may auto-play muted. + +### 0:18–0:45 — Fabric IQ (Matteo) + +> "Fabric IQ maps processors, prices, and transport — Matteo compares Path A raw sale vs Path B local processing." + +**Screen:** Live app segment opens (`index.html?autoplay=1`). Watch portfolio/optimize run. Point to **added value** and Path A vs Path B. + +**Teleprompter cues:** Olive Oil, 15 MT, organic/DOP if shown. + +### 0:45–1:05 — Sofia + Foundry compliance + +> "Sofia DOP checks the serious papers — EU DOP, organic evidence — before we claim the premium." + +**Screen:** Foundry typewriter compliance text in the IQ console. + +### 1:05–1:18 — Vision scan + +> "Vision scans the crop photo and applies a quality multiplier to Path B revenue." + +**Screen:** Vision Quality Scan animation / grade badge. Brief pause after Sofia before Vision (teleprompter bridge cue ~52s). + +### 1:18–1:35 — Agent memory + +> "Every optimization is saved to agent memory — Don Salvatore can recall recent trades in the AI Advisor." + +**Screen:** Trade history / advisor memory panel (ask "show trade history" or open memory UI if visible). + +### 1:35–1:55 — Copilot MCP contract + +> "One click copies the MCP prompt. Copilot uses our AgriValue MCP server to draft a Fair-Trade supply contract from live pricing." + +**Actions in VS Code Copilot Chat:** + +``` +@agrivalue-iq generate_supply_contract crop="Olive Oil" qtyTons=15 isOrganic=true buyer="Cooperativa Valle del Belice" + +Customize for EU DOP export to the US market. +``` + +Show the generated contract markdown. + +### 1:55–2:08 — Work IQ + close + +> "Work IQ can alert the cooperative on Teams. Mamma mia — andiamo!" + +**Action:** Click **Teams Alert** in the app (or show Work IQ pill / `/api/status`). End on teleprompter finale. + +--- + +## Manual recording (without auto-demo) + +If you prefer full manual control: + +1. Open [index.html](https://cdm227.github.io/AgriValue-Tracker/) +2. Run IQ Pipeline: Olive Oil, 15 MT, DOP enabled +3. Run Vision scan → optimize → export contract → Copilot MCP → Teams +4. Cut to VS Code for the MCP contract step above + +## What judges should see + +- [ ] Six-step workflow (including **Vision** and **Copilot**) +- [ ] Meaningful **added value** number +- [ ] Foundry grounded compliance text +- [ ] **Agent memory** / trade history +- [ ] **Copilot + MCP** generating contract in IDE +- [ ] Work IQ / Teams (or configured status in API) + +## Upload + +1. Export MP4 (≤2–3 min is fine; teleprompter targets ~2 min) +2. Upload to YouTube (unlisted) or Loom +3. Paste the link in the [Agents League portal](https://aka.ms/agentsleague/aisf) with your repo URL + +**Repo URL for judges:** https://github.com/cdm227/AgriValue-Tracker (`PROD` default branch). diff --git a/docs/MICROSOFT_IQ.md b/docs/MICROSOFT_IQ.md index 1bd73b0..2c30b57 100644 --- a/docs/MICROSOFT_IQ.md +++ b/docs/MICROSOFT_IQ.md @@ -22,14 +22,15 @@ GitHub Pages UI ──► Azure App Service API **Implementation:** `lib/fabric-lakehouse.js` + `lib/iq-data.js` -| Mode | Source | When | -|------|--------|------| -| Live | Microsoft Fabric Warehouse SQL | `FABRIC_SQL_SERVER` configured | -| Fallback | In-memory ontology | Default / connection failure | +| Mode | Source | When | +| -------- | ------------------------------ | ------------------------------ | +| Live | Microsoft Fabric Warehouse SQL | `FABRIC_SQL_SERVER` configured | +| Fallback | In-memory ontology | Default / connection failure | **Lakehouse setup:** Run `infra/fabric-schema.sql` in your Fabric SQL endpoint. **Capabilities:** + - Crop → processor → certification relationships - Price history and forecast series - Value-chain optimization (Path A vs Path B) @@ -70,12 +71,13 @@ AZURE_OPENAI_API_KEY=... **Implementation:** `lib/work-iq.js` -| Channel | Config | -|---------|--------| -| Microsoft Graph | `GRAPH_TENANT_ID`, `GRAPH_CLIENT_ID`, `GRAPH_CLIENT_SECRET`, `TEAMS_TEAM_ID`, `TEAMS_CHANNEL_ID` | -| Webhook fallback | `TEAMS_WEBHOOK_URL` | +| Channel | Config | +| ---------------- | ------------------------------------------------------------------------------------------------ | +| Microsoft Graph | `GRAPH_TENANT_ID`, `GRAPH_CLIENT_ID`, `GRAPH_CLIENT_SECRET`, `TEAMS_TEAM_ID`, `TEAMS_CHANNEL_ID` | +| Webhook fallback | `TEAMS_WEBHOOK_URL` | **Triggers:** + - Auto on `/api/optimize` when Work IQ active - Manual **Teams Alert** button in UI - On contract export via `/api/generate-contract` @@ -85,6 +87,7 @@ AZURE_OPENAI_API_KEY=... ## MCP Bridge `mcp-server.js` exposes IQ data to GitHub Copilot: + - `get_market_intelligence` - `generate_supply_contract` - `evaluate_crop_quality` diff --git a/docs/adr/001-triple-iq-stack.md b/docs/adr/001-triple-iq-stack.md new file mode 100644 index 0000000..57842df --- /dev/null +++ b/docs/adr/001-triple-iq-stack.md @@ -0,0 +1,25 @@ +# ADR 001: Triple Microsoft IQ stack + +## Status + +Accepted — Agents League submission (Battle #1). + +## Context + +AgriValue must show meaningful use of Microsoft intelligence platforms while remaining demoable without every judge configuring Azure resources. + +## Decision + +Integrate three layers with graceful fallback: + +1. **Fabric IQ** — semantic market graph; live Fabric SQL when configured, in-memory ontology otherwise. +2. **Foundry IQ** — compliance and advisor enrichment via Azure AI Foundry when configured; grounded rules in `fabric-iq.js` otherwise. +3. **Work IQ** — Teams notifications via Microsoft Graph or incoming webhook when configured; UI shows offline status otherwise. + +Expose the same domain logic to **GitHub Copilot MCP** (`mcp-server.js`) so contracts and market data stay consistent across UI and IDE. + +## Consequences + +- Judges can use GitHub Pages immediately; optional Azure unlocks live IQ pills. +- Single source of truth in `lib/iq-data.js` / `lib/fabric-iq.js` shared by API and MCP. +- `.env` holds all secrets; no credentials in the repo. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..c1b22cf --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,64 @@ +# Architecture + +AgriValue Tracker connects a static GitHub Pages UI to an optional Azure Node.js API, with three Microsoft IQ layers and a Copilot MCP bridge. + +## System context + +``` + ┌─────────────────────────────────────┐ + │ GitHub Copilot (VS Code + MCP) │ + │ mcp-server.js → lib/iq-data.js │ + └──────────────────┬──────────────────┘ + │ +┌──────────────┐ config.js │ ┌─────────────────────┐ +│ GitHub Pages │ ──fetch──────────────┼────────►│ Azure App Service │ +│ public/ │ │ │ server.js │ +└──────────────┘ │ └──────────┬──────────┘ + │ │ │ + │ static fallback │ ┌──────────┼──────────┐ + └ mock-api / fabric-iq-client │ ▼ ▼ ▼ + │ Fabric IQ Foundry IQ Work IQ + │ (SQL/mem) (Foundry) (Graph) + └──────────────────────────────── +``` + +## Layers + +| Layer | Module | Responsibility | +| ------------ | --------------------------------------------- | ----------------------------------------- | +| Presentation | `public/index.html`, `public/js/app.js` | Terminal UI, charts, advisor, vision scan | +| Judge demo | `public/demo.html` | 2-min teleprompter + embedded live app | +| API | `server.js` | REST routes, CORS, Foundry agent polling | +| Domain | `lib/iq-data.js` | Portfolio, optimize, advisor, contracts | +| Fabric IQ | `lib/fabric-iq.js`, `lib/fabric-lakehouse.js` | Crop → processor graph, Path A vs B | +| Agent memory | `lib/agent-memory.js` | In-session trade history | +| Work IQ | `lib/work-iq.js` | Teams via Graph or webhook | +| MCP | `mcp-server.js` | Copilot tool surface | + +## Key flows + +### Value-chain optimization + +1. UI or `/api/optimize` receives crop, qty, organic flag, quality multiplier. +2. `optimizeValueChain()` in `lib/iq-data.js` computes Path A (silo) vs Path B (local processing). +3. `createAgentTrace()` attaches Don Salvatore / Matteo / Sofia narrative. +4. `recordTrade()` saves to agent memory; Work IQ may notify Teams. + +### Static demo mode (Pages) + +When `public/config.js` has `staticFallback: true`, the browser uses `public/mock-api.js` and synced `fabric-iq-client.js` — no Azure required. + +## Deployment + +| Target | Trigger | Workflow | +| ---------------- | ------------------------------ | ------------------------------------ | +| GitHub Pages | Push to `PROD` | `.github/workflows/static.yml` | +| Azure API | Push to `PROD` (backend paths) | `.github/workflows/azure-deploy.yml` | +| CI (lint + test) | PR / push to `PROD` | `.github/workflows/ci.yml` | + +## Related docs + +- [MICROSOFT_IQ.md](MICROSOFT_IQ.md) — IQ integration detail +- [AZURE_DEPLOY.md](AZURE_DEPLOY.md) — provisioning and env vars +- [COPILOT.md](COPILOT.md) — Copilot development story +- [adr/001-triple-iq-stack.md](adr/001-triple-iq-stack.md) — ADR: why three IQ layers diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..de8b877 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,45 @@ +import js from "@eslint/js"; +import globals from "globals"; + +const nodeFiles = [ + "lib/**/*.js", + "server.js", + "mcp-server.js", + "scripts/**/*.js", + "scripts/**/*.mjs", + "tests/**/*.js", +]; + +export default [ + js.configs.recommended, + { + ignores: ["node_modules/**", "public/js/fabric-iq-client.js", "public/config.js"], + }, + { + files: nodeFiles, + languageOptions: { + ecmaVersion: 2022, + sourceType: "module", + globals: globals.node, + }, + rules: { + "no-unused-vars": ["warn", { argsIgnorePattern: "^_" }], + }, + }, + { + files: ["public/js/**/*.js", "public/mock-api.js"], + languageOptions: { + ecmaVersion: 2022, + sourceType: "module", + globals: { + ...globals.browser, + AGRIVALUE_CONFIG: "readonly", + lucide: "readonly", + Chart: "readonly", + showIntro: "readonly", + runDemoVisionScan: "readonly", + optimizeChain: "readonly", + }, + }, + }, +]; diff --git a/lib/agent-memory.js b/lib/agent-memory.js index d2c53ba..50a80f9 100644 --- a/lib/agent-memory.js +++ b/lib/agent-memory.js @@ -1,41 +1,40 @@ -/** - * Agent memory — in-session trade history for Don Salvatore, Matteo, and Sofia. - * Persists optimization runs so agents can reference past deals. - */ - -const MAX_TRADES = 50; -const trades = []; - -export function recordTrade(entry) { - const record = { - id: `tr-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, - at: new Date().toISOString(), - agents: ["Don Salvatore", "Matteo", "Sofia DOP"], - ...entry, - }; - trades.unshift(record); - if (trades.length > MAX_TRADES) trades.length = MAX_TRADES; - return record; -} - -export function getTradeHistory(limit = 20) { - return trades.slice(0, Math.min(limit, MAX_TRADES)); -} - -export function formatTradeMemoryReply(trades, lang = "en") { - if (!trades?.length) { - return lang === "it" - ? "**Don Salvatore:** Non abbiamo ancora memoria di trade, compare. Fai una pipeline e la salvo per te." - : "**Don Salvatore:** No trade memory yet, compare. Run the IQ pipeline once and I will remember it."; - } - const lines = trades.slice(0, 5).map((t) => { - const date = new Date(t.at).toLocaleDateString(lang === "it" ? "it-IT" : "en-US"); - const vision = t.qualityMultiplier && t.qualityMultiplier !== 1 - ? ` · Vision ${t.qualityMultiplier}x` - : ""; - return `• ${date}: **${t.crop}** ${t.qtyTons} MT → +$${Number(t.addedValue || 0).toLocaleString()} added value${vision}`; - }); - return lang === "it" - ? `**Don Salvatore:** Ecco la memoria degli agenti — gli ultimi trade salvati:\n${lines.join("\n")}` - : `**Don Salvatore:** Agent memory — your recent saved trades:\n${lines.join("\n")}`; -} +/** + * Agent memory — in-session trade history for Don Salvatore, Matteo, and Sofia. + * Persists optimization runs so agents can reference past deals. + */ + +const MAX_TRADES = 50; +const trades = []; + +export function recordTrade(entry) { + const record = { + id: `tr-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, + at: new Date().toISOString(), + agents: ["Don Salvatore", "Matteo", "Sofia DOP"], + ...entry, + }; + trades.unshift(record); + if (trades.length > MAX_TRADES) trades.length = MAX_TRADES; + return record; +} + +export function getTradeHistory(limit = 20) { + return trades.slice(0, Math.min(limit, MAX_TRADES)); +} + +export function formatTradeMemoryReply(trades, lang = "en") { + if (!trades?.length) { + return lang === "it" + ? "**Don Salvatore:** Non abbiamo ancora memoria di trade, compare. Fai una pipeline e la salvo per te." + : "**Don Salvatore:** No trade memory yet, compare. Run the IQ pipeline once and I will remember it."; + } + const lines = trades.slice(0, 5).map((t) => { + const date = new Date(t.at).toLocaleDateString(lang === "it" ? "it-IT" : "en-US"); + const vision = + t.qualityMultiplier && t.qualityMultiplier !== 1 ? ` · Vision ${t.qualityMultiplier}x` : ""; + return `• ${date}: **${t.crop}** ${t.qtyTons} MT → +$${Number(t.addedValue || 0).toLocaleString()} added value${vision}`; + }); + return lang === "it" + ? `**Don Salvatore:** Ecco la memoria degli agenti — gli ultimi trade salvati:\n${lines.join("\n")}` + : `**Don Salvatore:** Agent memory — your recent saved trades:\n${lines.join("\n")}`; +} diff --git a/lib/fabric-iq.js b/lib/fabric-iq.js index f154d5a..fbd14e0 100644 --- a/lib/fabric-iq.js +++ b/lib/fabric-iq.js @@ -62,10 +62,14 @@ export const fabricIQ = { { name: "Highland Cheese Plant", inputs: "Milk", costs: 250, location: "Enna" }, ], complianceRules: { - "Olive Oil": "Must meet EU DOP Extra Virgin criteria: acidity ≤ 0.8%, peroxide ≤ 20 mEq/kg. Premium bonus: +$450/ton when USDA Organic certified.", - Grapes: "DOC Sicilia appellation requires 85% Nero d'Avola varietal. Premium bonus: +$300/ton for biodynamic certification.", - Wheat: "Must be milled in a certified organic facility (USDA-ORGANIC-CLASS1). Premium bonus: +$45/ton.", - Coffee: "Ethically sourced under FLO-CERT-2026 Fair Trade guidelines. Premium bonus: +$250/ton.", + "Olive Oil": + "Must meet EU DOP Extra Virgin criteria: acidity ≤ 0.8%, peroxide ≤ 20 mEq/kg. Premium bonus: +$450/ton when USDA Organic certified.", + Grapes: + "DOC Sicilia appellation requires 85% Nero d'Avola varietal. Premium bonus: +$300/ton for biodynamic certification.", + Wheat: + "Must be milled in a certified organic facility (USDA-ORGANIC-CLASS1). Premium bonus: +$45/ton.", + Coffee: + "Ethically sourced under FLO-CERT-2026 Fair Trade guidelines. Premium bonus: +$250/ton.", Milk: "Pasture-raised grass-fed standards required for artisanal cheese route. Premium bonus: +$90/ton.", }, organicBonuses: { "Olive Oil": 450, Grapes: 300, Wheat: 45, Coffee: 250, Milk: 90 }, diff --git a/lib/fabric-lakehouse.js b/lib/fabric-lakehouse.js index 87a0666..768e624 100644 --- a/lib/fabric-lakehouse.js +++ b/lib/fabric-lakehouse.js @@ -1,164 +1,166 @@ -/** - * Microsoft Fabric IQ — Lakehouse / Warehouse SQL connector. - * Loads semantic market graph from Fabric when configured; falls back to in-memory graph. - */ -import sql from "mssql"; -import { DefaultAzureCredential } from "@azure/identity"; -import { fabricIQ as fallbackGraph, CROP_NAMES } from "./fabric-iq.js"; - -let activeGraph = null; -let dataSource = "in-memory"; -let lastSync = null; -let syncError = null; - -const SYNC_TTL_MS = 5 * 60 * 1000; - -function isFabricConfigured() { - return Boolean( - process.env.FABRIC_SQL_SERVER && - process.env.FABRIC_SQL_DATABASE - ); -} - -async function getSqlConfig() { - const server = process.env.FABRIC_SQL_SERVER; - const database = process.env.FABRIC_SQL_DATABASE; - - if (process.env.FABRIC_SQL_USER && process.env.FABRIC_SQL_PASSWORD) { - return { - server, - database, - user: process.env.FABRIC_SQL_USER, - password: process.env.FABRIC_SQL_PASSWORD, - options: { encrypt: true, trustServerCertificate: false }, - }; - } - - const credential = new DefaultAzureCredential(); - const token = await credential.getToken("https://database.windows.net/.default"); - - return { - server, - database, - authentication: { - type: "azure-active-directory-access-token", - options: { token: token.token }, - }, - options: { encrypt: true, trustServerCertificate: false }, - }; -} - -function rowToCrop(name, rows) { - const cropRows = rows.filter((r) => r.crop_name === name); - if (!cropRows.length) return null; - - const latest = cropRows.find((r) => r.record_type === "current") ?? cropRows[0]; - const history = cropRows - .filter((r) => r.record_type === "history") - .sort((a, b) => a.sort_order - b.sort_order) - .map((r) => Number(r.price)); - const forecast = cropRows - .filter((r) => r.record_type === "forecast") - .sort((a, b) => a.sort_order - b.sort_order) - .map((r) => Number(r.price)); - - return { - basePrice: Number(latest.base_price), - processedPrice: Number(latest.processed_price), - processedName: latest.processed_name, - region: latest.region, - history: history.length ? history : fallbackGraph.crops[name]?.history ?? [], - forecast: forecast.length ? forecast : fallbackGraph.crops[name]?.forecast ?? [], - certifications: (latest.certifications || "").split("|").filter(Boolean), - }; -} - -async function loadFromFabric() { - const pool = await sql.connect(await getSqlConfig()); - - const [cropResult, processorResult] = await Promise.all([ - pool.request().query(` - SELECT crop_name, base_price, processed_price, processed_name, region, - certifications, record_type, price, sort_order - FROM dbo.agrivalue_crops - ORDER BY crop_name, sort_order - `), - pool.request().query(` - SELECT name, inputs, costs, location FROM dbo.agrivalue_processors - `), - ]); - - await pool.close(); - - const crops = {}; - for (const name of CROP_NAMES) { - const crop = rowToCrop(name, cropResult.recordset); - if (crop) crops[name] = crop; - } - - const processors = processorResult.recordset.map((r) => ({ - name: r.name, - inputs: r.inputs, - costs: Number(r.costs), - location: r.location, - })); - - if (!Object.keys(crops).length) { - throw new Error("Fabric tables returned no crop data"); - } - - return { - crops, - processors: processors.length ? processors : fallbackGraph.processors, - complianceRules: fallbackGraph.complianceRules, - organicBonuses: fallbackGraph.organicBonuses, - }; -} - -export async function syncFabricGraph(force = false) { - if (!isFabricConfigured()) { - activeGraph = fallbackGraph; - dataSource = "in-memory"; - lastSync = new Date().toISOString(); - return { source: dataSource, crops: Object.keys(activeGraph.crops).length }; - } - - if (!force && activeGraph && lastSync && Date.now() - new Date(lastSync).getTime() < SYNC_TTL_MS) { - return { source: dataSource, crops: Object.keys(activeGraph.crops).length, cached: true }; - } - - try { - activeGraph = await loadFromFabric(); - dataSource = "microsoft-fabric"; - syncError = null; - lastSync = new Date().toISOString(); - console.log(`[FABRIC IQ] Synced ${Object.keys(activeGraph.crops).length} crops from lakehouse`); - } catch (err) { - syncError = err.message; - console.warn("[FABRIC IQ] Lakehouse sync failed, using fallback:", err.message); - activeGraph = fallbackGraph; - dataSource = "in-memory-fallback"; - lastSync = new Date().toISOString(); - } - - return { - source: dataSource, - crops: Object.keys(activeGraph.crops).length, - error: syncError, - lastSync, - }; -} - -export function getFabricGraph() { - return activeGraph ?? fallbackGraph; -} - -export function getFabricStatus() { - return { - configured: isFabricConfigured(), - source: dataSource, - lastSync, - error: syncError, - server: process.env.FABRIC_SQL_SERVER ? "•••" + process.env.FABRIC_SQL_SERVER.slice(-20) : null, - }; -} +/** + * Microsoft Fabric IQ — Lakehouse / Warehouse SQL connector. + * Loads semantic market graph from Fabric when configured; falls back to in-memory graph. + */ +import sql from "mssql"; +import { DefaultAzureCredential } from "@azure/identity"; +import { fabricIQ as fallbackGraph, CROP_NAMES } from "./fabric-iq.js"; + +let activeGraph = null; +let dataSource = "in-memory"; +let lastSync = null; +let syncError = null; + +const SYNC_TTL_MS = 5 * 60 * 1000; + +function isFabricConfigured() { + return Boolean(process.env.FABRIC_SQL_SERVER && process.env.FABRIC_SQL_DATABASE); +} + +async function getSqlConfig() { + const server = process.env.FABRIC_SQL_SERVER; + const database = process.env.FABRIC_SQL_DATABASE; + + if (process.env.FABRIC_SQL_USER && process.env.FABRIC_SQL_PASSWORD) { + return { + server, + database, + user: process.env.FABRIC_SQL_USER, + password: process.env.FABRIC_SQL_PASSWORD, + options: { encrypt: true, trustServerCertificate: false }, + }; + } + + const credential = new DefaultAzureCredential(); + const token = await credential.getToken("https://database.windows.net/.default"); + + return { + server, + database, + authentication: { + type: "azure-active-directory-access-token", + options: { token: token.token }, + }, + options: { encrypt: true, trustServerCertificate: false }, + }; +} + +function rowToCrop(name, rows) { + const cropRows = rows.filter((r) => r.crop_name === name); + if (!cropRows.length) return null; + + const latest = cropRows.find((r) => r.record_type === "current") ?? cropRows[0]; + const history = cropRows + .filter((r) => r.record_type === "history") + .sort((a, b) => a.sort_order - b.sort_order) + .map((r) => Number(r.price)); + const forecast = cropRows + .filter((r) => r.record_type === "forecast") + .sort((a, b) => a.sort_order - b.sort_order) + .map((r) => Number(r.price)); + + return { + basePrice: Number(latest.base_price), + processedPrice: Number(latest.processed_price), + processedName: latest.processed_name, + region: latest.region, + history: history.length ? history : (fallbackGraph.crops[name]?.history ?? []), + forecast: forecast.length ? forecast : (fallbackGraph.crops[name]?.forecast ?? []), + certifications: (latest.certifications || "").split("|").filter(Boolean), + }; +} + +async function loadFromFabric() { + const pool = await sql.connect(await getSqlConfig()); + + const [cropResult, processorResult] = await Promise.all([ + pool.request().query(` + SELECT crop_name, base_price, processed_price, processed_name, region, + certifications, record_type, price, sort_order + FROM dbo.agrivalue_crops + ORDER BY crop_name, sort_order + `), + pool.request().query(` + SELECT name, inputs, costs, location FROM dbo.agrivalue_processors + `), + ]); + + await pool.close(); + + const crops = {}; + for (const name of CROP_NAMES) { + const crop = rowToCrop(name, cropResult.recordset); + if (crop) crops[name] = crop; + } + + const processors = processorResult.recordset.map((r) => ({ + name: r.name, + inputs: r.inputs, + costs: Number(r.costs), + location: r.location, + })); + + if (!Object.keys(crops).length) { + throw new Error("Fabric tables returned no crop data"); + } + + return { + crops, + processors: processors.length ? processors : fallbackGraph.processors, + complianceRules: fallbackGraph.complianceRules, + organicBonuses: fallbackGraph.organicBonuses, + }; +} + +export async function syncFabricGraph(force = false) { + if (!isFabricConfigured()) { + activeGraph = fallbackGraph; + dataSource = "in-memory"; + lastSync = new Date().toISOString(); + return { source: dataSource, crops: Object.keys(activeGraph.crops).length }; + } + + if ( + !force && + activeGraph && + lastSync && + Date.now() - new Date(lastSync).getTime() < SYNC_TTL_MS + ) { + return { source: dataSource, crops: Object.keys(activeGraph.crops).length, cached: true }; + } + + try { + activeGraph = await loadFromFabric(); + dataSource = "microsoft-fabric"; + syncError = null; + lastSync = new Date().toISOString(); + console.log(`[FABRIC IQ] Synced ${Object.keys(activeGraph.crops).length} crops from lakehouse`); + } catch (err) { + syncError = err.message; + console.warn("[FABRIC IQ] Lakehouse sync failed, using fallback:", err.message); + activeGraph = fallbackGraph; + dataSource = "in-memory-fallback"; + lastSync = new Date().toISOString(); + } + + return { + source: dataSource, + crops: Object.keys(activeGraph.crops).length, + error: syncError, + lastSync, + }; +} + +export function getFabricGraph() { + return activeGraph ?? fallbackGraph; +} + +export function getFabricStatus() { + return { + configured: isFabricConfigured(), + source: dataSource, + lastSync, + error: syncError, + server: process.env.FABRIC_SQL_SERVER ? "•••" + process.env.FABRIC_SQL_SERVER.slice(-20) : null, + }; +} diff --git a/lib/iq-data.js b/lib/iq-data.js index 1b41e5c..f5af8c8 100644 --- a/lib/iq-data.js +++ b/lib/iq-data.js @@ -1,7 +1,7 @@ /** * Unified IQ data layer — Fabric lakehouse with in-memory fallback. */ -import { getFabricGraph, syncFabricGraph } from "./fabric-lakehouse.js"; +import { getFabricGraph } from "./fabric-lakehouse.js"; import { formatTradeMemoryReply } from "./agent-memory.js"; import { evaluateQuality as evalQuality, @@ -198,9 +198,26 @@ export function getPortfolioInsights(qtyTons = 10) { function detectAdvisorLanguage(message) { const text = message.toLowerCase(); const italianMarkers = [ - "ciao", "grazie", "per favore", "quanto", "come", "posso", "guadagnare", - "olive", "olio", "uva", "vino", "raccolto", "contadino", "certificazione", - "contratto", "migliore", "prezzo", "qualita", "qualità", "sicilia", + "ciao", + "grazie", + "per favore", + "quanto", + "come", + "posso", + "guadagnare", + "olive", + "olio", + "uva", + "vino", + "raccolto", + "contadino", + "certificazione", + "contratto", + "migliore", + "prezzo", + "qualita", + "qualità", + "sicilia", ]; return italianMarkers.some((word) => text.includes(word)) ? "it" : "en"; } @@ -227,7 +244,14 @@ export function getAdvisorReply(message, context = {}) { : "Mamma mia, I couldn't find market data for that crop. Try Olive Oil, Grapes, Wheat, Coffee, or Milk."; } - if (lower.includes("matteo") || lower.includes("yield math") || lower.includes("processing cost") || lower.includes("costo") || lower.includes("conti") || lower.includes("resa")) { + if ( + lower.includes("matteo") || + lower.includes("yield math") || + lower.includes("processing cost") || + lower.includes("costo") || + lower.includes("conti") || + lower.includes("resa") + ) { const opt = optimizeValueChain({ crop, qtyTons: context.qtyTons || 10, isOrganic: true }); const trace = opt?.agentTrace; return advisorReply( @@ -237,7 +261,15 @@ export function getAdvisorReply(message, context = {}) { ); } - if (lower.includes("sofia") || lower.includes("golden seal") || lower.includes("certification seal") || lower.includes("sigillo") || lower.includes("certificazione") || lower.includes("qualita") || lower.includes("qualità")) { + if ( + lower.includes("sofia") || + lower.includes("golden seal") || + lower.includes("certification seal") || + lower.includes("sigillo") || + lower.includes("certificazione") || + lower.includes("qualita") || + lower.includes("qualità") + ) { const opt = optimizeValueChain({ crop, qtyTons: context.qtyTons || 10, isOrganic: true }); const check = opt?.agentTrace?.compliance; return advisorReply( @@ -255,11 +287,26 @@ export function getAdvisorReply(message, context = {}) { ); } - if (lower.includes("trade history") || lower.includes("memory") || lower.includes("storico") || lower.includes("memoria") || lower.includes("past trade") || lower.includes("ultimi trade") || lower.includes("ricord")) { + if ( + lower.includes("trade history") || + lower.includes("memory") || + lower.includes("storico") || + lower.includes("memoria") || + lower.includes("past trade") || + lower.includes("ultimi trade") || + lower.includes("ricord") + ) { return formatTradeMemoryReply(context.recentTrades || [], lang); } - if (lower.includes("vision") || lower.includes("quality scan") || lower.includes("neural") || lower.includes("foto") || lower.includes("photo") || lower.includes("scan")) { + if ( + lower.includes("vision") || + lower.includes("quality scan") || + lower.includes("neural") || + lower.includes("foto") || + lower.includes("photo") || + lower.includes("scan") + ) { const vision = evaluateQuality(crop); return advisorReply( lang, @@ -276,7 +323,15 @@ export function getAdvisorReply(message, context = {}) { ); } - if (lower.includes("fabric") || lower.includes("price") || lower.includes("market") || lower.includes("prezzo") || lower.includes("mercato") || lower.includes("guadagnare") || lower.includes("migliore")) { + if ( + lower.includes("fabric") || + lower.includes("price") || + lower.includes("market") || + lower.includes("prezzo") || + lower.includes("mercato") || + lower.includes("guadagnare") || + lower.includes("migliore") + ) { return advisorReply( lang, `**Matteo:** Fabric IQ shows ${crop}: $${intel.rawPrice}/ton raw, but $${intel.processedPrice}/ton after processing as ${intel.processedProduct}. Best processor: ${intel.processor} in ${intel.processorLocation}. Organic/DOP bonus: +$${intel.organicBonusPerTon}/ton. That is where the sauce is.`, @@ -284,7 +339,14 @@ export function getAdvisorReply(message, context = {}) { ); } - if (lower.includes("foundry") || lower.includes("compliance") || lower.includes("organic") || lower.includes("conformita") || lower.includes("conformità") || lower.includes("organico")) { + if ( + lower.includes("foundry") || + lower.includes("compliance") || + lower.includes("organic") || + lower.includes("conformita") || + lower.includes("conformità") || + lower.includes("organico") + ) { return advisorReply( lang, `**Sofia DOP:** Compliance for ${crop}: ${intel.complianceRule} Certifications: ${intel.certifications.join(", ")}. If evidence passes, Don Salvatore can issue the Golden Certification Seal.`, @@ -292,7 +354,13 @@ export function getAdvisorReply(message, context = {}) { ); } - if (lower.includes("work") || lower.includes("teams") || lower.includes("notify") || lower.includes("notifica") || lower.includes("avvisa")) { + if ( + lower.includes("work") || + lower.includes("teams") || + lower.includes("notify") || + lower.includes("notifica") || + lower.includes("avvisa") + ) { return advisorReply( lang, "**Work IQ** can push optimization results to your Microsoft Teams channel via Graph API. Enable GRAPH_* env vars and click **Teams Alert** after optimizing.", @@ -308,7 +376,12 @@ export function getAdvisorReply(message, context = {}) { ); } - if (lower.includes("path b") || lower.includes("process") || lower.includes("trasforma") || lower.includes("trasformazione")) { + if ( + lower.includes("path b") || + lower.includes("process") || + lower.includes("trasforma") || + lower.includes("trasformazione") + ) { return advisorReply( lang, `**Don Salvatore:** Path B is the cooperative route. We send ${crop} to ${intel.processor}; **Matteo** models the economics, **Sofia DOP** checks certification, and the output becomes ${intel.processedProduct}. Slower than the silo, but usually much richer.`, diff --git a/lib/work-iq.js b/lib/work-iq.js index 9297271..81ac25a 100644 --- a/lib/work-iq.js +++ b/lib/work-iq.js @@ -1,125 +1,125 @@ -/** - * Work IQ — Microsoft Graph / Teams notifications for cooperative workflows. - * Notifies teams when optimization completes or contracts are generated. - */ -import { ClientSecretCredential } from "@azure/identity"; - -function isGraphConfigured() { - return Boolean( - process.env.GRAPH_TENANT_ID && - process.env.GRAPH_CLIENT_ID && - process.env.GRAPH_CLIENT_SECRET && - process.env.TEAMS_TEAM_ID && - process.env.TEAMS_CHANNEL_ID - ); -} - -function isWebhookConfigured() { - return Boolean(process.env.TEAMS_WEBHOOK_URL); -} - -async function getGraphToken() { - const credential = new ClientSecretCredential( - process.env.GRAPH_TENANT_ID, - process.env.GRAPH_CLIENT_ID, - process.env.GRAPH_CLIENT_SECRET - ); - const token = await credential.getToken("https://graph.microsoft.com/.default"); - return token.token; -} - -async function postViaGraph(htmlContent) { - const token = await getGraphToken(); - const url = `https://graph.microsoft.com/v1.0/teams/${process.env.TEAMS_TEAM_ID}/channels/${process.env.TEAMS_CHANNEL_ID}/messages`; - - const res = await fetch(url, { - method: "POST", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - body: { contentType: "html", content: htmlContent }, - }), - }); - - if (!res.ok) { - const err = await res.text(); - throw new Error(`Graph API error: ${res.status} ${err}`); - } - return res.json(); -} - -async function postViaWebhook(text) { - const res = await fetch(process.env.TEAMS_WEBHOOK_URL, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ text }), - }); - if (!res.ok) throw new Error(`Teams webhook error: ${res.status}`); -} - -function formatOptimizationMessage(result) { - const { crop, qtyTons, addedValue, processedPath } = result; - return { - title: `🌾 AgriValue Optimization — ${crop}`, - html: ` -

🌾 AgriValue Value-Chain Alert

-

Crop: ${crop} (${qtyTons} MT)

-

Recommended route: ${processedPath.processor} → ${processedPath.processedProduct}

-

Added value vs silo: $${addedValue.toLocaleString()}

-

Foundry compliance: ${processedPath.foundryIQRule?.slice(0, 200)}…

-

Sent via Work IQ — Microsoft Graph

- `, - text: `AgriValue: ${qtyTons} MT ${crop} → ${processedPath.processor}. Added value: $${addedValue.toLocaleString()}`, - }; -} - -export function getWorkIQStatus() { - return { - graphConfigured: isGraphConfigured(), - webhookConfigured: isWebhookConfigured(), - active: isGraphConfigured() || isWebhookConfigured(), - }; -} - -export async function notifyOptimizationComplete(result) { - if (!isGraphConfigured() && !isWebhookConfigured()) { - return { sent: false, reason: "Work IQ not configured" }; - } - - const msg = formatOptimizationMessage(result); - - try { - if (isGraphConfigured()) { - await postViaGraph(msg.html); - console.log("[WORK IQ] Teams notification sent via Microsoft Graph"); - return { sent: true, channel: "graph" }; - } - await postViaWebhook(msg.text); - console.log("[WORK IQ] Teams notification sent via webhook"); - return { sent: true, channel: "webhook" }; - } catch (err) { - console.warn("[WORK IQ] Notification failed:", err.message); - return { sent: false, error: err.message }; - } -} - -export async function notifyContractGenerated({ crop, qtyTons, buyer }) { - if (!isGraphConfigured() && !isWebhookConfigured()) { - return { sent: false, reason: "Work IQ not configured" }; - } - - const text = `📄 New Fair-Trade contract drafted: ${qtyTons} MT ${crop} for ${buyer || "cooperative"}. Review in AgriValue Terminal.`; - - try { - if (isGraphConfigured()) { - await postViaGraph(`

${text}

Work IQ — contract workflow

`); - return { sent: true, channel: "graph" }; - } - await postViaWebhook(text); - return { sent: true, channel: "webhook" }; - } catch (err) { - return { sent: false, error: err.message }; - } -} +/** + * Work IQ — Microsoft Graph / Teams notifications for cooperative workflows. + * Notifies teams when optimization completes or contracts are generated. + */ +import { ClientSecretCredential } from "@azure/identity"; + +function isGraphConfigured() { + return Boolean( + process.env.GRAPH_TENANT_ID && + process.env.GRAPH_CLIENT_ID && + process.env.GRAPH_CLIENT_SECRET && + process.env.TEAMS_TEAM_ID && + process.env.TEAMS_CHANNEL_ID + ); +} + +function isWebhookConfigured() { + return Boolean(process.env.TEAMS_WEBHOOK_URL); +} + +async function getGraphToken() { + const credential = new ClientSecretCredential( + process.env.GRAPH_TENANT_ID, + process.env.GRAPH_CLIENT_ID, + process.env.GRAPH_CLIENT_SECRET + ); + const token = await credential.getToken("https://graph.microsoft.com/.default"); + return token.token; +} + +async function postViaGraph(htmlContent) { + const token = await getGraphToken(); + const url = `https://graph.microsoft.com/v1.0/teams/${process.env.TEAMS_TEAM_ID}/channels/${process.env.TEAMS_CHANNEL_ID}/messages`; + + const res = await fetch(url, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + body: { contentType: "html", content: htmlContent }, + }), + }); + + if (!res.ok) { + const err = await res.text(); + throw new Error(`Graph API error: ${res.status} ${err}`); + } + return res.json(); +} + +async function postViaWebhook(text) { + const res = await fetch(process.env.TEAMS_WEBHOOK_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text }), + }); + if (!res.ok) throw new Error(`Teams webhook error: ${res.status}`); +} + +function formatOptimizationMessage(result) { + const { crop, qtyTons, addedValue, processedPath } = result; + return { + title: `🌾 AgriValue Optimization — ${crop}`, + html: ` +

🌾 AgriValue Value-Chain Alert

+

Crop: ${crop} (${qtyTons} MT)

+

Recommended route: ${processedPath.processor} → ${processedPath.processedProduct}

+

Added value vs silo: $${addedValue.toLocaleString()}

+

Foundry compliance: ${processedPath.foundryIQRule?.slice(0, 200)}…

+

Sent via Work IQ — Microsoft Graph

+ `, + text: `AgriValue: ${qtyTons} MT ${crop} → ${processedPath.processor}. Added value: $${addedValue.toLocaleString()}`, + }; +} + +export function getWorkIQStatus() { + return { + graphConfigured: isGraphConfigured(), + webhookConfigured: isWebhookConfigured(), + active: isGraphConfigured() || isWebhookConfigured(), + }; +} + +export async function notifyOptimizationComplete(result) { + if (!isGraphConfigured() && !isWebhookConfigured()) { + return { sent: false, reason: "Work IQ not configured" }; + } + + const msg = formatOptimizationMessage(result); + + try { + if (isGraphConfigured()) { + await postViaGraph(msg.html); + console.log("[WORK IQ] Teams notification sent via Microsoft Graph"); + return { sent: true, channel: "graph" }; + } + await postViaWebhook(msg.text); + console.log("[WORK IQ] Teams notification sent via webhook"); + return { sent: true, channel: "webhook" }; + } catch (err) { + console.warn("[WORK IQ] Notification failed:", err.message); + return { sent: false, error: err.message }; + } +} + +export async function notifyContractGenerated({ crop, qtyTons, buyer }) { + if (!isGraphConfigured() && !isWebhookConfigured()) { + return { sent: false, reason: "Work IQ not configured" }; + } + + const text = `📄 New Fair-Trade contract drafted: ${qtyTons} MT ${crop} for ${buyer || "cooperative"}. Review in AgriValue Terminal.`; + + try { + if (isGraphConfigured()) { + await postViaGraph(`

${text}

Work IQ — contract workflow

`); + return { sent: true, channel: "graph" }; + } + await postViaWebhook(text); + return { sent: true, channel: "webhook" }; + } catch (err) { + return { sent: false, error: err.message }; + } +} diff --git a/mcp-server.js b/mcp-server.js index af3543f..d643f9a 100644 --- a/mcp-server.js +++ b/mcp-server.js @@ -23,7 +23,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ inputSchema: { type: "object", properties: { - crop: { type: "string", enum: CROP_NAMES, description: "Sicilian agricultural commodity" }, + crop: { + type: "string", + enum: CROP_NAMES, + description: "Sicilian agricultural commodity", + }, }, required: ["crop"], }, @@ -38,7 +42,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ crop: { type: "string", enum: CROP_NAMES }, qtyTons: { type: "number", description: "Quantity in metric tons" }, buyer: { type: "string", description: "Seller/cooperative name" }, - isOrganic: { type: "boolean", description: "Whether DOP/DOC or organic certification applies" }, + isOrganic: { + type: "boolean", + description: "Whether DOP/DOC or organic certification applies", + }, }, required: ["crop", "qtyTons"], }, @@ -82,10 +89,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { const result = evaluateQuality(args?.crop); if (!result) throw new Error(`Unknown crop: ${args?.crop}`); return { - content: [{ - type: "text", - text: `Grade: ${result.grade}\nMultiplier: ${result.multiplier}x\nAnalysis: ${result.analysis}`, - }], + content: [ + { + type: "text", + text: `Grade: ${result.grade}\nMultiplier: ${result.multiplier}x\nAnalysis: ${result.analysis}`, + }, + ], }; } diff --git a/package-lock.json b/package-lock.json index 6fdd5fa..729122a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,13 @@ "dotenv": "^16.4.5", "express": "^4.19.2", "mssql": "^11.0.1" + }, + "devDependencies": { + "@eslint/js": "^9.28.0", + "eslint": "^9.28.0", + "globals": "^16.2.0", + "husky": "^9.1.7", + "prettier": "^3.5.3" } }, "node_modules/@azure-rest/core-client": { @@ -280,6 +287,237 @@ "node": ">=20" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@eslint/config-array/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@eslint/eslintrc/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/eslintrc/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/@hono/node-server": { "version": "1.19.14", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", @@ -292,6 +530,72 @@ "hono": "^4" } }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@js-joda/core": { "version": "5.7.0", "resolved": "https://registry.npmjs.org/@js-joda/core/-/core-5.7.0.tgz", @@ -646,6 +950,20 @@ "integrity": "sha512-7qSgZbincDDDFyRweCIEvZULFAw5iz/DeunhvuxpL31nfntX3P4Yd4HkHBRg9H8CdqY1e5WFN1PZIz/REL9MVQ==", "license": "MIT" }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "25.9.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz", @@ -703,6 +1021,29 @@ "node": ">= 0.6" } }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -745,12 +1086,42 @@ } } }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -822,6 +1193,17 @@ "node": ">= 0.8" } }, + "node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/buffer": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", @@ -905,6 +1287,53 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, "node_modules/commander": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", @@ -914,6 +1343,13 @@ "node": ">=16" } }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -990,6 +1426,13 @@ "ms": "2.0.0" } }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/default-browser": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", @@ -1135,6 +1578,222 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -1253,6 +1912,20 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", @@ -1269,6 +1942,19 @@ ], "license": "BSD-3-Clause" }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/finalhandler": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", @@ -1287,6 +1973,44 @@ "node": ">= 0.8" } }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -1351,6 +2075,32 @@ "node": ">= 0.4" } }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -1363,6 +2113,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -1488,6 +2248,22 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -1520,6 +2296,43 @@ ], "license": "BSD-3-Clause" }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -1559,6 +2372,29 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-inside-container": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", @@ -1619,6 +2455,36 @@ "integrity": "sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA==", "license": "MIT" }, + "node_modules/js-yaml": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -1631,6 +2497,13 @@ "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", "license": "BSD-2-Clause" }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/jsonwebtoken": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", @@ -1680,6 +2553,46 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -1716,6 +2629,13 @@ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", "license": "MIT" }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.once": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", @@ -1791,6 +2711,19 @@ "node": ">= 0.6" } }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", @@ -1846,6 +2779,13 @@ "integrity": "sha512-E7QQoM+3jvNtlmyfqRZ0/U75VFgCls+fSkbml2MpgWkWyz3ox8Y58gNhfuziuQYGNNQAbFZJQck55LHCnCK6CA==", "license": "MIT" }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -1915,6 +2855,69 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -1924,6 +2927,16 @@ "node": ">= 0.8" } }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -1948,6 +2961,32 @@ "node": ">=16.20.0" } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.8.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.4.tgz", + "integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", @@ -1970,6 +3009,16 @@ "node": ">= 0.10" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.15.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", @@ -2050,6 +3099,16 @@ "node": ">=0.10.0" } }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/rfdc": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", @@ -2323,6 +3382,32 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/tarn": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/tarn/-/tarn-3.0.2.tgz", @@ -2380,6 +3465,19 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -2408,6 +3506,16 @@ "node": ">= 0.8" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -2441,6 +3549,16 @@ "node": ">= 8" } }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -2462,6 +3580,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zod": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", diff --git a/package.json b/package.json index 0ffd899..dc54e97 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,19 @@ "mcp": "node mcp-server.js", "agent:action": "node scripts/agent-action.mjs", "build:static": "node scripts/sync-static-data.js && node scripts/inject-config.js", - "prestart": "node scripts/sync-static-data.js" + "prestart": "node scripts/sync-static-data.js", + "test": "node --test tests/*.test.js", + "lint": "eslint .", + "format": "prettier --write \"lib/**\" \"tests/**\" \"server.js\" \"mcp-server.js\" \"scripts/*.{js,mjs}\" \"docs/**\" \"*.md\" \".github/**\" \"eslint.config.js\" \"package.json\" \".prettierrc\" \"AGENTS.md\" \"CLAUDE.md\"", + "format:check": "prettier --check \"lib/**\" \"tests/**\" \"server.js\" \"mcp-server.js\" \"scripts/*.{js,mjs}\" \"docs/**\" \"*.md\" \".github/**\" \"eslint.config.js\" \"package.json\" \".prettierrc\" \"AGENTS.md\" \"CLAUDE.md\"", + "prepare": "husky" + }, + "devDependencies": { + "@eslint/js": "^9.28.0", + "eslint": "^9.28.0", + "globals": "^16.2.0", + "husky": "^9.1.7", + "prettier": "^3.5.3" }, "dependencies": { "@azure/identity": "^4.2.1", diff --git a/public/css/app.css b/public/css/app.css index 1b8cacf..c9f0cf9 100644 --- a/public/css/app.css +++ b/public/css/app.css @@ -33,7 +33,7 @@ body.h-full { .welcome-hero-scrim { position: absolute; inset: 0; - background: rgba(0, 0, 0, 0.28); + background: linear-gradient(180deg, rgba(0, 0, 0, 0.22) 0%, rgba(0, 0, 0, 0.52) 100%); pointer-events: none; } @@ -63,11 +63,19 @@ body.h-full { font-weight: 800; line-height: 1.15; color: #ffffff; - text-shadow: 0 2px 14px rgba(0, 0, 0, 0.45); + text-shadow: 0 2px 14px rgba(0, 0, 0, 0.55); max-width: 22ch; } -.welcome-hero-btn-dettagli { +.welcome-hero-lead { + font-size: clamp(0.78rem, 2.6vw, 0.88rem); + line-height: 1.45; + color: #f1f5f9; + text-shadow: 0 1px 12px rgba(0, 0, 0, 0.5); + max-width: 36ch; +} + +.welcome-hero-btn-details { margin-top: 4px; border: 2px solid #f8fafc; border-radius: 10px; @@ -82,13 +90,13 @@ body.h-full { transition: background 0.2s ease, color 0.2s ease, transform 0.15s ease; } -.welcome-hero-btn-dettagli:hover { +.welcome-hero-btn-details:hover { background: #ffffff; color: #052e16; transform: translateY(-1px); } -.welcome-hero-btn-dettagli:focus-visible { +.welcome-hero-btn-details:focus-visible { outline: 3px solid #6ee7b7; outline-offset: 2px; } @@ -98,7 +106,18 @@ body.h-full { padding: 14px 16px; border-radius: 14px; border: 1px solid rgba(52, 211, 153, 0.22); - background: rgba(2, 6, 23, 0.55); + background: rgba(2, 6, 23, 0.85); +} + +.welcome-hero-details-copy { + font-size: 0.875rem; + line-height: 1.55; + text-align: left; + color: #e2e8f0; +} + +.welcome-hero-summary { + color: #cbd5e1; } .welcome-hero-details.is-open { @@ -591,6 +610,20 @@ body[data-theme="light"] .upload-photo-btn:hover { .query-seal { color: #fcd34d; border-color: rgba(252, 211, 77, 0.24); background: rgba(252, 211, 77, 0.08); } .query-contract { color: #c4b5fd; border-color: rgba(196, 181, 253, 0.22); background: rgba(196, 181, 253, 0.07); } +.intro-lead { + color: #e2e8f0; + font-size: 1rem; + line-height: 1.6; +} + +#intro-skip { + color: #94a3b8; +} + +#intro-skip:hover { + color: #e2e8f0; +} + .intro-overlay { align-items: flex-start; justify-content: center; @@ -897,7 +930,7 @@ body[data-theme="light"] .upload-photo-btn:hover { align-items: stretch; } - .welcome-hero-btn-dettagli { + .welcome-hero-btn-details { width: 100%; text-align: center; } diff --git a/public/demo.html b/public/demo.html index f793631..0483c47 100644 --- a/public/demo.html +++ b/public/demo.html @@ -57,7 +57,7 @@ .demo-hero-scrim { position: absolute; inset: 0; - background: rgba(0, 0, 0, 0.28); + background: linear-gradient(180deg, rgba(0, 0, 0, 0.22) 0%, rgba(0, 0, 0, 0.52) 100%); pointer-events: none; } .demo-hero-content { @@ -89,12 +89,12 @@ .demo-hero-lead { font-size: clamp(.8rem, 2.8vw, .9rem); line-height: 1.5; - color: #f5f5f4; + color: #f1f5f9; max-width: 52ch; - text-shadow: 0 1px 10px rgba(0,0,0,.4); + text-shadow: 0 1px 12px rgba(0,0,0,.5); } /* APCA/WCAG ≥ 5.48:1 — #0c0a09 on #f8fafc ≈ 19:1 */ - .demo-hero-btn-dettagli { + .demo-hero-btn-details { margin-top: 6px; border: 2px solid #f8fafc; border-radius: 12px; @@ -108,12 +108,12 @@ box-shadow: 0 8px 24px rgba(0,0,0,.35); transition: background .2s ease, color .2s ease, transform .15s ease; } - .demo-hero-btn-dettagli:hover { + .demo-hero-btn-details:hover { background: #ffffff; color: #052e16; transform: translateY(-1px); } - .demo-hero-btn-dettagli:focus-visible { + .demo-hero-btn-details:focus-visible { outline: 3px solid #6ee7b7; outline-offset: 2px; } @@ -415,7 +415,7 @@ aside { order: 2; height: auto !important; max-height: 42vh; overflow-y: auto; flex-shrink: 0; } .demo-hero { min-height: clamp(200px, 32vh, 280px); } .demo-hero-content { align-items: stretch; } - .demo-hero-btn-dettagli { width: 100%; text-align: center; } + .demo-hero-btn-details { width: 100%; text-align: center; } aside .metric-explainer, aside .grid.grid-cols-3, aside .space-y-3 { display: none; } @@ -556,7 +556,7 @@

AgriValue IQ

Battle #1 · Intro

From raw harvest to certified value.

Don Salvatore, Matteo, and Sofia turn a silo sale into local DOP value — then Copilot drafts the contract.

- +

Welcome to AgriValue IQ

-

Many Sicilian farmers sell raw crops for too little. Don Salvatore helps find the smarter route — more value, less "mamma mia".

+

Many Sicilian farmers sell raw crops for too little. Don Salvatore helps find the smarter route — more value, less guesswork.

@@ -193,22 +193,23 @@

AgriValue IQ · Sicily

-

Your best route, explained simply

- +

Your best route, explained simply

+

Compare raw silo sale vs local processing — then export a contract-ready deal.

+
-

+

Run the IQ pipeline on the left — Matteo compares Path A vs Path B, Sofia checks DOP, then export the contract.

diff --git a/public/js/app.js b/public/js/app.js index 15939ad..911aad1 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -78,7 +78,7 @@ function setupIntro() { } function setupWelcomeHero() { - const btn = document.getElementById("welcome-hero-dettagli"); + const btn = document.getElementById("welcome-hero-details-btn"); const panel = document.getElementById("welcome-hero-details"); if (!btn || !panel) return; btn.addEventListener("click", () => { @@ -86,7 +86,7 @@ function setupWelcomeHero() { panel.classList.toggle("hidden", !open); panel.classList.toggle("is-open", open); btn.setAttribute("aria-expanded", open ? "true" : "false"); - btn.textContent = open ? "Nascondi" : "Dettagli"; + btn.textContent = open ? "Hide" : "Details"; if (open) panel.scrollIntoView({ behavior: "smooth", block: "nearest" }); }); } diff --git a/public/js/fabric-iq-client.js b/public/js/fabric-iq-client.js index f154d5a..fbd14e0 100644 --- a/public/js/fabric-iq-client.js +++ b/public/js/fabric-iq-client.js @@ -62,10 +62,14 @@ export const fabricIQ = { { name: "Highland Cheese Plant", inputs: "Milk", costs: 250, location: "Enna" }, ], complianceRules: { - "Olive Oil": "Must meet EU DOP Extra Virgin criteria: acidity ≤ 0.8%, peroxide ≤ 20 mEq/kg. Premium bonus: +$450/ton when USDA Organic certified.", - Grapes: "DOC Sicilia appellation requires 85% Nero d'Avola varietal. Premium bonus: +$300/ton for biodynamic certification.", - Wheat: "Must be milled in a certified organic facility (USDA-ORGANIC-CLASS1). Premium bonus: +$45/ton.", - Coffee: "Ethically sourced under FLO-CERT-2026 Fair Trade guidelines. Premium bonus: +$250/ton.", + "Olive Oil": + "Must meet EU DOP Extra Virgin criteria: acidity ≤ 0.8%, peroxide ≤ 20 mEq/kg. Premium bonus: +$450/ton when USDA Organic certified.", + Grapes: + "DOC Sicilia appellation requires 85% Nero d'Avola varietal. Premium bonus: +$300/ton for biodynamic certification.", + Wheat: + "Must be milled in a certified organic facility (USDA-ORGANIC-CLASS1). Premium bonus: +$45/ton.", + Coffee: + "Ethically sourced under FLO-CERT-2026 Fair Trade guidelines. Premium bonus: +$250/ton.", Milk: "Pasture-raised grass-fed standards required for artisanal cheese route. Premium bonus: +$90/ton.", }, organicBonuses: { "Olive Oil": 450, Grapes: 300, Wheat: 45, Coffee: 250, Milk: 90 }, diff --git a/scripts/inject-config.js b/scripts/inject-config.js index 964c5a8..10d9925 100644 --- a/scripts/inject-config.js +++ b/scripts/inject-config.js @@ -1,16 +1,16 @@ -import { writeFileSync } from "fs"; -import { fileURLToPath } from "url"; -import path from "path"; - -const root = path.dirname(fileURLToPath(import.meta.url)); -const apiUrl = (process.env.AZURE_API_URL || "").replace(/\/$/, ""); - -const config = `// Auto-generated — do not edit. Azure API for GitHub Pages frontend. -window.AGRIVALUE_CONFIG = { - apiBase: "${apiUrl}", - staticFallback: ${apiUrl ? "false" : "true"}, -}; -`; - -writeFileSync(path.join(root, "../public/config.js"), config); -console.log(apiUrl ? `Injected API URL: ${apiUrl}` : "No AZURE_API_URL — static fallback mode"); +import { writeFileSync } from "fs"; +import { fileURLToPath } from "url"; +import path from "path"; + +const root = path.dirname(fileURLToPath(import.meta.url)); +const apiUrl = (process.env.AZURE_API_URL || "").replace(/\/$/, ""); + +const config = `// Auto-generated — do not edit. Azure API for GitHub Pages frontend. +window.AGRIVALUE_CONFIG = { + apiBase: "${apiUrl}", + staticFallback: ${apiUrl ? "false" : "true"}, +}; +`; + +writeFileSync(path.join(root, "../public/config.js"), config); +console.log(apiUrl ? `Injected API URL: ${apiUrl}` : "No AZURE_API_URL — static fallback mode"); diff --git a/scripts/sync-static-data.js b/scripts/sync-static-data.js index 8505be6..ff02ca8 100644 --- a/scripts/sync-static-data.js +++ b/scripts/sync-static-data.js @@ -1,12 +1,12 @@ -#!/usr/bin/env node -/** Keeps public/js/fabric-iq-client.js in sync with lib/fabric-iq.js for GitHub Pages */ -import { copyFileSync } from "fs"; -import { fileURLToPath } from "url"; -import path from "path"; - -const root = path.dirname(fileURLToPath(import.meta.url)); -copyFileSync( - path.join(root, "../lib/fabric-iq.js"), - path.join(root, "../public/js/fabric-iq-client.js") -); -console.log("Synced fabric-iq.js → public/js/fabric-iq-client.js"); +#!/usr/bin/env node +/** Keeps public/js/fabric-iq-client.js in sync with lib/fabric-iq.js for GitHub Pages */ +import { copyFileSync } from "fs"; +import { fileURLToPath } from "url"; +import path from "path"; + +const root = path.dirname(fileURLToPath(import.meta.url)); +copyFileSync( + path.join(root, "../lib/fabric-iq.js"), + path.join(root, "../public/js/fabric-iq-client.js") +); +console.log("Synced fabric-iq.js → public/js/fabric-iq-client.js"); diff --git a/server.js b/server.js index c66b8ee..5fca75f 100644 --- a/server.js +++ b/server.js @@ -1,7 +1,7 @@ -import express from 'express'; -import path from 'path'; -import { fileURLToPath } from 'url'; -import dotenv from 'dotenv'; +import express from "express"; +import path from "path"; +import { fileURLToPath } from "url"; +import dotenv from "dotenv"; import { DefaultAzureCredential } from "@azure/identity"; import { SecretClient } from "@azure/keyvault-secrets"; import { @@ -15,16 +15,24 @@ import { getPortfolioInsights, syncFabricGraph, getFabricStatus, -} from './lib/iq-data.js'; -import { getWorkIQStatus, notifyOptimizationComplete, notifyContractGenerated } from './lib/work-iq.js'; -import { recordTrade, getTradeHistory } from './lib/agent-memory.js'; +} from "./lib/iq-data.js"; +import { + getWorkIQStatus, + notifyOptimizationComplete, + notifyContractGenerated, +} from "./lib/work-iq.js"; +import { recordTrade, getTradeHistory } from "./lib/agent-memory.js"; dotenv.config(); const app = express(); -app.set('trust proxy', 1); // Azure App Service runs behind a proxy — needed for per-IP rate limits +app.set("trust proxy", 1); // Azure App Service runs behind a proxy — needed for per-IP rate limits const PORT = process.env.PORT || 3000; -const ALLOWED_ORIGINS = (process.env.CORS_ORIGINS || 'https://cdm227.github.io,http://localhost:3000').split(',').map(s => s.trim()); +const ALLOWED_ORIGINS = ( + process.env.CORS_ORIGINS || "https://cdm227.github.io,http://localhost:3000" +) + .split(",") + .map((s) => s.trim()); const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -42,22 +50,22 @@ function sameRequestHost(source, req) { function isAllowedOrigin(origin, req) { if (!origin) return true; if (sameRequestHost(origin, req)) return true; - return ALLOWED_ORIGINS.some(o => origin === o || origin.startsWith(o.replace(/\/$/, ''))); + return ALLOWED_ORIGINS.some((o) => origin === o || origin.startsWith(o.replace(/\/$/, ""))); } app.use((req, res, next) => { const origin = req.headers.origin; if (origin && isAllowedOrigin(origin, req)) { - res.setHeader('Access-Control-Allow-Origin', origin); + res.setHeader("Access-Control-Allow-Origin", origin); } - res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); - res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); - if (req.method === 'OPTIONS') return res.sendStatus(204); + res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type"); + if (req.method === "OPTIONS") return res.sendStatus(204); next(); }); // Origin gate: reject browser API calls from foreign sites (CORS alone doesn't block the request server-side) -app.use('/api', (req, res, next) => { +app.use("/api", (req, res, next) => { const source = req.headers.origin || req.headers.referer; if (source && !isAllowedOrigin(source, req)) { console.warn(`[SECURITY] Blocked foreign origin: ${source} → ${req.path}`); @@ -72,7 +80,7 @@ function rateLimit(windowMs, max) { return (req, res, next) => { const key = `${req.ip}:${windowMs}:${max}`; const now = Date.now(); - const hits = (rateBuckets.get(key) || []).filter(t => now - t < windowMs); + const hits = (rateBuckets.get(key) || []).filter((t) => now - t < windowMs); if (hits.length >= max) { return res.status(429).json({ error: "Too many requests — slow down" }); } @@ -84,16 +92,17 @@ function rateLimit(windowMs, max) { setInterval(() => { const now = Date.now(); for (const [key, hits] of rateBuckets) { - const alive = hits.filter(t => now - t < 600000); - if (alive.length === 0) rateBuckets.delete(key); else rateBuckets.set(key, alive); + const alive = hits.filter((t) => now - t < 600000); + if (alive.length === 0) rateBuckets.delete(key); + else rateBuckets.set(key, alive); } }, 60000).unref(); -app.use('/api', rateLimit(60000, 60)); // 60 req/min — all API -const expensiveLimit = rateLimit(300000, 15); // 15 req/5min — Azure-backed routes +app.use("/api", rateLimit(60000, 60)); // 60 req/min — all API +const expensiveLimit = rateLimit(300000, 15); // 15 req/5min — Azure-backed routes app.use(express.json()); -app.use(express.static(path.join(__dirname, 'public'))); +app.use(express.static(path.join(__dirname, "public"))); const vaultName = process.env.KEYVAULT_NAME; let secretClient = null; @@ -112,7 +121,7 @@ async function getSecureSecret(secretName) { console.warn(`⚠️ Key Vault lookup failed for [${secretName}]`); } } - return process.env[secretName.replace(/-/g, '_')]; + return process.env[secretName.replace(/-/g, "_")]; } async function getFoundryAuthHeaders(apiKey) { @@ -126,11 +135,12 @@ async function getFoundryAuthHeaders(apiKey) { async function pollRun(threadId, runId, apiKey, endpoint) { const url = `${endpoint}/openai/threads/${threadId}/runs/${runId}?api-version=2024-02-15-preview`; while (true) { - const res = await fetch(url, { headers: { 'api-key': apiKey } }); + const res = await fetch(url, { headers: { "api-key": apiKey } }); const data = await res.json(); - if (data.status === 'completed') return data; - if (data.status === 'failed' || data.status === 'cancelled') throw new Error(`Agent run: ${data.status}`); - await new Promise(r => setTimeout(r, 1000)); + if (data.status === "completed") return data; + if (data.status === "failed" || data.status === "cancelled") + throw new Error(`Agent run: ${data.status}`); + await new Promise((r) => setTimeout(r, 1000)); } } @@ -154,7 +164,8 @@ function parseFoundryText(data) { async function queryFoundryResponsesAgent({ crop, qtyTons, isOrganic }) { const baseUrl = process.env.FOUNDRY_OPENAI_BASE_URL?.replace(/\/$/, ""); - const endpoint = process.env.FOUNDRY_RESPONSES_ENDPOINT || (baseUrl ? `${baseUrl}/responses` : null); + const endpoint = + process.env.FOUNDRY_RESPONSES_ENDPOINT || (baseUrl ? `${baseUrl}/responses` : null); const model = process.env.FOUNDRY_MODEL_DEPLOYMENT || "gpt-4o"; const apiKey = await getSecureSecret("FOUNDRY-API-KEY"); if (!endpoint) return null; @@ -175,9 +186,7 @@ async function queryFoundryResponsesAgent({ crop, qtyTons, isOrganic }) { ...(await getFoundryAuthHeaders(apiKey)), }; - const body = endpoint.includes("/openai/v1/") - ? { model, input: prompt } - : { input: prompt }; + const body = endpoint.includes("/openai/v1/") ? { model, input: prompt } : { input: prompt }; const res = await fetch(endpoint, { method: "POST", @@ -204,31 +213,37 @@ async function queryFoundryAgent({ crop, qtyTons, isOrganic }) { const apiVersion = "2024-02-15-preview"; const threadRes = await fetch(`${endpoint}/openai/threads?api-version=${apiVersion}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'api-key': azureApiKey } + method: "POST", + headers: { "Content-Type": "application/json", "api-key": azureApiKey }, }); const thread = await threadRes.json(); await fetch(`${endpoint}/openai/threads/${thread.id}/messages?api-version=${apiVersion}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'api-key': azureApiKey }, + method: "POST", + headers: { "Content-Type": "application/json", "api-key": azureApiKey }, body: JSON.stringify({ role: "user", - content: `Farmer has ${qtyTons} tons of ${crop}. Organic certified: ${isOrganic}. Query knowledge files. Output compliance rule and premium bonus as $X/ton.` - }) + content: `Farmer has ${qtyTons} tons of ${crop}. Organic certified: ${isOrganic}. Query knowledge files. Output compliance rule and premium bonus as $X/ton.`, + }), }); - const runRes = await fetch(`${endpoint}/openai/threads/${thread.id}/runs?api-version=${apiVersion}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'api-key': azureApiKey }, - body: JSON.stringify({ assistant_id: agentId }) - }); + const runRes = await fetch( + `${endpoint}/openai/threads/${thread.id}/runs?api-version=${apiVersion}`, + { + method: "POST", + headers: { "Content-Type": "application/json", "api-key": azureApiKey }, + body: JSON.stringify({ assistant_id: agentId }), + } + ); const run = await runRes.json(); await pollRun(thread.id, run.id, azureApiKey, endpoint); - const msgRes = await fetch(`${endpoint}/openai/threads/${thread.id}/messages?api-version=${apiVersion}`, { - headers: { 'api-key': azureApiKey } - }); + const msgRes = await fetch( + `${endpoint}/openai/threads/${thread.id}/messages?api-version=${apiVersion}`, + { + headers: { "api-key": azureApiKey }, + } + ); const msgs = await msgRes.json(); return msgs.data[0]?.content[0]?.text?.value ?? null; } @@ -241,54 +256,61 @@ function foundryConfigured() { ); } -app.get('/api/status', async (_req, res) => { +app.get("/api/status", async (_req, res) => { const fabric = getFabricStatus(); const work = getWorkIQStatus(); res.json({ fabricIQ: fabric, foundryIQ: { configured: foundryConfigured(), live: foundryConfigured() }, workIQ: work, - version: '3.0.0', + version: "3.0.0", }); }); -app.get('/api/processors', (_req, res) => { +app.get("/api/processors", (_req, res) => { res.json(getProcessorNetwork()); }); -app.get('/api/crop/:name', (req, res) => { +app.get("/api/crop/:name", (req, res) => { const crop = getCrop(req.params.name); if (!crop) return res.status(404).json({ error: "Crop not found" }); res.json(crop); }); -app.post('/api/evaluate-quality', (req, res) => { +app.post("/api/evaluate-quality", (req, res) => { const { crop } = req.body; if (!crop) return res.status(400).json({ error: "No crop specified" }); res.json(evaluateQuality(crop)); }); -app.post('/api/advisor', expensiveLimit, async (req, res) => { +app.post("/api/advisor", expensiveLimit, async (req, res) => { const { message, crop, language, stream, recentTrades } = req.body; if (!message) return res.status(400).json({ error: "Message required" }); - const memory = Array.isArray(recentTrades) && recentTrades.length ? recentTrades : getTradeHistory(5); + const memory = + Array.isArray(recentTrades) && recentTrades.length ? recentTrades : getTradeHistory(5); let reply = getAdvisorReply(message, { crop, language, recentTrades: memory }); if (foundryConfigured() && message.length > 20) { try { - const agentReply = await queryFoundryAgent({ crop: crop || 'Olive Oil', qtyTons: 10, isOrganic: true }); + const agentReply = await queryFoundryAgent({ + crop: crop || "Olive Oil", + qtyTons: 10, + isOrganic: true, + }); if (agentReply) reply = `${reply}\n\n**Foundry IQ adds:** ${agentReply.slice(0, 500)}`; - } catch { /* advisor falls back to rule-based */ } + } catch { + /* advisor falls back to rule-based */ + } } if (stream) { - res.setHeader('Content-Type', 'text/event-stream'); - res.setHeader('Cache-Control', 'no-cache'); - const words = reply.split(' '); + res.setHeader("Content-Type", "text/event-stream"); + res.setHeader("Cache-Control", "no-cache"); + const words = reply.split(" "); for (const word of words) { - res.write(`data: ${JSON.stringify({ token: word + ' ' })}\n\n`); - await new Promise(r => setTimeout(r, 30)); + res.write(`data: ${JSON.stringify({ token: word + " " })}\n\n`); + await new Promise((r) => setTimeout(r, 30)); } res.write(`data: ${JSON.stringify({ done: true })}\n\n`); return res.end(); @@ -297,23 +319,25 @@ app.post('/api/advisor', expensiveLimit, async (req, res) => { res.json({ reply }); }); -app.get('/api/insights/portfolio', (req, res) => { +app.get("/api/insights/portfolio", (req, res) => { const qtyTons = Number(req.query.qty) || 10; res.json(getPortfolioInsights(qtyTons)); }); -app.get('/api/insights/:crop', (req, res) => { +app.get("/api/insights/:crop", (req, res) => { const qtyTons = Number(req.query.qty) || 10; const insights = getCropInsights(req.params.crop, qtyTons); if (!insights) return res.status(404).json({ error: "Crop not found" }); res.json(insights); }); -app.post('/api/optimize', expensiveLimit, async (req, res) => { +app.post("/api/optimize", expensiveLimit, async (req, res) => { const { crop, qtyTons, isOrganic, qualityMultiplier = 1.0, notifyTeams } = req.body; if (!crop || !qtyTons) return res.status(400).json({ error: "Missing parameters" }); - console.log(`[IQ PIPELINE] ${crop} | ${qtyTons} MT | organic=${isOrganic} | quality=${qualityMultiplier}x`); + console.log( + `[IQ PIPELINE] ${crop} | ${qtyTons} MT | organic=${isOrganic} | quality=${qualityMultiplier}x` + ); let foundryRule = null; let bonusOverride = 0; @@ -337,14 +361,17 @@ app.post('/api/optimize', expensiveLimit, async (req, res) => { const prev = result.processedPath.organicBonus; result.processedPath.organicBonus = bonusOverride; result.processedPath.gross = result.processedPath.gross - prev + bonusOverride; - result.processedPath.net = result.processedPath.gross - result.processedPath.processingCost - result.processedPath.transport; + result.processedPath.net = + result.processedPath.gross - + result.processedPath.processingCost - + result.processedPath.transport; result.addedValue = Math.max(0, result.processedPath.net - result.rawPath.net); } } result.iqLayers = { fabric: getFabricStatus().source, - foundry: foundryRule ? 'live-agent' : (foundryConfigured() ? 'fallback-rules' : 'local-rules'), + foundry: foundryRule ? "live-agent" : foundryConfigured() ? "fallback-rules" : "local-rules", }; if (notifyTeams !== false && getWorkIQStatus().active) { @@ -363,12 +390,12 @@ app.post('/api/optimize', expensiveLimit, async (req, res) => { res.json(result); }); -app.get('/api/trade-history', (req, res) => { +app.get("/api/trade-history", (req, res) => { const limit = Number(req.query.limit) || 20; res.json({ trades: getTradeHistory(limit) }); }); -app.post('/api/generate-contract', async (req, res) => { +app.post("/api/generate-contract", async (req, res) => { const { crop, qtyTons, buyer, isOrganic, notifyTeams } = req.body; if (!crop || !qtyTons) return res.status(400).json({ error: "Missing parameters" }); @@ -383,14 +410,14 @@ app.post('/api/generate-contract', async (req, res) => { res.json({ contract, workIQ }); }); -app.post('/api/notify-teams', async (req, res) => { +app.post("/api/notify-teams", async (req, res) => { const result = req.body; if (!result?.crop) return res.status(400).json({ error: "Optimization result required" }); const workIQ = await notifyOptimizationComplete(result); res.json(workIQ); }); -app.post('/api/fabric/sync', rateLimit(300000, 3), async (_req, res) => { +app.post("/api/fabric/sync", rateLimit(300000, 3), async (_req, res) => { const sync = await syncFabricGraph(true); res.json(sync); }); @@ -399,7 +426,9 @@ async function bootstrap() { await syncFabricGraph(true); app.listen(PORT, () => { console.log(`🛡️ AgriValue IQ server v3 — http://localhost:${PORT}`); - console.log(` Fabric: ${getFabricStatus().source} | Foundry: ${foundryConfigured() ? 'configured' : 'local'} | Work IQ: ${getWorkIQStatus().active ? 'active' : 'off'}`); + console.log( + ` Fabric: ${getFabricStatus().source} | Foundry: ${foundryConfigured() ? "configured" : "local"} | Work IQ: ${getWorkIQStatus().active ? "active" : "off"}` + ); }); } diff --git a/tests/agent-memory.test.js b/tests/agent-memory.test.js new file mode 100644 index 0000000..1dfef99 --- /dev/null +++ b/tests/agent-memory.test.js @@ -0,0 +1,37 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { recordTrade, getTradeHistory, formatTradeMemoryReply } from "../lib/agent-memory.js"; + +describe("agent memory", () => { + it("formatTradeMemoryReply handles empty history", () => { + const reply = formatTradeMemoryReply([], "en"); + assert.ok(reply.includes("No trade memory")); + }); + + it("formatTradeMemoryReply lists recent trades", () => { + const trades = [ + { + at: new Date().toISOString(), + crop: "Olive Oil", + qtyTons: 15, + addedValue: 97000, + qualityMultiplier: 1.15, + }, + ]; + const reply = formatTradeMemoryReply(trades, "en"); + assert.ok(reply.includes("Olive Oil")); + assert.ok(reply.includes("97,000") || reply.includes("97000")); + assert.ok(reply.includes("Vision")); + }); + + it("recordTrade returns id and timestamp", () => { + const entry = recordTrade({ + crop: "Grapes", + qtyTons: 10, + addedValue: 5000, + }); + assert.ok(entry.id.startsWith("tr-")); + assert.ok(entry.at); + assert.ok(getTradeHistory(5).some((t) => t.id === entry.id)); + }); +}); diff --git a/tests/iq-core.test.js b/tests/iq-core.test.js new file mode 100644 index 0000000..76374e6 --- /dev/null +++ b/tests/iq-core.test.js @@ -0,0 +1,72 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + CROP_NAMES, + getMarketIntelligence, + generateSupplyContract, + evaluateQuality, + optimizeValueChain, +} from "../lib/fabric-iq.js"; + +describe("CROP_NAMES", () => { + it("includes five Sicilian crops", () => { + assert.equal(CROP_NAMES.length, 5); + assert.ok(CROP_NAMES.includes("Olive Oil")); + }); +}); + +describe("getMarketIntelligence", () => { + it("returns pricing and compliance for Olive Oil", () => { + const intel = getMarketIntelligence("Olive Oil"); + assert.ok(intel); + assert.equal(intel.crop, "Olive Oil"); + assert.ok(intel.rawPrice > 0); + assert.ok(intel.summary.includes("Olive Oil")); + }); + + it("returns null for unknown crop", () => { + assert.equal(getMarketIntelligence("Corn"), null); + }); +}); + +describe("optimizeValueChain", () => { + it("Path B beats Path A for Olive Oil with organic premium", () => { + const result = optimizeValueChain({ + crop: "Olive Oil", + qtyTons: 15, + isOrganic: true, + qualityMultiplier: 1.15, + }); + assert.ok(result); + assert.equal(result.crop, "Olive Oil"); + assert.ok(result.addedValue > 50000); + assert.ok(result.processedPath.net > result.rawPath.net); + assert.ok(result.agentTrace.goldenSeal); + }); + + it("returns null for invalid crop", () => { + assert.equal(optimizeValueChain({ crop: "Unknown", qtyTons: 10, isOrganic: false }), null); + }); +}); + +describe("generateSupplyContract", () => { + it("includes crop, quantity, and compliance sections", () => { + const md = generateSupplyContract({ + crop: "Olive Oil", + qtyTons: 15, + buyer: "Cooperativa Valle del Belice", + isOrganic: true, + }); + assert.ok(md.includes("Olive Oil")); + assert.ok(md.includes("15 metric tons")); + assert.ok(md.includes("Fair-Trade") || md.includes("SUPPLY")); + }); +}); + +describe("evaluateQuality", () => { + it("returns grade and multiplier for Grapes", () => { + const q = evaluateQuality("Grapes"); + assert.ok(q.grade); + assert.ok(q.multiplier >= 1); + }); +});