Primeiro Commit v0.beta1
This commit is contained in:
309
SKILL.md
Normal file
309
SKILL.md
Normal file
@@ -0,0 +1,309 @@
|
||||
---
|
||||
name: knowledge-one-view-cli
|
||||
description: Fetch Oracle Analytics LAD Knowledge One View sales data directly through Oracle DV APIs instead of scraping the browser UI. Use when Codex needs opportunity details, pipeline and close-date data, revenue-line analysis, SR details, fiscal-year or session context, or ad hoc Oracle DV queries for the Sales Intelligence Knowledge One View workbook at `salesintelligence-dv.oracle.com`.
|
||||
---
|
||||
|
||||
# Knowledge-One-View-CLI
|
||||
|
||||
Use the direct API path first. Read only the smallest reference needed, run the shortest matching command, and open Chrome only when direct auth or workbook tracing requires it.
|
||||
|
||||
## Quick Navigation
|
||||
|
||||
- Need the complete consolidated skill documentation:
|
||||
[docs/docs.html](docs/docs.html)
|
||||
- Need manager, hierarchy, architect-productivity, or team-rollup business context:
|
||||
[references/initial-context-catalog.md](references/initial-context-catalog.md)
|
||||
- Need the explicit annual `week` lookup such as `w2`, `w43`, or `week 43`:
|
||||
[references/script-catalog.md#week-reference](references/script-catalog.md#week-reference)
|
||||
- Need the exact CLI surface, flags, or script contract:
|
||||
[references/script-catalog.md](references/script-catalog.md)
|
||||
- Need traced endpoints, workbook defaults, or view reconstruction details:
|
||||
[references/api-map.md](references/api-map.md)
|
||||
- Need a migration checklist for a new computer:
|
||||
[portable-setup.md](portable-setup.md)
|
||||
|
||||
## Core Rules
|
||||
|
||||
- Use `scripts/fetch-sales-intelligence.mjs` for normal backend reads.
|
||||
- Do not use REPL, browser scraping, or workbook rendering calls for routine retrieval.
|
||||
- Open Chrome only when direct auth is blocked or the workbook trace must be refreshed.
|
||||
- Prefer the fewest steps that still produce a correct answer.
|
||||
- Choose exactly one execution mode per fetch call: `--intent`, `--view`, `--query`, or `--sql`.
|
||||
- Prefer instructions already encoded in the local catalogs over re-deriving Oracle fields by hand.
|
||||
|
||||
## Trigger Cues
|
||||
|
||||
Use this skill when the request is about any of these:
|
||||
|
||||
- LAD Knowledge One View opportunity or SR data
|
||||
- close-date pipeline windows or month/day close-date asks
|
||||
- pipeline, opportunity, won, close-window, territory, or owner analysis from Knowledge DV - Opportunities - V2
|
||||
- hours or hierarchy analysis from Knowledge DV - Hours - V2
|
||||
- presales coverage or involvement from Knowledge DV - Presales Involvement Aux - V1
|
||||
- Oracle DV fiscal-year/session variables used by the workbook
|
||||
- ad hoc Oracle logical SQL or alias-based query mode against the workbook data
|
||||
- manager hierarchy, architect productivity, or team rollups tied to these datasets
|
||||
|
||||
## Fast Path
|
||||
|
||||
1. Decide whether the ask is a manager or hierarchy ask.
|
||||
2. If it is, read [references/initial-context-catalog.md](references/initial-context-catalog.md) first.
|
||||
3. If the ask uses `week`, `wk`, or `w1`..`w5`, read [references/script-catalog.md#week-reference](references/script-catalog.md#week-reference) first.
|
||||
4. If auth may be stale, run `--check-auth`.
|
||||
5. Execute the shortest matching path immediately:
|
||||
- recurring business ask -> `--intent`
|
||||
- known extract -> `--view`
|
||||
- ad hoc aliases, filters, grouping, ordering -> `--query`
|
||||
- only if query mode cannot express it cleanly -> `--sql`
|
||||
6. Read [references/script-catalog.md](references/script-catalog.md) or run `--catalog` only when one contract detail is still unknown.
|
||||
7. Read [references/api-map.md](references/api-map.md) only when API behavior changed or a workbook view must be retraced.
|
||||
|
||||
## Preferred Execution Order
|
||||
|
||||
### 1. Intents
|
||||
|
||||
Use intents first for recurring business asks.
|
||||
|
||||
- `opportunities-default`
|
||||
Use as the canonical default for generic opportunity and pipeline retrieval on Knowledge DV - Opportunities - V2.
|
||||
- `opportunities-close-date`
|
||||
Use for one exact close date with `--date`.
|
||||
- `opportunities-close-month`
|
||||
Use for one calendar month with `--month`.
|
||||
- `opportunities-close-quarter`
|
||||
Use for one fiscal quarter with `--quarter`, including `current-quarter`.
|
||||
- `opportunities-current-team-close-window`
|
||||
Use for asks that really mean current-team coverage, such as allocated-resource manager, people attached through SR work, or similar team-involvement wording. This intent keeps the full org-wide opportunity/workload window and only enriches resource fields from the current team.
|
||||
- `opportunities-by-resource-close-window`
|
||||
Use when the ask names one explicit resource and you need opportunities in a close-date window tied to that person through coverage data, with commercial fields coming from Knowledge DV - Opportunities - V2.
|
||||
- `opportunities-by-manager-close-window`
|
||||
Use when the ask names one explicit manager email and you need opportunities in a close-date window tied to that hierarchy anchor, with commercial fields coming from Knowledge DV - Opportunities - V2.
|
||||
- `workloads-by-owner-current`
|
||||
Use for current-state workload asks for one explicit territory owner. It defaults to `current-quarter`, `Cloud Infrastructure - Workloads`, and `WORKLOAD`.
|
||||
- `workloads-by-owner-manager-current`
|
||||
Use for current-state workload asks for owners under one explicit manager-of-owner. It defaults to `current-quarter`, `Cloud Infrastructure - Workloads`, and `WORKLOAD`.
|
||||
- `workloads-by-manager-resource-close-window`
|
||||
Use for org-wide workload revenue lines closing inside a direct date window when the ask is about opportunities that have allocated resources under one or more named managers. This intent gets manager/resource linkage only from `Knowledge DV - Presales Involvement Aux - V1`, gets commercial rows only from `Knowledge DV - Opportunities - V2`, filters on `Revenue Line Close Date`, and does not query `DV - SE Team` unless the user explicitly asks for SR details.
|
||||
- `srs-by-resource`
|
||||
Use for SRs by person with `--resource`.
|
||||
|
||||
### 2. Views
|
||||
|
||||
Use a view when the extract shape is already known.
|
||||
|
||||
- `opportunity-details`
|
||||
- `sr-details`
|
||||
- `opportunities-close-window`
|
||||
- `srs-by-resource-email`
|
||||
- `sa-attach-pipeline-details`
|
||||
- `sa-attach-open-pipeline`
|
||||
- `sa-attach-won-pipeline`
|
||||
- `sa-attach-involvement-distribution`
|
||||
- `sa-attach-hours-by-opportunity`
|
||||
- `sa-attach-presales-coverage`
|
||||
|
||||
Read [references/script-catalog.md](references/script-catalog.md) for the full built-in script surface.
|
||||
|
||||
### 3. Query Mode
|
||||
|
||||
Use `--query` for general ad hoc retrieval when the ask can be expressed as aliases plus filters, grouping, and ordering.
|
||||
|
||||
- Prefer `--query` over `--sql` for most non-recurring asks.
|
||||
- Use field aliases instead of raw Oracle expressions whenever possible.
|
||||
- Let query mode infer the safest source from the selected aliases: commercial aliases go org-wide-first on XSA datasets, while SR/session aliases stay on `DV - SE Team`.
|
||||
- Use `--scope my-team` only when the user explicitly asks for the executing user's current-team view on org-wide-capable sources.
|
||||
|
||||
### 4. Raw SQL
|
||||
|
||||
Use `--sql` only when `--query` cannot express the ask cleanly.
|
||||
|
||||
- Prefer placeholders such as `{{opportunityId}}`, `{{closeDate}}`, `{{bookingValue}}`, `{{workloadAmount}}`, `{{subjectArea}}`, and `{{securityEntryPointFilter}}`.
|
||||
- Use `--render-sql` when you need to validate the final SQL locally without a backend call.
|
||||
- If quoting becomes fragile in PowerShell, use `scripts/run-sales-intelligence-sql.mjs`.
|
||||
|
||||
## Authentication Workflow
|
||||
|
||||
1. Run `--check-auth` only when the session may be stale.
|
||||
2. For normal fetch calls, if the saved session files are missing or the backend redirects to Oracle sign-in, `fetch-sales-intelligence.mjs` now opens the browser refresh flow automatically, waits for login, writes fresh session files, and retries the backend request.
|
||||
3. Use `scripts/refresh-auth-state.mjs` manually only when you want to pre-refresh the browser session yourself or troubleshoot the automatic flow.
|
||||
4. Use `scripts/refresh-auth-direct.mjs` when browser auth is unavailable or when you explicitly want the terminal-driven MFA flow.
|
||||
5. `--check-auth` stays diagnostic-only and does not open the browser automatically.
|
||||
|
||||
## Data-Specific Rules
|
||||
|
||||
- For manager or hierarchy asks, start with [references/initial-context-catalog.md](references/initial-context-catalog.md) before touching the CLI catalog.
|
||||
- For generic opportunity and pipeline asks, prefer `opportunities-default`.
|
||||
- For close-date asks, prefer `opportunities-close-date`, `opportunities-close-month`, or `opportunities-close-window`.
|
||||
- For fiscal-quarter asks, prefer `--quarter` with `FY26-Q4`, `Q4 FY26`, or `current-quarter`.
|
||||
- For asks phrased as `week 1`..`week 53` or `w1`..`w53`, use the annual June-to-May week mapping in [references/script-catalog.md#week-reference](references/script-catalog.md#week-reference) and prefer `--week` plus optional `--week-year`.
|
||||
- Treat wording like `agora`, `atual`, `mês corrente`, `quarter atual`, and `FY corrente` as current-period shortcuts whenever the matching flag supports it.
|
||||
- For `opportunities-close-window`, close-window intents, and `sa-attach-*` commercial views, date-window flags now constrain `Revenue Line Close Date` directly, including `--week`, `--quarter`, and explicit `--from-date/--to-date`.
|
||||
- For present-state workload asks about one seller or one seller-manager, prefer `workloads-by-owner-current` or `workloads-by-owner-manager-current`.
|
||||
- For current-team coverage asks, prefer `opportunities-current-team-close-window`.
|
||||
- For asks like `recursos do <manager de recurso> em pipe na week 2`, prefer `opportunities-by-manager-close-window --manager-email <manager de recurso> --week w2`.
|
||||
- For asks like `workloads com close date nos proximos 7 dias em oportunidades com recursos abaixo de <manager de recurso 1> e <manager de recurso 2>`, prefer `workloads-by-manager-resource-close-window --manager-email-list <email do manager de recurso 1>,<email do manager de recurso 2> --from-date today --to-date +7d`.
|
||||
- For that workload-by-manager-resource intent, treat `resourceEmails` as the org-wide allocated resource list from Presales Involvement Aux. The default result is involvement/allocation only; do not fetch SR details unless the user explicitly asks for SRs.
|
||||
- When crossing SRs to workloads or opportunities, keep the `opportunityId` join as the confirmed linkage and also consider same-resource same-customer SRs without a usable `opportunityId` as internal-SR candidates. This is the current safe fallback because `Parent SR Number` is still not queryable through the logical SQL path.
|
||||
- For SR resource lookups, default to SR Team Level `1..4` email matching and use `Lead Email` only for explicit leader intent.
|
||||
- Accept short Oracle user ids through `--resource` when available.
|
||||
- Use `--owner-email` for direct territory-owner filters and `--owner-manager-email` for owner-hierarchy filters on SA Attach workload reads.
|
||||
- Use `--forecast-type` for natural-language asks such as `forecast`, `upside`, or `won` on SA Attach workload reads.
|
||||
- Keep opportunity-linked monetary outputs consistent as `bookingValue` and `workloadAmount`.
|
||||
- Treat `Knowledge DV - Opportunities - V2` as the normal source for pipeline, opportunity, won, close date, territory, owner, booking, and workload.
|
||||
- Interpret user wording `vendedor`, `sales rep`, and `lider de vendas` as `territory owner` by default.
|
||||
- Treat org-wide-capable XSA datasets as org-wide reads in `--scope auto`: `Knowledge DV - Opportunities - V2`, `Knowledge DV - Hours - V2`, `Knowledge DV - Presales Involvement Aux - V1`, and `Knowledge DV - Revn Lines by SE`.
|
||||
- Treat `Knowledge DV - Hours - V2` as the normal source for hierarchy ownership and hours; default to org-wide unless the ask explicitly requests current-team scope.
|
||||
- Treat `Knowledge DV - Presales Involvement Aux - V1` as the normal source for allocated resource, manager, and presales-coverage linkage; default to org-wide unless the ask explicitly requests current-team scope.
|
||||
- Treat `Knowledge DV - Revn Lines by SE` as the normal source for detailed revenue-line extracts; default to org-wide unless the ask explicitly requests current-team scope.
|
||||
- Treat `DV - SE Team` as self-scoped to the executing user, with hierarchy starting from that user and expanding only downward through their team.
|
||||
- Treat `DV - SE Team` as fallback for commercial views only when the ask is explicitly about the current user's team, and as the normal source for session context and SR reads.
|
||||
- When mixing org-wide commercial rows with current-team resource enrichment, preserve every org-wide opportunity/workload row and leave resource fields empty when there is no match.
|
||||
- When a result mixes org-wide commercial rows with team-scoped resources, say that explicitly in the answer: `Opportunities/workloads: org-wide`, `Resources: only your current team`, and `rows without a match keep empty resource fields`.
|
||||
- When a result includes any `scope.components`, prefer those component lines over a single simplified label because they identify which source is org-wide and which source is my-team-only.
|
||||
|
||||
## Inputs And Outputs
|
||||
|
||||
Input expectations:
|
||||
|
||||
- business ask phrased in natural language, or
|
||||
- a known intent/view/query/SQL request, optionally with date, month, resource, manager, or status filters
|
||||
|
||||
Normal output contract from `fetch-sales-intelligence.mjs`:
|
||||
|
||||
- `request`
|
||||
- `scope`
|
||||
- `apiMetrics`
|
||||
- `columns`
|
||||
- `records`
|
||||
- `summary` for derived intents when relevant
|
||||
- `warnings`
|
||||
- `raw` for backend debugging
|
||||
|
||||
User-facing answer contract:
|
||||
|
||||
- Every answer that reports DV data must include a short "Context / assumptions" note near the result. Do not leave scope, defaults, or implicit DV assumptions only in the JSON.
|
||||
- Always state the effective scope as one of `org-wide`, `my-team-only`, or `mixed-consistent`. If `scope.components` exists, use it as the authoritative source-by-source disclosure instead of compressing the result into one label.
|
||||
- Always state the main source and execution path used: `--intent`, `--view`, `--query`, or `--sql`; the source subject area when known; and whether `--scope auto`, `--scope org-wide`, or `--scope my-team` was used.
|
||||
- When defaults are applied, explicitly say so. At minimum disclose these common defaults when relevant: `cluster = Brazil`; `--scope auto`; live Oracle DV session fiscal year; current-period shortcuts such as `current-quarter`, current FY, or the current June-to-May week cycle; default status filters; default `--limit`; and SA Attach defaults for solution, product LOB, revenue type group, sales credit type, and minimum opportunity probability.
|
||||
- If the user did not specify geography and the selected SA Attach/commercial path applies the traced workbook filter `cluster = Brazil`, say `Cluster default: Brazil`. If geography was overridden or no cluster filter applies, say that instead.
|
||||
- If date windows, weeks, quarters, or "current" wording were interpreted, state the resolved period and the date field being filtered. For commercial SA Attach close-window answers, say that the window filters `Revenue Line Close Date`.
|
||||
- If user wording required role interpretation, state it briefly, for example `vendedor`/`sales rep`/`lider de vendas` interpreted as `territory owner`, or short Oracle uid normalized to `@oracle.com`.
|
||||
- If SR, resource, manager, or workload enrichment involved implicit joins, state the join assumption: `opportunityId` is the confirmed linkage; same-resource same-customer SRs without usable `opportunityId` are only fallback candidates; and current-team enrichment can leave unmatched org-wide rows with empty resource fields.
|
||||
- Always include API effort and data-shape caveats: `Raw rows pulled from the API: X`; whether the result hit the requested/default limit; any `warnings`; and whether displayed monetary fields use truncated `k` values while `raw` keeps backend-native values.
|
||||
- Never describe the numbers as "all company", "all Brazil", or "complete pipeline" unless the disclosed `scope`, filters, defaults, and limits actually support that statement.
|
||||
|
||||
Scope disclosure rule:
|
||||
|
||||
- Use the emitted `scope` object when wording the answer.
|
||||
- `scope.scopeMode = org-wide` means the visible rows are org-wide.
|
||||
- `scope.scopeMode = my-team-only` means the visible rows are limited to the executing user's current team.
|
||||
- `scope.scopeMode = mixed-consistent` means the opportunity/workload rows remain org-wide, resource enrichment is team-scoped, and unmatched rows stay in the output with empty resource fields.
|
||||
- `scope.components` is the most precise source-by-source disclosure. Use it when present, especially for mixed answers.
|
||||
- For `workloads-by-manager-resource-close-window`, `scope.scopeMode = org-wide` means both workload rows and manager/resource allocation came from org-wide Knowledge DV sources. If the user explicitly asks for SR details, disclose the separate SR lookup as `DV - SE Team` / my-team-only.
|
||||
- Explain in the output to the user which context was used to retrieve the information.
|
||||
|
||||
API effort disclosure rule:
|
||||
|
||||
- Use `apiMetrics.totalRowsPulledFromApi` as the canonical count of raw rows returned by Oracle DV for that invocation.
|
||||
- Do not replace that number with final `records.length`; they mean different things.
|
||||
- Do not add local-cache-only rows to this number. If the skill reused local fiscal-year/session metadata, those local reads do not count toward `apiMetrics.totalRowsPulledFromApi`.
|
||||
- Prefer wording the answer with a sentence like `Raw rows pulled from the API: X.`
|
||||
|
||||
Display-format rule:
|
||||
|
||||
- `bookingValue` and `workloadAmount`, plus `summary.totalBookingValue` and `summary.totalWorkloadAmount`, are emitted as truncated thousands with a `k` suffix, for example `849k`. The `raw` section keeps the backend-native values.
|
||||
|
||||
Read [references/script-catalog.md](references/script-catalog.md) for the full CLI contract and [references/api-map.md](references/api-map.md) for traced backend details.
|
||||
|
||||
## Minimal Commands
|
||||
|
||||
Codex normally invokes this skill from the current agent workspace, so the default runtime is `./knowledge-one-view-cli/runtime/`. If you run the scripts manually from the installed skill directory or any other location, pass `--runtime-home` or set `ORACLE_SI_RUNTIME_HOME` to point back to the target workspace.
|
||||
|
||||
For the consolidated technical guide, testing strategy, and workspace artifact layout, read [docs/docs.html](docs/docs.html). Test definitions stay inside the installed skill `tests/` folder, while test-run artifacts go to the workspace-side `knowledge-one-view-cli/output/` and `knowledge-one-view-cli/tmp/` folders.
|
||||
|
||||
Check auth:
|
||||
|
||||
```powershell
|
||||
node .\scripts\fetch-sales-intelligence.mjs `
|
||||
--check-auth
|
||||
```
|
||||
|
||||
Generic pipeline or opportunities:
|
||||
|
||||
```powershell
|
||||
node .\scripts\fetch-sales-intelligence.mjs `
|
||||
--intent opportunities-default `
|
||||
--limit 200
|
||||
```
|
||||
|
||||
Exact close date:
|
||||
|
||||
```powershell
|
||||
node .\scripts\fetch-sales-intelligence.mjs `
|
||||
--intent opportunities-close-date `
|
||||
--date 2026-05-02
|
||||
```
|
||||
|
||||
Calendar month:
|
||||
|
||||
```powershell
|
||||
node .\scripts\fetch-sales-intelligence.mjs `
|
||||
--intent opportunities-close-month `
|
||||
--month 2026-04
|
||||
```
|
||||
|
||||
Annual week shortcut:
|
||||
|
||||
```powershell
|
||||
node .\scripts\fetch-sales-intelligence.mjs `
|
||||
--intent opportunities-by-manager-close-window `
|
||||
--manager-email <email do manager de recurso> `
|
||||
--week w2 `
|
||||
--week-year 2026
|
||||
```
|
||||
|
||||
Current workload by owner:
|
||||
|
||||
```powershell
|
||||
node .\scripts\fetch-sales-intelligence.mjs `
|
||||
--intent workloads-by-owner-current `
|
||||
--owner-email <email do vendedor> `
|
||||
--forecast-type Forecast,Upside
|
||||
```
|
||||
|
||||
Current workload by owner manager:
|
||||
|
||||
```powershell
|
||||
node .\scripts\fetch-sales-intelligence.mjs `
|
||||
--intent workloads-by-owner-manager-current `
|
||||
--owner-manager-email <email do manager do vendedor> `
|
||||
--quarter current-quarter `
|
||||
--forecast-type Forecast
|
||||
```
|
||||
|
||||
Org-wide workload close window by manager/resource:
|
||||
|
||||
```powershell
|
||||
node .\scripts\fetch-sales-intelligence.mjs `
|
||||
--intent workloads-by-manager-resource-close-window `
|
||||
--manager-email-list <email do manager de recurso 1>,<email do manager de recurso 2> `
|
||||
--from-date today `
|
||||
--to-date +7d
|
||||
```
|
||||
|
||||
SRs by person:
|
||||
|
||||
```powershell
|
||||
node .\scripts\fetch-sales-intelligence.mjs `
|
||||
--intent srs-by-resource `
|
||||
--resource <recurso>
|
||||
```
|
||||
|
||||
Alias-based ad hoc query:
|
||||
|
||||
```powershell
|
||||
node .\scripts\fetch-sales-intelligence.mjs `
|
||||
--query `
|
||||
--select opportunityId,opportunityName,closeDate,bookingValue,workloadAmount `
|
||||
--where "closeDate = DATE '2026-05-02'"
|
||||
```
|
||||
4
agents/openai.yaml
Normal file
4
agents/openai.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Knowledge-One-View-CLI"
|
||||
short_description: "Fetch LAD sales data via Oracle DV APIs."
|
||||
default_prompt: "Use $knowledge-one-view-cli to retrieve LAD Knowledge One View opportunity, workload, SR, or SA Attach data directly from Oracle DV APIs."
|
||||
2119
docs/docs.html
Normal file
2119
docs/docs.html
Normal file
File diff suppressed because it is too large
Load Diff
1073
docs/tests.html
Normal file
1073
docs/tests.html
Normal file
File diff suppressed because it is too large
Load Diff
106
portable-setup.md
Normal file
106
portable-setup.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# Portable Setup
|
||||
|
||||
Use this flow when moving the skill to another computer.
|
||||
|
||||
## What to copy
|
||||
|
||||
- Copy the full `knowledge-one-view-cli` skill folder into the target Codex `skills` directory.
|
||||
|
||||
## What does not matter
|
||||
|
||||
- The workspace name does not need to be `fusion`.
|
||||
- The workspace may be empty.
|
||||
|
||||
## First run on the new computer
|
||||
|
||||
When Codex invokes the skill from the agent workspace, no extra path flag is needed.
|
||||
|
||||
Browser helper note:
|
||||
|
||||
- `scripts/refresh-auth-state.mjs` now tries browser automation in this order:
|
||||
- local module resolution for `playwright` or `playwright-core`
|
||||
- shared runtime at `~/.codex/skills/playwright`
|
||||
- bundled Codex runtimes such as `~/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules`
|
||||
- In a normal Codex desktop installation, this means the browser helper should usually work on a new machine even if `~/.codex/skills/playwright` was not copied over.
|
||||
- If you use the skill outside Codex and none of those runtimes exist, install a shared Playwright runtime under `~/.codex/skills/playwright`.
|
||||
|
||||
If you want to test manually from outside the workspace, point `--runtime-home` back to the target workspace runtime:
|
||||
|
||||
```powershell
|
||||
node /path/to/.codex/skills/knowledge-one-view-cli/scripts/refresh-auth-direct.mjs `
|
||||
--runtime-home ./knowledge-one-view-cli/runtime `
|
||||
--username <user-email>
|
||||
```
|
||||
|
||||
This creates the runtime state inside the workspace-side skill folder:
|
||||
|
||||
- `knowledge-one-view-cli/runtime/auth-state.json`
|
||||
- `knowledge-one-view-cli/runtime/session-meta.json`
|
||||
- `knowledge-one-view-cli/runtime/chrome-profile/`
|
||||
|
||||
## Bootstrap rule
|
||||
|
||||
The first test run in a new workspace must bootstrap the full workspace-side tree, even if the workspace starts empty.
|
||||
|
||||
Expected workspace-side structure:
|
||||
|
||||
- `knowledge-one-view-cli/output/`
|
||||
- `knowledge-one-view-cli/tmp/` (todos os temporarios e intermediarios diretamente aqui, sem subpastas obrigatorias)
|
||||
- `knowledge-one-view-cli/runtime/`
|
||||
- `knowledge-one-view-cli/runtime/chrome-profile/`
|
||||
|
||||
Notes:
|
||||
|
||||
- permanent test definitions stay in the installed skill under `.../knowledge-one-view-cli/tests/`
|
||||
- run outputs, diffs, temporary inputs, rendered SQL and runtime state stay in the workspace-side `knowledge-one-view-cli/` folder
|
||||
- the bootstrap runner also writes the first run bundle under `output/`
|
||||
|
||||
Example bootstrap test run:
|
||||
|
||||
```powershell
|
||||
node /path/to/.codex/skills/knowledge-one-view-cli/tests/run-systemic-suite.mjs `
|
||||
--bootstrap-only `
|
||||
--workspace-root .
|
||||
```
|
||||
|
||||
If you launch the command from inside the workspace-side `knowledge-one-view-cli/` folder instead of the workspace root, the skill now normalizes that location back to the real workspace root automatically, so it will not create `knowledge-one-view-cli/knowledge-one-view-cli/...`.
|
||||
|
||||
Recommended post-migration smoke:
|
||||
|
||||
```powershell
|
||||
node /path/to/.codex/skills/knowledge-one-view-cli/tests/run-post-migration-smoke.mjs `
|
||||
--workspace-root .
|
||||
```
|
||||
|
||||
This smoke runner does the standard migration validation flow:
|
||||
|
||||
- bootstraps the workspace-side tree
|
||||
- validates the Oracle session with `--check-auth`
|
||||
- runs a real Brazil workload sample for `current-quarter`
|
||||
- writes the sample JSON into `knowledge-one-view-cli/output/post-migration-workload-sample.json`
|
||||
- writes the run bundle into `knowledge-one-view-cli/output/test-runs/<run-id>/`
|
||||
|
||||
## Daily use
|
||||
|
||||
```powershell
|
||||
node /path/to/.codex/skills/knowledge-one-view-cli/scripts/fetch-sales-intelligence.mjs `
|
||||
--runtime-home ./knowledge-one-view-cli/runtime `
|
||||
--intent opportunities-default `
|
||||
--limit 200
|
||||
```
|
||||
|
||||
When running directly from the workspace-side `knowledge-one-view-cli/` folder, the default path inference now keeps outputs in `./output/`, `./tmp/` and `./runtime/` under that folder instead of creating a second nested `knowledge-one-view-cli/`.
|
||||
|
||||
## Optional development helper
|
||||
|
||||
If direct auth is blocked or you need to refresh workbook/browser tracing, use:
|
||||
|
||||
```powershell
|
||||
node /path/to/.codex/skills/knowledge-one-view-cli/scripts/refresh-auth-state.mjs `
|
||||
--runtime-home ./knowledge-one-view-cli/runtime
|
||||
```
|
||||
|
||||
## Related references
|
||||
|
||||
- `docs/docs.html`
|
||||
- `references/script-catalog.md`
|
||||
514
references/api-map.md
Normal file
514
references/api-map.md
Normal file
@@ -0,0 +1,514 @@
|
||||
# Knowledge-One-View-CLI API Map
|
||||
|
||||
Read this file when Oracle DV behavior changes, a workbook tab must be retraced, or you need traced endpoint and default-filter details that are too low-level for the main skill guide.
|
||||
|
||||
## Quick Navigation
|
||||
|
||||
- Workbook identity: [Traced Workbook](#traced-workbook)
|
||||
- Required auth/session state: [Required Session Inputs](#required-session-inputs)
|
||||
- Direct and fallback endpoints: [Traced Endpoints](#traced-endpoints)
|
||||
- Local metadata and field shortcuts: [Fast Local Metadata](#fast-local-metadata)
|
||||
- Common query patterns: [Fast Query Patterns](#fast-query-patterns)
|
||||
- Traced workbook-tab defaults: [Traced Tab Defaults](#traced-tab-defaults)
|
||||
|
||||
## Traced Workbook
|
||||
|
||||
- Workbook URL:
|
||||
`https://salesintelligence-dv.oracle.com/ui/dv/ui/project.jsp?pageid=visualAnalyzer&reportmode=presentation&reportpath=%2F%40Catalog%2Fshared%2FTeam%20Sharing%20Area%2FLAD%2FLAD%20Sales%20Operations%2FLAD%20Production%2FLAD%20Knowledge%20One%20View`
|
||||
- Platform:
|
||||
Oracle Analytics / Oracle DV
|
||||
- Primary commercial subject area:
|
||||
`XSA('ANNA.DE.MAURO@ORACLE.COM'.'Knowledge DV - Opportunities - V2')`
|
||||
|
||||
Keep the `ANNA.DE.MAURO@ORACLE.COM` token exactly as traced in XSA dataset references. It is part of the logical dataset identifier, not a disposable example.
|
||||
|
||||
## Required Session Inputs
|
||||
|
||||
The direct API calls worked with:
|
||||
|
||||
- Cookies from Playwright `storageState`
|
||||
- `authorization: session`
|
||||
- `x-csrf-token`
|
||||
- `x-bitech-clientbin`
|
||||
- `x-requested-with: XMLHttpRequest`
|
||||
- Workbook `referer`
|
||||
|
||||
`x-bitech-clientbin` also appears in the workbook bootstrap HTML inside static asset paths such as:
|
||||
|
||||
`/static/dv/oracle-jet-bundles/18.1.2.fd48e9d8895d/...`
|
||||
|
||||
The refresh helper writes both the cookies and these request headers to disk:
|
||||
|
||||
- `auth-state.json`
|
||||
- `session-meta.json`
|
||||
|
||||
The fetch script also persists refreshed cookies plus updated CSRF/clientbin metadata back to those files after a session restore so later runs reuse the corrected backend state.
|
||||
When the saved files are missing or Oracle redirects a normal fetch call back to sign-in, the fetch script can now invoke the browser refresh helper automatically, then reload the session artifacts and retry the backend request.
|
||||
That makes the runtime/session path parameters operationally important, not just convenience flags.
|
||||
|
||||
## Traced Endpoints
|
||||
|
||||
### Preferred Direct Endpoint
|
||||
|
||||
- `POST /ui/dv/ui/api/v1/sqlquery/execute`
|
||||
Use for custom Oracle logical SQL and for the reusable `opportunity-details` and `sr-details` views in this skill.
|
||||
Body shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"aQueryInfos": [
|
||||
{
|
||||
"sQuery": "SELECT valueof(RV_CURR_FISCAL_YEAR) FROM \"DV - SE Team\"",
|
||||
"nResultsLimit": 1
|
||||
}
|
||||
],
|
||||
"bUseCache": true
|
||||
}
|
||||
```
|
||||
|
||||
Response shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"aQueriesResults": [
|
||||
{
|
||||
"eStatus": "FINISHED",
|
||||
"aColumnTypes": ["varchar"],
|
||||
"aResults": [["FY26"]]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Metadata / Bootstrap Endpoints
|
||||
|
||||
- `POST /ui/dv/ui/api/v1/modelmetadata/reportdatasetsmetadatajson`
|
||||
Traced from `Data Quality`. Useful when Oracle changes dataset metadata or a new workbook view must be reconstructed.
|
||||
- `POST /ui/dv/ui/api/v1/infer/activities`
|
||||
Telemetry only. Ignore for data retrieval.
|
||||
- `GET /ui/dv/ui/api/v3/items-content`
|
||||
Static workbook assets such as images. Ignore for data retrieval.
|
||||
|
||||
### Workbook Rendering Endpoints
|
||||
|
||||
- `POST /ui/dv/ui/api/v2/data/executeOrPoll`
|
||||
- `POST /ui/dv/ui/api/v2/data/poll`
|
||||
|
||||
These drive the rendered workbook tabs. They are useful as fallback references because the POST payloads contain the exact traced columns and default filters, but they are noisier than `sqlquery/execute`.
|
||||
|
||||
## Fast Local Metadata
|
||||
|
||||
The skill now keeps a local field catalog inside `scripts/fetch-sales-intelligence.mjs` for the most common logical fields, so repeated asks do not need rediscovery. Use:
|
||||
|
||||
- `--catalog`
|
||||
- `--list-intents`
|
||||
- `--describe-intent srs-by-resource`
|
||||
- `--list-fields`
|
||||
- `--list-fields --entity Opportunity`
|
||||
- `--list-fields --role filter`
|
||||
- `--describe-field closeDate`
|
||||
|
||||
Use the intent shortcuts first for the recurring asks that previously caused parameter guesswork:
|
||||
|
||||
- `opportunities-close-date`
|
||||
Uses `--date`
|
||||
- `opportunities-close-month`
|
||||
Uses `--month`
|
||||
- `srs-by-resource`
|
||||
Uses `--resource`
|
||||
|
||||
For ad hoc SQL, prefer template placeholders over raw Oracle expressions:
|
||||
|
||||
- `{{opportunityId}}`
|
||||
- `{{closeDate}}`
|
||||
- `{{bookingValue}}`
|
||||
- `{{workloadAmount}}`
|
||||
- `{{subjectArea}}`
|
||||
- `{{securityEntryPointFilter}}`
|
||||
|
||||
Render those placeholders locally without a backend call:
|
||||
|
||||
```powershell
|
||||
node .\scripts\fetch-sales-intelligence.mjs `
|
||||
--render-sql `
|
||||
--sql "SELECT {{opportunityId}}, {{closeDate}} FROM {{subjectArea}} WHERE {{securityEntryPointFilter}}"
|
||||
```
|
||||
|
||||
For most ad hoc retrieval, prefer declarative query mode over raw SQL:
|
||||
|
||||
```powershell
|
||||
node .\scripts\fetch-sales-intelligence.mjs `
|
||||
--query `
|
||||
--select opportunityId,opportunityName,closeDate,bookingValue,workloadAmount `
|
||||
--where "closeDate = DATE '2026-05-02'"
|
||||
```
|
||||
|
||||
Query mode infers the safest source from the selected aliases. Commercial opportunity, revenue-line, hours, and presales-coverage aliases use org-wide-capable XSA sources in `--scope auto`; SR and session aliases remain on `DV - SE Team`, which is my-team-only.
|
||||
|
||||
Important known fields:
|
||||
|
||||
- `closeDate`
|
||||
`"Opportunity"."Close Date"`
|
||||
Use direct date literals: `DATE '2026-03-27'`
|
||||
- `opportunityId`
|
||||
`"Opportunity"."Opportunity ID"`
|
||||
- `opportunityName`
|
||||
`"Opportunity"."Opportunity Name"`
|
||||
- `opportunityStatus`
|
||||
`"Opportunity"."Status"`
|
||||
- `customerName`
|
||||
`"Customer"."Customer Name"`
|
||||
- `salesStage`
|
||||
`"Opportunity"."Sales Stage"`
|
||||
- `opportunityValue`
|
||||
`"Opportunity"."Opportunity Value"`
|
||||
- `bookingValue`
|
||||
`"Opportunity"."Opportunity Value"`
|
||||
Preferred alias when the intent is to compare booking against `workloadAmount`.
|
||||
- `workloadAmount`
|
||||
`"Opportunity"."Workload Amount"`
|
||||
- `optyCloseFiscalYear`
|
||||
`"Time (Close Date Opportunity)"."Opty Close Fiscal Year"`
|
||||
- `srNumber`
|
||||
`"Service Request"."SR Number"`
|
||||
- `srStatus`
|
||||
`"Service Request"."Status"`
|
||||
- `srTeamLevel1EmailAddress`
|
||||
`"SR Team"."SE Team Level 1 Email Address"`
|
||||
- `srTeamLevel2EmailAddress`
|
||||
`"SR Team"."SE Team Level 2 Email Address"`
|
||||
- `srTeamLevel3EmailAddress`
|
||||
`"SR Team"."SE Team Level 3 Email Address"`
|
||||
- `srTeamLevel4EmailAddress`
|
||||
`"SR Team"."SE Team Level 4 Email Address"`
|
||||
- `leadEmail`
|
||||
`"Service Request"."Lead Email"`
|
||||
Use only when the intent is explicitly to query the SR leader.
|
||||
|
||||
## Fast Query Patterns
|
||||
|
||||
### Ad Hoc SQL Without Oracle Expression Guesswork
|
||||
|
||||
Use the local placeholder expansion:
|
||||
|
||||
```powershell
|
||||
node .\scripts\fetch-sales-intelligence.mjs `
|
||||
--sql "SELECT {{opportunityId}}, {{opportunityName}}, {{closeDate}}, {{bookingValue}}, {{workloadAmount}} FROM {{subjectArea}} WHERE {{securityEntryPointFilter}} AND {{closeDate}} = DATE '2026-05-02'"
|
||||
```
|
||||
|
||||
The script expands that locally to Oracle logical SQL before execution.
|
||||
When the `SELECT` list is simple enough, the script also infers output column names and returns object-shaped `records` instead of only raw row arrays. Explicit `AS` aliases are respected.
|
||||
|
||||
### Declarative Query Mode
|
||||
|
||||
Use query mode when the ask is naturally "fields + filter + grouping + ordering":
|
||||
|
||||
```powershell
|
||||
node .\scripts\fetch-sales-intelligence.mjs `
|
||||
--query `
|
||||
--select opportunityId `
|
||||
--measure sum:bookingValue:totalBooking,sum:workloadAmount:totalWorkload `
|
||||
--where "closeDate = DATE '2026-05-02'" `
|
||||
--group-by opportunityId `
|
||||
--order-by totalBooking:desc
|
||||
```
|
||||
|
||||
Query mode reduces guesswork by:
|
||||
|
||||
- inferring the configured source automatically from the selected aliases
|
||||
- using org-wide XSA sources first for commercial aliases in `--scope auto`
|
||||
- injecting the standard team-security filter only when the selected source requires it or when `--scope my-team` is explicitly requested on an org-wide-capable source
|
||||
- accepting field aliases directly inside `--select` and `--where`
|
||||
- supporting aggregate specs like `sum:bookingValue:totalBooking`
|
||||
- returning object-shaped records with stable output columns
|
||||
|
||||
### Backend Scope Reality
|
||||
|
||||
Use this when deciding whether a request can be answered org-wide or must be disclosed as current-team only:
|
||||
|
||||
- `Knowledge DV - Opportunities - V2`, `Knowledge DV - Hours - V2`, `Knowledge DV - Presales Involvement Aux - V1`, and `Knowledge DV - Revn Lines by SE` are treated as org-wide-capable XSA datasets by the skill. In `--scope auto`, the script avoids the current-team security predicate unless the chosen view or intent explicitly needs it.
|
||||
- `--scope my-team` on an org-wide-capable source intentionally adds the current-team predicate so the same dataset can be used for a narrower current-team read.
|
||||
- `DV - SE Team` is effectively my-team-only for this skill. Use it for SRs, session context, and fields that have no org-wide source. Do not use it as a generic org-wide commercial fallback.
|
||||
- For mixed outputs, prefer `scope.components` and `scope.disclosureLines` over a single label because one component may be org-wide while another remains my-team-only.
|
||||
|
||||
### Opportunities By Close-Date Window
|
||||
|
||||
Use the built-in intent or built-in view instead of rewriting SQL:
|
||||
|
||||
Exact date:
|
||||
|
||||
```powershell
|
||||
node .\scripts\fetch-sales-intelligence.mjs `
|
||||
--intent opportunities-close-date `
|
||||
--date 2026-05-02
|
||||
```
|
||||
|
||||
Calendar month:
|
||||
|
||||
```powershell
|
||||
node .\scripts\fetch-sales-intelligence.mjs `
|
||||
--intent opportunities-close-month `
|
||||
--month 2026-04
|
||||
```
|
||||
|
||||
Explicit window:
|
||||
|
||||
```powershell
|
||||
node .\scripts\fetch-sales-intelligence.mjs `
|
||||
--view opportunities-close-window `
|
||||
--from-date today `
|
||||
--to-date +7d
|
||||
```
|
||||
|
||||
Primary logical SQL pattern:
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
XSA('ANNA.DE.MAURO@ORACLE.COM'.'Knowledge DV - Opportunities - V2')."Columns"."Opportunity ID",
|
||||
XSA('ANNA.DE.MAURO@ORACLE.COM'.'Knowledge DV - Opportunities - V2')."Columns"."Opportunity Name",
|
||||
XSA('ANNA.DE.MAURO@ORACLE.COM'.'Knowledge DV - Opportunities - V2')."Columns"."Customer Name (Original)",
|
||||
XSA('ANNA.DE.MAURO@ORACLE.COM'.'Knowledge DV - Opportunities - V2')."Columns"."Opportunity Status",
|
||||
XSA('ANNA.DE.MAURO@ORACLE.COM'.'Knowledge DV - Opportunities - V2')."Columns"."Close Date",
|
||||
XSA('ANNA.DE.MAURO@ORACLE.COM'.'Knowledge DV - Opportunities - V2')."Columns"."Sales Stage",
|
||||
MAX(XSA('ANNA.DE.MAURO@ORACLE.COM'.'Knowledge DV - Opportunities - V2')."Columns"."Pipeline") AS bookingValue,
|
||||
MAX(XSA('ANNA.DE.MAURO@ORACLE.COM'.'Knowledge DV - Opportunities - V2')."Columns"."Pipeline") AS workloadAmount
|
||||
FROM XSA('ANNA.DE.MAURO@ORACLE.COM'.'Knowledge DV - Opportunities - V2')
|
||||
WHERE XSA('ANNA.DE.MAURO@ORACLE.COM'.'Knowledge DV - Opportunities - V2')."Columns"."Close Date" >= DATE '2026-03-27'
|
||||
AND XSA('ANNA.DE.MAURO@ORACLE.COM'.'Knowledge DV - Opportunities - V2')."Columns"."Close Date" <= DATE '2026-04-03'
|
||||
GROUP BY
|
||||
XSA('ANNA.DE.MAURO@ORACLE.COM'.'Knowledge DV - Opportunities - V2')."Columns"."Opportunity ID",
|
||||
XSA('ANNA.DE.MAURO@ORACLE.COM'.'Knowledge DV - Opportunities - V2')."Columns"."Opportunity Name",
|
||||
XSA('ANNA.DE.MAURO@ORACLE.COM'.'Knowledge DV - Opportunities - V2')."Columns"."Customer Name (Original)",
|
||||
XSA('ANNA.DE.MAURO@ORACLE.COM'.'Knowledge DV - Opportunities - V2')."Columns"."Opportunity Status",
|
||||
XSA('ANNA.DE.MAURO@ORACLE.COM'.'Knowledge DV - Opportunities - V2')."Columns"."Close Date",
|
||||
XSA('ANNA.DE.MAURO@ORACLE.COM'.'Knowledge DV - Opportunities - V2')."Columns"."Sales Stage"
|
||||
ORDER BY 5, 1
|
||||
```
|
||||
|
||||
Fallback behavior:
|
||||
|
||||
- if the ask is explicitly about the current user's team and the primary XSA query fails, the skill may rerun the commercial read against `DV - SE Team`
|
||||
- generic org-wide commercial reads stay on `Knowledge DV - Opportunities - V2` and do not fall back to `DV - SE Team`
|
||||
|
||||
In the built-in `opportunities-close-window` view, the last two fields are returned from the org-wide XSA `Pipeline` column:
|
||||
|
||||
- `bookingValue` -> `Knowledge DV - Opportunities - V2` / `"Columns"."Pipeline"`
|
||||
- `workloadAmount` -> `Knowledge DV - Opportunities - V2` / `"Columns"."Pipeline"`
|
||||
|
||||
The `bookingValue` and `workloadAmount` names are preserved for user-facing compatibility with opportunity and workload reports. For org-wide query-mode reads from `Knowledge DV - Opportunities - V2`, prefer the same `Pipeline` backing field because direct row-level reads of `"Opportunity Value"` and `"Workload Amount"` can fail in Oracle DV.
|
||||
|
||||
### SRs By Resource Email
|
||||
|
||||
Use the built-in intent or built-in view instead of writing custom SQL:
|
||||
|
||||
Shortest path:
|
||||
|
||||
```powershell
|
||||
node .\scripts\fetch-sales-intelligence.mjs `
|
||||
--intent srs-by-resource `
|
||||
--resource resource.member
|
||||
```
|
||||
|
||||
Equivalent direct view call:
|
||||
|
||||
```powershell
|
||||
node .\scripts\fetch-sales-intelligence.mjs `
|
||||
--view srs-by-resource-email `
|
||||
--resource resource.member
|
||||
```
|
||||
|
||||
Default matching rule:
|
||||
|
||||
```sql
|
||||
(
|
||||
"SR Team"."SE Team Level 1 Email Address" = 'resource.member@oracle.com'
|
||||
OR "SR Team"."SE Team Level 2 Email Address" = 'resource.member@oracle.com'
|
||||
OR "SR Team"."SE Team Level 3 Email Address" = 'resource.member@oracle.com'
|
||||
OR "SR Team"."SE Team Level 4 Email Address" = 'resource.member@oracle.com'
|
||||
)
|
||||
```
|
||||
|
||||
Only use lead matching when the ask is explicitly about the leader:
|
||||
|
||||
```powershell
|
||||
node .\scripts\fetch-sales-intelligence.mjs `
|
||||
--view srs-by-resource-email `
|
||||
--resource-email resource.member@oracle.com `
|
||||
--match-role lead
|
||||
```
|
||||
|
||||
The built-in `srs-by-resource-email` view also returns:
|
||||
|
||||
- `bookingValue` -> `"Opportunity"."Opportunity Value"`
|
||||
- `workloadAmount` -> `"Opportunity"."Workload Amount"`
|
||||
|
||||
The CLI now normalizes Oracle date strings in built-in output, so values such as `02-MAY-26` are returned as `2026-05-02`.
|
||||
|
||||
## Traced Tab Defaults
|
||||
|
||||
### Opportunity Details
|
||||
|
||||
- Current canvas:
|
||||
`canvas!4`
|
||||
- Plugin:
|
||||
`oracle.bi.tech.table`
|
||||
- Default filters:
|
||||
- `Security.Entry Point in VALUEOF(NQ_SESSION."SEC_ARIA_SI-SR-TEAM")`
|
||||
- `Opty Close Fiscal Year = current FY`
|
||||
- `Opportunity Status in (Open, Won, Won Pending)`
|
||||
- `SE Team Active Flag = Y`
|
||||
- `SE Team Manager Flag = N`
|
||||
- `SR Source = Opportunity`
|
||||
- Important columns:
|
||||
- `Opportunity ID`
|
||||
- `# Opportunity Hours`
|
||||
- `Status`
|
||||
- `Workload Amount`
|
||||
- `Registry ID`
|
||||
- `Customer Name`
|
||||
- `Deal Qualification`
|
||||
- `Forecast Type Group`
|
||||
- `Opportunity Value`
|
||||
- `Current FY Opp Hours`
|
||||
- `Total Opportunity Hours`
|
||||
- `Opty Close Fiscal Quarter`
|
||||
- `# SEs With Effort`
|
||||
- `# Service Requests with Effort`
|
||||
- `Country`
|
||||
- `Sales Stage`
|
||||
- `Revenue Type Group`
|
||||
- `LOB Type`
|
||||
- `All Products`
|
||||
- `Opportunity Name`
|
||||
- `All Workloads`
|
||||
- `Covered Flag`
|
||||
- `All Implementers`
|
||||
- `Win Probability`
|
||||
|
||||
### SR Details
|
||||
|
||||
- Current canvas:
|
||||
`canvas!5`
|
||||
- Plugin:
|
||||
`oracle.bi.tech.table`
|
||||
- Default filters:
|
||||
- `Security.Entry Point in VALUEOF(NQ_SESSION."SEC_ARIA_SI-SR-TEAM")`
|
||||
- `Opty Close Fiscal Year = current FY`
|
||||
- `Opportunity Status = Open`
|
||||
- `SE Team Active Flag = Y`
|
||||
- `SE Team Manager Flag = N`
|
||||
- Important columns:
|
||||
- `SR Number`
|
||||
- `Parent SR Number`
|
||||
- `Service Request Status`
|
||||
- `Title`
|
||||
- `Queue Name`
|
||||
- `Country`
|
||||
- `Registry ID`
|
||||
- `Customer Name`
|
||||
- `SR Source`
|
||||
- `Opportunity ID`
|
||||
- `Opportunity Name`
|
||||
- `Opportunity Status`
|
||||
- `Win Probability`
|
||||
- `Primary Pillar`
|
||||
- `Opportunity Value`
|
||||
- `Workload Amount`
|
||||
- `Total SR Hours`
|
||||
- `Lead Email`
|
||||
- `SR Requester Email Address`
|
||||
- `Create SR Day`
|
||||
- `Close SR Day`
|
||||
- `Customer Deadline`
|
||||
- `Internal Deadline`
|
||||
- `Last Effort`
|
||||
- `# Service Requests with Effort`
|
||||
- `# SEs With Effort`
|
||||
- `# Hours (Excl. Time Off)`
|
||||
- `Service Description`
|
||||
|
||||
Current limitation:
|
||||
|
||||
- `Parent SR Number` is visible in traced workbook metadata, but the direct logical SQL path used by the CLI still fails when selecting it explicitly.
|
||||
- Because of that, current internal-SR reconciliation in the skill uses a safe fallback: confirmed joins stay based on `Opportunity ID`, and additional internal-SR candidates are inferred only when the same resource has SRs for the same customer without a usable `Opportunity ID`.
|
||||
|
||||
### Data Quality
|
||||
|
||||
The `Data Quality` tab used:
|
||||
|
||||
- `POST /ui/dv/ui/api/v1/sqlquery/execute`
|
||||
- `POST /ui/dv/ui/api/v1/modelmetadata/reportdatasetsmetadatajson`
|
||||
|
||||
That trace confirms the workbook can be queried directly through Oracle logical SQL without rendering the visualization.
|
||||
|
||||
### Commercial Opportunity Datasets
|
||||
|
||||
- Current canvas:
|
||||
`canvas!15`
|
||||
- Traced endpoints:
|
||||
- `POST /ui/dv/ui/api/v2/data/executeOrPoll`
|
||||
- `POST /ui/dv/ui/api/v2/modelmetadata/subjectareas`
|
||||
- `POST /ui/dv/ui/api/v2/metadata/interactionpaths`
|
||||
- `POST /ui/dv/ui/api/v1/dataset/datasets/metadata`
|
||||
- `POST /ui/dv/ui/api/v1/modelmetadata/reportdatasetsmetadatajson`
|
||||
- Traced workbook sources:
|
||||
- `XSA('ANNA.DE.MAURO@ORACLE.COM'.'Knowledge DV - Opportunities - V2')`
|
||||
- `XSA('ANNA.DE.MAURO@ORACLE.COM'.'Knowledge DV - Hours - V2')`
|
||||
- `XSA('ANNA.DE.MAURO@ORACLE.COM'.'Knowledge DV - Presales Involvement Aux - V1')`
|
||||
- `XSA('ANNA.DE.MAURO@ORACLE.COM'.'Knowledge DV - Revn Lines by SE')`
|
||||
- Observed metadata lookup:
|
||||
- `XSA('ANNA.DE.MAURO@ORACLE.COM'.'One View Tech - Usage Information')`
|
||||
|
||||
The first traced SA Attach visuals used `Knowledge DV - Opportunities - V2` as the active subject area and exposed these traced defaults:
|
||||
|
||||
- Recommended default for generic opportunity retrieval:
|
||||
`--intent opportunities-default`
|
||||
- `Cluster = Brazil`
|
||||
- `Solution in ('Cloud Infrastructure - Workloads', 'Hardware - Bookings', 'On-Premise Technology - Bookings')`
|
||||
- `Sales Credit Type = QUOTA`
|
||||
- `Executive Product LOB in ('Cloud Infrastructure', 'Hardware', 'License')`
|
||||
- `Revenue Type Group in ('NEW', 'EXPANSION', 'WORKLOAD')`
|
||||
- Current skill default: `Opportunity Probability >= 0`
|
||||
- Older workbook traces sometimes showed `Opportunity Probability >= 40`
|
||||
- `Revenue Line Status = Open` for the open-pipeline visuals
|
||||
- `Revenue Line Status in ('Won', 'Won Pending')` for the won-pipeline visual
|
||||
- Open rows are allowed in any close year, while won rows are constrained to previous/current fiscal year
|
||||
- Compatibility note:
|
||||
older traces may still mention `License and Hardware` or `WORKLOADS`; the skill now normalizes those aliases to the backend values `License` + `Hardware` and `WORKLOAD`.
|
||||
|
||||
Traced view mappings:
|
||||
|
||||
- `view!1597`
|
||||
`oracle.bi.tech.ngperformancetile`
|
||||
Open pipeline tile based on `SUM(Pipeline)` from `Knowledge DV - Opportunities - V2`
|
||||
- `view!1612`
|
||||
`oracle.bi.tech.ngperformancetile`
|
||||
Won pipeline tile based on `SUM(Pipeline)` from `Knowledge DV - Opportunities - V2`
|
||||
- `view!1602`
|
||||
`oracle.bi.tech.chart.stackbar100`
|
||||
Involvement distribution chart based on `Pipeline`, `Revenue Line Probability`, `Presales Involvement Flag`, and `Opportunity Hours Attribute`
|
||||
|
||||
The traced involvement bucket logic is:
|
||||
|
||||
```sql
|
||||
CASE
|
||||
WHEN SUM(
|
||||
CASE WHEN <current-team filter on Presales Involvement Aux - V1>
|
||||
THEN "Opportunity Hours Attribute"
|
||||
END BY "Opportunity ID"
|
||||
) > 0
|
||||
THEN '1. My Team Involvement'
|
||||
WHEN "Presales Involvement Flag" = 'Y'
|
||||
THEN '2. Other Presales Involvement'
|
||||
ELSE '3. No Presales Involvement'
|
||||
END
|
||||
```
|
||||
|
||||
Current skill coverage now maps that tab through these direct views:
|
||||
|
||||
- `sa-attach-pipeline-details`
|
||||
- `sa-attach-open-pipeline`
|
||||
- `sa-attach-won-pipeline`
|
||||
- `sa-attach-involvement-distribution`
|
||||
- `sa-attach-hours-by-opportunity`
|
||||
- `sa-attach-presales-coverage`
|
||||
83
references/initial-context-catalog.md
Normal file
83
references/initial-context-catalog.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# Initial Context Catalog
|
||||
|
||||
Read this file at skill load time when the ask is about managers, hierarchy, architect productivity, team rollups, or when you need quick business context before touching the CLI catalog. This is a curated seed catalog for fast execution, not a replacement for `fetch-sales-intelligence.mjs --catalog`.
|
||||
|
||||
Use this file for org and business-context interpretation. Skip it for routine close-date, SR-by-person, or generic opportunity asks that already map directly to an intent or built-in view.
|
||||
|
||||
## Quick Navigation
|
||||
|
||||
- Why this file exists: [Why This Exists](#why-this-exists)
|
||||
- Hierarchy heuristics: [Hierarchy Shortcuts](#hierarchy-shortcuts)
|
||||
- Verified manager anchors: [Verified Org Anchors](#verified-org-anchors)
|
||||
- Reusable business translations: [Query Translation Shortcuts](#query-translation-shortcuts)
|
||||
- SA Attach filters and anchors: [SA Attach Quick Map](#sa-attach-quick-map)
|
||||
- Final guardrails: [Practical Rules](#practical-rules)
|
||||
|
||||
## Why This Exists
|
||||
|
||||
- The script catalog is best for CLI and field-contract discovery.
|
||||
- This file is best for front-loading org or hierarchy context, query translation heuristics, and recurring business shortcuts.
|
||||
- Use this first when the user already gave a manager name, an email, or wording such as `top architects`, `time do manager`, `produtividade`, `booking/workload por arquiteto`, or `deals WON`.
|
||||
|
||||
## Hierarchy Shortcuts
|
||||
|
||||
- For SA Attach productivity-style asks, the working source of truth is usually `Knowledge DV - Hours - V2`.
|
||||
- In that dataset, the most useful working assumption is:
|
||||
- `SE Team Level 3 Email Address` -> manager scope anchor for many manager-rollup asks.
|
||||
- `SE Team Level 4 Email Address` -> direct manager under the level-3 manager.
|
||||
- `SE Team Level 5 Email Address` -> architect or IC email for per-person productivity rollups.
|
||||
- `SE Team Level 6..8 Email Address` -> often repeats the same IC email. Do not sum across those levels as separate people.
|
||||
- Do not assume `Security.Entry Point = <manager email>` reproduces the org hierarchy you need. In practice, the entry point can differ from the visible manager email and may return no rows for a valid manager.
|
||||
- For manager-scoped productivity asks, the fastest stable pattern is:
|
||||
1. Filter `Knowledge DV - Hours - V2` by the manager email at the matching hierarchy level.
|
||||
2. Aggregate hours by `SE Team Level 5 Email Address`.
|
||||
3. Join or intersect with `Knowledge DV - Opportunities - V2` on `Opportunity ID` to apply `Close Date`, `Status`, `Opportunity Value`, `Workload Amount`, territory, and owner context.
|
||||
|
||||
## SA Attach Quick Map
|
||||
|
||||
- Hierarchy fields: `SE Team Level 3 Email Address` = manager anchor; `SE Team Level 4 Email Address` = direct manager; `SE Team Level 5 Email Address` = architect/IC.
|
||||
- SA Attach params: `--cluster`, `--solution`, `--revenue-line-status`, `--executive-product-lob`, `--revenue-type-group`, `--sales-credit-type`, `--min-opportunity-probability`.
|
||||
- SA Attach defaults: `--cluster=Brazil`; `--solution=Cloud Infrastructure - Workloads,Hardware - Bookings,On-Premise Technology - Bookings`; `--revenue-line-status=Open,Won,Won Pending`; `--executive-product-lob=Cloud Infrastructure,Hardware,License`; `--revenue-type-group=NEW,EXPANSION,WORKLOAD`; `--sales-credit-type=QUOTA`; `--min-opportunity-probability=0`.
|
||||
- SA Attach aliases: `cloud applications` -> `Cloud Applications`; `cloud infrastructure` -> `Cloud Infrastructure`; `hardware` -> `Hardware`; `license` -> `License`; `license and hardware` -> `License,Hardware`; `new` -> `NEW`; `expansion` -> `EXPANSION`; `workload|workloads` -> `WORKLOAD`.
|
||||
|
||||
## Verified Org Anchors
|
||||
|
||||
Last verified during a live run on `2026-03-27`.
|
||||
|
||||
### Higher-level anchors
|
||||
|
||||
| Level | Email | Notes |
|
||||
|---|---|---|
|
||||
| L1 | `luiz.meisler@oracle.com` | Executive-level anchor seen in SA Attach hours hierarchy. |
|
||||
| L2 | `marcelo.christianini@oracle.com` | Next-level org anchor above multiple manager branches. |
|
||||
|
||||
Keep this section intentionally small. Only verified hierarchy anchors that materially shortcut discovery should stay here; one-off example managers should not.
|
||||
|
||||
## Query Translation Shortcuts
|
||||
|
||||
| Ask shape | Fastest reliable translation |
|
||||
|---|---|
|
||||
| `vendedor`, `sales rep`, `lider de vendas` | Interpret as `territory owner` by default for opportunity/pipeline asks unless the user explicitly requests another role. |
|
||||
| `Top arquitetos do manager X em WON com close date em janela Y` | Filter `Knowledge DV - Hours - V2` by manager hierarchy level, aggregate `# Opportunity Hours` by `SE Team Level 5 Email Address`, then intersect with `Knowledge DV - Opportunities - V2` `Opportunity ID`s filtered by `Close Date` and `Status = 'Won'`. |
|
||||
| `Mostrar booking/workload dos top arquitetos` | Use the same top-architect result, then de-duplicate by `architectEmail + opportunityId` before summing `Opportunity Value` and `Workload Amount` from `Knowledge DV - Opportunities - V2`. |
|
||||
| `Quais clientes compoem o total do arquiteto` | Reuse the same de-duplicated architect or opportunity set and pull `Customer Name`, `Opportunity Value`, and `Workload Amount` from `Knowledge DV - Opportunities - V2`. |
|
||||
| `Preciso descobrir qual nível representa o manager` | Probe `SE Team Level 1..8 Email Address` with a `%surname%` or exact-email search in `Knowledge DV - Hours - V2` before building the final query. |
|
||||
| `Quero deals WON do time do manager` | Prefer `Knowledge DV - Opportunities - V2` for `Close Date`, `Status`, `Opportunity Value`, `Workload Amount`, territory, and owner context; prefer `Knowledge DV - Hours - V2` for hierarchy ownership and allocated hours. |
|
||||
|
||||
## Practical Rules
|
||||
|
||||
- Prefer `--query` for ad hoc joins, filters, and groupings when it can express the ask cleanly.
|
||||
- Prefer `--sql` only when you need a dataset the normal `Security Entry Point` filter cannot express or when you must query XSA hierarchy columns directly.
|
||||
- Do not trust `Won Flag` from the hours dataset as the only `WON` filter for business reporting. Use `Knowledge DV - Opportunities - V2` `Opportunity Status = 'Won'` plus the required close-date window.
|
||||
- Use `Knowledge DV - Opportunities - V2` and the other Knowledge DV views for org-wide reads whose hierarchy starts at Latin America leadership.
|
||||
- Use `DV - SE Team` only for self-scoped questions where "my team" really means the executing user and the people below that user, plus the normal SR and session-context questions.
|
||||
- Do not use `DV - SE Team` as generic fallback for manager, territory, owner, or org-wide commercial reads.
|
||||
- When crossing org-wide opportunities/workloads with team-scoped resource enrichment, keep every org-wide row and leave the resource fields empty when no team match exists.
|
||||
- When listing clients and values for each architect, sum booking and workload only once per `architectEmail + opportunityId`.
|
||||
- If the SQL gets quote-heavy on Windows PowerShell, use the SQL runner helper script documented in [script-catalog.md](script-catalog.md) instead of passing a long `--sql` string directly through the shell.
|
||||
|
||||
## Suggested First Read Order
|
||||
|
||||
1. This file for manager or hierarchy or business context.
|
||||
2. [script-catalog.md](script-catalog.md) only if the CLI contract is still unclear.
|
||||
3. `fetch-sales-intelligence.mjs --catalog` only if the data contract is still unclear after the two files above.
|
||||
473
references/script-catalog.md
Normal file
473
references/script-catalog.md
Normal file
@@ -0,0 +1,473 @@
|
||||
# Script Catalog
|
||||
|
||||
Read this file when you need the exact CLI surface of the bundled scripts without rediscovering flags from source.
|
||||
|
||||
Read this file for flag names, parameter conflicts, execution modes, and helper-script contracts. Skip it for routine asks that already map cleanly to a known intent or view.
|
||||
|
||||
## Quick Navigation
|
||||
|
||||
- Discovery policy: [Discovery Rule](#discovery-rule)
|
||||
- Shortest path by ask type: [Minimal-Step Decision Table](#minimal-step-decision-table)
|
||||
- Annual June-to-May week lookup: [Week Shortcuts](#week-shortcuts)
|
||||
- Main fetch command: [Script: `fetch-sales-intelligence.mjs`](#script-fetch-sales-intelligencemjs)
|
||||
- Browser refresh helper: [Script: `refresh-auth-state.mjs`](#script-refresh-auth-statemjs)
|
||||
- Direct auth helper: [Script: `refresh-auth-direct.mjs`](#script-refresh-auth-directmjs)
|
||||
- Quote-safe SQL helper: [Script: `run-sales-intelligence-sql.mjs`](#script-run-sales-intelligence-sqlmjs)
|
||||
|
||||
For manager, hierarchy, architect-productivity, or business-parameter context, read [initial-context-catalog.md](initial-context-catalog.md) first. That skill-level catalog front-loads verified manager emails, hierarchy heuristics, and common query translations so the agent can execute faster before touching the generated CLI catalog.
|
||||
|
||||
## Discovery Rule
|
||||
|
||||
- Prefer [initial-context-catalog.md](initial-context-catalog.md) first for manager or hierarchy asks.
|
||||
- Prefer `node scripts/fetch-sales-intelligence.mjs --catalog` first for CLI and data-contract discovery.
|
||||
- The catalog now returns `views`, `intents`, `entities`, `fields`, `macros`, `parameters`, and `scripts`.
|
||||
- For focused inspection, use `--list-parameters`, `--describe-parameter`, `--list-scripts`, and `--describe-script`.
|
||||
- Treat `--catalog` as a one-shot resolver, not the first step in every request.
|
||||
- If the ask already maps to a known intent, view, or query pattern, execute directly and skip discovery.
|
||||
- After `--catalog`, go straight to the execution command. Avoid chaining `--list-*` calls unless one exact contract detail is still missing.
|
||||
|
||||
## Operational Keepers
|
||||
|
||||
These are the local catalog items worth keeping highly visible because they reduce trial-and-error during real backend execution:
|
||||
|
||||
- Discovery helpers: `--catalog`, `--list-parameters`, `--describe-parameter`, `--list-scripts`, `--describe-script`.
|
||||
- Workspace/runtime path controls: `--runtime-home`, `--state-path`, `--meta-path`, `--output-file`.
|
||||
- Backend targeting: `--base-url`, plus auth refresh `--url`.
|
||||
- Browser-auth recovery: `--profile-dir`, `--chrome-path`, `--timeout-ms`.
|
||||
- Direct-auth recovery: `--username`, `--factor`.
|
||||
|
||||
Role-shaped placeholder emails used below are illustrative only. Keep verified hierarchy anchors in `initial-context-catalog.md`, but keep routine usage examples generic.
|
||||
|
||||
## Minimal-Step Decision Table
|
||||
|
||||
| Ask shape | Shortest default |
|
||||
|---|---|
|
||||
| Recurring business request | `--intent ...` |
|
||||
| Known row extract | `--view ...` |
|
||||
| Ad hoc aliases + filters + grouping | `--query ...` |
|
||||
| Need exact Oracle expression | `--sql ...` |
|
||||
| Need contract discovery | `--catalog` once |
|
||||
| Need one specific flag or script | `--describe-parameter ...` or `--describe-script ...` |
|
||||
|
||||
## Week Shortcuts
|
||||
|
||||
Use these as fixed lookups for June-to-May cycle asks:
|
||||
|
||||
| Token | Meaning |
|
||||
|---|---|
|
||||
| `W1` / `week 1` | Starts on June 1 of the cycle start year |
|
||||
| `W2` / `week 2` | Starts 7 days after `W1` |
|
||||
| `W43` / `week 43` | Continues counting across January without resetting |
|
||||
| `W53` / `week 53` | Final partial week ending on the last day of May |
|
||||
|
||||
CLI flags:
|
||||
|
||||
- `--week <token>` accepts `1..53`, `w1..w53`, or `week 1..week 53`
|
||||
- `--week-year <YYYY>` selects the cycle start year for that June-to-May map
|
||||
- For the explicit default-cycle table, read [Week Reference](#week-reference)
|
||||
|
||||
# Week Reference
|
||||
|
||||
Use this file for asks that mention `week`, `wk`, or `w1`..`w53`.
|
||||
|
||||
Week numbering is anchored on `June 1` and runs continuously until the last day of the following `May`.
|
||||
|
||||
Interpretation rules:
|
||||
|
||||
- `week-year` means the cycle start year.
|
||||
- `week-year 2025` means the cycle `2025-06-01` through `2026-05-31`.
|
||||
- If the user does not specify a year, default to the current June-cycle.
|
||||
- Do not recalculate the default cycle in normal execution. Use the explicit table below.
|
||||
|
||||
## Default Cycle Lookup
|
||||
|
||||
Current default cycle for this workspace date context:
|
||||
|
||||
- `week-year 2025`
|
||||
- cycle start: `2025-06-01`
|
||||
- cycle end: `2026-05-31`
|
||||
|
||||
| Week | Start | End |
|
||||
|---|---|---|
|
||||
| W1 | 2025-06-01 | 2025-06-07 |
|
||||
| W2 | 2025-06-08 | 2025-06-14 |
|
||||
| W3 | 2025-06-15 | 2025-06-21 |
|
||||
| W4 | 2025-06-22 | 2025-06-28 |
|
||||
| W5 | 2025-06-29 | 2025-07-05 |
|
||||
| W6 | 2025-07-06 | 2025-07-12 |
|
||||
| W7 | 2025-07-13 | 2025-07-19 |
|
||||
| W8 | 2025-07-20 | 2025-07-26 |
|
||||
| W9 | 2025-07-27 | 2025-08-02 |
|
||||
| W10 | 2025-08-03 | 2025-08-09 |
|
||||
| W11 | 2025-08-10 | 2025-08-16 |
|
||||
| W12 | 2025-08-17 | 2025-08-23 |
|
||||
| W13 | 2025-08-24 | 2025-08-30 |
|
||||
| W14 | 2025-08-31 | 2025-09-06 |
|
||||
| W15 | 2025-09-07 | 2025-09-13 |
|
||||
| W16 | 2025-09-14 | 2025-09-20 |
|
||||
| W17 | 2025-09-21 | 2025-09-27 |
|
||||
| W18 | 2025-09-28 | 2025-10-04 |
|
||||
| W19 | 2025-10-05 | 2025-10-11 |
|
||||
| W20 | 2025-10-12 | 2025-10-18 |
|
||||
| W21 | 2025-10-19 | 2025-10-25 |
|
||||
| W22 | 2025-10-26 | 2025-11-01 |
|
||||
| W23 | 2025-11-02 | 2025-11-08 |
|
||||
| W24 | 2025-11-09 | 2025-11-15 |
|
||||
| W25 | 2025-11-16 | 2025-11-22 |
|
||||
| W26 | 2025-11-23 | 2025-11-29 |
|
||||
| W27 | 2025-11-30 | 2025-12-06 |
|
||||
| W28 | 2025-12-07 | 2025-12-13 |
|
||||
| W29 | 2025-12-14 | 2025-12-20 |
|
||||
| W30 | 2025-12-21 | 2025-12-27 |
|
||||
| W31 | 2025-12-28 | 2026-01-03 |
|
||||
| W32 | 2026-01-04 | 2026-01-10 |
|
||||
| W33 | 2026-01-11 | 2026-01-17 |
|
||||
| W34 | 2026-01-18 | 2026-01-24 |
|
||||
| W35 | 2026-01-25 | 2026-01-31 |
|
||||
| W36 | 2026-02-01 | 2026-02-07 |
|
||||
| W37 | 2026-02-08 | 2026-02-14 |
|
||||
| W38 | 2026-02-15 | 2026-02-21 |
|
||||
| W39 | 2026-02-22 | 2026-02-28 |
|
||||
| W40 | 2026-03-01 | 2026-03-07 |
|
||||
| W41 | 2026-03-08 | 2026-03-14 |
|
||||
| W42 | 2026-03-15 | 2026-03-21 |
|
||||
| W43 | 2026-03-22 | 2026-03-28 |
|
||||
| W44 | 2026-03-29 | 2026-04-04 |
|
||||
| W45 | 2026-04-05 | 2026-04-11 |
|
||||
| W46 | 2026-04-12 | 2026-04-18 |
|
||||
| W47 | 2026-04-19 | 2026-04-25 |
|
||||
| W48 | 2026-04-26 | 2026-05-02 |
|
||||
| W49 | 2026-05-03 | 2026-05-09 |
|
||||
| W50 | 2026-05-10 | 2026-05-16 |
|
||||
| W51 | 2026-05-17 | 2026-05-23 |
|
||||
| W52 | 2026-05-24 | 2026-05-30 |
|
||||
| W53 | 2026-05-31 | 2026-05-31 |
|
||||
|
||||
Examples:
|
||||
|
||||
- `week 1` -> `2025-06-01` to `2025-06-07`
|
||||
- `recursos do resource.manager em pipe na week 43` -> use `--intent opportunities-by-manager-close-window --manager-email resource.manager@oracle.com --week w43`
|
||||
|
||||
|
||||
## Script: `fetch-sales-intelligence.mjs`
|
||||
|
||||
Primary backend retrieval entrypoint for Oracle DV.
|
||||
|
||||
### Discovery flags
|
||||
|
||||
| Flag | Type | Description |
|
||||
|---|---|---|
|
||||
| `--catalog` | flag | Return the full local catalog, including script and parameter metadata. |
|
||||
| `--list-views` | flag | List built-in views. |
|
||||
| `--describe-view <view>` | string | Describe one built-in view. |
|
||||
| `--list-intents` | flag | List recurring business intents. |
|
||||
| `--describe-intent <intent>` | string | Describe one intent. |
|
||||
| `--list-entities` | flag | List entities and the views that expose them. |
|
||||
| `--list-fields` | flag | List local field aliases. |
|
||||
| `--describe-field <alias>` | string | Describe one field alias. |
|
||||
| `--list-parameters` | flag | List the parameter catalog. |
|
||||
| `--describe-parameter <name-or-flag>` | string | Describe one parameter. Accepts `resource` or `--resource`. |
|
||||
| `--list-scripts` | flag | List runnable scripts in the skill. |
|
||||
| `--describe-script <script>` | string | Describe one bundled script. |
|
||||
| `--help` | flag | Show usage. |
|
||||
|
||||
### Execution selectors
|
||||
|
||||
Choose exactly one execution mode:
|
||||
|
||||
| Flag | Type | Description |
|
||||
|---|---|---|
|
||||
| `--view <view>` | string | Run one built-in view. |
|
||||
| `--intent <intent>` | string | Run one higher-level shortcut. |
|
||||
| `--query` | flag | Enable declarative query mode. Requires `--select` and/or `--measure`. |
|
||||
| `--sql "<logical sql>"` | string | Run custom Oracle logical SQL. |
|
||||
|
||||
Conflicts:
|
||||
|
||||
- Do not combine `--view`, `--intent`, `--query`, and `--sql`.
|
||||
- `--render-sql` may be combined with `--query` or `--sql`, but not by itself.
|
||||
|
||||
PowerShell note:
|
||||
|
||||
- Prefer `--query` over `--sql` when the ask can be expressed with aliases, filters, grouping, and ordering.
|
||||
- If custom SQL is still required and the logical SQL is quote-heavy, use `run-sales-intelligence-sql.mjs` with `--sql-file` or `--stdin` instead of passing a long `--sql` string directly through PowerShell.
|
||||
|
||||
### Global execution flags
|
||||
|
||||
| Flag | Type | Default | Description |
|
||||
|---|---|---:|---|
|
||||
| `--check-auth` | flag | `false` | Validate the saved Oracle DV session. |
|
||||
| `--limit <rows>` | number | `5000` | Max rows requested from Oracle DV. |
|
||||
| `--scope auto\|org-wide\|my-team` | enum | `auto` | Controls source scope. `auto` is org-wide-first for org-wide-capable XSA datasets and my-team-only for `DV - SE Team` fields. |
|
||||
| `--fiscal-year FY26` | string | live session | Override fiscal year for views that need one. |
|
||||
| `--runtime-home <dir>` | path | `<workspace>/knowledge-one-view-cli/runtime` | Override the workspace runtime root. |
|
||||
| `--state-path <file>` | path | `<workspace>/knowledge-one-view-cli/runtime/auth-state.json` | Override auth-state location. Reads can still fall back to legacy `~/.codex/oracle-sales-intelligence-direct/auth-state.json` if the new workspace runtime is still empty. |
|
||||
| `--meta-path <file>` | path | `<workspace>/knowledge-one-view-cli/runtime/session-meta.json` | Override session-meta location. Reads can still fall back to legacy `~/.codex/oracle-sales-intelligence-direct/session-meta.json` if the new workspace runtime is still empty. |
|
||||
| `--base-url <url>` | string | `https://salesintelligence-dv.oracle.com` | Override DV base URL. |
|
||||
| `--no-cache` | flag | `false` | Disable cache-based reuse for the run. |
|
||||
| `--output-file <file>` | path | none | Also write JSON output to a file. Relative paths without a directory go under `<workspace>/knowledge-one-view-cli/output/`, except temporary/intermediate names (`tmp-*`, `temp-*`, `scratch-*`, `intermediate-*`, `render-output-*`, `*.tmp.*`, `.tmp`, `.temp`) that are routed to `<workspace>/knowledge-one-view-cli/tmp/`; other relative paths are resolved under the workspace skill folder. |
|
||||
| `--render-sql` | flag | `false` | Render final SQL locally without backend execution. |
|
||||
|
||||
Environment fallbacks:
|
||||
|
||||
- `ORACLE_SI_RUNTIME_HOME`
|
||||
- `ORACLE_SI_STATE_PATH`
|
||||
- `ORACLE_SI_META_PATH`
|
||||
- `ORACLE_SI_BASE_URL`
|
||||
|
||||
Auth behavior note:
|
||||
|
||||
- For normal execution modes other than `--check-auth`, the fetch script can automatically launch `refresh-auth-state.mjs`, wait for Oracle browser login, persist the refreshed session, and retry the backend request when auth artifacts are missing or the backend redirects to sign-in.
|
||||
- `--check-auth` remains diagnostic-only and reports validity without opening the browser automatically.
|
||||
|
||||
### `--list-fields` filters
|
||||
|
||||
These apply only with `--list-fields`.
|
||||
|
||||
| Flag | Type | Description |
|
||||
|---|---|---|
|
||||
| `--entity <entity>` | string | Filter fields to one entity, such as `Opportunity`. |
|
||||
| `--role <role>` | string | Filter fields by role, such as `select`, `filter`, `aggregate`, or `order`. |
|
||||
| `--search <text>` | string | Search alias, entity, expression, and description. |
|
||||
|
||||
### Query mode flags
|
||||
|
||||
These apply only with `--query`.
|
||||
|
||||
| Flag | Type | Description |
|
||||
|---|---|---|
|
||||
| `--select fieldA,fieldB` | csv | Row-level field aliases. |
|
||||
| `--measure aggregate:field[:alias]` | csv | Aggregate outputs, for example `sum:bookingValue:totalBooking`. |
|
||||
| `--where "<predicate>"` | string | Row filter clause. Field aliases are resolved automatically. |
|
||||
| `--group-by fieldA,fieldB` | csv | Explicit grouping outputs or field aliases. |
|
||||
| `--having "<predicate>"` | string | Post-aggregation filter clause. |
|
||||
| `--order-by field:asc|desc` | csv | Output ordering. |
|
||||
| `--distinct` | flag | Emit `SELECT DISTINCT`. |
|
||||
| `--scope auto\|org-wide\|my-team` | enum | Source-scope policy. In `auto`, query mode infers XSA org-wide for commercial aliases and `DV - SE Team` my-team-only for SR/session aliases. |
|
||||
|
||||
Org-wide commercial value aliases:
|
||||
|
||||
- In `--scope auto` and `--scope org-wide`, query-mode resolves `bookingValue`, `workloadAmount`, and `opportunityValue` to the org-wide `Knowledge DV - Opportunities - V2` `Pipeline` column.
|
||||
- Use `pipelineAmount` when you want the backing field name to be explicit in the output.
|
||||
- In `--scope my-team`, `bookingValue` and `workloadAmount` keep the `DV - SE Team` opportunity aliases.
|
||||
|
||||
### Date-window flags
|
||||
|
||||
Used by `opportunities-close-window`, the close-window intents, and the commercial `sa-attach-*` views.
|
||||
|
||||
| Flag | Type | Description |
|
||||
|---|---|---|
|
||||
| `--date YYYY-MM-DD` | string | Exact close date. |
|
||||
| `--month YYYY-MM|current-month` | string | Whole calendar month. Also accepts `current-month`. |
|
||||
| `--quarter FY26-Q4|Q4 FY26|current-quarter` | string | Whole fiscal quarter. Also accepts `current-quarter`. |
|
||||
| `--week <1..53|w1..w53|week 1..week 53|current-week>` | string | Annual week lookup anchored on June 1. Also accepts `current-week`. |
|
||||
| `--week-year <YYYY>` | number | Cycle start year used with `--week`. Defaults to the current June-cycle start year. |
|
||||
| `--from-date <date>` | string | Start date. Accepts `YYYY-MM-DD`, `today`, `+7d`, `-3d`, or `today+7d`. |
|
||||
| `--to-date <date>` | string | End date. Same accepted formats as `--from-date`. |
|
||||
| `--opty-status Open,Won,Closed` | csv | Optional opportunity-status filter. |
|
||||
| `--cluster <cluster>` | string | Optional commercial cluster filter for close-window opportunity intents. |
|
||||
|
||||
Rule:
|
||||
|
||||
- Use exactly one of `--date`, `--month`, `--quarter`, `--week`, or the pair `--from-date` plus `--to-date`.
|
||||
- On `opportunities-close-window`, close-window intents, and `sa-attach-*` commercial views, these flags are applied to `Revenue Line Close Date`.
|
||||
- `opportunities-close-date`, `opportunities-close-month`, `opportunities-close-quarter`, `opportunities-current-team-close-window`, `opportunities-by-resource-close-window`, and `opportunities-by-manager-close-window` honor an explicit `--cluster <cluster>` on the commercial `Knowledge DV - Opportunities - V2` window. If `--cluster` is omitted, these close-window opportunity intents preserve the existing unfiltered geography behavior rather than applying the SA Attach default cluster.
|
||||
|
||||
### Resource / SR flags
|
||||
|
||||
Used by `srs-by-resource-email`, `srs-by-resource`, and the explicit opportunity coverage intents.
|
||||
|
||||
| Flag | Type | Default | Description |
|
||||
|---|---|---:|---|
|
||||
| `--resource-email <user@oracle.com>` | string | none | Explicit Oracle email for SR matching. |
|
||||
| `--resource <uid-or-email>` | string | none | Short uid or full email. Uid is normalized to `@oracle.com`. |
|
||||
| `--manager-email <user@oracle.com>` | string | none | Explicit manager email for hierarchy-scoped opportunity coverage lookups. |
|
||||
| `--manager-email-list emailA,emailB` | csv | none | Comma-separated manager emails for the org-wide workload-by-manager-resource intent. |
|
||||
| `--match-role team|lead` | enum | `team` | Match `SR Team Level 1..4` or `Lead Email`. |
|
||||
| `--sr-status Open,Closed` | csv | none | Optional SR status filter. |
|
||||
|
||||
Coverage result contract:
|
||||
|
||||
- Coverage intents preserve the full org-wide commercial window when one component is narrower than the commercial source.
|
||||
- `opportunities-current-team-close-window` emits `mixed-consistent`: opportunities/workloads are org-wide, resource fields are only from the current team, and rows without a match keep empty resource fields.
|
||||
- `opportunities-by-resource-close-window` and `opportunities-by-manager-close-window` use org-wide Presales Involvement Aux linkage by default, so explicit resource/manager coverage can see beyond the executing user's team.
|
||||
- `workloads-by-manager-resource-close-window` emits `org-wide`: workload rows and manager/resource allocation both come from org-wide Knowledge DV sources. It honors explicit `--cluster <cluster>` on the workload commercial window and defaults to Brazil when omitted. It does not enrich SRs unless the user explicitly asks for SR details.
|
||||
- When present, `scope.components` is the authoritative source-by-source disclosure for downstream wording.
|
||||
- The emitted `apiMetrics.totalRowsPulledFromApi` reports the raw Oracle DV rows returned by the backend for the invocation. It does not include rows resolved only from local cache or local metadata files.
|
||||
|
||||
### Opportunity Coverage Intents
|
||||
|
||||
Use these when the ask is explicitly about opportunities tied to a person or manager in a close-date window.
|
||||
|
||||
| Intent | Best use |
|
||||
|---|---|
|
||||
| `opportunities-current-team-close-window` | Current-session team coverage path when the ask really means "my team" or current-team involvement for the executing user. Keeps the org-wide opportunity/workload rows and enriches only the current-team resource fields. |
|
||||
| `opportunities-by-resource-close-window` | Explicit resource-scoped opportunity coverage in a close-date window. Keeps org-wide opportunity/workload rows and matches the named resource through org-wide `Knowledge DV - Presales Involvement Aux - V1` coverage. |
|
||||
| `opportunities-by-manager-close-window` | Explicit manager-scoped opportunity coverage in a close-date window. Keeps org-wide opportunity/workload rows and matches the manager hierarchy through org-wide `Knowledge DV - Presales Involvement Aux - V1` coverage. |
|
||||
| `workloads-by-manager-resource-close-window` | Org-wide workload revenue lines in a close-date window, filtered to opportunities that have Presales Involvement Aux rows under one or more explicit managers. Uses `Knowledge DV - Opportunities - V2` for workload rows and `Knowledge DV - Presales Involvement Aux - V1` for org-wide manager/resource allocation. |
|
||||
|
||||
Week-based example for manager coverage:
|
||||
|
||||
```powershell
|
||||
node .\scripts\fetch-sales-intelligence.mjs `
|
||||
--intent opportunities-by-manager-close-window `
|
||||
--manager-email resource.manager@oracle.com `
|
||||
--week w2 `
|
||||
--week-year 2026
|
||||
```
|
||||
|
||||
Cluster-filtered manager coverage example:
|
||||
|
||||
```powershell
|
||||
node .\scripts\fetch-sales-intelligence.mjs `
|
||||
--intent opportunities-by-manager-close-window `
|
||||
--manager-email resource.manager@oracle.com `
|
||||
--month 2026-05 `
|
||||
--cluster Mexico
|
||||
```
|
||||
|
||||
Close-window example for org-wide workloads by manager/resource:
|
||||
|
||||
```powershell
|
||||
node .\scripts\fetch-sales-intelligence.mjs `
|
||||
--intent workloads-by-manager-resource-close-window `
|
||||
--manager-email-list resource.manager.a@oracle.com,resource.manager.b@oracle.com `
|
||||
--from-date today `
|
||||
--to-date +7d `
|
||||
--cluster Mexico
|
||||
```
|
||||
|
||||
Result contract note for `workloads-by-manager-resource-close-window`:
|
||||
|
||||
- `managerEmails` keeps the matched manager list from Presales Involvement Aux.
|
||||
- `resourceEmails` keeps the matched allocated resource list from Presales Involvement Aux.
|
||||
- `resourceDetails` adds one object per resource with `resourceEmail` and `companionEmails` for the other allocated resources on the same workload row. SR detail fields are omitted in this intent because the default ask is involvement/allocation, not SR detail.
|
||||
|
||||
### Current Workload Intents
|
||||
|
||||
Use these when the ask is about how workload pipeline looks now for one seller or for sellers under one manager-of-owner.
|
||||
|
||||
| Intent | Best use |
|
||||
|---|---|
|
||||
| `workloads-by-owner-current` | Current-state workload rows for one explicit territory owner. Defaults to `current-quarter`, `Cloud Infrastructure - Workloads`, and `WORKLOAD`. |
|
||||
| `workloads-by-owner-manager-current` | Current-state workload rows for owners under one explicit manager-of-owner. Defaults to `current-quarter`, `Cloud Infrastructure - Workloads`, and `WORKLOAD`. |
|
||||
|
||||
Both current workload intents use SA Attach commercial filters, so `--cluster <cluster>` is supported and defaults to Brazil when omitted.
|
||||
|
||||
Examples:
|
||||
|
||||
```powershell
|
||||
node .\scripts\fetch-sales-intelligence.mjs `
|
||||
--intent workloads-by-owner-current `
|
||||
--owner-email territory.owner@oracle.com `
|
||||
--forecast-type Forecast,Upside
|
||||
```
|
||||
|
||||
```powershell
|
||||
node .\scripts\fetch-sales-intelligence.mjs `
|
||||
--intent workloads-by-owner-manager-current `
|
||||
--owner-manager-email territory.manager@oracle.com `
|
||||
--quarter current-quarter `
|
||||
--forecast-type Forecast
|
||||
```
|
||||
|
||||
### SA Attach flags
|
||||
|
||||
Used by `sa-attach-*` views except `sa-attach-hours-by-opportunity` and `sa-attach-presales-coverage`.
|
||||
|
||||
| Flag | Type | Default |
|
||||
|---|---|---|
|
||||
| `--cluster <cluster>` | string | `Brazil` |
|
||||
| `--solution valueA,valueB` | csv | `Cloud Infrastructure - Workloads,Hardware - Bookings,On-Premise Technology - Bookings` |
|
||||
| `--revenue-line-status Open,Won,Won Pending` | csv | view default |
|
||||
| `--executive-product-lob valueA,valueB` | csv | `Cloud Infrastructure,Hardware,License` |
|
||||
| `--revenue-type-group NEW,EXPANSION,WORKLOAD` | csv | `NEW,EXPANSION,WORKLOAD` |
|
||||
| `--sales-credit-type <type>` | string | `QUOTA` |
|
||||
| `--min-opportunity-probability <number>` | number | `0` |
|
||||
| `--owner-email <user@oracle.com>` | string | none |
|
||||
| `--owner-manager-email <user@oracle.com>` | string | none |
|
||||
| `--forecast-type Forecast,Upside,Won` | csv | all forecast types |
|
||||
|
||||
Notes:
|
||||
|
||||
- `--owner-email` matches the direct territory owner on `Level 7 Territory Owner E-mail`.
|
||||
- `--owner-manager-email` matches upper owner hierarchy levels `2..6`.
|
||||
- `--forecast-type` filters `Revenue Line Forecast Type Group`.
|
||||
- When a date window is present, commercial SA Attach SQL now filters by `Revenue Line Close Date` directly instead of depending on a separate fiscal-year resolver.
|
||||
- `opportunities-default`, `workloads-by-owner-current`, and `workloads-by-owner-manager-current` use these SA Attach filters; `--cluster` defaults to Brazil for those intents.
|
||||
- DV - SE Team and session/effort/SR intents such as `srs-by-resource`, `sr-details`, `current-fiscal-year`, `data-quality-session-context`, `effort-by-se-team`, `effort-task-type-summary`, and `won-and-open-by-se-opty` do not support the commercial `Cluster` filter.
|
||||
|
||||
## Script: `refresh-auth-state.mjs`
|
||||
|
||||
Optional browser-based development helper. Opens Chrome, waits for a valid Oracle DV page, and writes fresh session files. Daily use should prefer `refresh-auth-direct.mjs`.
|
||||
|
||||
| Flag | Type | Default | Description |
|
||||
|---|---|---:|---|
|
||||
| `--runtime-home <dir>` | path | `<workspace>/knowledge-one-view-cli/runtime` | Override the workspace runtime root. |
|
||||
| `--state-path <file>` | path | `<workspace>/knowledge-one-view-cli/runtime/auth-state.json` | Override auth-state output path. |
|
||||
| `--meta-path <file>` | path | `<workspace>/knowledge-one-view-cli/runtime/session-meta.json` | Override session-meta output path. |
|
||||
| `--profile-dir <dir>` | path | `<workspace>/knowledge-one-view-cli/runtime/chrome-profile` | Override Chrome profile directory. |
|
||||
| `--chrome-path <file>` | path | auto-detected by platform | Override Chrome executable. |
|
||||
| `--url <url>` | string | workbook URL | Override target Oracle DV workbook URL. |
|
||||
| `--timeout-ms <ms>` | number | `600000` | Max wait for authenticated Oracle DV page. |
|
||||
| `--help` | flag | `false` | Show usage. |
|
||||
|
||||
Environment fallbacks:
|
||||
|
||||
- `ORACLE_SI_RUNTIME_HOME`
|
||||
- `ORACLE_SI_STATE_PATH`
|
||||
- `ORACLE_SI_META_PATH`
|
||||
- `ORACLE_SI_PROFILE_DIR`
|
||||
- `ORACLE_SI_CHROME_PATH`
|
||||
- `ORACLE_SI_TARGET_URL`
|
||||
|
||||
## Script: `refresh-auth-direct.mjs`
|
||||
|
||||
Direct Oracle sign-in flow without opening Chrome. Useful when browser auth is unavailable or you want explicit MFA control.
|
||||
|
||||
| Flag | Type | Default | Description |
|
||||
|---|---|---:|---|
|
||||
| `--runtime-home <dir>` | path | `<workspace>/knowledge-one-view-cli/runtime` | Override the workspace runtime root. |
|
||||
| `--state-path <file>` | path | `<workspace>/knowledge-one-view-cli/runtime/auth-state.json` | Override auth-state output path. |
|
||||
| `--meta-path <file>` | path | `<workspace>/knowledge-one-view-cli/runtime/session-meta.json` | Override session-meta output path. |
|
||||
| `--username <email>` | string | prompted | Username for the direct sign-in flow. Also accepts `ORACLE_SI_USERNAME`. |
|
||||
| `--factor <name>` | enum | none | Preferred MFA factor. Supported values: `PUSH`, `SMS`, `EMAIL`, `PHONE_CALL`, `TOTP`, `BYPASSCODE`, `PASSWORD`, `FIDO_PASSKEY`. |
|
||||
| `--url <url>` | string | workbook URL | Override target Oracle DV workbook URL. |
|
||||
| `--help` | flag | `false` | Show usage. |
|
||||
|
||||
Environment fallbacks:
|
||||
|
||||
- `ORACLE_SI_RUNTIME_HOME`
|
||||
- `ORACLE_SI_STATE_PATH`
|
||||
- `ORACLE_SI_META_PATH`
|
||||
- `ORACLE_SI_USERNAME`
|
||||
- `ORACLE_SI_TARGET_URL`
|
||||
|
||||
## Script: `run-sales-intelligence-sql.mjs`
|
||||
|
||||
Wrapper for `fetch-sales-intelligence.mjs` that preserves complex logical SQL on Windows shells by reading the SQL from a file or stdin, then forwarding the remaining arguments unchanged.
|
||||
|
||||
| Flag | Type | Default | Description |
|
||||
|---|---|---:|---|
|
||||
| `--sql-file <file>` | path | none | Read logical SQL from a file and pass it to `fetch-sales-intelligence.mjs --sql`. |
|
||||
| `--stdin` | flag | `false` | Read logical SQL from stdin and pass it to `fetch-sales-intelligence.mjs --sql`. |
|
||||
| `--help` | flag | `false` | Show usage. |
|
||||
|
||||
Pass-through behavior:
|
||||
|
||||
- Any other flags are passed straight through to `fetch-sales-intelligence.mjs`.
|
||||
- Typical pass-through flags include `--limit`, `--render-sql`, `--output-file`, `--state-path`, `--meta-path`, `--base-url`, and `--no-cache`.
|
||||
|
||||
Examples:
|
||||
|
||||
```powershell
|
||||
node .\scripts\run-sales-intelligence-sql.mjs `
|
||||
--sql-file .\query.sql `
|
||||
--limit 200
|
||||
```
|
||||
|
||||
```powershell
|
||||
Get-Content .\query.sql | node .\scripts\run-sales-intelligence-sql.mjs `
|
||||
--stdin `
|
||||
--render-sql
|
||||
```
|
||||
6917
scripts/fetch-sales-intelligence.mjs
Normal file
6917
scripts/fetch-sales-intelligence.mjs
Normal file
File diff suppressed because it is too large
Load Diff
256
scripts/lib/auth-shared.mjs
Normal file
256
scripts/lib/auth-shared.mjs
Normal file
@@ -0,0 +1,256 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
function domainMatches(hostname, cookieDomain) {
|
||||
const normalized = String(cookieDomain || "").replace(/^\./, "").toLowerCase();
|
||||
const host = String(hostname || "").toLowerCase();
|
||||
if (!normalized || !host) return false;
|
||||
if (normalized === host) return true;
|
||||
if (normalized === "localhost") return host === "localhost";
|
||||
return host.endsWith(`.${normalized}`);
|
||||
}
|
||||
|
||||
export function normalizeSameSite(value) {
|
||||
const normalized = String(value || "").trim().toLowerCase();
|
||||
if (normalized === "strict") return "Strict";
|
||||
if (normalized === "none") return "None";
|
||||
return "Lax";
|
||||
}
|
||||
|
||||
export function normalizeCookie(cookie) {
|
||||
if (!cookie?.name || !cookie?.domain) return null;
|
||||
return {
|
||||
path: "/",
|
||||
expires: -1,
|
||||
httpOnly: false,
|
||||
secure: false,
|
||||
sameSite: "Lax",
|
||||
...cookie,
|
||||
domain: String(cookie.domain).replace(/^\./, "").toLowerCase(),
|
||||
path: cookie.path || "/",
|
||||
expires:
|
||||
Number.isFinite(cookie.expires) || cookie.expires === -1
|
||||
? cookie.expires
|
||||
: Number(cookie.expires) || -1,
|
||||
httpOnly: Boolean(cookie.httpOnly),
|
||||
secure: Boolean(cookie.secure),
|
||||
sameSite: normalizeSameSite(cookie.sameSite),
|
||||
};
|
||||
}
|
||||
|
||||
function findCookieIndex(cookies, candidate) {
|
||||
return cookies.findIndex(
|
||||
(cookie) =>
|
||||
cookie.name === candidate.name &&
|
||||
String(cookie.domain || "").replace(/^\./, "").toLowerCase() === candidate.domain &&
|
||||
(cookie.path || "/") === candidate.path
|
||||
);
|
||||
}
|
||||
|
||||
export function cookieHeaderFor(urlString, cookies) {
|
||||
const url = new URL(urlString);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
return cookies
|
||||
.map(normalizeCookie)
|
||||
.filter((cookie) => {
|
||||
if (!cookie) return false;
|
||||
if (!domainMatches(url.hostname, cookie.domain)) return false;
|
||||
if (!url.pathname.startsWith(cookie.path || "/")) return false;
|
||||
if (cookie.secure && url.protocol !== "https:") return false;
|
||||
if (cookie.expires && cookie.expires !== -1 && cookie.expires < now) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.sort((left, right) => (right.path?.length || 0) - (left.path?.length || 0))
|
||||
.map((cookie) => `${cookie.name}=${cookie.value}`)
|
||||
.join("; ");
|
||||
}
|
||||
|
||||
export function splitSetCookieHeader(headerValue) {
|
||||
if (!headerValue) return [];
|
||||
const parts = [];
|
||||
let current = "";
|
||||
let inExpires = false;
|
||||
for (let index = 0; index < headerValue.length; index += 1) {
|
||||
const char = headerValue[index];
|
||||
const next = headerValue.slice(index);
|
||||
if (!inExpires && next.toLowerCase().startsWith("expires=")) {
|
||||
inExpires = true;
|
||||
}
|
||||
if (char === "," && !inExpires) {
|
||||
const trimmed = current.trim();
|
||||
if (trimmed) parts.push(trimmed);
|
||||
current = "";
|
||||
continue;
|
||||
}
|
||||
current += char;
|
||||
if (inExpires && char === ";") {
|
||||
inExpires = false;
|
||||
}
|
||||
}
|
||||
const trimmed = current.trim();
|
||||
if (trimmed) parts.push(trimmed);
|
||||
return parts;
|
||||
}
|
||||
|
||||
function defaultCookiePath(url) {
|
||||
const pathname = url.pathname || "/";
|
||||
if (!pathname.startsWith("/") || pathname === "/") return "/";
|
||||
const lastSlash = pathname.lastIndexOf("/");
|
||||
return lastSlash <= 0 ? "/" : pathname.slice(0, lastSlash);
|
||||
}
|
||||
|
||||
export function mergeCookiesFromHeader(state, urlString, headerValue) {
|
||||
const url = new URL(urlString);
|
||||
const parts = splitSetCookieHeader(headerValue);
|
||||
let changed = false;
|
||||
for (const part of parts) {
|
||||
const segments = part.split(";").map((segment) => segment.trim()).filter(Boolean);
|
||||
const [nameValue, ...attributes] = segments;
|
||||
const eqIndex = nameValue?.indexOf("=") ?? -1;
|
||||
if (eqIndex <= 0) continue;
|
||||
const name = nameValue.slice(0, eqIndex);
|
||||
const value = nameValue.slice(eqIndex + 1);
|
||||
const nextCookie = {
|
||||
name,
|
||||
value,
|
||||
domain: url.hostname.toLowerCase(),
|
||||
path: defaultCookiePath(url),
|
||||
expires: -1,
|
||||
httpOnly: false,
|
||||
secure: url.protocol === "https:",
|
||||
sameSite: "Lax",
|
||||
};
|
||||
for (const attribute of attributes) {
|
||||
const separator = attribute.indexOf("=");
|
||||
const rawKey = (separator >= 0 ? attribute.slice(0, separator) : attribute).trim();
|
||||
const rawValue = separator >= 0 ? attribute.slice(separator + 1).trim() : "";
|
||||
const key = rawKey.toLowerCase();
|
||||
if (key === "domain") nextCookie.domain = rawValue.replace(/^\./, "").toLowerCase() || url.hostname.toLowerCase();
|
||||
if (key === "path") nextCookie.path = rawValue || "/";
|
||||
if (key === "secure") nextCookie.secure = true;
|
||||
if (key === "httponly") nextCookie.httpOnly = true;
|
||||
if (key === "samesite") nextCookie.sameSite = normalizeSameSite(rawValue);
|
||||
if (key === "expires") {
|
||||
const seconds = Math.floor(new Date(rawValue).getTime() / 1000);
|
||||
nextCookie.expires = Number.isFinite(seconds) ? seconds : -1;
|
||||
}
|
||||
if (key === "max-age") {
|
||||
const maxAge = Number(rawValue);
|
||||
nextCookie.maxAge = maxAge;
|
||||
nextCookie.expires = Number.isFinite(maxAge)
|
||||
? Math.floor(Date.now() / 1000) + maxAge
|
||||
: nextCookie.expires;
|
||||
}
|
||||
}
|
||||
const normalized = normalizeCookie(nextCookie);
|
||||
if (!normalized) continue;
|
||||
const existingIndex = findCookieIndex(state.cookies, normalized);
|
||||
if (normalized.expires === 0 || normalized.maxAge === 0) {
|
||||
if (existingIndex >= 0) {
|
||||
state.cookies.splice(existingIndex, 1);
|
||||
changed = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (existingIndex >= 0) {
|
||||
const previous = JSON.stringify(normalizeCookie(state.cookies[existingIndex]));
|
||||
state.cookies[existingIndex] = { ...state.cookies[existingIndex], ...normalized };
|
||||
if (previous !== JSON.stringify(normalizeCookie(state.cookies[existingIndex]))) {
|
||||
changed = true;
|
||||
}
|
||||
} else {
|
||||
state.cookies.push(normalized);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
function getHeaderValue(headers, name) {
|
||||
if (!headers) return null;
|
||||
if (typeof headers.get === "function") {
|
||||
return headers.get(name) || headers.get(name.toLowerCase()) || headers.get(name.toUpperCase()) || null;
|
||||
}
|
||||
return headers[name] || headers[name.toLowerCase()] || headers[name.toUpperCase()] || null;
|
||||
}
|
||||
|
||||
export function extractClientBin(html, headers = null) {
|
||||
const fromHeader = getHeaderValue(headers, "x-bitech-svrbin");
|
||||
if (fromHeader) return fromHeader;
|
||||
return String(html || "").match(/oracle-jet-bundles\/[^.]+\.([a-z0-9]+)\//i)?.[1] || null;
|
||||
}
|
||||
|
||||
export function extractCsrfToken(value) {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
for (const [key, candidate] of Object.entries(value)) {
|
||||
if (typeof candidate === "string" && key.toLowerCase() === "csrftoken") {
|
||||
return candidate;
|
||||
}
|
||||
const nested = extractCsrfToken(candidate);
|
||||
if (nested) return nested;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function readJson(filePath, label) {
|
||||
let raw;
|
||||
try {
|
||||
raw = await fs.readFile(filePath, "utf8");
|
||||
} catch (error) {
|
||||
if (error?.code === "ENOENT") {
|
||||
throw new Error(
|
||||
`Could not find ${label} at ${filePath}. Run scripts/refresh-auth-direct.mjs first to create the session files.`
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch (error) {
|
||||
throw new Error(`Could not parse ${label} at ${filePath}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function persistSessionArtifacts(statePath, metaPath, state, meta, { ensureParentDir } = {}) {
|
||||
if (typeof ensureParentDir === "function") {
|
||||
await ensureParentDir(statePath);
|
||||
await ensureParentDir(metaPath);
|
||||
}
|
||||
await fs.writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
||||
await fs.writeFile(metaPath, `${JSON.stringify(meta, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
export function replaceObjectContents(target, nextValue) {
|
||||
for (const key of Object.keys(target)) {
|
||||
delete target[key];
|
||||
}
|
||||
Object.assign(target, nextValue);
|
||||
return target;
|
||||
}
|
||||
|
||||
export function inferRuntimeHome(statePath, metaPath) {
|
||||
const stateDir = statePath ? path.dirname(statePath) : null;
|
||||
const metaDir = metaPath ? path.dirname(metaPath) : null;
|
||||
if (stateDir && metaDir && stateDir === metaDir) {
|
||||
return stateDir;
|
||||
}
|
||||
return stateDir || metaDir || null;
|
||||
}
|
||||
|
||||
export function isAuthRefreshRequiredError(error) {
|
||||
const message = String(error?.message || "");
|
||||
return [
|
||||
"Refresh auth state first",
|
||||
"Request redirected",
|
||||
"Session redirected to Oracle sign-in",
|
||||
"Oracle DV session restore redirected to Oracle sign-in",
|
||||
"Request redirected while restoring Oracle DV session",
|
||||
"Oracle DV session is not valid anymore",
|
||||
"Could not find auth state",
|
||||
"Could not find session metadata",
|
||||
"Session metadata at",
|
||||
"Expected JSON",
|
||||
].some((fragment) => message.includes(fragment));
|
||||
}
|
||||
30
scripts/lib/cli-shared.mjs
Normal file
30
scripts/lib/cli-shared.mjs
Normal file
@@ -0,0 +1,30 @@
|
||||
export function parseArgs(argv) {
|
||||
const args = {};
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const token = argv[index];
|
||||
if (!token.startsWith("--")) continue;
|
||||
const key = token.slice(2);
|
||||
const next = argv[index + 1];
|
||||
if (!next || next.startsWith("--")) {
|
||||
args[key] = "true";
|
||||
continue;
|
||||
}
|
||||
const valueParts = [next];
|
||||
let valueIndex = index + 2;
|
||||
while (valueIndex < argv.length && !argv[valueIndex].startsWith("--")) {
|
||||
valueParts.push(argv[valueIndex]);
|
||||
valueIndex += 1;
|
||||
}
|
||||
args[key] = valueParts.join(" ");
|
||||
index = valueIndex - 1;
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
export function splitCsv(value, fallback = []) {
|
||||
if (!value) return fallback;
|
||||
return value
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
340
scripts/lib/date-windows.mjs
Normal file
340
scripts/lib/date-windows.mjs
Normal file
@@ -0,0 +1,340 @@
|
||||
const ANNUAL_WEEK_START_MONTH_INDEX = 5;
|
||||
const ANNUAL_WEEK_START_DAY = 1;
|
||||
const CURRENT_PERIOD_TOKENS = new Set([
|
||||
"current",
|
||||
"current-week",
|
||||
"this-week",
|
||||
"week-current",
|
||||
"semana-atual",
|
||||
"week atual",
|
||||
]);
|
||||
const CURRENT_MONTH_TOKENS = new Set([
|
||||
"current",
|
||||
"current-month",
|
||||
"this-month",
|
||||
"month-current",
|
||||
"mes-atual",
|
||||
"mês-atual",
|
||||
"mes atual",
|
||||
"mês atual",
|
||||
]);
|
||||
const CURRENT_QUARTER_TOKENS = new Set([
|
||||
"current",
|
||||
"current-quarter",
|
||||
"this-quarter",
|
||||
"quarter-current",
|
||||
"quarter atual",
|
||||
"trimestre-atual",
|
||||
"trimestre atual",
|
||||
]);
|
||||
const CURRENT_FISCAL_YEAR_TOKENS = new Set([
|
||||
"current",
|
||||
"current-fy",
|
||||
"this-fy",
|
||||
"fy-current",
|
||||
"fy atual",
|
||||
"ano-fiscal-atual",
|
||||
"ano fiscal atual",
|
||||
]);
|
||||
|
||||
function quoteLiteral(value) {
|
||||
return `'${String(value).replace(/'/g, "''")}'`;
|
||||
}
|
||||
|
||||
function lastDayOfMonth(year, monthIndex) {
|
||||
return new Date(year, monthIndex + 1, 0);
|
||||
}
|
||||
|
||||
function formatIsoDate(date) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function addDays(date, days) {
|
||||
const next = new Date(date.getTime());
|
||||
next.setDate(next.getDate() + days);
|
||||
return next;
|
||||
}
|
||||
|
||||
function isLeapYear(year) {
|
||||
return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
|
||||
}
|
||||
|
||||
function isValidIsoCalendarDate(raw) {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(raw);
|
||||
if (!match) return false;
|
||||
const [, yearText, monthText, dayText] = match;
|
||||
const year = Number(yearText);
|
||||
const month = Number(monthText);
|
||||
const day = Number(dayText);
|
||||
if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) {
|
||||
return false;
|
||||
}
|
||||
if (month < 1 || month > 12) return false;
|
||||
const daysInMonth = [31, isLeapYear(year) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
||||
return day >= 1 && day <= daysInMonth[month - 1];
|
||||
}
|
||||
|
||||
function resolveDateInput(value, fallback) {
|
||||
const raw = String(value || fallback || "").trim();
|
||||
if (!raw) {
|
||||
throw new Error("Date input is required.");
|
||||
}
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) {
|
||||
if (!isValidIsoCalendarDate(raw)) {
|
||||
throw new Error(`Invalid calendar date "${raw}". Use a real date in YYYY-MM-DD format.`);
|
||||
}
|
||||
return { input: raw, isoDate: raw };
|
||||
}
|
||||
if (raw.toLowerCase() === "today") {
|
||||
return { input: raw, isoDate: formatIsoDate(new Date()) };
|
||||
}
|
||||
const relativeMatch = /^(?:today)?([+-]\d+)d$/i.exec(raw);
|
||||
if (relativeMatch) {
|
||||
const days = Number(relativeMatch[1]);
|
||||
return { input: raw, isoDate: formatIsoDate(addDays(new Date(), days)) };
|
||||
}
|
||||
throw new Error(
|
||||
`Unsupported date token "${raw}". Use YYYY-MM-DD, today, +7d, -3d, or today+7d.`
|
||||
);
|
||||
}
|
||||
|
||||
function resolveMonthInput(value) {
|
||||
const raw = String(value || "").trim();
|
||||
if (CURRENT_MONTH_TOKENS.has(raw.toLowerCase())) {
|
||||
const today = new Date();
|
||||
const first = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
const last = lastDayOfMonth(today.getFullYear(), today.getMonth());
|
||||
return {
|
||||
input: raw,
|
||||
fromDate: formatIsoDate(first),
|
||||
toDate: formatIsoDate(last),
|
||||
};
|
||||
}
|
||||
if (!/^\d{4}-\d{2}$/.test(raw)) {
|
||||
throw new Error(`Unsupported month token "${raw}". Use YYYY-MM, for example 2026-04.`);
|
||||
}
|
||||
const [yearText, monthText] = raw.split("-");
|
||||
const year = Number(yearText);
|
||||
const month = Number(monthText);
|
||||
if (!Number.isInteger(year) || !Number.isInteger(month) || month < 1 || month > 12) {
|
||||
throw new Error(`Unsupported month token "${raw}". Use YYYY-MM, for example 2026-04.`);
|
||||
}
|
||||
const first = new Date(year, month - 1, 1);
|
||||
const last = lastDayOfMonth(year, month - 1);
|
||||
return {
|
||||
input: raw,
|
||||
fromDate: formatIsoDate(first),
|
||||
toDate: formatIsoDate(last),
|
||||
};
|
||||
}
|
||||
|
||||
function fiscalQuarterToDateWindow(fiscalYear, quarter) {
|
||||
const normalizedFiscalYear = String(fiscalYear || "").trim().toUpperCase();
|
||||
const normalizedQuarter = String(quarter || "").trim().toUpperCase();
|
||||
const fiscalMatch = /^FY(\d{2,4})$/.exec(normalizedFiscalYear);
|
||||
const quarterMatch = /^Q([1-4])$/.exec(normalizedQuarter);
|
||||
if (!fiscalMatch || !quarterMatch) {
|
||||
throw new Error(`Unsupported fiscal quarter token "${fiscalYear} ${quarter}". Use FY26-Q4 or Q4 FY26.`);
|
||||
}
|
||||
const endYear = fiscalMatch[1].length === 2 ? 2000 + Number(fiscalMatch[1]) : Number(fiscalMatch[1]);
|
||||
const quarterNumber = Number(quarterMatch[1]);
|
||||
const startYear = endYear - 1;
|
||||
const quarterWindows = {
|
||||
1: { from: new Date(startYear, 5, 1), to: lastDayOfMonth(startYear, 7) },
|
||||
2: { from: new Date(startYear, 8, 1), to: lastDayOfMonth(startYear, 10) },
|
||||
3: { from: new Date(startYear, 11, 1), to: lastDayOfMonth(endYear, 1) },
|
||||
4: { from: new Date(endYear, 2, 1), to: lastDayOfMonth(endYear, 4) },
|
||||
};
|
||||
return {
|
||||
fiscalYear: normalizedFiscalYear,
|
||||
quarter: `Q${quarterNumber}`,
|
||||
fromDate: formatIsoDate(quarterWindows[quarterNumber].from),
|
||||
toDate: formatIsoDate(quarterWindows[quarterNumber].to),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveQuarterInput(value) {
|
||||
const raw = String(value || "").trim();
|
||||
const normalized = raw.toLowerCase();
|
||||
if (CURRENT_QUARTER_TOKENS.has(normalized)) {
|
||||
const today = new Date();
|
||||
const cycleYear = resolveWeekYearInput("");
|
||||
const monthIndex = today.getMonth();
|
||||
let quarter = "Q1";
|
||||
if (monthIndex >= 5 && monthIndex <= 7) {
|
||||
quarter = "Q1";
|
||||
} else if (monthIndex >= 8 && monthIndex <= 10) {
|
||||
quarter = "Q2";
|
||||
} else if (monthIndex === 11 || monthIndex <= 1) {
|
||||
quarter = "Q3";
|
||||
} else {
|
||||
quarter = "Q4";
|
||||
}
|
||||
const fiscalYear = `FY${String(cycleYear + 1).padStart(2, "0").slice(-2)}`;
|
||||
return {
|
||||
input: raw,
|
||||
...fiscalQuarterToDateWindow(fiscalYear, quarter),
|
||||
};
|
||||
}
|
||||
const compactMatch = /^(FY\d{2,4})[-\s]Q([1-4])$/i.exec(raw);
|
||||
if (compactMatch) {
|
||||
return {
|
||||
input: raw,
|
||||
...fiscalQuarterToDateWindow(compactMatch[1], `Q${compactMatch[2]}`),
|
||||
};
|
||||
}
|
||||
const reversedMatch = /^Q([1-4])[-\s]?(FY\d{2,4})$/i.exec(raw);
|
||||
if (reversedMatch) {
|
||||
return {
|
||||
input: raw,
|
||||
...fiscalQuarterToDateWindow(reversedMatch[2], `Q${reversedMatch[1]}`),
|
||||
};
|
||||
}
|
||||
throw new Error(`Unsupported quarter token "${raw}". Use FY26-Q4, Q4 FY26, or current-quarter.`);
|
||||
}
|
||||
|
||||
export function resolveWeekYearInput(value) {
|
||||
const raw = String(value || "").trim();
|
||||
if (!raw || CURRENT_FISCAL_YEAR_TOKENS.has(raw.toLowerCase())) {
|
||||
const today = new Date();
|
||||
const cycleStart = new Date(today.getFullYear(), ANNUAL_WEEK_START_MONTH_INDEX, ANNUAL_WEEK_START_DAY);
|
||||
return today >= cycleStart ? today.getFullYear() : today.getFullYear() - 1;
|
||||
}
|
||||
if (!/^\d{4}$/.test(raw)) {
|
||||
throw new Error(`Unsupported week year token "${raw}". Use the cycle start year YYYY, for example 2025.`);
|
||||
}
|
||||
return Number(raw);
|
||||
}
|
||||
|
||||
export function resolveAnnualWeekInput(value, weekYear) {
|
||||
const raw = String(value || "").trim().toLowerCase();
|
||||
if (CURRENT_PERIOD_TOKENS.has(raw)) {
|
||||
const year = resolveWeekYearInput(weekYear);
|
||||
const cycleStart = new Date(year, ANNUAL_WEEK_START_MONTH_INDEX, ANNUAL_WEEK_START_DAY);
|
||||
const cycleEnd = new Date(year + 1, ANNUAL_WEEK_START_MONTH_INDEX, 0);
|
||||
const today = new Date();
|
||||
const boundedToday =
|
||||
today < cycleStart ? cycleStart : today > cycleEnd ? cycleEnd : today;
|
||||
const week = Math.floor((boundedToday - cycleStart) / (7 * 24 * 60 * 60 * 1000)) + 1;
|
||||
return resolveAnnualWeekInput(`w${week}`, year);
|
||||
}
|
||||
const match = raw.match(/^(?:w|week\s*)?([1-9]|[1-4]\d|5[0-3])$/);
|
||||
if (!match) {
|
||||
throw new Error(`Unsupported week token "${value}". Use 1..53, w1..w53, or week 1..week 53.`);
|
||||
}
|
||||
const week = Number(match[1]);
|
||||
const year = resolveWeekYearInput(weekYear);
|
||||
const cycleStart = new Date(year, ANNUAL_WEEK_START_MONTH_INDEX, ANNUAL_WEEK_START_DAY);
|
||||
const cycleEnd = new Date(year + 1, ANNUAL_WEEK_START_MONTH_INDEX, 0);
|
||||
const weekStart = addDays(cycleStart, (week - 1) * 7);
|
||||
if (weekStart > cycleEnd) {
|
||||
throw new Error(`Week ${week} is outside the ${year}-${year + 1} cycle. Use 1..53.`);
|
||||
}
|
||||
const provisionalWeekEnd = addDays(weekStart, 6);
|
||||
const weekEnd = provisionalWeekEnd > cycleEnd ? cycleEnd : provisionalWeekEnd;
|
||||
return {
|
||||
input: String(value || "").trim(),
|
||||
week,
|
||||
weekLabel: `W${week}`,
|
||||
year,
|
||||
fromDate: formatIsoDate(weekStart),
|
||||
toDate: formatIsoDate(weekEnd),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveDateWindow(args, defaults = { from: "today", to: "+7d" }) {
|
||||
const hasSingleDate = Boolean(args.date);
|
||||
const hasMonth = Boolean(args.month);
|
||||
const hasWeek = Boolean(args.week);
|
||||
const hasQuarter = Boolean(args.quarter);
|
||||
const hasExplicitWindow = Boolean(args["from-date"] || args["to-date"]);
|
||||
if (
|
||||
[hasSingleDate, hasMonth, hasWeek, hasQuarter].filter(Boolean).length > 1 ||
|
||||
((hasSingleDate || hasMonth || hasWeek || hasQuarter) && hasExplicitWindow)
|
||||
) {
|
||||
throw new Error(
|
||||
"Use either --date, --month, --quarter, --week, or the pair --from-date/--to-date for date-window queries."
|
||||
);
|
||||
}
|
||||
if (hasSingleDate) {
|
||||
const exact = resolveDateInput(args.date);
|
||||
return {
|
||||
fromDateInput: exact.input,
|
||||
toDateInput: exact.input,
|
||||
fromDate: exact.isoDate,
|
||||
toDate: exact.isoDate,
|
||||
fromDateSql: `DATE ${quoteLiteral(exact.isoDate)}`,
|
||||
toDateSql: `DATE ${quoteLiteral(exact.isoDate)}`,
|
||||
preset: "single-date",
|
||||
};
|
||||
}
|
||||
if (hasMonth) {
|
||||
const month = resolveMonthInput(args.month);
|
||||
return {
|
||||
fromDateInput: month.input,
|
||||
toDateInput: month.input,
|
||||
fromDate: month.fromDate,
|
||||
toDate: month.toDate,
|
||||
fromDateSql: `DATE ${quoteLiteral(month.fromDate)}`,
|
||||
toDateSql: `DATE ${quoteLiteral(month.toDate)}`,
|
||||
preset: "calendar-month",
|
||||
};
|
||||
}
|
||||
if (hasQuarter) {
|
||||
const quarter = resolveQuarterInput(args.quarter);
|
||||
return {
|
||||
fromDateInput: quarter.input,
|
||||
toDateInput: quarter.input,
|
||||
fromDate: quarter.fromDate,
|
||||
toDate: quarter.toDate,
|
||||
fromDateSql: `DATE ${quoteLiteral(quarter.fromDate)}`,
|
||||
toDateSql: `DATE ${quoteLiteral(quarter.toDate)}`,
|
||||
preset: "fiscal-quarter",
|
||||
fiscalYear: quarter.fiscalYear,
|
||||
fiscalQuarter: quarter.quarter,
|
||||
};
|
||||
}
|
||||
if (hasWeek) {
|
||||
const week = resolveAnnualWeekInput(args.week, args["week-year"]);
|
||||
return {
|
||||
fromDateInput: week.input,
|
||||
toDateInput: week.input,
|
||||
fromDate: week.fromDate,
|
||||
toDate: week.toDate,
|
||||
fromDateSql: `DATE ${quoteLiteral(week.fromDate)}`,
|
||||
toDateSql: `DATE ${quoteLiteral(week.toDate)}`,
|
||||
preset: "annual-week",
|
||||
week: week.week,
|
||||
weekLabel: week.weekLabel,
|
||||
weekYear: week.year,
|
||||
};
|
||||
}
|
||||
const from = resolveDateInput(args["from-date"], defaults.from);
|
||||
const to = resolveDateInput(args["to-date"], defaults.to);
|
||||
if (from.isoDate > to.isoDate) {
|
||||
throw new Error(`Invalid date window: from-date ${from.isoDate} is after to-date ${to.isoDate}.`);
|
||||
}
|
||||
return {
|
||||
fromDateInput: from.input,
|
||||
toDateInput: to.input,
|
||||
fromDate: from.isoDate,
|
||||
toDate: to.isoDate,
|
||||
fromDateSql: `DATE ${quoteLiteral(from.isoDate)}`,
|
||||
toDateSql: `DATE ${quoteLiteral(to.isoDate)}`,
|
||||
preset: "date-window",
|
||||
};
|
||||
}
|
||||
|
||||
export function hasAnyDateWindowArgs(args) {
|
||||
return Boolean(
|
||||
args.date ||
|
||||
args.month ||
|
||||
args.quarter ||
|
||||
args.week ||
|
||||
args["from-date"] ||
|
||||
args["to-date"]
|
||||
);
|
||||
}
|
||||
200
scripts/lib/output-contract.mjs
Normal file
200
scripts/lib/output-contract.mjs
Normal file
@@ -0,0 +1,200 @@
|
||||
import fs from "node:fs/promises";
|
||||
import {
|
||||
ensureParentDir,
|
||||
resolveOutputFilePath,
|
||||
} from "./runtime-paths.mjs";
|
||||
|
||||
const NULL_SENTINEL = "__CODEX_NULL__";
|
||||
const MONETARY_FIELD_KEYS = new Set(["bookingValue", "opportunityValue", "workloadAmount", "pipelineAmount"]);
|
||||
const THOUSANDS_DISPLAY_KEYS = new Set([
|
||||
...MONETARY_FIELD_KEYS,
|
||||
"totalBookingValue",
|
||||
"totalOpportunityValue",
|
||||
"totalWorkloadAmount",
|
||||
"totalPipelineAmount",
|
||||
]);
|
||||
const THOUSANDS_DISPLAY_SOURCE_FIELDS = new Set(MONETARY_FIELD_KEYS);
|
||||
const ORACLE_MONTHS = {
|
||||
JAN: "01",
|
||||
FEB: "02",
|
||||
MAR: "03",
|
||||
APR: "04",
|
||||
MAY: "05",
|
||||
JUN: "06",
|
||||
JUL: "07",
|
||||
AUG: "08",
|
||||
SEP: "09",
|
||||
OCT: "10",
|
||||
NOV: "11",
|
||||
DEC: "12",
|
||||
};
|
||||
|
||||
export function buildWarnings({ requestedLimit, recordsCount, customSql, customSqlColumnsResolved = false }) {
|
||||
const warnings = [];
|
||||
if (requestedLimit > 0 && recordsCount >= requestedLimit) {
|
||||
warnings.push(
|
||||
`The query returned ${recordsCount} rows, which matches the requested limit (${requestedLimit}). Results may be truncated; rerun with a higher --limit if you need a complete extract.`
|
||||
);
|
||||
}
|
||||
if (customSql && !customSqlColumnsResolved) {
|
||||
warnings.push("Custom SQL records are returned as raw row arrays because the Oracle DV response does not guarantee stable field aliases.");
|
||||
}
|
||||
return warnings;
|
||||
}
|
||||
|
||||
export function parseNumericValue(value) {
|
||||
if (typeof value === "number") return Number.isFinite(value) ? value : 0;
|
||||
const normalized = String(value ?? "")
|
||||
.replace(/,/g, "")
|
||||
.trim();
|
||||
if (!normalized) return 0;
|
||||
const parsed = Number(normalized);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function isLikelyMonetaryFieldName(fieldName) {
|
||||
return /booking|workload|pipeline|opportunityvalue/i.test(String(fieldName || ""));
|
||||
}
|
||||
|
||||
function formatThousandsDisplayValue(value) {
|
||||
const thousands = Math.trunc(parseNumericValue(value) / 1000);
|
||||
const grouped = String(thousands).replace(/\B(?=(\d{3})+(?!\d))/g, ".");
|
||||
return `${grouped}k`;
|
||||
}
|
||||
|
||||
function formatDisplaySection(node, displayKeys = THOUSANDS_DISPLAY_KEYS) {
|
||||
if (Array.isArray(node)) {
|
||||
return node.map((item) => formatDisplaySection(item, displayKeys));
|
||||
}
|
||||
if (!node || typeof node !== "object") {
|
||||
return node;
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(node).map(([key, value]) => {
|
||||
if ((displayKeys.has(key) || isLikelyMonetaryFieldName(key)) && value !== null && value !== undefined && value !== "") {
|
||||
return [key, formatThousandsDisplayValue(value)];
|
||||
}
|
||||
return [key, formatDisplaySection(value, displayKeys)];
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function collectThousandsDisplayKeys(payload) {
|
||||
const displayKeys = new Set(THOUSANDS_DISPLAY_KEYS);
|
||||
for (const columnName of payload?.columns || []) {
|
||||
if (MONETARY_FIELD_KEYS.has(columnName) || isLikelyMonetaryFieldName(columnName)) {
|
||||
displayKeys.add(columnName);
|
||||
}
|
||||
}
|
||||
const querySpec = payload?.request?.query;
|
||||
if (querySpec && typeof querySpec === "object") {
|
||||
for (const item of [...(querySpec.select || []), ...(querySpec.measure || [])]) {
|
||||
if (!item || typeof item !== "object") continue;
|
||||
if (!THOUSANDS_DISPLAY_SOURCE_FIELDS.has(item.fieldName)) continue;
|
||||
if (item.outputName) displayKeys.add(item.outputName);
|
||||
}
|
||||
}
|
||||
|
||||
for (const inferredColumn of payload?.request?.inferredColumns || []) {
|
||||
if (MONETARY_FIELD_KEYS.has(inferredColumn) || isLikelyMonetaryFieldName(inferredColumn)) {
|
||||
displayKeys.add(inferredColumn);
|
||||
}
|
||||
}
|
||||
|
||||
return displayKeys;
|
||||
}
|
||||
|
||||
function rowsPulledFromApi(result) {
|
||||
return Array.isArray(result?.aResults) ? result.aResults.length : 0;
|
||||
}
|
||||
|
||||
function buildApiQueryMetric({
|
||||
label,
|
||||
result = null,
|
||||
rowsReturned = null,
|
||||
source = null,
|
||||
countedInTotal = true,
|
||||
} = {}) {
|
||||
const resolvedRowsReturned = Number.isFinite(rowsReturned) ? rowsReturned : rowsPulledFromApi(result);
|
||||
return {
|
||||
label: label || "query",
|
||||
rowsPulledFromApi: resolvedRowsReturned,
|
||||
source: source || null,
|
||||
countedInTotal: countedInTotal !== false,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildApiMetrics(entries = []) {
|
||||
const queries = entries
|
||||
.filter(Boolean)
|
||||
.map((entry) =>
|
||||
buildApiQueryMetric({
|
||||
label: entry.label,
|
||||
result: entry.result,
|
||||
rowsReturned: entry.rowsReturned,
|
||||
source: entry.source,
|
||||
countedInTotal: entry.countedInTotal,
|
||||
})
|
||||
);
|
||||
const totalRowsPulledFromApi = queries.reduce(
|
||||
(sum, query) => sum + (query.countedInTotal ? query.rowsPulledFromApi : 0),
|
||||
0
|
||||
);
|
||||
return {
|
||||
totalRowsPulledFromApi,
|
||||
queries,
|
||||
explanation: `Raw rows pulled from API: ${totalRowsPulledFromApi}.`,
|
||||
};
|
||||
}
|
||||
|
||||
export function preparePayloadForOutput(payload) {
|
||||
const displayKeys = collectThousandsDisplayKeys(payload);
|
||||
return {
|
||||
...payload,
|
||||
apiMetrics: payload?.apiMetrics || buildApiMetrics(),
|
||||
...(Object.prototype.hasOwnProperty.call(payload, "records")
|
||||
? { records: formatDisplaySection(payload.records, displayKeys) }
|
||||
: {}),
|
||||
...(payload.summary && typeof payload.summary === "object"
|
||||
? { summary: formatDisplaySection(payload.summary, displayKeys) }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeOracleDateString(value) {
|
||||
if (typeof value !== "string") return value;
|
||||
const match = /^(\d{2})-([A-Z]{3})-(\d{2}|\d{4})$/.exec(value.trim().toUpperCase());
|
||||
if (!match) return value;
|
||||
const [, day, monthToken, yearToken] = match;
|
||||
const month = ORACLE_MONTHS[monthToken];
|
||||
if (!month) return value;
|
||||
const year =
|
||||
yearToken.length === 4
|
||||
? yearToken
|
||||
: Number(yearToken) >= 70
|
||||
? `19${yearToken}`
|
||||
: `20${yearToken}`;
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
export function normalizeCell(value) {
|
||||
if (value === NULL_SENTINEL) return null;
|
||||
return normalizeOracleDateString(value ?? null);
|
||||
}
|
||||
|
||||
export function mapRows(fields, rows) {
|
||||
return rows.map((row) =>
|
||||
Object.fromEntries(fields.map((field, index) => [field, normalizeCell(row[index])]))
|
||||
);
|
||||
}
|
||||
|
||||
export function emitJson(args, payload, runtimeHome) {
|
||||
const json = JSON.stringify(preparePayloadForOutput(payload), null, 2);
|
||||
const outputFilePath = resolveOutputFilePath(args["output-file"], runtimeHome);
|
||||
if (outputFilePath) {
|
||||
return ensureParentDir(outputFilePath)
|
||||
.then(() => fs.writeFile(outputFilePath, `${json}\n`, "utf8"))
|
||||
.then(() => json);
|
||||
}
|
||||
return Promise.resolve(json);
|
||||
}
|
||||
351
scripts/lib/runtime-paths.mjs
Normal file
351
scripts/lib/runtime-paths.mjs
Normal file
@@ -0,0 +1,351 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export const SKILL_SLUG = "knowledge-one-view-cli";
|
||||
export const LEGACY_SKILL_SLUG = "oracle-sales-intelligence-direct";
|
||||
export const DISPLAY_RUNTIME_HOME = "<workspace>/knowledge-one-view-cli/runtime";
|
||||
export const DISPLAY_STATE_PATH = "<workspace>/knowledge-one-view-cli/runtime/auth-state.json";
|
||||
export const DISPLAY_META_PATH = "<workspace>/knowledge-one-view-cli/runtime/session-meta.json";
|
||||
export const DISPLAY_PROFILE_DIR = "<workspace>/knowledge-one-view-cli/runtime/chrome-profile";
|
||||
export const DISPLAY_CHROME_PATH = "auto-detected by platform";
|
||||
export const LEGACY_DISPLAY_HOME = `~/.codex/${LEGACY_SKILL_SLUG}`;
|
||||
|
||||
const helperDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
export const SKILL_HOME = path.resolve(helperDir, "..", "..");
|
||||
export const CODEX_HOME = path.join(os.homedir(), ".codex");
|
||||
export const LEGACY_HOME = path.join(os.homedir(), ".codex", LEGACY_SKILL_SLUG);
|
||||
export const LEGACY_STATE_PATH = path.join(LEGACY_HOME, "auth-state.json");
|
||||
export const LEGACY_META_PATH = path.join(LEGACY_HOME, "session-meta.json");
|
||||
export const LEGACY_PROFILE_DIR = path.join(LEGACY_HOME, "chrome-profile");
|
||||
|
||||
function pathExists(targetPath) {
|
||||
return Boolean(targetPath) && fs.existsSync(targetPath);
|
||||
}
|
||||
|
||||
function normalizeDir(targetPath) {
|
||||
return path.resolve(targetPath);
|
||||
}
|
||||
|
||||
function normalizeWorkspaceRuntimeHomeCandidate(targetPath) {
|
||||
const resolved = normalizeDir(targetPath);
|
||||
const runtimeName = path.basename(resolved).toLowerCase();
|
||||
if (runtimeName === "runtime") {
|
||||
return resolved;
|
||||
}
|
||||
if (!runtimeName.startsWith("runtime-")) {
|
||||
return resolved;
|
||||
}
|
||||
const parentDir = path.dirname(resolved);
|
||||
if (path.basename(parentDir) !== SKILL_SLUG) {
|
||||
return resolved;
|
||||
}
|
||||
// Keep one canonical runtime path in the workspace skill folder.
|
||||
return path.join(parentDir, "runtime");
|
||||
}
|
||||
|
||||
function looksLikeTemporaryFileName(fileName) {
|
||||
const lower = String(fileName || "").toLowerCase();
|
||||
return (
|
||||
lower.startsWith("tmp-") ||
|
||||
lower.startsWith("temp-") ||
|
||||
lower.startsWith("scratch-") ||
|
||||
lower.startsWith("intermediate-") ||
|
||||
lower.startsWith("render-output-") ||
|
||||
lower.includes(".tmp.") ||
|
||||
lower.endsWith(".tmp") ||
|
||||
lower.endsWith(".temp")
|
||||
);
|
||||
}
|
||||
|
||||
function isSameOrInside(targetPath, parentPath) {
|
||||
const relative = path.relative(parentPath, targetPath);
|
||||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
function looksLikeInstalledSkillHome(targetPath) {
|
||||
return ["SKILL.md", "scripts", "docs", "references", "tests"].some((name) =>
|
||||
pathExists(path.join(targetPath, name))
|
||||
);
|
||||
}
|
||||
|
||||
function looksLikeWorkspaceSkillHome(targetPath) {
|
||||
if (path.basename(targetPath) !== SKILL_SLUG) return false;
|
||||
if (looksLikeInstalledSkillHome(targetPath)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function candidateChromePaths() {
|
||||
if (process.platform === "win32") {
|
||||
return [
|
||||
process.env.ORACLE_SI_CHROME_PATH,
|
||||
process.env.LOCALAPPDATA
|
||||
? path.join(process.env.LOCALAPPDATA, "Google", "Chrome", "Application", "chrome.exe")
|
||||
: null,
|
||||
process.env["PROGRAMFILES"]
|
||||
? path.join(process.env["PROGRAMFILES"], "Google", "Chrome", "Application", "chrome.exe")
|
||||
: null,
|
||||
process.env["PROGRAMFILES(X86)"]
|
||||
? path.join(process.env["PROGRAMFILES(X86)"], "Google", "Chrome", "Application", "chrome.exe")
|
||||
: null,
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
if (process.platform === "darwin") {
|
||||
return [
|
||||
process.env.ORACLE_SI_CHROME_PATH,
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
path.join(os.homedir(), "Applications", "Google Chrome.app", "Contents", "MacOS", "Google Chrome"),
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
return [
|
||||
process.env.ORACLE_SI_CHROME_PATH,
|
||||
"/usr/bin/google-chrome",
|
||||
"/usr/bin/chromium",
|
||||
"/usr/bin/chromium-browser",
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
export function resolveWorkspaceRoot(candidatePath = process.cwd()) {
|
||||
const cwd = normalizeDir(candidatePath);
|
||||
const codexHome = normalizeDir(CODEX_HOME);
|
||||
if (isSameOrInside(cwd, codexHome)) {
|
||||
throw new Error(
|
||||
`Cannot infer a workspace root from ${cwd}. Run this command from the target workspace or pass --runtime-home (or ORACLE_SI_RUNTIME_HOME) explicitly. Refusing to create runtime files inside ${codexHome}.`
|
||||
);
|
||||
}
|
||||
if (looksLikeWorkspaceSkillHome(cwd)) {
|
||||
return path.dirname(cwd);
|
||||
}
|
||||
return cwd;
|
||||
}
|
||||
|
||||
export function buildRuntimeHomeFromWorkspace(workspaceRoot) {
|
||||
return path.join(normalizeDir(workspaceRoot), SKILL_SLUG, "runtime");
|
||||
}
|
||||
|
||||
export function buildLegacyRuntimeHomeFromWorkspace(workspaceRoot) {
|
||||
return path.join(normalizeDir(workspaceRoot), LEGACY_SKILL_SLUG, "runtime");
|
||||
}
|
||||
|
||||
export function resolveWorkspaceSkillHome(runtimeHome) {
|
||||
return path.dirname(resolveRuntimeHome(runtimeHome));
|
||||
}
|
||||
|
||||
export function resolveLegacyWorkspaceSkillHome(runtimeHome) {
|
||||
return path.dirname(resolveLegacyRuntimeHomeCandidate(runtimeHome));
|
||||
}
|
||||
|
||||
export function resolveRuntimeHome(explicitPath) {
|
||||
if (explicitPath) return normalizeWorkspaceRuntimeHomeCandidate(explicitPath);
|
||||
if (process.env.ORACLE_SI_RUNTIME_HOME) {
|
||||
return normalizeWorkspaceRuntimeHomeCandidate(process.env.ORACLE_SI_RUNTIME_HOME);
|
||||
}
|
||||
return buildRuntimeHomeFromWorkspace(resolveWorkspaceRoot());
|
||||
}
|
||||
|
||||
export function resolveLegacyRuntimeHomeCandidate(runtimeHome) {
|
||||
const resolvedRuntimeHome = resolveRuntimeHome(runtimeHome);
|
||||
const workspaceRoot = path.dirname(path.dirname(resolvedRuntimeHome));
|
||||
return buildLegacyRuntimeHomeFromWorkspace(workspaceRoot);
|
||||
}
|
||||
|
||||
export function resolveOutputDir(runtimeHome) {
|
||||
return path.join(resolveWorkspaceSkillHome(runtimeHome), "output");
|
||||
}
|
||||
|
||||
export function resolveOutputFilePath(explicitPath, runtimeHome) {
|
||||
if (!explicitPath) return null;
|
||||
if (path.isAbsolute(explicitPath)) return path.resolve(explicitPath);
|
||||
|
||||
const workspaceSkillHome = resolveWorkspaceSkillHome(runtimeHome);
|
||||
if (path.dirname(explicitPath) === ".") {
|
||||
// Keep user-facing/final outputs in output/, but route temporary/intermediate
|
||||
// file names directly into tmp/ to avoid polluting output/.
|
||||
const fileName = path.basename(explicitPath);
|
||||
if (looksLikeTemporaryFileName(fileName)) {
|
||||
return path.join(resolveTmpDir(runtimeHome), fileName);
|
||||
}
|
||||
return path.join(resolveOutputDir(runtimeHome), explicitPath);
|
||||
}
|
||||
|
||||
return path.join(workspaceSkillHome, explicitPath);
|
||||
}
|
||||
|
||||
export function resolveTmpDir(runtimeHome) {
|
||||
return path.join(resolveWorkspaceSkillHome(runtimeHome), "tmp");
|
||||
}
|
||||
|
||||
export function resolveTestRunsDir(runtimeHome) {
|
||||
return path.join(resolveOutputDir(runtimeHome), "test-runs");
|
||||
}
|
||||
|
||||
export function resolveTmpTestInputsDir(runtimeHome) {
|
||||
return resolveTmpDir(runtimeHome);
|
||||
}
|
||||
|
||||
export function resolveTmpRenderedSqlDir(runtimeHome) {
|
||||
return resolveTmpDir(runtimeHome);
|
||||
}
|
||||
|
||||
export function resolveTmpScratchDir(runtimeHome) {
|
||||
return resolveTmpDir(runtimeHome);
|
||||
}
|
||||
|
||||
export function resolveStateWritePath(options = {}) {
|
||||
if (options.explicitPath) return path.resolve(options.explicitPath);
|
||||
if (process.env.ORACLE_SI_STATE_PATH) return path.resolve(process.env.ORACLE_SI_STATE_PATH);
|
||||
return path.join(resolveRuntimeHome(options.runtimeHome), "auth-state.json");
|
||||
}
|
||||
|
||||
export function resolveMetaWritePath(options = {}) {
|
||||
if (options.explicitPath) return path.resolve(options.explicitPath);
|
||||
if (process.env.ORACLE_SI_META_PATH) return path.resolve(process.env.ORACLE_SI_META_PATH);
|
||||
return path.join(resolveRuntimeHome(options.runtimeHome), "session-meta.json");
|
||||
}
|
||||
|
||||
export function resolveProfileDir(options = {}) {
|
||||
if (options.explicitPath) return path.resolve(options.explicitPath);
|
||||
if (process.env.ORACLE_SI_PROFILE_DIR) return path.resolve(process.env.ORACLE_SI_PROFILE_DIR);
|
||||
return path.join(resolveRuntimeHome(options.runtimeHome), "chrome-profile");
|
||||
}
|
||||
|
||||
export function resolveStateReadPath(options = {}) {
|
||||
const preferred = resolveStateWritePath(options);
|
||||
if (pathExists(preferred)) return preferred;
|
||||
const legacyWorkspacePath = path.join(
|
||||
resolveLegacyRuntimeHomeCandidate(options.runtimeHome),
|
||||
"auth-state.json"
|
||||
);
|
||||
if (pathExists(legacyWorkspacePath)) return legacyWorkspacePath;
|
||||
return pathExists(LEGACY_STATE_PATH) ? LEGACY_STATE_PATH : preferred;
|
||||
}
|
||||
|
||||
export function resolveMetaReadPath(options = {}) {
|
||||
const preferred = resolveMetaWritePath(options);
|
||||
if (pathExists(preferred)) return preferred;
|
||||
const legacyWorkspacePath = path.join(
|
||||
resolveLegacyRuntimeHomeCandidate(options.runtimeHome),
|
||||
"session-meta.json"
|
||||
);
|
||||
if (pathExists(legacyWorkspacePath)) return legacyWorkspacePath;
|
||||
return pathExists(LEGACY_META_PATH) ? LEGACY_META_PATH : preferred;
|
||||
}
|
||||
|
||||
export function resolveChromePath(explicitPath) {
|
||||
if (explicitPath) return path.resolve(explicitPath);
|
||||
if (process.env.ORACLE_SI_CHROME_PATH) return path.resolve(process.env.ORACLE_SI_CHROME_PATH);
|
||||
|
||||
for (const candidate of candidateChromePaths()) {
|
||||
if (pathExists(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return candidateChromePaths()[0] || "google-chrome";
|
||||
}
|
||||
|
||||
export async function ensureDir(dirPath) {
|
||||
await fs.promises.mkdir(dirPath, { recursive: true });
|
||||
}
|
||||
|
||||
export async function ensureParentDir(filePath) {
|
||||
await ensureDir(path.dirname(filePath));
|
||||
}
|
||||
|
||||
async function migrateLegacyTmpSubdirs(tmpDir) {
|
||||
const legacySubdirs = ["test-inputs", "rendered-sql", "scratch"];
|
||||
const moveFileToTmpRoot = async (sourcePath) => {
|
||||
const ext = path.extname(sourcePath);
|
||||
const base = path.basename(sourcePath, ext);
|
||||
let candidateName = path.basename(sourcePath);
|
||||
let targetPath = path.join(tmpDir, candidateName);
|
||||
let suffix = 1;
|
||||
|
||||
while (pathExists(targetPath)) {
|
||||
candidateName = `${base}-migrated-${suffix}${ext}`;
|
||||
targetPath = path.join(tmpDir, candidateName);
|
||||
suffix += 1;
|
||||
}
|
||||
|
||||
await fs.promises.rename(sourcePath, targetPath);
|
||||
};
|
||||
|
||||
const walkAndMoveFiles = async (currentDir) => {
|
||||
const entries = await fs.promises.readdir(currentDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const absolutePath = path.join(currentDir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await walkAndMoveFiles(absolutePath);
|
||||
continue;
|
||||
}
|
||||
await moveFileToTmpRoot(absolutePath);
|
||||
}
|
||||
};
|
||||
|
||||
for (const subdirName of legacySubdirs) {
|
||||
const legacyDir = path.join(tmpDir, subdirName);
|
||||
if (!pathExists(legacyDir)) continue;
|
||||
await walkAndMoveFiles(legacyDir);
|
||||
await fs.promises.rm(legacyDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function migrateTemporaryOutputFiles(outputDir, tmpDir) {
|
||||
if (!pathExists(outputDir)) return;
|
||||
const entries = await fs.promises.readdir(outputDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) continue;
|
||||
if (!looksLikeTemporaryFileName(entry.name)) continue;
|
||||
const sourcePath = path.join(outputDir, entry.name);
|
||||
let candidateName = entry.name;
|
||||
let targetPath = path.join(tmpDir, candidateName);
|
||||
let suffix = 1;
|
||||
const ext = path.extname(entry.name);
|
||||
const base = path.basename(entry.name, ext);
|
||||
while (pathExists(targetPath)) {
|
||||
candidateName = `${base}-migrated-${suffix}${ext}`;
|
||||
targetPath = path.join(tmpDir, candidateName);
|
||||
suffix += 1;
|
||||
}
|
||||
await fs.promises.rename(sourcePath, targetPath);
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureRuntimeTree(runtimeHome) {
|
||||
const resolvedRuntimeHome = resolveRuntimeHome(runtimeHome);
|
||||
const workspaceSkillHome = resolveWorkspaceSkillHome(resolvedRuntimeHome);
|
||||
const outputDir = resolveOutputDir(resolvedRuntimeHome);
|
||||
const testRunsDir = resolveTestRunsDir(resolvedRuntimeHome);
|
||||
const tmpDir = resolveTmpDir(resolvedRuntimeHome);
|
||||
const tmpTestInputsDir = resolveTmpTestInputsDir(resolvedRuntimeHome);
|
||||
const tmpRenderedSqlDir = resolveTmpRenderedSqlDir(resolvedRuntimeHome);
|
||||
const tmpScratchDir = resolveTmpScratchDir(resolvedRuntimeHome);
|
||||
const profileDir = resolveProfileDir({ runtimeHome: resolvedRuntimeHome });
|
||||
|
||||
await ensureDir(workspaceSkillHome);
|
||||
await ensureDir(resolvedRuntimeHome);
|
||||
await ensureDir(outputDir);
|
||||
await ensureDir(testRunsDir);
|
||||
await ensureDir(tmpDir);
|
||||
await migrateLegacyTmpSubdirs(tmpDir);
|
||||
await migrateTemporaryOutputFiles(outputDir, tmpDir);
|
||||
await ensureDir(tmpTestInputsDir);
|
||||
await ensureDir(tmpRenderedSqlDir);
|
||||
await ensureDir(tmpScratchDir);
|
||||
await ensureDir(profileDir);
|
||||
|
||||
return {
|
||||
workspaceSkillHome,
|
||||
runtimeHome: resolvedRuntimeHome,
|
||||
outputDir,
|
||||
testRunsDir,
|
||||
tmpDir,
|
||||
tmpTestInputsDir,
|
||||
tmpRenderedSqlDir,
|
||||
tmpScratchDir,
|
||||
profileDir,
|
||||
};
|
||||
}
|
||||
1201
scripts/refresh-auth-direct.mjs
Normal file
1201
scripts/refresh-auth-direct.mjs
Normal file
File diff suppressed because it is too large
Load Diff
295
scripts/refresh-auth-state.mjs
Normal file
295
scripts/refresh-auth-state.mjs
Normal file
@@ -0,0 +1,295 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import {
|
||||
DISPLAY_CHROME_PATH,
|
||||
DISPLAY_META_PATH,
|
||||
DISPLAY_RUNTIME_HOME,
|
||||
DISPLAY_PROFILE_DIR,
|
||||
DISPLAY_STATE_PATH,
|
||||
ensureDir,
|
||||
ensureParentDir,
|
||||
ensureRuntimeTree,
|
||||
resolveChromePath,
|
||||
resolveMetaWritePath,
|
||||
resolveProfileDir,
|
||||
resolveRuntimeHome,
|
||||
resolveStateWritePath,
|
||||
} from "./lib/runtime-paths.mjs";
|
||||
import { parseArgs } from "./lib/cli-shared.mjs";
|
||||
import { extractClientBin } from "./lib/auth-shared.mjs";
|
||||
|
||||
const DEFAULT_URL =
|
||||
"https://salesintelligence-dv.oracle.com/ui/dv/ui/project.jsp?pageid=visualAnalyzer&reportmode=presentation&reportpath=%2F%40Catalog%2Fshared%2FTeam%20Sharing%20Area%2FLAD%2FLAD%20Sales%20Operations%2FLAD%20Production%2FLAD%20Knowledge%20One%20View";
|
||||
const scriptsDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const skillHome = path.resolve(scriptsDir, "..");
|
||||
const skillsHome = path.resolve(skillHome, "..");
|
||||
|
||||
function usage() {
|
||||
return `Refresh Oracle Sales Intelligence auth by opening Chrome.
|
||||
|
||||
This is an optional development and troubleshooting helper. Daily use should prefer scripts/refresh-auth-direct.mjs.
|
||||
|
||||
Usage:
|
||||
node scripts/refresh-auth-state.mjs [options]
|
||||
|
||||
Options:
|
||||
--runtime-home <dir> Override workspace runtime root (default: ${DISPLAY_RUNTIME_HOME})
|
||||
--state-path <file> Override auth-state.json output path (default: ${DISPLAY_STATE_PATH})
|
||||
--meta-path <file> Override session-meta.json output path (default: ${DISPLAY_META_PATH})
|
||||
--profile-dir <dir> Override Chrome profile directory (default: ${DISPLAY_PROFILE_DIR})
|
||||
--chrome-path <file> Override Chrome executable path (default: ${DISPLAY_CHROME_PATH})
|
||||
--url <url> Target Oracle DV workbook URL
|
||||
--timeout-ms <ms> Wait time for authenticated Oracle DV page (default: 600000)
|
||||
--help Show this help
|
||||
`;
|
||||
}
|
||||
|
||||
async function pathExists(targetPath) {
|
||||
try {
|
||||
await fs.access(targetPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function uniquePaths(values) {
|
||||
const seen = new Set();
|
||||
const result = [];
|
||||
for (const value of values.filter(Boolean)) {
|
||||
const resolved = path.resolve(value);
|
||||
if (seen.has(resolved)) continue;
|
||||
seen.add(resolved);
|
||||
result.push(resolved);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function bundledRuntimePackageCandidates(packageName) {
|
||||
const execDir = path.dirname(process.execPath || "");
|
||||
const envNodeModules = [
|
||||
process.env.CODEX_NODE_MODULES,
|
||||
process.env.CODEX_BUNDLED_NODE_MODULES,
|
||||
process.env.NODE_PATH,
|
||||
].filter(Boolean);
|
||||
|
||||
const packageJsonCandidates = [];
|
||||
for (const nodeModulesPath of envNodeModules) {
|
||||
for (const basePath of String(nodeModulesPath).split(path.delimiter)) {
|
||||
if (!basePath) continue;
|
||||
packageJsonCandidates.push(path.join(basePath, packageName, "package.json"));
|
||||
}
|
||||
}
|
||||
|
||||
if (process.env.CODEX_PRIMARY_RUNTIME_HOME) {
|
||||
packageJsonCandidates.push(
|
||||
path.join(
|
||||
process.env.CODEX_PRIMARY_RUNTIME_HOME,
|
||||
"dependencies",
|
||||
"node",
|
||||
"node_modules",
|
||||
packageName,
|
||||
"package.json",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
packageJsonCandidates.push(
|
||||
path.join(execDir, "node_modules", packageName, "package.json"),
|
||||
path.join(execDir, "..", "node_modules", packageName, "package.json"),
|
||||
path.join(execDir, "..", "..", "node_modules", packageName, "package.json"),
|
||||
path.join(
|
||||
os.homedir(),
|
||||
".cache",
|
||||
"codex-runtimes",
|
||||
"codex-primary-runtime",
|
||||
"dependencies",
|
||||
"node",
|
||||
"node_modules",
|
||||
packageName,
|
||||
"package.json",
|
||||
),
|
||||
);
|
||||
|
||||
return uniquePaths(packageJsonCandidates);
|
||||
}
|
||||
|
||||
async function loadPlaywright() {
|
||||
const packageNames = ["playwright", "playwright-core"];
|
||||
|
||||
for (const packageName of packageNames) {
|
||||
try {
|
||||
return {
|
||||
runtime: await import(packageName),
|
||||
sourceLabel: `local-module:${packageName}`,
|
||||
};
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const sharedSkillPackageJson = path.join(skillsHome, "playwright", "package.json");
|
||||
if (await pathExists(sharedSkillPackageJson)) {
|
||||
const requireFromSharedSkill = createRequire(sharedSkillPackageJson);
|
||||
for (const packageName of packageNames) {
|
||||
try {
|
||||
return {
|
||||
runtime: requireFromSharedSkill(packageName),
|
||||
sourceLabel: `shared-skill:${packageName}`,
|
||||
};
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
for (const packageName of packageNames) {
|
||||
for (const packageJsonPath of bundledRuntimePackageCandidates(packageName)) {
|
||||
if (!(await pathExists(packageJsonPath))) continue;
|
||||
try {
|
||||
const requireFromBundledRuntime = createRequire(packageJsonPath);
|
||||
return {
|
||||
runtime: requireFromBundledRuntime(packageName),
|
||||
sourceLabel: `bundled-runtime:${packageJsonPath}`,
|
||||
};
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
[
|
||||
"Could not load Playwright for the browser auth helper.",
|
||||
"Tried local module resolution, ~/.codex/skills/playwright, and bundled Codex runtime locations.",
|
||||
"Install a shared Playwright runtime under ~/.codex/skills/playwright or run this helper from a Codex environment with bundled node_modules.",
|
||||
].join(" "),
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureChromeExecutable(chromePath) {
|
||||
try {
|
||||
await fs.access(chromePath);
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Could not find a Chrome executable at ${chromePath}. Pass --chrome-path or set ORACLE_SI_CHROME_PATH to a valid Chrome installation.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function main(argv = process.argv.slice(2)) {
|
||||
const args = parseArgs(argv);
|
||||
if (args.help === "true") {
|
||||
console.log(usage());
|
||||
return;
|
||||
}
|
||||
const runtimeHome = resolveRuntimeHome(args["runtime-home"]);
|
||||
await ensureRuntimeTree(runtimeHome);
|
||||
const statePath = resolveStateWritePath({ explicitPath: args["state-path"], runtimeHome });
|
||||
const metaPath = resolveMetaWritePath({ explicitPath: args["meta-path"], runtimeHome });
|
||||
const profileDir = resolveProfileDir({ explicitPath: args["profile-dir"], runtimeHome });
|
||||
const chromePath = resolveChromePath(args["chrome-path"]);
|
||||
const targetUrl = args.url || process.env.ORACLE_SI_TARGET_URL || DEFAULT_URL;
|
||||
const timeoutMs = Number(args["timeout-ms"] || 10 * 60 * 1000);
|
||||
|
||||
await ensureChromeExecutable(chromePath);
|
||||
const { runtime: playwrightRuntime, sourceLabel } = await loadPlaywright();
|
||||
const { chromium } = playwrightRuntime;
|
||||
|
||||
await ensureParentDir(statePath);
|
||||
await ensureParentDir(metaPath);
|
||||
await ensureDir(profileDir);
|
||||
|
||||
console.log(`Opening Chrome profile at ${profileDir}`);
|
||||
console.log(`Using browser automation runtime: ${sourceLabel}`);
|
||||
console.log("Complete Oracle login in the opened window if prompted.");
|
||||
|
||||
const context = await chromium.launchPersistentContext(profileDir, {
|
||||
executablePath: chromePath,
|
||||
headless: false,
|
||||
ignoreHTTPSErrors: true,
|
||||
viewport: { width: 1600, height: 900 },
|
||||
});
|
||||
|
||||
let headerMeta = null;
|
||||
const requestListener = (request) => {
|
||||
if (headerMeta) return;
|
||||
if (!request.url().includes("/ui/dv/ui/api/")) return;
|
||||
const headers = request.headers();
|
||||
const csrfToken = headers["x-csrf-token"] || null;
|
||||
const clientBin = headers["x-bitech-clientbin"] || null;
|
||||
if (!csrfToken || !clientBin) return;
|
||||
headerMeta = {
|
||||
xCsrfToken: csrfToken,
|
||||
xBitechClientbin: clientBin,
|
||||
referer: headers.referer || targetUrl,
|
||||
targetUrl,
|
||||
capturedAt: new Date().toISOString(),
|
||||
};
|
||||
};
|
||||
context.on("request", requestListener);
|
||||
|
||||
try {
|
||||
const page = context.pages()[0] ?? (await context.newPage());
|
||||
await page.goto(targetUrl, { waitUntil: "domcontentloaded", timeout: 120000 });
|
||||
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
await page.waitForTimeout(2000);
|
||||
const url = page.url();
|
||||
const title = await page.title().catch(() => "");
|
||||
console.log(`Waiting for Oracle Analytics session: ${url}`);
|
||||
|
||||
const authenticated =
|
||||
url.startsWith("https://salesintelligence-dv.oracle.com/") &&
|
||||
!url.includes("signon.oracle.com") &&
|
||||
!url.includes("signon-int.oracle.com") &&
|
||||
/Oracle Analytics|LAD Knowledge One View/i.test(title || url);
|
||||
|
||||
if (!authenticated) continue;
|
||||
|
||||
const html = await page.content();
|
||||
const fallbackClientBin = extractClientBin(html);
|
||||
|
||||
if (!headerMeta) {
|
||||
await page.reload({ waitUntil: "domcontentloaded" }).catch(() => {});
|
||||
await page.waitForTimeout(4000);
|
||||
}
|
||||
|
||||
if (!headerMeta?.xBitechClientbin && fallbackClientBin) {
|
||||
headerMeta = {
|
||||
...(headerMeta || {}),
|
||||
xCsrfToken: headerMeta?.xCsrfToken || null,
|
||||
xBitechClientbin: fallbackClientBin,
|
||||
referer: headerMeta?.referer || targetUrl,
|
||||
targetUrl,
|
||||
capturedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
if (!headerMeta?.xCsrfToken || !headerMeta?.xBitechClientbin) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const state = await context.storageState();
|
||||
await fs.writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
||||
await fs.writeFile(metaPath, `${JSON.stringify(headerMeta, null, 2)}\n`, "utf8");
|
||||
|
||||
console.log(`Saved auth state to ${statePath}`);
|
||||
console.log(`Saved session metadata to ${metaPath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for an authenticated Oracle DV page after ${timeoutMs}ms.`);
|
||||
} finally {
|
||||
context.off("request", requestListener);
|
||||
await context.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
const entryUrl = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : null;
|
||||
if (import.meta.url === entryUrl) {
|
||||
main().catch((error) => {
|
||||
console.error(error.message);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
92
scripts/run-sales-intelligence-sql.mjs
Normal file
92
scripts/run-sales-intelligence-sql.mjs
Normal file
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "node:fs/promises";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const fetchScriptPath = path.join(scriptDir, "fetch-sales-intelligence.mjs");
|
||||
|
||||
function printUsage() {
|
||||
console.log(`Usage:
|
||||
node "${path.resolve(process.argv[1] || "run-sales-intelligence-sql.mjs")}" --sql-file <file> [fetch args...]
|
||||
node "${path.resolve(process.argv[1] || "run-sales-intelligence-sql.mjs")}" --stdin [fetch args...]
|
||||
|
||||
Examples:
|
||||
node "${path.resolve(process.argv[1] || "run-sales-intelligence-sql.mjs")}" --sql-file query.sql --limit 200
|
||||
Get-Content query.sql | node "${path.resolve(process.argv[1] || "run-sales-intelligence-sql.mjs")}" --stdin --render-sql
|
||||
|
||||
Notes:
|
||||
- This helper preserves complex logical SQL that is awkward to pass through PowerShell quoting.
|
||||
- All flags other than --sql-file / --stdin / --help are passed through to fetch-sales-intelligence.mjs.
|
||||
`);
|
||||
}
|
||||
|
||||
async function readStdin() {
|
||||
const chunks = [];
|
||||
for await (const chunk of process.stdin) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
return Buffer.concat(chunks).toString("utf8");
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
let sqlFilePath = null;
|
||||
let useStdin = false;
|
||||
const passthroughArgs = [];
|
||||
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
if (arg === "--help") {
|
||||
printUsage();
|
||||
process.exit(0);
|
||||
}
|
||||
if (arg === "--sql-file") {
|
||||
sqlFilePath = args[index + 1];
|
||||
index += 1;
|
||||
if (!sqlFilePath) {
|
||||
console.error("Missing value for --sql-file");
|
||||
process.exit(1);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (arg === "--stdin") {
|
||||
useStdin = true;
|
||||
continue;
|
||||
}
|
||||
passthroughArgs.push(arg);
|
||||
}
|
||||
|
||||
if (sqlFilePath && useStdin) {
|
||||
console.error("Use either --sql-file or --stdin, not both.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!sqlFilePath && !useStdin) {
|
||||
console.error("One of --sql-file or --stdin is required.");
|
||||
printUsage();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const sql = useStdin ? await readStdin() : await fs.readFile(path.resolve(sqlFilePath), "utf8");
|
||||
if (!sql.trim()) {
|
||||
console.error("SQL input is empty.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const stdout = execFileSync(process.execPath, [fetchScriptPath, "--sql", sql, ...passthroughArgs], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
process.stdout.write(stdout);
|
||||
} catch (error) {
|
||||
if (typeof error.stdout === "string" && error.stdout.length > 0) {
|
||||
process.stdout.write(error.stdout);
|
||||
}
|
||||
if (typeof error.stderr === "string" && error.stderr.length > 0) {
|
||||
process.stderr.write(error.stderr);
|
||||
}
|
||||
process.exit(typeof error.status === "number" ? error.status : 1);
|
||||
}
|
||||
30
tests/README.md
Normal file
30
tests/README.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# Knowledge One View CLI Tests
|
||||
|
||||
This folder keeps the skill tests intentionally small and executable.
|
||||
|
||||
The suite covers only two themes:
|
||||
|
||||
1. `functions.test.mjs` checks local functions and output contracts.
|
||||
2. `sales-prompts.test.mjs` checks whether common sales prompts map to quick, documented CLI commands for Oracle DV.
|
||||
|
||||
The tests read `SKILL.md` and `references/*.md` as the source of truth. They should not keep a parallel copy of long parameter lists, aliases, defaults, or week mappings.
|
||||
|
||||
Run offline tests:
|
||||
|
||||
```sh
|
||||
node tests/run.mjs
|
||||
```
|
||||
|
||||
Run live backend checks:
|
||||
|
||||
```sh
|
||||
KNOWLEDGE_ONE_VIEW_LIVE=1 \
|
||||
ORACLE_SI_RUNTIME_HOME=/path/to/workspace/knowledge-one-view-cli/runtime \
|
||||
node tests/run.mjs
|
||||
```
|
||||
|
||||
Offline mode does not call Oracle DV. Live mode starts with a no-cache session-context read, then runs prompt-sized backend checks. Prompts that should be org-wide commercial reads assert the primary source from `references/api-map.md`, reject fallback to `DV - SE Team`, and use larger live limits when row volume is part of the expected behavior.
|
||||
|
||||
Every test invocation of `fetch-sales-intelligence.mjs` is forced through `--no-cache`.
|
||||
|
||||
When `ORACLE_SI_RUNTIME_HOME` is set, the tests copy `auth-state.json` and `session-meta.json` into a temporary runtime first, so live checks do not rewrite the source runtime. Set `ORACLE_SI_TEST_RUNTIME_HOME` when you intentionally want to provide a writable test runtime.
|
||||
597
tests/functions.test.mjs
Normal file
597
tests/functions.test.mjs
Normal file
@@ -0,0 +1,597 @@
|
||||
import test, { after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import {
|
||||
catalogSaysApiMetricsAreRawRows,
|
||||
catalogSaysCurrentWorkloadDefaults,
|
||||
catalogSaysPreserveMixedRows,
|
||||
catalogSaysUseRevenueLineCloseDate,
|
||||
parseDefaultWeekYear,
|
||||
parseCurrentWorkloadDefaults,
|
||||
parsePrimaryCommercialSubjectArea,
|
||||
parseSaAttachAliases,
|
||||
parseSaAttachDateWindowField,
|
||||
parseSaAttachDefaults,
|
||||
parseWeekRow,
|
||||
readSkillCatalogs,
|
||||
} from "./helpers/catalogs.mjs";
|
||||
import { skillHome } from "./helpers/cli.mjs";
|
||||
|
||||
const catalogs = await readSkillCatalogs();
|
||||
const fetchModule = await import(
|
||||
pathToFileURL(path.join(skillHome, "scripts", "fetch-sales-intelligence.mjs")).href
|
||||
);
|
||||
|
||||
const functionTestRows = [];
|
||||
|
||||
function functionTest(id, title, details, fn) {
|
||||
test(title, async () => {
|
||||
const row = {
|
||||
id,
|
||||
teste: title,
|
||||
entrada: details.entrada,
|
||||
saida: details.saida || "",
|
||||
avaliacao: "PASS",
|
||||
};
|
||||
try {
|
||||
const output = await fn();
|
||||
if (output) row.saida = output;
|
||||
} catch (error) {
|
||||
row.avaliacao = `FAIL; ${error.message}`;
|
||||
throw error;
|
||||
} finally {
|
||||
functionTestRows.push(row);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
after((t) => {
|
||||
t.diagnostic(formatFunctionResultsTable(functionTestRows));
|
||||
});
|
||||
|
||||
functionTest(
|
||||
"auth-refresh-json-stdout",
|
||||
"automatic auth refresh notice is emitted on stderr",
|
||||
{
|
||||
entrada: "source scan for the automatic refresh notice in fetch-sales-intelligence.mjs",
|
||||
},
|
||||
async () => {
|
||||
const source = await fs.readFile(
|
||||
path.join(skillHome, "scripts", "fetch-sales-intelligence.mjs"),
|
||||
"utf8"
|
||||
);
|
||||
const notice = "Oracle DV session is missing or expired. Opening browser login to refresh auth...";
|
||||
assert.match(source, new RegExp(`console\\.error\\(${JSON.stringify(notice)}\\)`));
|
||||
assert.doesNotMatch(source, new RegExp(`console\\.log\\(${JSON.stringify(notice)}\\)`));
|
||||
|
||||
const originalLog = console.log;
|
||||
const originalError = console.error;
|
||||
const stdoutMessages = [];
|
||||
const stderrMessages = [];
|
||||
console.log = (...args) => stdoutMessages.push(args.join(" "));
|
||||
console.error = (...args) => stderrMessages.push(args.join(" "));
|
||||
try {
|
||||
await fetchModule.withConsoleLogRoutedToStderr(async () => {
|
||||
console.log("helper progress");
|
||||
});
|
||||
} finally {
|
||||
console.log = originalLog;
|
||||
console.error = originalError;
|
||||
}
|
||||
assert.deepEqual(stdoutMessages, []);
|
||||
assert.deepEqual(stderrMessages, ["helper progress"]);
|
||||
return "auto-refresh notice and imported helper progress use stderr, preserving JSON-only stdout";
|
||||
}
|
||||
);
|
||||
|
||||
functionTest(
|
||||
"date-window",
|
||||
"date-window functions follow documented shortcuts and week catalog",
|
||||
{
|
||||
entrada: "resolveDateWindow com --date, --month, --quarter, --week e combinação inválida",
|
||||
},
|
||||
() => {
|
||||
const exact = fetchModule.resolveDateWindow({ date: "2026-05-02" });
|
||||
assert.equal(exact.preset, "single-date");
|
||||
assert.equal(exact.fromDate, "2026-05-02");
|
||||
assert.equal(exact.toDate, "2026-05-02");
|
||||
|
||||
const leapDay = fetchModule.resolveDateWindow({ date: "2024-02-29" });
|
||||
assert.equal(leapDay.fromDate, "2024-02-29");
|
||||
assert.equal(leapDay.toDate, "2024-02-29");
|
||||
|
||||
assert.throws(
|
||||
() => fetchModule.resolveDateWindow({ date: "2026-99-99" }),
|
||||
/Invalid calendar date "2026-99-99"/
|
||||
);
|
||||
assert.throws(
|
||||
() => fetchModule.resolveDateWindow({ date: "2026-02-29" }),
|
||||
/Invalid calendar date "2026-02-29"/
|
||||
);
|
||||
|
||||
const apiaCheck = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
"--input-type=module",
|
||||
"-e",
|
||||
`
|
||||
const fetchModule = await import(${JSON.stringify(pathToFileURL(path.join(skillHome, "scripts", "fetch-sales-intelligence.mjs")).href)});
|
||||
const window = fetchModule.resolveDateWindow({ date: "2011-12-30" });
|
||||
console.log(window.fromDate);
|
||||
`,
|
||||
],
|
||||
{
|
||||
env: { ...process.env, TZ: "Pacific/Apia" },
|
||||
encoding: "utf8",
|
||||
}
|
||||
);
|
||||
assert.equal(apiaCheck.status, 0, apiaCheck.stderr);
|
||||
assert.equal(apiaCheck.stdout.trim(), "2011-12-30");
|
||||
|
||||
const month = fetchModule.resolveDateWindow({ month: "2026-04" });
|
||||
assert.equal(month.preset, "calendar-month");
|
||||
assert.equal(month.fromDate, "2026-04-01");
|
||||
assert.equal(month.toDate, "2026-04-30");
|
||||
|
||||
const quarter = fetchModule.resolveDateWindow({ quarter: "FY26-Q4" });
|
||||
assert.equal(quarter.preset, "fiscal-quarter");
|
||||
assert.equal(quarter.fiscalYear, "FY26");
|
||||
assert.equal(quarter.fiscalQuarter, "Q4");
|
||||
assert.equal(quarter.fromDate, "2026-03-01");
|
||||
assert.equal(quarter.toDate, "2026-05-31");
|
||||
|
||||
const weekYear = parseDefaultWeekYear(catalogs.scriptCatalog);
|
||||
const expectedWeek = parseWeekRow(catalogs.scriptCatalog, "W43");
|
||||
const week = fetchModule.resolveDateWindow({
|
||||
week: expectedWeek.weekLabel.toLowerCase(),
|
||||
"week-year": String(weekYear),
|
||||
});
|
||||
assert.equal(week.weekLabel, expectedWeek.weekLabel);
|
||||
assert.equal(week.fromDate, expectedWeek.fromDate);
|
||||
assert.equal(week.toDate, expectedWeek.toDate);
|
||||
|
||||
assert.throws(
|
||||
() => fetchModule.resolveDateWindow({ date: "2026-05-02", month: "2026-05" }),
|
||||
/Use either --date/
|
||||
);
|
||||
return `single=${exact.fromDate}; month=${month.fromDate}..${month.toDate}; quarter=${quarter.fromDate}..${quarter.toDate}; week=${week.fromDate}..${week.toDate}; invalid-combo rejected`;
|
||||
});
|
||||
|
||||
functionTest(
|
||||
"sa-attach-defaults",
|
||||
"SA Attach defaults and aliases come from initial-context-catalog",
|
||||
{
|
||||
entrada: "parseSaAttachDefaults/parseSaAttachAliases + resolveSaAttachFilters",
|
||||
},
|
||||
() => {
|
||||
const defaults = parseSaAttachDefaults(catalogs.initialContext);
|
||||
const aliases = parseSaAttachAliases(catalogs.initialContext);
|
||||
|
||||
const filters = fetchModule.resolveSaAttachFilters({}, {
|
||||
defaultStatuses: defaults.revenueLineStatus,
|
||||
});
|
||||
assert.equal(filters.cluster, defaults.cluster[0]);
|
||||
assert.deepEqual(filters.solutions, defaults.solution);
|
||||
assert.deepEqual(filters.statuses, defaults.revenueLineStatus);
|
||||
assert.deepEqual(filters.productLobs, defaults.executiveProductLob);
|
||||
assert.deepEqual(filters.revenueTypeGroups, defaults.revenueTypeGroup);
|
||||
assert.equal(filters.salesCreditType, defaults.salesCreditType[0]);
|
||||
assert.equal(filters.minOpportunityProbability, Number(defaults.minOpportunityProbability[0]));
|
||||
|
||||
const lobAlias = Object.entries(aliases).find(([, values]) => values.length > 1);
|
||||
const targetRevenueType = defaults.revenueTypeGroup.at(-1);
|
||||
const revenueTypeAlias = Object.entries(aliases).find(([, values]) => values.includes(targetRevenueType));
|
||||
assert.ok(lobAlias, "Expected at least one multi-value LOB alias in the catalog.");
|
||||
assert.ok(revenueTypeAlias, "Expected a revenue-type alias for the catalog default workload group.");
|
||||
|
||||
const aliasFilters = fetchModule.resolveSaAttachFilters(
|
||||
{
|
||||
"executive-product-lob": lobAlias[0],
|
||||
"revenue-type-group": revenueTypeAlias[0].split("|")[0],
|
||||
},
|
||||
{ defaultStatuses: defaults.revenueLineStatus }
|
||||
);
|
||||
assert.deepEqual(aliasFilters.productLobs, lobAlias[1]);
|
||||
assert.deepEqual(aliasFilters.revenueTypeGroups, revenueTypeAlias[1]);
|
||||
return `cluster=${filters.cluster}; solutions=${filters.solutions.length}; statuses=${filters.statuses.length}; aliases validated`;
|
||||
});
|
||||
|
||||
functionTest(
|
||||
"close-window-explicit-cluster",
|
||||
"close-window commercial SQL honors explicit cluster without SA Attach defaults",
|
||||
{
|
||||
entrada: "buildPrimaryOpportunityWhereClauses com cluster Mexico",
|
||||
},
|
||||
() => {
|
||||
const clauses = fetchModule.buildPrimaryOpportunityWhereClauses({
|
||||
fromDateSql: "DATE '2026-05-01'",
|
||||
toDateSql: "DATE '2026-05-31'",
|
||||
statuses: ["Open"],
|
||||
cluster: "Mexico",
|
||||
});
|
||||
|
||||
const sql = clauses.join("\n");
|
||||
assert.match(sql, /"Revenue Line Close Date" >= DATE '2026-05-01'/);
|
||||
assert.match(sql, /"Revenue Line Close Date" <= DATE '2026-05-31'/);
|
||||
assert.match(sql, /"Opportunity Status" IN \('Open'\)/);
|
||||
assert.match(sql, /"Cluster" = 'Mexico'/);
|
||||
assert.doesNotMatch(sql, /"Solution" IN/);
|
||||
assert.doesNotMatch(sql, /"Revenue Type Group" IN/);
|
||||
assert.doesNotMatch(sql, /"Sales Credit Type" =/);
|
||||
|
||||
const withoutCluster = fetchModule.buildPrimaryOpportunityWhereClauses({
|
||||
fromDateSql: "DATE '2026-05-01'",
|
||||
toDateSql: "DATE '2026-05-31'",
|
||||
statuses: ["Open"],
|
||||
}).join("\n");
|
||||
assert.doesNotMatch(withoutCluster, /"Cluster" =/);
|
||||
|
||||
return "explicit cluster is applied; absent cluster preserves existing unfiltered geography behavior";
|
||||
});
|
||||
|
||||
functionTest(
|
||||
"manager-resource-workload-explicit-cluster",
|
||||
"manager/resource workload SQL honors explicit cluster and defaults to Brazil",
|
||||
{
|
||||
entrada: "buildManagerResourceWorkloadSql com cluster Mexico e default",
|
||||
},
|
||||
() => {
|
||||
const mexicoSql = fetchModule.buildManagerResourceWorkloadSql({
|
||||
fromDateSql: "DATE '2026-05-01'",
|
||||
toDateSql: "DATE '2026-05-31'",
|
||||
cluster: "Mexico",
|
||||
});
|
||||
assert.match(mexicoSql, /"Cluster" = 'Mexico'/);
|
||||
|
||||
const defaultSql = fetchModule.buildManagerResourceWorkloadSql({
|
||||
fromDateSql: "DATE '2026-05-01'",
|
||||
toDateSql: "DATE '2026-05-31'",
|
||||
});
|
||||
assert.match(defaultSql, /"Cluster" = 'Brazil'/);
|
||||
|
||||
return "manager/resource workload cluster is explicit when provided and Brazil by default";
|
||||
});
|
||||
|
||||
functionTest(
|
||||
"coverage-limit-cap",
|
||||
"coverage enrichment limit never exceeds Oracle DV maximum",
|
||||
{
|
||||
entrada: "resolveCoverageLimit com limites baixo, normal e alto",
|
||||
},
|
||||
() => {
|
||||
assert.equal(fetchModule.resolveCoverageLimit(10), 5000);
|
||||
assert.equal(fetchModule.resolveCoverageLimit(1000), 8000);
|
||||
assert.equal(fetchModule.resolveCoverageLimit(5000), 10000);
|
||||
assert.equal(fetchModule.resolveCoverageLimit(10000), 10000);
|
||||
|
||||
return "coverage limit is bounded to 5,000..10,000";
|
||||
});
|
||||
|
||||
functionTest(
|
||||
"current-workload-defaults",
|
||||
"current workload defaults are documented and applied locally",
|
||||
{
|
||||
entrada: "applyCurrentWorkloadIntentDefaults para owner atual e month explícito",
|
||||
},
|
||||
() => {
|
||||
assert.equal(catalogSaysCurrentWorkloadDefaults(catalogs, "workloads-by-owner-current"), true);
|
||||
assert.equal(catalogSaysCurrentWorkloadDefaults(catalogs, "workloads-by-owner-manager-current"), true);
|
||||
const expectedDefaults = parseCurrentWorkloadDefaults(catalogs, "workloads-by-owner-current");
|
||||
|
||||
const defaults = fetchModule.applyCurrentWorkloadIntentDefaults({
|
||||
intent: "workloads-by-owner-current",
|
||||
"owner-email": "territory.owner@oracle.com",
|
||||
});
|
||||
assert.equal(defaults.quarter, expectedDefaults.quarter);
|
||||
assert.equal(defaults.solution, expectedDefaults.solution);
|
||||
assert.equal(defaults["revenue-type-group"], expectedDefaults.revenueTypeGroup);
|
||||
assert.equal(fetchModule.hasAnyDateWindowArgs(defaults), true);
|
||||
|
||||
const explicitMonth = fetchModule.applyCurrentWorkloadIntentDefaults({
|
||||
month: "2026-04",
|
||||
"owner-email": "territory.owner@oracle.com",
|
||||
});
|
||||
assert.equal(explicitMonth.month, "2026-04");
|
||||
assert.equal(explicitMonth.quarter, undefined);
|
||||
assert.equal(explicitMonth.solution, expectedDefaults.solution);
|
||||
return `quarter=${defaults.quarter}; solution=${defaults.solution}; revenueTypeGroup=${defaults["revenue-type-group"]}; explicitMonth preserved`;
|
||||
});
|
||||
|
||||
functionTest(
|
||||
"sa-attach-window-sql",
|
||||
"SA Attach window SQL uses the documented revenue-line close date field",
|
||||
{
|
||||
entrada: "buildSaAttachOpportunityWhereClauses com janela 2026-03-01..2026-05-31",
|
||||
},
|
||||
() => {
|
||||
assert.equal(catalogSaysUseRevenueLineCloseDate(catalogs), true);
|
||||
|
||||
const defaults = parseSaAttachDefaults(catalogs.initialContext);
|
||||
const dateField = parseSaAttachDateWindowField(catalogs);
|
||||
const dataset = parsePrimaryCommercialSubjectArea(catalogs.apiMap);
|
||||
const clauses = fetchModule.buildSaAttachOpportunityWhereClauses({
|
||||
dataset,
|
||||
fiscalYear: null,
|
||||
previousFiscalYear: null,
|
||||
fromDateSql: "DATE '2026-03-01'",
|
||||
toDateSql: "DATE '2026-05-31'",
|
||||
cluster: defaults.cluster[0],
|
||||
solutions: [defaults.solution[0]],
|
||||
statuses: defaults.revenueLineStatus,
|
||||
productLobs: [defaults.executiveProductLob[0]],
|
||||
revenueTypeGroups: [defaults.revenueTypeGroup.at(-1)],
|
||||
salesCreditType: defaults.salesCreditType[0],
|
||||
minOpportunityProbability: Number(defaults.minOpportunityProbability[0]),
|
||||
ownerEmail: "territory.owner@oracle.com",
|
||||
ownerManagerEmail: "territory.manager@oracle.com",
|
||||
forecastTypes: ["Forecast"],
|
||||
});
|
||||
const sql = clauses.join("\n");
|
||||
|
||||
assert.match(sql, new RegExp(escapeRegExp(dateField)));
|
||||
assert.match(sql, /DATE '2026-03-01'/);
|
||||
assert.match(sql, /DATE '2026-05-31'/);
|
||||
assert.match(sql, /Level 7 Territory Owner E-mail/);
|
||||
assert.match(sql, /Revenue Line Forecast Type Group/);
|
||||
assert.doesNotMatch(sql, /Revenue Line Close Year/);
|
||||
return `field=${dateField}; SQL inclui forecast/owner e não usa Revenue Line Close Year`;
|
||||
});
|
||||
|
||||
functionTest(
|
||||
"scope-sql",
|
||||
"scope-related SQL follows catalog-backed org-wide and my-team rules",
|
||||
{
|
||||
entrada: "renderViewSql para Presales Aux, Hours e srs-by-resource-email",
|
||||
},
|
||||
() => {
|
||||
const coverageOrg = fetchModule.renderViewSql("sa-attach-presales-coverage");
|
||||
const coverageTeam = fetchModule.renderViewSql("sa-attach-presales-coverage", { scope: "my-team" });
|
||||
const hoursOrg = fetchModule.renderViewSql("sa-attach-hours-by-opportunity");
|
||||
const hoursTeam = fetchModule.renderViewSql("sa-attach-hours-by-opportunity", { scope: "my-team" });
|
||||
const srTeam = fetchModule.renderViewSql("srs-by-resource-email", {
|
||||
resourceEmailSql: "'resource.member@oracle.com'",
|
||||
matchRole: "team",
|
||||
srStatuses: [],
|
||||
});
|
||||
const srLead = fetchModule.renderViewSql("srs-by-resource-email", {
|
||||
resourceEmailSql: "'resource.member@oracle.com'",
|
||||
matchRole: "lead",
|
||||
srStatuses: [],
|
||||
});
|
||||
|
||||
assert.match(catalogs.skill, /Presales Involvement Aux - V1.*org-wide/s);
|
||||
assert.doesNotMatch(coverageOrg, /SEC_ARIA_SI-SR-TEAM/);
|
||||
assert.match(coverageTeam, /SEC_ARIA_SI-SR-TEAM/);
|
||||
assert.doesNotMatch(hoursOrg, /SEC_ARIA_SI-SR-TEAM/);
|
||||
assert.match(hoursTeam, /SEC_ARIA_SI-SR-TEAM/);
|
||||
assert.match(srTeam, /SE Team Level 1 Email Address/);
|
||||
assert.match(srTeam, /SE Team Level 4 Email Address/);
|
||||
assert.match(srLead, /"Service Request"\."Lead Email" = 'resource.member@oracle.com'/);
|
||||
return "org-wide sem SEC_ARIA; my-team com SEC_ARIA; SR team usa levels 1..4; lead só quando explícito";
|
||||
});
|
||||
|
||||
functionTest(
|
||||
"current-team-summary",
|
||||
"current-team coverage summaries preserve unmatched commercial rows",
|
||||
{
|
||||
entrada: "summarizeCurrentTeamOpportunityCoverage com 2 oportunidades e 1 cobertura",
|
||||
},
|
||||
() => {
|
||||
assert.equal(catalogSaysPreserveMixedRows(catalogs), true);
|
||||
|
||||
const result = fetchModule.summarizeCurrentTeamOpportunityCoverage(
|
||||
[
|
||||
{
|
||||
opportunityId: "OPP-1",
|
||||
opportunityName: "Alpha",
|
||||
customerName: "Acme",
|
||||
opportunityStatus: "Open",
|
||||
closeDate: "2026-03-31",
|
||||
salesStage: "Develop",
|
||||
bookingValue: 100000,
|
||||
workloadAmount: 50000,
|
||||
},
|
||||
{
|
||||
opportunityId: "OPP-2",
|
||||
opportunityName: "Beta",
|
||||
customerName: "Bravo",
|
||||
opportunityStatus: "Open",
|
||||
closeDate: "2026-04-01",
|
||||
salesStage: "Close",
|
||||
bookingValue: 200000,
|
||||
workloadAmount: 75000,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
opportunityId: "OPP-1",
|
||||
seTeamLevel5EmailAddress: "engineer.one@oracle.com",
|
||||
opportunityHoursAttribute: 6,
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
assert.equal(result.records.length, 2);
|
||||
assert.equal(result.summary.totalWindowOpportunities, 2);
|
||||
assert.equal(result.summary.matchedOpportunities, 1);
|
||||
assert.equal(result.summary.unmatchedOpportunities, 1);
|
||||
assert.equal(result.summary.totalBookingValue, 300000);
|
||||
assert.equal(result.summary.totalWorkloadAmount, 125000);
|
||||
|
||||
const unmatched = result.records.find((record) => record.opportunityId === "OPP-2");
|
||||
assert.deepEqual(unmatched.teamMemberEmails, []);
|
||||
assert.equal(unmatched.coverageRows, 0);
|
||||
return `records=${result.records.length}; matched=${result.summary.matchedOpportunities}; unmatched=${result.summary.unmatchedOpportunities}; totalWorkload=${result.summary.totalWorkloadAmount}`;
|
||||
});
|
||||
|
||||
functionTest(
|
||||
"manager-resource-summary",
|
||||
"manager-resource workload summaries keep resource involvement details",
|
||||
{
|
||||
entrada: "summarizeOrgWideRevenueLineCoverage com cobertura Aux e cenário opcional com SR injetado",
|
||||
},
|
||||
() => {
|
||||
const defaultResult = fetchModule.summarizeOrgWideRevenueLineCoverage(
|
||||
[
|
||||
{
|
||||
closeDate: "2026-04-06",
|
||||
revenueLineId: "RL-1",
|
||||
opportunityId: "OPP-1",
|
||||
opportunityName: "Alpha",
|
||||
customerName: "Acme",
|
||||
workloadAmount: 240000,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
opportunityId: "OPP-1",
|
||||
seTeamLevel4EmailAddress: "resource.manager.a@oracle.com",
|
||||
seTeamLevel5EmailAddress: "resource.member.a@oracle.com",
|
||||
},
|
||||
{
|
||||
opportunityId: "OPP-1",
|
||||
seTeamLevel4EmailAddress: "resource.manager.b@oracle.com",
|
||||
seTeamLevel5EmailAddress: "resource.member.b@oracle.com",
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
assert.deepEqual(defaultResult.records[0].resourceDetails, [
|
||||
{
|
||||
resourceEmail: "resource.member.a@oracle.com",
|
||||
companionEmails: ["resource.member.b@oracle.com"],
|
||||
},
|
||||
{
|
||||
resourceEmail: "resource.member.b@oracle.com",
|
||||
companionEmails: ["resource.member.a@oracle.com"],
|
||||
},
|
||||
]);
|
||||
assert.equal(Object.hasOwn(defaultResult.records[0].resourceDetails[0], "srNumbers"), false);
|
||||
|
||||
const result = fetchModule.summarizeOrgWideRevenueLineCoverage(
|
||||
[
|
||||
{
|
||||
closeDate: "2026-04-06",
|
||||
revenueLineId: "RL-1",
|
||||
opportunityId: "OPP-1",
|
||||
opportunityName: "Alpha",
|
||||
customerName: "Acme",
|
||||
workloadAmount: 240000,
|
||||
},
|
||||
{
|
||||
closeDate: "2026-04-07",
|
||||
revenueLineId: "RL-2",
|
||||
opportunityId: "OPP-2",
|
||||
opportunityName: "Beta",
|
||||
customerName: "Bravo",
|
||||
workloadAmount: 367000,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
opportunityId: "OPP-1",
|
||||
seTeamLevel4EmailAddress: "resource.manager.a@oracle.com",
|
||||
seTeamLevel5EmailAddress: "resource.member.a@oracle.com",
|
||||
},
|
||||
{
|
||||
opportunityId: "OPP-1",
|
||||
seTeamLevel4EmailAddress: "resource.manager.b@oracle.com",
|
||||
seTeamLevel5EmailAddress: "resource.member.b@oracle.com",
|
||||
},
|
||||
],
|
||||
new Map([["OPP-1::resource.member.a@oracle.com", new Set(["SR-100"])] ]),
|
||||
new Map([
|
||||
[
|
||||
"resource.member.a@oracle.com",
|
||||
new Map([
|
||||
["SR-900", { srNumber: "SR-900", customerName: "Acme", title: "Internal follow-up" }],
|
||||
["SR-901", { srNumber: "SR-901", customerName: "Other Corp", title: "Ignore me" }],
|
||||
]),
|
||||
],
|
||||
])
|
||||
);
|
||||
|
||||
assert.equal(result.records.length, 1);
|
||||
assert.equal(result.records[0].revenueLineId, "RL-1");
|
||||
assert.deepEqual(result.records[0].managerEmails, [
|
||||
"resource.manager.a@oracle.com",
|
||||
"resource.manager.b@oracle.com",
|
||||
]);
|
||||
assert.deepEqual(result.records[0].resourceEmails, [
|
||||
"resource.member.a@oracle.com",
|
||||
"resource.member.b@oracle.com",
|
||||
]);
|
||||
|
||||
const resourceA = result.records[0].resourceDetails.find(
|
||||
(detail) => detail.resourceEmail === "resource.member.a@oracle.com"
|
||||
);
|
||||
assert.deepEqual(resourceA.srNumbers, ["SR-100", "SR-900"]);
|
||||
assert.deepEqual(resourceA.directSrNumbers, ["SR-100"]);
|
||||
assert.deepEqual(resourceA.candidateInternalSrNumbers, ["SR-900"]);
|
||||
assert.equal(resourceA.srLinkageMode, "direct-plus-candidate-internal");
|
||||
assert.deepEqual(resourceA.companionEmails, ["resource.member.b@oracle.com"]);
|
||||
|
||||
assert.equal(result.summary.totalWindowRevenueLines, 2);
|
||||
assert.equal(result.summary.matchedRevenueLines, 1);
|
||||
assert.equal(result.summary.matchedOpportunities, 1);
|
||||
assert.equal(result.summary.totalWorkloadAmount, 240000);
|
||||
assert.equal(result.summary.resourcesMatched, 2);
|
||||
return `defaultResourceDetails=${defaultResult.records[0].resourceDetails.length}; defaultSrFields=omitted; optionalSrLinkage=${resourceA.srLinkageMode}; resourcesMatched=${result.summary.resourcesMatched}`;
|
||||
});
|
||||
|
||||
functionTest(
|
||||
"api-metrics",
|
||||
"API metrics count raw backend rows and ignore local-cache-only rows",
|
||||
{
|
||||
entrada: "buildApiMetrics com rows 3 + 2 e cache local não contado",
|
||||
},
|
||||
() => {
|
||||
assert.equal(catalogSaysApiMetricsAreRawRows(catalogs), true);
|
||||
|
||||
const metrics = fetchModule.buildApiMetrics([
|
||||
{
|
||||
label: "window",
|
||||
result: { aResults: [[1], [2], [3]] },
|
||||
},
|
||||
{
|
||||
label: "coverage",
|
||||
result: { aResults: [[1], [2]] },
|
||||
},
|
||||
{
|
||||
label: "sessionMetaCache",
|
||||
rowsReturned: 0,
|
||||
source: "session-meta-cache",
|
||||
countedInTotal: false,
|
||||
},
|
||||
]);
|
||||
|
||||
assert.equal(metrics.totalRowsPulledFromApi, 5);
|
||||
assert.equal(metrics.queries.length, 3);
|
||||
assert.equal(metrics.explanation, "Raw rows pulled from API: 5.");
|
||||
return `totalRowsPulledFromApi=${metrics.totalRowsPulledFromApi}; queries=${metrics.queries.length}; local-cache ignored`;
|
||||
});
|
||||
|
||||
function formatFunctionResultsTable(rows) {
|
||||
const header = ["id", "teste", "entrada", "saida", "avaliacao"];
|
||||
const lines = [
|
||||
`| ${header.join(" | ")} |`,
|
||||
`| ${header.map(() => "---").join(" | ")} |`,
|
||||
...rows.map((row) =>
|
||||
`| ${markdownCell(row.id)} | ${markdownCell(row.teste)} | ${markdownCell(row.entrada)} | ${markdownCell(row.saida)} | ${markdownCell(row.avaliacao)} |`
|
||||
),
|
||||
];
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function markdownCell(value) {
|
||||
return String(value ?? "")
|
||||
.replace(/\r?\n/g, "<br>")
|
||||
.replace(/\|/g, "\\|");
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
190
tests/helpers/catalogs.mjs
Normal file
190
tests/helpers/catalogs.mjs
Normal file
@@ -0,0 +1,190 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { skillHome } from "./cli.mjs";
|
||||
|
||||
export async function readSkillCatalogs() {
|
||||
const [skill, scriptCatalog, initialContext, apiMap] = await Promise.all([
|
||||
fs.readFile(path.join(skillHome, "SKILL.md"), "utf8"),
|
||||
fs.readFile(path.join(skillHome, "references", "script-catalog.md"), "utf8"),
|
||||
fs.readFile(path.join(skillHome, "references", "initial-context-catalog.md"), "utf8"),
|
||||
fs.readFile(path.join(skillHome, "references", "api-map.md"), "utf8"),
|
||||
]);
|
||||
return {
|
||||
skill,
|
||||
scriptCatalog,
|
||||
initialContext,
|
||||
apiMap,
|
||||
allText: [skill, scriptCatalog, initialContext, apiMap].join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
export function documentedFlags(catalogs) {
|
||||
const flags = new Set();
|
||||
const regex = /`(--[a-z0-9-]+)(?:\s+[^`]*)?`/gi;
|
||||
let match = regex.exec(catalogs.allText);
|
||||
while (match) {
|
||||
flags.add(match[1]);
|
||||
match = regex.exec(catalogs.allText);
|
||||
}
|
||||
return flags;
|
||||
}
|
||||
|
||||
export function catalogMentionsIntent(catalogs, intentName) {
|
||||
return new RegExp(`\\b${escapeRegExp(intentName)}\\b`).test(catalogs.skill + "\n" + catalogs.scriptCatalog);
|
||||
}
|
||||
|
||||
export function catalogMentionsView(catalogs, viewName) {
|
||||
return new RegExp(`\\b${escapeRegExp(viewName)}\\b`).test(catalogs.skill + "\n" + catalogs.scriptCatalog);
|
||||
}
|
||||
|
||||
export function commandAppearsInCatalog(catalogs, args) {
|
||||
const normalizedCatalog = normalizeWhitespace(catalogs.skill + "\n" + catalogs.scriptCatalog + "\n" + catalogs.apiMap);
|
||||
const normalizedArgs = normalizeWhitespace(args.join(" "));
|
||||
return normalizedCatalog.includes(normalizedArgs);
|
||||
}
|
||||
|
||||
export function parseDefaultWeekYear(scriptCatalog) {
|
||||
const match = /-\s*`week-year\s+(\d{4})`/i.exec(scriptCatalog);
|
||||
if (!match) {
|
||||
throw new Error("Could not parse default week-year from references/script-catalog.md.");
|
||||
}
|
||||
return Number(match[1]);
|
||||
}
|
||||
|
||||
export function parseWeekRow(scriptCatalog, weekLabel) {
|
||||
const normalizedLabel = String(weekLabel).toUpperCase().replace(/^W?/, "W");
|
||||
const regex = new RegExp(`\\|\\s*${escapeRegExp(normalizedLabel)}\\s*\\|\\s*(\\d{4}-\\d{2}-\\d{2})\\s*\\|\\s*(\\d{4}-\\d{2}-\\d{2})\\s*\\|`, "i");
|
||||
const match = regex.exec(scriptCatalog);
|
||||
if (!match) {
|
||||
throw new Error(`Could not parse ${normalizedLabel} from references/script-catalog.md.`);
|
||||
}
|
||||
return {
|
||||
weekLabel: normalizedLabel,
|
||||
fromDate: match[1],
|
||||
toDate: match[2],
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSaAttachDefaults(initialContext) {
|
||||
const line = findLine(initialContext, "SA Attach defaults:");
|
||||
const rawDefaults = line.slice(line.indexOf(":") + 1).split(";").map((part) => part.trim()).filter(Boolean);
|
||||
const defaults = {};
|
||||
for (const part of rawDefaults) {
|
||||
const match = /`--([^=]+)=([^`]+)`/.exec(part);
|
||||
if (!match) continue;
|
||||
defaults[toCamelKey(match[1])] = splitCatalogCsv(match[2]);
|
||||
}
|
||||
return defaults;
|
||||
}
|
||||
|
||||
export function parseSaAttachAliases(initialContext) {
|
||||
const line = findLine(initialContext, "SA Attach aliases:");
|
||||
const aliases = {};
|
||||
const rawAliases = line.slice(line.indexOf(":") + 1).split(";").map((part) => part.trim()).filter(Boolean);
|
||||
for (const item of rawAliases) {
|
||||
const match = /`([^`]+)`\s*->\s*`([^`]+)`/.exec(item);
|
||||
if (!match) continue;
|
||||
aliases[match[1].toLowerCase()] = splitCatalogCsv(match[2]);
|
||||
}
|
||||
return aliases;
|
||||
}
|
||||
|
||||
export function catalogSaysCurrentWorkloadDefaults(catalogs, intentName) {
|
||||
try {
|
||||
parseCurrentWorkloadDefaults(catalogs, intentName);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseCurrentWorkloadDefaults(catalogs, intentName) {
|
||||
const source = catalogs.skill + "\n" + catalogs.scriptCatalog;
|
||||
const pattern = new RegExp(
|
||||
`\\\`${escapeRegExp(intentName)}\\\`[\\s\\S]{0,500}?defaults? to \\\`([^\\\`]+)\\\`, \\\`([^\\\`]+)\\\`, and \\\`([^\\\`]+)\\\``,
|
||||
"i"
|
||||
);
|
||||
const match = pattern.exec(source);
|
||||
if (!match) {
|
||||
throw new Error(`Could not parse documented defaults for ${intentName}.`);
|
||||
}
|
||||
return {
|
||||
quarter: match[1],
|
||||
solution: match[2],
|
||||
revenueTypeGroup: match[3],
|
||||
};
|
||||
}
|
||||
|
||||
export function catalogSaysUseRevenueLineCloseDate(catalogs) {
|
||||
try {
|
||||
parseSaAttachDateWindowField(catalogs);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseSaAttachDateWindowField(catalogs) {
|
||||
const match =
|
||||
/date-window flags now constrain `([^`]+)` directly/i.exec(catalogs.skill) ||
|
||||
/date-window flags are applied to `([^`]+)`/i.exec(catalogs.scriptCatalog);
|
||||
if (!match) {
|
||||
throw new Error("Could not parse the SA Attach date-window field from references.");
|
||||
}
|
||||
return match[1];
|
||||
}
|
||||
|
||||
export function parsePrimaryCommercialSubjectArea(apiMap) {
|
||||
const match = /Primary commercial subject area:[\s\S]*?`([^`]+)`/i.exec(apiMap);
|
||||
if (!match) {
|
||||
throw new Error("Could not parse the primary commercial subject area from references/api-map.md.");
|
||||
}
|
||||
return match[1];
|
||||
}
|
||||
|
||||
export function parsePrimaryCommercialSourceName(apiMap) {
|
||||
const subjectArea = parsePrimaryCommercialSubjectArea(apiMap);
|
||||
const match = /\.'([^']+)'\)$/i.exec(subjectArea);
|
||||
if (!match) {
|
||||
throw new Error("Could not parse the primary commercial source name from references/api-map.md.");
|
||||
}
|
||||
return match[1];
|
||||
}
|
||||
|
||||
export function catalogSaysPreserveMixedRows(catalogs) {
|
||||
return /preserve every org-wide opportunity\/workload row/i.test(catalogs.skill);
|
||||
}
|
||||
|
||||
export function catalogSaysApiMetricsAreRawRows(catalogs) {
|
||||
return /apiMetrics\.totalRowsPulledFromApi/i.test(catalogs.skill)
|
||||
&& /Do not add local-cache-only rows/i.test(catalogs.skill);
|
||||
}
|
||||
|
||||
export function splitCatalogCsv(value) {
|
||||
return String(value || "")
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function findLine(text, marker) {
|
||||
const line = String(text)
|
||||
.split(/\r?\n/)
|
||||
.find((candidate) => candidate.includes(marker));
|
||||
if (!line) {
|
||||
throw new Error(`Could not find "${marker}" in catalog text.`);
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
function toCamelKey(flagName) {
|
||||
return flagName.replace(/-([a-z])/g, (_, char) => char.toUpperCase());
|
||||
}
|
||||
|
||||
function normalizeWhitespace(value) {
|
||||
return String(value).replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
168
tests/helpers/cli.mjs
Normal file
168
tests/helpers/cli.mjs
Normal file
@@ -0,0 +1,168 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const helperDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export const testsDir = path.resolve(helperDir, "..");
|
||||
export const skillHome = path.resolve(testsDir, "..");
|
||||
export const fetchScriptPath = path.join(skillHome, "scripts", "fetch-sales-intelligence.mjs");
|
||||
|
||||
let preparedRuntimeHomePromise = null;
|
||||
|
||||
export function testRuntimeHome() {
|
||||
return (
|
||||
process.env.ORACLE_SI_TEST_RUNTIME_HOME ||
|
||||
path.join(os.tmpdir(), "knowledge-one-view-cli-tests", "runtime")
|
||||
);
|
||||
}
|
||||
|
||||
async function prepareRuntimeHome() {
|
||||
if (process.env.ORACLE_SI_TEST_RUNTIME_HOME) {
|
||||
await fs.mkdir(process.env.ORACLE_SI_TEST_RUNTIME_HOME, { recursive: true });
|
||||
return process.env.ORACLE_SI_TEST_RUNTIME_HOME;
|
||||
}
|
||||
|
||||
const runtimeHome = testRuntimeHome();
|
||||
if (!process.env.ORACLE_SI_RUNTIME_HOME) {
|
||||
await fs.mkdir(runtimeHome, { recursive: true });
|
||||
return runtimeHome;
|
||||
}
|
||||
|
||||
if (!preparedRuntimeHomePromise) {
|
||||
preparedRuntimeHomePromise = (async () => {
|
||||
await fs.rm(runtimeHome, { recursive: true, force: true });
|
||||
await fs.mkdir(runtimeHome, { recursive: true });
|
||||
for (const fileName of ["auth-state.json", "session-meta.json"]) {
|
||||
await fs.copyFile(
|
||||
path.join(process.env.ORACLE_SI_RUNTIME_HOME, fileName),
|
||||
path.join(runtimeHome, fileName)
|
||||
);
|
||||
}
|
||||
return runtimeHome;
|
||||
})();
|
||||
}
|
||||
|
||||
return preparedRuntimeHomePromise;
|
||||
}
|
||||
|
||||
export function flagsInArgs(args) {
|
||||
return args.filter((arg) => String(arg).startsWith("--"));
|
||||
}
|
||||
|
||||
export function argValue(args, flagName) {
|
||||
const index = args.indexOf(flagName);
|
||||
return index >= 0 ? args[index + 1] : null;
|
||||
}
|
||||
|
||||
export function executionModes(args) {
|
||||
return ["--intent", "--view", "--query", "--sql"].filter((flagName) => args.includes(flagName));
|
||||
}
|
||||
|
||||
export function withoutFlags(args, flagsToRemove) {
|
||||
const flags = new Set(flagsToRemove);
|
||||
const next = [];
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const token = args[index];
|
||||
if (!flags.has(token)) {
|
||||
next.push(token);
|
||||
continue;
|
||||
}
|
||||
if (token !== "--query" && token !== "--render-sql" && args[index + 1] && !args[index + 1].startsWith("--")) {
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export async function runFetch(cliArgs, options = {}) {
|
||||
const {
|
||||
expectCode = 0,
|
||||
timeoutMs = 120000,
|
||||
runtimeHome: requestedRuntimeHome = null,
|
||||
} = options;
|
||||
const runtimeHome = requestedRuntimeHome || (await prepareRuntimeHome());
|
||||
const noCacheArgs = cliArgs.includes("--no-cache") ? [...cliArgs] : [...cliArgs, "--no-cache"];
|
||||
const finalArgs = noCacheArgs.includes("--runtime-home")
|
||||
? noCacheArgs
|
||||
: [...noCacheArgs, "--runtime-home", runtimeHome];
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, [fetchScriptPath, ...finalArgs], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: process.env,
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let timedOut = false;
|
||||
const startedAt = process.hrtime.bigint();
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
child.kill();
|
||||
}, timeoutMs);
|
||||
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk;
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
|
||||
child.on("error", reject);
|
||||
child.on("close", (code) => {
|
||||
clearTimeout(timer);
|
||||
const durationMs = Number(process.hrtime.bigint() - startedAt) / 1_000_000;
|
||||
let json = null;
|
||||
try {
|
||||
json = stdout.trim() ? JSON.parse(stdout) : null;
|
||||
} catch {}
|
||||
|
||||
const result = {
|
||||
args: finalArgs,
|
||||
code,
|
||||
stdout,
|
||||
stderr,
|
||||
json,
|
||||
timedOut,
|
||||
durationMs,
|
||||
};
|
||||
|
||||
if (expectCode !== null && code !== expectCode) {
|
||||
reject(
|
||||
new Error(
|
||||
[
|
||||
`Unexpected exit code ${code}; expected ${expectCode}.`,
|
||||
`Command: ${process.execPath} ${fetchScriptPath} ${finalArgs.join(" ")}`,
|
||||
stderr.trim() || stdout.trim(),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (expectCode === 0 && (!json || typeof json !== "object")) {
|
||||
reject(
|
||||
new Error(
|
||||
[
|
||||
"Expected JSON output from fetch-sales-intelligence.mjs.",
|
||||
`Command: ${process.execPath} ${fetchScriptPath} ${finalArgs.join(" ")}`,
|
||||
stderr.trim() || stdout.trim(),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(result);
|
||||
});
|
||||
});
|
||||
}
|
||||
142
tests/helpers/examples.mjs
Normal file
142
tests/helpers/examples.mjs
Normal file
@@ -0,0 +1,142 @@
|
||||
import { runFetch } from "./cli.mjs";
|
||||
|
||||
const EMAIL_REGEX = /^[a-z0-9._%+-]+@oracle\.com$/i;
|
||||
const PLACEHOLDER_REGEX = /\{\{([a-zA-Z0-9]+)\}\}/g;
|
||||
|
||||
export function hasPlaceholders(value) {
|
||||
return PLACEHOLDER_REGEX.test(String(value || ""));
|
||||
}
|
||||
|
||||
export function resolvePlaceholders(value, examples = {}, { allowUnresolved = false } = {}) {
|
||||
return String(value).replace(PLACEHOLDER_REGEX, (match, key) => {
|
||||
if (examples[key]) return examples[key];
|
||||
if (allowUnresolved) return match;
|
||||
throw new Error(`Missing live example value for {{${key}}}.`);
|
||||
});
|
||||
}
|
||||
|
||||
export function resolvePromptCase(item, examples = {}, { allowUnresolved = false } = {}) {
|
||||
return {
|
||||
...item,
|
||||
prompt: resolvePlaceholders(item.prompt, examples, { allowUnresolved }),
|
||||
args: item.args.map((arg) => resolvePlaceholders(arg, examples, { allowUnresolved })),
|
||||
...(item.liveArgs
|
||||
? { liveArgs: item.liveArgs.map((arg) => resolvePlaceholders(arg, examples, { allowUnresolved })) }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildLiveExampleContext(catalogs) {
|
||||
const catalogResourceManagers = emailsFromCatalog(catalogs.initialContext);
|
||||
const [pipelineSample, presalesSample] = await Promise.all([
|
||||
runFetch(["--view", "sa-attach-pipeline-details", "--limit", "500"], { timeoutMs: 180000 }),
|
||||
runFetch(["--view", "sa-attach-presales-coverage", "--limit", "500"], { timeoutMs: 180000 }),
|
||||
]);
|
||||
|
||||
const pipelineRecords = pipelineSample.json.records || [];
|
||||
const presalesRecords = presalesSample.json.records || [];
|
||||
const resourceManagerEmails = uniqueEmails([
|
||||
...catalogResourceManagers,
|
||||
...valuesFromRecords(presalesRecords, ["seTeamLevel4EmailAddress"]),
|
||||
]);
|
||||
const resourceMemberEmails = uniqueEmails(valuesFromRecords(presalesRecords, ["seTeamLevel5EmailAddress"]));
|
||||
const territoryOwnerEmails = uniqueEmails(
|
||||
valuesFromRecords(pipelineRecords, ["level7TerritoryOwnerEmail", "territoryOwnerEmail"])
|
||||
);
|
||||
const territoryManagerEmails = uniqueEmails(
|
||||
valuesFromRecords(pipelineRecords, [
|
||||
"level2TerritoryOwnerEmail",
|
||||
"level3TerritoryOwnerEmail",
|
||||
"level4TerritoryOwnerEmail",
|
||||
"level5TerritoryOwnerEmail",
|
||||
"level6TerritoryOwnerEmail",
|
||||
])
|
||||
);
|
||||
const [resourceManagerEmailA, resourceManagerEmailB] = pickDistinct(
|
||||
resourceManagerEmails,
|
||||
2,
|
||||
"resource manager emails"
|
||||
);
|
||||
|
||||
return {
|
||||
examples: {
|
||||
resourceManagerEmail: pickRandom(resourceManagerEmails, "resource manager email"),
|
||||
resourceManagerEmailA,
|
||||
resourceManagerEmailB,
|
||||
resourceManagerEmailList: `${resourceManagerEmailA},${resourceManagerEmailB}`,
|
||||
territoryOwnerEmail: pickRandom(territoryOwnerEmails, "territory owner email"),
|
||||
territoryManagerEmail: pickRandom(territoryManagerEmails, "territory manager email"),
|
||||
resourceMember: pickRandom(resourceMemberEmails, "resource member email"),
|
||||
},
|
||||
trace: [
|
||||
{
|
||||
script: "fetch-sales-intelligence.mjs",
|
||||
args: pipelineSample.args,
|
||||
result: `ok; records=${pipelineRecords.length}; source=${pipelineSample.json.request?.dataSource || "unknown"}`,
|
||||
profiles: ["territoryOwnerEmail", "territoryManagerEmail"],
|
||||
},
|
||||
{
|
||||
script: "fetch-sales-intelligence.mjs",
|
||||
args: presalesSample.args,
|
||||
result: `ok; records=${presalesRecords.length}; source=${presalesSample.json.request?.dataSource || "unknown"}`,
|
||||
profiles: ["resourceManagerEmail", "resourceManagerEmailA", "resourceManagerEmailB", "resourceMember"],
|
||||
},
|
||||
],
|
||||
poolSizes: {
|
||||
resourceManagerEmails: resourceManagerEmails.length,
|
||||
resourceMemberEmails: resourceMemberEmails.length,
|
||||
territoryOwnerEmails: territoryOwnerEmails.length,
|
||||
territoryManagerEmails: territoryManagerEmails.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function placeholderKeysForItem(item) {
|
||||
const text = [item.prompt, ...(item.args || []), ...(item.liveArgs || [])].join("\n");
|
||||
return Array.from(text.matchAll(PLACEHOLDER_REGEX), (match) => match[1]);
|
||||
}
|
||||
|
||||
function emailsFromCatalog(text) {
|
||||
return uniqueEmails(Array.from(String(text || "").matchAll(/[a-z0-9._%+-]+@oracle\.com/gi), (match) => match[0]));
|
||||
}
|
||||
|
||||
function valuesFromRecords(records, fieldNames) {
|
||||
const values = [];
|
||||
for (const record of records || []) {
|
||||
for (const fieldName of fieldNames) {
|
||||
if (record?.[fieldName]) values.push(record[fieldName]);
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function uniqueEmails(values) {
|
||||
return Array.from(
|
||||
new Set(
|
||||
values
|
||||
.map((value) => String(value || "").trim().toLowerCase())
|
||||
.filter((value) => EMAIL_REGEX.test(value))
|
||||
.filter((value) => !/^(resource|territory)\./.test(value))
|
||||
)
|
||||
).sort();
|
||||
}
|
||||
|
||||
function pickRandom(values, label) {
|
||||
if (!values.length) {
|
||||
throw new Error(`Could not resolve any real ${label} from the skill catalogs or live DV sample.`);
|
||||
}
|
||||
return values[Math.floor(Math.random() * values.length)];
|
||||
}
|
||||
|
||||
function pickDistinct(values, count, label) {
|
||||
if (values.length < count) {
|
||||
throw new Error(`Could not resolve ${count} distinct real ${label} from the skill catalogs or live DV sample.`);
|
||||
}
|
||||
const remaining = [...values];
|
||||
const picked = [];
|
||||
while (picked.length < count) {
|
||||
const index = Math.floor(Math.random() * remaining.length);
|
||||
picked.push(remaining.splice(index, 1)[0]);
|
||||
}
|
||||
return picked;
|
||||
}
|
||||
163
tests/lib-contracts.test.mjs
Normal file
163
tests/lib-contracts.test.mjs
Normal file
@@ -0,0 +1,163 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { skillHome } from "./helpers/cli.mjs";
|
||||
|
||||
const cliShared = await import(pathToFileURL(path.join(skillHome, "scripts", "lib", "cli-shared.mjs")).href);
|
||||
const outputContract = await import(pathToFileURL(path.join(skillHome, "scripts", "lib", "output-contract.mjs")).href);
|
||||
const runtimePaths = await import(pathToFileURL(path.join(skillHome, "scripts", "lib", "runtime-paths.mjs")).href);
|
||||
const authShared = await import(pathToFileURL(path.join(skillHome, "scripts", "lib", "auth-shared.mjs")).href);
|
||||
|
||||
test("cli-shared parses flags and csv values for agent-built commands", () => {
|
||||
assert.deepEqual(
|
||||
cliShared.parseArgs([
|
||||
"ignored-position",
|
||||
"--query",
|
||||
"--select",
|
||||
"opportunityId,",
|
||||
"customerName",
|
||||
"--render-sql",
|
||||
"--limit",
|
||||
"10",
|
||||
]),
|
||||
{
|
||||
query: "true",
|
||||
select: "opportunityId, customerName",
|
||||
"render-sql": "true",
|
||||
limit: "10",
|
||||
}
|
||||
);
|
||||
|
||||
const fallback = ["default"];
|
||||
assert.equal(cliShared.splitCsv("", fallback), fallback);
|
||||
assert.deepEqual(cliShared.splitCsv(" opportunityId, , customerName , bookingValue "), [
|
||||
"opportunityId",
|
||||
"customerName",
|
||||
"bookingValue",
|
||||
]);
|
||||
});
|
||||
|
||||
test("output-contract formats display money while preserving API metrics defaults", () => {
|
||||
const payload = outputContract.preparePayloadForOutput({
|
||||
columns: ["opportunityId", "bookingValue", "workloadAmount"],
|
||||
request: {
|
||||
query: {
|
||||
measure: [{ fieldName: "bookingValue", outputName: "totalBooking" }],
|
||||
},
|
||||
},
|
||||
records: [
|
||||
{
|
||||
opportunityId: "OPP-1",
|
||||
bookingValue: 1234567,
|
||||
workloadAmount: "90,000",
|
||||
totalBooking: 5000,
|
||||
note: "unchanged",
|
||||
},
|
||||
],
|
||||
summary: {
|
||||
totalBookingValue: 1234567,
|
||||
nested: { workloadAmount: 23000 },
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(payload.records[0].bookingValue, "1.234k");
|
||||
assert.equal(payload.records[0].workloadAmount, "90k");
|
||||
assert.equal(payload.records[0].totalBooking, "5k");
|
||||
assert.equal(payload.records[0].note, "unchanged");
|
||||
assert.equal(payload.summary.totalBookingValue, "1.234k");
|
||||
assert.equal(payload.summary.nested.workloadAmount, "23k");
|
||||
assert.equal(payload.apiMetrics.totalRowsPulledFromApi, 0);
|
||||
});
|
||||
|
||||
test("output-contract normalizes Oracle cells and warnings", () => {
|
||||
assert.equal(outputContract.normalizeCell("02-MAY-26"), "2026-05-02");
|
||||
assert.equal(outputContract.normalizeCell("31-DEC-99"), "1999-12-31");
|
||||
assert.equal(outputContract.normalizeCell("__CODEX_NULL__"), null);
|
||||
assert.equal(outputContract.normalizeCell(undefined), null);
|
||||
|
||||
assert.deepEqual(
|
||||
outputContract.mapRows(["closeDate", "amount"], [["02-MAY-2026", "__CODEX_NULL__"]]),
|
||||
[{ closeDate: "2026-05-02", amount: null }]
|
||||
);
|
||||
|
||||
const warnings = outputContract.buildWarnings({
|
||||
requestedLimit: 10,
|
||||
recordsCount: 10,
|
||||
customSql: true,
|
||||
customSqlColumnsResolved: false,
|
||||
});
|
||||
assert.equal(warnings.length, 2);
|
||||
assert.match(warnings[0], /matches the requested limit/);
|
||||
assert.match(warnings[1], /Custom SQL records are returned as raw row arrays/);
|
||||
});
|
||||
|
||||
test("runtime-paths routes final and temporary outputs under the workspace skill folder", () => {
|
||||
const workspaceRoot = path.join("/tmp", "kov-runtime-contracts");
|
||||
const runtimeHome = path.join(workspaceRoot, "knowledge-one-view-cli", "runtime-test");
|
||||
const normalizedRuntimeHome = path.join(workspaceRoot, "knowledge-one-view-cli", "runtime");
|
||||
|
||||
assert.equal(runtimePaths.resolveRuntimeHome(runtimeHome), normalizedRuntimeHome);
|
||||
assert.equal(
|
||||
runtimePaths.resolveOutputFilePath("final.json", runtimeHome),
|
||||
path.join(workspaceRoot, "knowledge-one-view-cli", "output", "final.json")
|
||||
);
|
||||
assert.equal(
|
||||
runtimePaths.resolveOutputFilePath("tmp-render.json", runtimeHome),
|
||||
path.join(workspaceRoot, "knowledge-one-view-cli", "tmp", "tmp-render.json")
|
||||
);
|
||||
assert.equal(
|
||||
runtimePaths.resolveOutputFilePath("reports/final.json", runtimeHome),
|
||||
path.join(workspaceRoot, "knowledge-one-view-cli", "reports", "final.json")
|
||||
);
|
||||
});
|
||||
|
||||
test("runtime-paths refuses to infer a workspace inside .codex", () => {
|
||||
assert.throws(
|
||||
() => runtimePaths.resolveWorkspaceRoot(path.join(runtimePaths.CODEX_HOME, "skills", "knowledge-one-view-cli")),
|
||||
/Cannot infer a workspace root/
|
||||
);
|
||||
});
|
||||
|
||||
test("auth-shared builds cookie headers with domain, path, secure, and expiry rules", () => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const header = authShared.cookieHeaderFor("https://salesintelligence-dv.oracle.com/analytics/saw.dll", [
|
||||
{ name: "root", value: "1", domain: ".oracle.com", path: "/", secure: true, expires: -1 },
|
||||
{ name: "deep", value: "2", domain: "salesintelligence-dv.oracle.com", path: "/analytics", secure: true, expires: now + 60 },
|
||||
{ name: "expired", value: "3", domain: "oracle.com", path: "/", secure: true, expires: now - 60 },
|
||||
{ name: "httpOnly", value: "4", domain: "oracle.com", path: "/", secure: true, expires: -1 },
|
||||
{ name: "wrong", value: "5", domain: "example.com", path: "/", secure: true, expires: -1 },
|
||||
{ name: "insecureOnly", value: "6", domain: "oracle.com", path: "/", secure: false, expires: -1 },
|
||||
]);
|
||||
|
||||
assert.equal(header, "deep=2; root=1; httpOnly=4; insecureOnly=6");
|
||||
});
|
||||
|
||||
test("auth-shared splits and merges Set-Cookie headers", () => {
|
||||
assert.deepEqual(
|
||||
authShared.splitSetCookieHeader("a=1; Expires=Wed, 21 Oct 2030 07:28:00 GMT; Path=/, b=2; Path=/analytics; HttpOnly; SameSite=None; Secure"),
|
||||
[
|
||||
"a=1; Expires=Wed, 21 Oct 2030 07:28:00 GMT; Path=/",
|
||||
"b=2; Path=/analytics; HttpOnly; SameSite=None; Secure",
|
||||
]
|
||||
);
|
||||
|
||||
const state = { cookies: [{ name: "a", value: "old", domain: "oracle.com", path: "/", expires: -1 }] };
|
||||
const changed = authShared.mergeCookiesFromHeader(
|
||||
state,
|
||||
"https://salesintelligence-dv.oracle.com/analytics/saw.dll",
|
||||
"a=1; Domain=.oracle.com; Path=/, b=2; Path=/analytics; HttpOnly; SameSite=None; Secure"
|
||||
);
|
||||
|
||||
assert.equal(changed, true);
|
||||
assert.equal(state.cookies.length, 2);
|
||||
assert.equal(state.cookies.find((cookie) => cookie.name === "a").value, "1");
|
||||
assert.equal(state.cookies.find((cookie) => cookie.name === "b").sameSite, "None");
|
||||
assert.equal(state.cookies.find((cookie) => cookie.name === "b").httpOnly, true);
|
||||
});
|
||||
|
||||
test("auth-shared classifies refresh-required errors", () => {
|
||||
assert.equal(authShared.isAuthRefreshRequiredError(new Error("Request redirected to Oracle sign-in")), true);
|
||||
assert.equal(authShared.isAuthRefreshRequiredError(new Error("Could not find auth state at /tmp/auth-state.json")), true);
|
||||
assert.equal(authShared.isAuthRefreshRequiredError(new Error("Unsupported --limit abc")), false);
|
||||
});
|
||||
93
tests/prompts/sales-prompts.json
Normal file
93
tests/prompts/sales-prompts.json
Normal file
@@ -0,0 +1,93 @@
|
||||
[
|
||||
{
|
||||
"id": "generic-pipeline",
|
||||
"prompt": "Me traga o pipeline de oportunidades no Knowledge One View.",
|
||||
"catalogRefs": ["SKILL.md"],
|
||||
"args": ["--intent", "opportunities-default", "--limit", "20"],
|
||||
"liveArgs": ["--intent", "opportunities-default", "--limit", "1000"],
|
||||
"liveExpect": {
|
||||
"scopeMode": "org-wide",
|
||||
"dataSource": "primaryCommercial",
|
||||
"allowFallback": false,
|
||||
"minRecords": 100,
|
||||
"minApiRows": 100
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "exact-close-date",
|
||||
"prompt": "Quais oportunidades fecham em 2026-05-02?",
|
||||
"catalogRefs": ["SKILL.md"],
|
||||
"args": ["--intent", "opportunities-close-date", "--date", "2026-05-02", "--limit", "10"],
|
||||
"liveArgs": ["--intent", "opportunities-close-date", "--date", "2026-05-02", "--limit", "1000"],
|
||||
"liveExpect": {
|
||||
"scopeMode": "org-wide",
|
||||
"dataSource": "primaryCommercial",
|
||||
"allowFallback": false,
|
||||
"minRecords": 1,
|
||||
"minApiRows": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "calendar-month",
|
||||
"prompt": "Quais oportunidades fecham em abril de 2026?",
|
||||
"catalogRefs": ["SKILL.md"],
|
||||
"args": ["--intent", "opportunities-close-month", "--month", "2026-04", "--limit", "10"],
|
||||
"liveArgs": ["--intent", "opportunities-close-month", "--month", "2026-04", "--limit", "1000"],
|
||||
"liveExpect": {
|
||||
"scopeMode": "org-wide",
|
||||
"dataSource": "primaryCommercial",
|
||||
"allowFallback": false,
|
||||
"minRecords": 11,
|
||||
"minApiRows": 11
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "manager-week",
|
||||
"prompt": "Quais oportunidades do manager {{resourceManagerEmail}} fecham na week 43?",
|
||||
"catalogRefs": ["SKILL.md", "references/initial-context-catalog.md", "references/script-catalog.md#week-reference"],
|
||||
"args": ["--intent", "opportunities-by-manager-close-window", "--manager-email", "{{resourceManagerEmail}}", "--week", "w43", "--week-year", "catalog-default", "--limit", "10"]
|
||||
},
|
||||
{
|
||||
"id": "owner-workload-current",
|
||||
"prompt": "Me mostre os workloads em forecast agora do vendedor {{territoryOwnerEmail}}.",
|
||||
"catalogRefs": ["SKILL.md"],
|
||||
"args": ["--intent", "workloads-by-owner-current", "--owner-email", "{{territoryOwnerEmail}}", "--forecast-type", "Forecast", "--limit", "10"]
|
||||
},
|
||||
{
|
||||
"id": "owner-manager-workload-current",
|
||||
"prompt": "Quais workloads em forecast agora dos owners abaixo de {{territoryManagerEmail}}?",
|
||||
"catalogRefs": ["SKILL.md", "references/initial-context-catalog.md"],
|
||||
"args": ["--intent", "workloads-by-owner-manager-current", "--owner-manager-email", "{{territoryManagerEmail}}", "--forecast-type", "Forecast", "--limit", "10"]
|
||||
},
|
||||
{
|
||||
"id": "manager-resource-next-7-days",
|
||||
"prompt": "Workloads com close date nos proximos 7 dias em oportunidades com recursos abaixo de {{resourceManagerEmailA}} e {{resourceManagerEmailB}}.",
|
||||
"catalogRefs": ["SKILL.md", "references/initial-context-catalog.md"],
|
||||
"args": ["--intent", "workloads-by-manager-resource-close-window", "--manager-email-list", "{{resourceManagerEmailList}}", "--from-date", "today", "--to-date", "+7d", "--limit", "10"],
|
||||
"liveExpect": {
|
||||
"scopeMode": "org-wide",
|
||||
"dataSource": "primaryCommercial",
|
||||
"allowFallback": false,
|
||||
"noDvSeTeamQueries": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "resource-srs",
|
||||
"prompt": "Quais SRs estao com {{resourceMember}} agora?",
|
||||
"catalogRefs": ["SKILL.md"],
|
||||
"args": ["--intent", "srs-by-resource", "--resource", "{{resourceMember}}", "--limit", "10"]
|
||||
},
|
||||
{
|
||||
"id": "current-team-close-window",
|
||||
"prompt": "Quais oportunidades fecham nos proximos 4 dias e tem recursos do meu time?",
|
||||
"catalogRefs": ["SKILL.md"],
|
||||
"args": ["--intent", "opportunities-current-team-close-window", "--from-date", "today", "--to-date", "+4d", "--limit", "10"]
|
||||
},
|
||||
{
|
||||
"id": "ad-hoc-query",
|
||||
"prompt": "Quero opportunityId, cliente, close date, booking e workload das oportunidades de 2026-05-02.",
|
||||
"catalogRefs": ["SKILL.md"],
|
||||
"args": ["--query", "--select", "opportunityId,customerName,closeDate,bookingValue,workloadAmount", "--where", "closeDate = DATE '2026-05-02'", "--render-sql"],
|
||||
"live": false
|
||||
}
|
||||
]
|
||||
21
tests/run.mjs
Normal file
21
tests/run.mjs
Normal file
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const testsDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const testFiles = [
|
||||
path.join(testsDir, "functions.test.mjs"),
|
||||
path.join(testsDir, "lib-contracts.test.mjs"),
|
||||
path.join(testsDir, "sales-prompts.test.mjs"),
|
||||
];
|
||||
|
||||
const child = spawn(process.execPath, ["--test", ...testFiles], {
|
||||
stdio: "inherit",
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
child.on("close", (code) => {
|
||||
process.exitCode = code ?? 1;
|
||||
});
|
||||
536
tests/sales-prompts.test.mjs
Normal file
536
tests/sales-prompts.test.mjs
Normal file
@@ -0,0 +1,536 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
argValue,
|
||||
executionModes,
|
||||
flagsInArgs,
|
||||
runFetch,
|
||||
testsDir,
|
||||
withoutFlags,
|
||||
} from "./helpers/cli.mjs";
|
||||
import {
|
||||
catalogMentionsIntent,
|
||||
documentedFlags,
|
||||
parseCurrentWorkloadDefaults,
|
||||
parseDefaultWeekYear,
|
||||
parsePrimaryCommercialSubjectArea,
|
||||
parsePrimaryCommercialSourceName,
|
||||
readSkillCatalogs,
|
||||
} from "./helpers/catalogs.mjs";
|
||||
import {
|
||||
buildLiveExampleContext,
|
||||
placeholderKeysForItem,
|
||||
resolvePromptCase,
|
||||
} from "./helpers/examples.mjs";
|
||||
|
||||
const liveEnabled = process.env.KNOWLEDGE_ONE_VIEW_LIVE === "1";
|
||||
const catalogs = await readSkillCatalogs();
|
||||
const promptCases = JSON.parse(
|
||||
await fs.readFile(path.join(testsDir, "prompts", "sales-prompts.json"), "utf8")
|
||||
);
|
||||
|
||||
function resolveArgs(args, examples = {}, { allowUnresolved = true } = {}) {
|
||||
const weekYear = parseDefaultWeekYear(catalogs.scriptCatalog);
|
||||
return args
|
||||
.map((arg) => (arg === "catalog-default" ? String(weekYear) : arg))
|
||||
.map((arg) => String(arg).replace(/\{\{([a-zA-Z0-9]+)\}\}/g, (match, key) => {
|
||||
if (examples[key]) return examples[key];
|
||||
if (allowUnresolved) return match;
|
||||
throw new Error(`Missing live example value for {{${key}}}.`);
|
||||
}));
|
||||
}
|
||||
|
||||
function resolvedCase(item, examples = {}, { allowUnresolved = true } = {}) {
|
||||
return resolvePromptCase(item, examples, { allowUnresolved });
|
||||
}
|
||||
|
||||
function caseArgs(item, examples = {}, options = {}) {
|
||||
return resolveArgs(resolvedCase(item, examples, options).args, examples, options);
|
||||
}
|
||||
|
||||
function liveArgs(item, examples = {}, options = {}) {
|
||||
const resolved = resolvedCase(item, examples, options);
|
||||
return resolveArgs(resolved.liveArgs || withoutFlags(resolved.args, ["--render-sql"]), examples, options);
|
||||
}
|
||||
|
||||
function promptNeedsInitialContext(prompt) {
|
||||
return /\b(manager|abaixo|hierarquia|owners abaixo)\b/i.test(prompt);
|
||||
}
|
||||
|
||||
function promptNeedsWeekReference(prompt) {
|
||||
return /\bweek\b|\bwk\b|\bw\d+\b/i.test(prompt);
|
||||
}
|
||||
|
||||
function globalParameterFlags(parameters) {
|
||||
return new Set((parameters || []).map((parameter) => parameter.cli));
|
||||
}
|
||||
|
||||
test("sales prompt commands are short, direct, and documented in references", () => {
|
||||
const flagsFromReferences = documentedFlags(catalogs);
|
||||
|
||||
for (const item of promptCases) {
|
||||
const args = caseArgs(item);
|
||||
const modes = executionModes(args);
|
||||
|
||||
assert.deepEqual(modes, [args.includes("--intent") ? "--intent" : "--query"], item.id);
|
||||
assert.equal(args.includes("--catalog"), false, `${item.id} should not start with discovery.`);
|
||||
assert.equal(args.includes("--check-auth"), false, `${item.id} should not add auth checks to the normal fetch.`);
|
||||
assert.ok(flagsInArgs(args).length <= 6, `${item.id} should remain easy for an agent to execute.`);
|
||||
|
||||
for (const flag of flagsInArgs(args)) {
|
||||
assert.ok(flagsFromReferences.has(flag), `${item.id} uses undocumented flag ${flag}.`);
|
||||
}
|
||||
|
||||
assert.ok(item.catalogRefs.includes("SKILL.md"), `${item.id} must start from SKILL.md.`);
|
||||
if (promptNeedsInitialContext(item.prompt)) {
|
||||
assert.ok(
|
||||
item.catalogRefs.includes("references/initial-context-catalog.md"),
|
||||
`${item.id} should point manager or hierarchy wording to initial-context-catalog.`
|
||||
);
|
||||
}
|
||||
if (promptNeedsWeekReference(item.prompt)) {
|
||||
assert.ok(
|
||||
item.catalogRefs.includes("references/script-catalog.md#week-reference"),
|
||||
`${item.id} should point week wording to the reference week table.`
|
||||
);
|
||||
}
|
||||
|
||||
const intent = argValue(args, "--intent");
|
||||
if (intent) {
|
||||
assert.equal(catalogMentionsIntent(catalogs, intent), true, `${item.id} intent is not mentioned in references.`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("sales prompt people examples are resolved dynamically, not hard-coded", () => {
|
||||
const serializedPrompts = JSON.stringify(promptCases);
|
||||
assert.doesNotMatch(serializedPrompts, /resource\.manager(?:\.a|\.b)?@oracle\.com/);
|
||||
assert.doesNotMatch(serializedPrompts, /territory\.(?:owner|manager)@oracle\.com/);
|
||||
assert.doesNotMatch(serializedPrompts, /resource\.member\b/);
|
||||
|
||||
const requiredPlaceholders = new Set([
|
||||
"resourceManagerEmail",
|
||||
"resourceManagerEmailA",
|
||||
"resourceManagerEmailB",
|
||||
"resourceManagerEmailList",
|
||||
"territoryOwnerEmail",
|
||||
"territoryManagerEmail",
|
||||
"resourceMember",
|
||||
]);
|
||||
const usedPlaceholders = new Set(promptCases.flatMap((item) => placeholderKeysForItem(item)));
|
||||
for (const placeholder of requiredPlaceholders) {
|
||||
assert.ok(usedPlaceholders.has(placeholder), `Expected {{${placeholder}}} in prompt test definitions.`);
|
||||
}
|
||||
});
|
||||
|
||||
test("intent prompt commands match the live CLI contract without local flag copies", async () => {
|
||||
const parameterCatalog = await runFetch(["--list-parameters"]);
|
||||
const globalFlags = globalParameterFlags(parameterCatalog.json.parameters);
|
||||
|
||||
for (const item of promptCases) {
|
||||
const args = caseArgs(item);
|
||||
const intent = argValue(args, "--intent");
|
||||
if (!intent) continue;
|
||||
|
||||
const result = await runFetch(["--describe-intent", intent]);
|
||||
const intentFlags = new Set((result.json.intent.parameters || []).map((parameter) => parameter.cli));
|
||||
|
||||
assert.equal(result.json.intent.name, intent, item.id);
|
||||
assert.equal(catalogMentionsIntent(catalogs, intent), true, item.id);
|
||||
|
||||
for (const flag of flagsInArgs(args)) {
|
||||
if (flag === "--intent") continue;
|
||||
assert.ok(globalFlags.has(flag), `${item.id} uses a flag missing from --list-parameters: ${flag}`);
|
||||
assert.ok(
|
||||
intentFlags.has(flag) || ["--limit"].includes(flag),
|
||||
`${item.id} uses a flag that is not part of the intent contract: ${flag}`
|
||||
);
|
||||
}
|
||||
|
||||
if (["workloads-by-owner-current", "workloads-by-owner-manager-current"].includes(intent)) {
|
||||
const expectedDefaults = parseCurrentWorkloadDefaults(catalogs, intent);
|
||||
assert.match(result.json.intent.description, /current/i);
|
||||
assert.ok(
|
||||
(result.json.intent.parameters || []).some(
|
||||
(parameter) => parameter.cli === "--quarter" && parameter.default === expectedDefaults.quarter
|
||||
),
|
||||
`${item.id} current workload intent must keep the documented quarter default in the catalog.`
|
||||
);
|
||||
assert.ok(catalogs.allText.includes(expectedDefaults.solution));
|
||||
assert.ok(catalogs.allText.includes(expectedDefaults.revenueTypeGroup));
|
||||
}
|
||||
|
||||
if (intent === "workloads-by-manager-resource-close-window") {
|
||||
assert.match(result.json.intent.description, /Presales Involvement Aux - V1/);
|
||||
assert.match(result.json.intent.description, /does not fetch SR details from DV - SE Team/);
|
||||
assert.doesNotMatch(result.json.intent.description, /resolved SRs/i);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("ad hoc sales prompt renders an org-wide opportunity query", async () => {
|
||||
const item = promptCases.find((entry) => entry.id === "ad-hoc-query");
|
||||
const args = caseArgs(item);
|
||||
const result = await runFetch(args);
|
||||
const selectAliases = argValue(args, "--select").split(",").map((value) => value.trim());
|
||||
|
||||
assert.equal(result.json.requestMode, "query");
|
||||
assert.equal(result.json.scope.scopeMode, "org-wide");
|
||||
assert.ok(catalogs.allText.includes(result.json.query.source), "Query source should be documented.");
|
||||
assert.match(result.json.expandedSql, new RegExp(escapeRegExp(parsePrimaryCommercialSubjectArea(catalogs.apiMap))));
|
||||
assert.doesNotMatch(result.json.expandedSql, /SEC_ARIA_SI-SR-TEAM/);
|
||||
|
||||
for (const alias of selectAliases) {
|
||||
assert.ok(catalogs.allText.includes(alias), `Query alias ${alias} should be documented in references.`);
|
||||
assert.ok(result.json.columns.includes(alias), `Rendered query should keep selected alias ${alias}.`);
|
||||
}
|
||||
});
|
||||
|
||||
test("org-wide query money aliases use the pipeline measure that Oracle DV accepts", async () => {
|
||||
const result = await runFetch([
|
||||
"--query",
|
||||
"--select",
|
||||
"opportunityId,customerName,closeDate,bookingValue,workloadAmount",
|
||||
"--where",
|
||||
"closeDate >= DATE '2026-04-26' AND closeDate <= DATE '2026-05-02' AND opportunityStatus IN ('Open','Won','Won Pending') AND cluster = 'Brazil'",
|
||||
"--render-sql",
|
||||
]);
|
||||
|
||||
assert.equal(result.json.requestMode, "query");
|
||||
assert.equal(result.json.scope.scopeMode, "org-wide");
|
||||
assert.match(result.json.expandedSql, /"Columns"\."Pipeline" AS bookingValue/);
|
||||
assert.match(result.json.expandedSql, /"Columns"\."Pipeline" AS workloadAmount/);
|
||||
assert.doesNotMatch(result.json.expandedSql, /"Columns"\."Opportunity Value" AS bookingValue/);
|
||||
assert.doesNotMatch(result.json.expandedSql, /"Columns"\."Workload Amount" AS workloadAmount/);
|
||||
assert.match(result.json.expandedSql, /"Columns"\."Cluster" = 'Brazil'/);
|
||||
assert.match(result.json.expandedSql, /"Columns"\."Revenue Line Close Date" >= DATE '2026-04-26'/);
|
||||
});
|
||||
|
||||
test("CLI rejects invalid limit values before backend execution", async () => {
|
||||
for (const value of ["abc", "0", "-1", "1.5"]) {
|
||||
const result = await runFetch(
|
||||
[
|
||||
"--render-sql",
|
||||
"--query",
|
||||
"--select",
|
||||
"opportunityId",
|
||||
"--limit",
|
||||
value,
|
||||
],
|
||||
{ expectCode: 1 }
|
||||
);
|
||||
assert.match(result.stderr, new RegExp(`Unsupported --limit "${value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"`));
|
||||
assert.equal(result.stdout, "");
|
||||
}
|
||||
});
|
||||
|
||||
test(
|
||||
"sales prompt commands run against Oracle DV when live mode is enabled",
|
||||
{ skip: liveEnabled ? false : "set KNOWLEDGE_ONE_VIEW_LIVE=1 to run backend checks" },
|
||||
async (t) => {
|
||||
assert.ok(
|
||||
process.env.ORACLE_SI_RUNTIME_HOME || process.env.ORACLE_SI_TEST_RUNTIME_HOME,
|
||||
"Set ORACLE_SI_RUNTIME_HOME or ORACLE_SI_TEST_RUNTIME_HOME for live DV tests."
|
||||
);
|
||||
|
||||
const sessionContext = await runFetch(
|
||||
["--view", "data-quality-session-context", "--limit", "1"],
|
||||
{ timeoutMs: 120000 }
|
||||
);
|
||||
assert.ok(Array.isArray(sessionContext.json.records), sessionContext.stdout || sessionContext.stderr);
|
||||
assert.ok(sessionContext.json.apiMetrics.totalRowsPulledFromApi >= 0);
|
||||
assert.ok(sessionContext.args.includes("--no-cache"), "The live warm-up must run with --no-cache.");
|
||||
|
||||
const liveExampleContext = await buildLiveExampleContext(catalogs);
|
||||
const failures = [];
|
||||
const tableRows = [];
|
||||
|
||||
for (const item of promptCases.filter((entry) => entry.live !== false)) {
|
||||
const resolvedItem = {
|
||||
...resolvedCase(item, liveExampleContext.examples, { allowUnresolved: false }),
|
||||
placeholderKeys: placeholderKeysForItem(item),
|
||||
};
|
||||
let result = null;
|
||||
let checks = [];
|
||||
let commandError = null;
|
||||
try {
|
||||
result = await runFetch(liveArgs(item, liveExampleContext.examples, { allowUnresolved: false }), { timeoutMs: 180000 });
|
||||
checks = evaluateLivePrompt(resolvedItem, result);
|
||||
} catch (error) {
|
||||
commandError = error;
|
||||
checks = [
|
||||
{
|
||||
name: "command exited successfully",
|
||||
pass: false,
|
||||
actual: error.message,
|
||||
expected: "JSON payload with exit code 0",
|
||||
},
|
||||
];
|
||||
}
|
||||
tableRows.push(buildPromptTableRow(resolvedItem, result, checks, commandError, liveExampleContext));
|
||||
|
||||
for (const check of checks) {
|
||||
if (!check.pass) {
|
||||
failures.push(`${item.id} :: ${check.name} :: expected ${formatValue(check.expected)}, got ${formatValue(check.actual)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
t.diagnostic(formatPromptResultsTable(tableRows));
|
||||
assert.deepEqual(failures, [], failures.join("\n"));
|
||||
}
|
||||
);
|
||||
|
||||
function evaluateLivePrompt(item, result) {
|
||||
const payload = result.json;
|
||||
const expected = item.liveExpect || {};
|
||||
const checks = [];
|
||||
|
||||
checks.push({
|
||||
name: "command uses --no-cache",
|
||||
pass: result.args.includes("--no-cache"),
|
||||
actual: result.args.includes("--no-cache"),
|
||||
expected: true,
|
||||
});
|
||||
checks.push({
|
||||
name: "records array exists",
|
||||
pass: Array.isArray(payload.records),
|
||||
actual: Array.isArray(payload.records),
|
||||
expected: true,
|
||||
});
|
||||
checks.push({
|
||||
name: "api rows metric exists",
|
||||
pass: typeof payload.apiMetrics?.totalRowsPulledFromApi === "number",
|
||||
actual: typeof payload.apiMetrics?.totalRowsPulledFromApi,
|
||||
expected: "number",
|
||||
});
|
||||
|
||||
const intent = argValue(caseArgs(item), "--intent");
|
||||
checks.push(
|
||||
intent
|
||||
? {
|
||||
name: "intent echoed by CLI",
|
||||
pass: payload.request?.intent === intent,
|
||||
actual: payload.request?.intent,
|
||||
expected: intent,
|
||||
}
|
||||
: {
|
||||
name: "query mode echoed by CLI",
|
||||
pass: payload.request?.mode === "query",
|
||||
actual: payload.request?.mode,
|
||||
expected: "query",
|
||||
}
|
||||
);
|
||||
|
||||
if (expected.scopeMode) {
|
||||
checks.push({
|
||||
name: "scope mode",
|
||||
pass: payload.scope?.scopeMode === expected.scopeMode,
|
||||
actual: payload.scope?.scopeMode,
|
||||
expected: expected.scopeMode,
|
||||
});
|
||||
}
|
||||
|
||||
if (expected.dataSource === "primaryCommercial") {
|
||||
const expectedSource = parsePrimaryCommercialSourceName(catalogs.apiMap);
|
||||
checks.push({
|
||||
name: "primary commercial data source from references/api-map.md",
|
||||
pass: payload.request?.dataSource === expectedSource,
|
||||
actual: payload.request?.dataSource,
|
||||
expected: expectedSource,
|
||||
});
|
||||
}
|
||||
|
||||
if (expected.allowFallback === false) {
|
||||
checks.push({
|
||||
name: "no fallback for org-wide commercial prompt",
|
||||
pass: fallbackUsed(payload) === false,
|
||||
actual: fallbackUsed(payload),
|
||||
expected: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (expected.noDvSeTeamQueries === true) {
|
||||
const dvSeTeamQueries = (payload.apiMetrics?.queries || [])
|
||||
.filter((query) => query?.source === "DV - SE Team")
|
||||
.map((query) => query.label);
|
||||
checks.push({
|
||||
name: "no DV - SE Team execution for involvement prompt",
|
||||
pass: dvSeTeamQueries.length === 0,
|
||||
actual: dvSeTeamQueries,
|
||||
expected: [],
|
||||
});
|
||||
}
|
||||
|
||||
if (Number.isFinite(expected.minRecords)) {
|
||||
checks.push({
|
||||
name: "minimum final records",
|
||||
pass: Array.isArray(payload.records) && payload.records.length >= expected.minRecords,
|
||||
actual: Array.isArray(payload.records) ? payload.records.length : null,
|
||||
expected: `>= ${expected.minRecords}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (Number.isFinite(expected.minApiRows)) {
|
||||
checks.push({
|
||||
name: "minimum raw API rows",
|
||||
pass: payload.apiMetrics?.totalRowsPulledFromApi >= expected.minApiRows,
|
||||
actual: payload.apiMetrics?.totalRowsPulledFromApi,
|
||||
expected: `>= ${expected.minApiRows}`,
|
||||
});
|
||||
}
|
||||
|
||||
return checks;
|
||||
}
|
||||
|
||||
function buildPromptTableRow(item, result, checks, commandError = null, liveExampleContext = null) {
|
||||
const passed = checks.every((check) => check.pass);
|
||||
return {
|
||||
id: item.id,
|
||||
prompt: item.prompt,
|
||||
resultado: buildResultCell(result, checks, passed),
|
||||
trace: buildTraceCell(result, commandError, liveExampleContext, item),
|
||||
escopo: buildScopeCell(result),
|
||||
};
|
||||
}
|
||||
|
||||
function buildResultCell(result, checks, passed) {
|
||||
if (!result) {
|
||||
return `FAIL; ${checks.map((check) => `${check.name}: ${formatValue(check.actual)}`).join("; ")}`;
|
||||
}
|
||||
const records = Array.isArray(result.json?.records) ? result.json.records.length : "n/a";
|
||||
const apiRows = result.json?.apiMetrics?.totalRowsPulledFromApi ?? "n/a";
|
||||
const failedChecks = checks.filter((check) => !check.pass).map((check) => check.name);
|
||||
return [
|
||||
passed ? "PASS" : "FAIL",
|
||||
`records=${records}`,
|
||||
`apiRows=${apiRows}`,
|
||||
failedChecks.length > 0 ? `falhas=${failedChecks.join(", ")}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("; ");
|
||||
}
|
||||
|
||||
function buildTraceCell(result, commandError = null, liveExampleContext = null, item = null) {
|
||||
const resolverTrace = buildResolverTrace(liveExampleContext, item);
|
||||
if (!result) {
|
||||
return [
|
||||
resolverTrace,
|
||||
`fetch-sales-intelligence.mjs ${formatArgs(commandError?.args || [])} -> erro: ${commandError?.message || "unknown error"}`,
|
||||
].filter(Boolean).join("; ");
|
||||
}
|
||||
const payload = result.json;
|
||||
const commandTrace = `fetch-sales-intelligence.mjs ${formatArgs(result.args)} -> ok code=${result.code} durationMs=${Math.round(result.durationMs)}`;
|
||||
const source = payload?.request?.dataSource || payload?.scope?.baseSource || "unknown source";
|
||||
if (fallbackUsed(payload)) {
|
||||
const primaryError = payload?.request?.primarySqlError || firstFallbackWarning(payload) || "primary source failed";
|
||||
return [resolverTrace, `${commandTrace}; primary -> erro: ${primaryError}; fallback ${source} -> ok`]
|
||||
.filter(Boolean)
|
||||
.join("; ");
|
||||
}
|
||||
return [resolverTrace, `${commandTrace}; primary ${source} -> ok`].filter(Boolean).join("; ");
|
||||
}
|
||||
|
||||
function buildScopeCell(result) {
|
||||
if (!result?.json) return "sem payload; escopo desconhecido";
|
||||
const payload = result.json;
|
||||
const scopeMode = payload.scope?.scopeMode || payload.request?.scopeMode || "unknown";
|
||||
const source = payload.request?.dataSource || payload.scope?.baseSource || "unknown source";
|
||||
if (fallbackUsed(payload)) {
|
||||
return `${scopeMode}; caiu para fallback ${source}; ${scopeDisclosure(payload)}`;
|
||||
}
|
||||
return `${scopeMode}; sem fallback; fonte ${source}; ${scopeDisclosure(payload)}`;
|
||||
}
|
||||
|
||||
function formatPromptResultsTable(rows) {
|
||||
const header = ["id", "prompt", "resultado", "trace da execucao do agente", "descricao escopo/fallback"];
|
||||
const lines = [
|
||||
`| ${header.join(" | ")} |`,
|
||||
`| ${header.map(() => "---").join(" | ")} |`,
|
||||
...rows.map((row) =>
|
||||
`| ${markdownCell(row.id)} | ${markdownCell(row.prompt)} | ${markdownCell(row.resultado)} | ${markdownCell(row.trace)} | ${markdownCell(row.escopo)} |`
|
||||
),
|
||||
];
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function formatArgs(args) {
|
||||
return (args || [])
|
||||
.map((arg) => (arg === "--runtime-home" ? "--runtime-home <test-runtime>" : arg))
|
||||
.filter((arg, index, list) => list[index - 1] !== "--runtime-home <test-runtime>")
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function scopeDisclosure(payload) {
|
||||
return payload?.scope?.disclosureLines?.join(" ") || payload?.scope?.explanation || "";
|
||||
}
|
||||
|
||||
function buildResolverTrace(liveExampleContext, item) {
|
||||
const keys = new Set(item?.placeholderKeys || placeholderKeysForItem(item || {}));
|
||||
if (!keys.size || !liveExampleContext) return "";
|
||||
const traces = [];
|
||||
for (const trace of liveExampleContext.trace || []) {
|
||||
if (!trace.profiles?.some((profile) => keys.has(profile))) continue;
|
||||
const relevantProfiles = trace.profiles
|
||||
.filter((profile) => keys.has(profile))
|
||||
.map((profile) => `${profile}=${liveExampleContext.examples[profile]}`)
|
||||
.join(", ");
|
||||
traces.push(`${trace.script} ${formatArgs(trace.args)} -> ${trace.result}; selected ${relevantProfiles}`);
|
||||
}
|
||||
return traces.join("; ");
|
||||
}
|
||||
|
||||
function firstFallbackWarning(payload) {
|
||||
return (payload?.warnings || []).find((warning) => /fell back|fallback/i.test(warning));
|
||||
}
|
||||
|
||||
function markdownCell(value) {
|
||||
return String(value ?? "")
|
||||
.replace(/\r?\n/g, "<br>")
|
||||
.replace(/\|/g, "\\|");
|
||||
}
|
||||
|
||||
function buildPromptDiagnostic(item, result, checks) {
|
||||
return {
|
||||
test: item.id,
|
||||
prompt: item.prompt,
|
||||
input: {
|
||||
args: result ? result.args : liveArgs(item),
|
||||
},
|
||||
output: result
|
||||
? {
|
||||
records: Array.isArray(result.json?.records) ? result.json.records.length : null,
|
||||
apiRows: result.json?.apiMetrics?.totalRowsPulledFromApi ?? null,
|
||||
scopeMode: result.json?.scope?.scopeMode ?? null,
|
||||
dataSource: result.json?.request?.dataSource ?? null,
|
||||
fallbackUsed: fallbackUsed(result.json),
|
||||
warnings: result.json?.warnings || [],
|
||||
}
|
||||
: null,
|
||||
evaluation: checks.map((check) => ({
|
||||
name: check.name,
|
||||
passed: check.pass,
|
||||
expected: check.expected,
|
||||
actual: check.actual,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function fallbackUsed(payload) {
|
||||
const warningText = (payload?.warnings || []).join("\n");
|
||||
return Boolean(
|
||||
payload?.request?.fallbackUsed ||
|
||||
payload?.scope?.fallbackUsed ||
|
||||
/fell back|fallback used/i.test(warningText)
|
||||
);
|
||||
}
|
||||
|
||||
function formatValue(value) {
|
||||
return typeof value === "string" ? value : JSON.stringify(value);
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
Reference in New Issue
Block a user