diff --git a/.coveragerc b/.coveragerc index 122ffd55..e951249a 100644 --- a/.coveragerc +++ b/.coveragerc @@ -17,6 +17,8 @@ omit = */website_profiling/llm/providers/* */website_profiling/llm/* */website_profiling/llm_config.py + */website_profiling/llm_client_http.py + */website_profiling/commands/chat_cmd.py */website_profiling/cli.py */website_profiling/commands/enrich_cmd.py # FastAPI server — tested via integration tests, not unit tests diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4e977f19..d3db1bdb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -133,3 +133,23 @@ jobs: dotnet-version: '10.0.x' - name: Test Data service run: dotnet test services/Data/Data.slnx + + ai: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + - name: Test AiService + run: dotnet test services/AiService/AiService.slnx + + integrations: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + - name: Test IntegrationsService + run: dotnet test services/IntegrationsService/IntegrationsService.slnx diff --git a/AGENT.md b/AGENT.md index fcd3c030..d972c89d 100644 --- a/AGENT.md +++ b/AGENT.md @@ -4,19 +4,21 @@ Developer reference for agents and contributors. User-facing overview: [README.m **What it is:** `python -m src` from repo root (`src/__main__.py` -> package **`website_profiling`**). Config: stored in **PostgreSQL** (`pipeline_config` table, `key/value/is_unknown/updated_at`). A shadow **`pipeline-config.txt`** is auto-written to `DATA_DIR` on every Save/Run. CLI loads DB first (`DATABASE_URL`), then shadow file; `--config` overrides with a file. Reference keys: `input.txt.example` and `pipeline-config.example.txt` (not auto-loaded). -**LLM / AI:** Settings live in **`llm_config`** table in PostgreSQL. Providers: OpenAI, Google Gemini, Anthropic, Groq, Ollama (`web/src/lib/llmConfigSchema.ts`). Configure only via web UI **AI** tab (`GET/PUT /api/llm-config`, localhost). Never in `pipeline-config.txt` or `--config`. +**LLM / AI:** Settings live in **`llm_config`** (and related tables) in PostgreSQL. Providers: OpenAI, Google Gemini, Anthropic, Groq, Ollama (`web/src/lib/llmConfigSchema.ts`). **Browser writes** for API keys and LLM toggles go **BFF → AiService** (`PUT /api/secrets`, `PUT /api/llm-config`). Configure via **Secrets** (`/secrets`) and **Run audit → AI settings**. Never in `pipeline-config.txt` or `--config`. Worker/CLI calls AiService via `llm_client_http.py` (`AI_SERVICE_URL`, default `:8092`). -**Frontend:** **`web/`** (Vite + React SPA) — browser calls **`services/Bff/`** for all `/api/*`; BFF proxies to FastAPI and FileService. +**Frontend:** **`web/`** (Vite + React SPA) — browser calls **`services/Bff/`** for all `/api/*`; BFF proxies to FastAPI, AiService, Data, and FileService. **Key paths** -- `src/website_profiling/` -- `cli.py`, `config.py`, `crawl/`, `db/storage.py`, `lighthouse/`, `reporting/`, `analysis/`, `llm/`, `tools/` -- `services/Bff/` -- .NET BFF (auth, CORS, `/api/*` proxy) +- `src/website_profiling/` -- `cli.py`, `config.py`, `crawl/`, `db/storage.py`, `lighthouse/`, `reporting/`, `analysis/`, `llm_client_http.py`, `tools/` +- `services/Bff/` -- .NET BFF (auth, CORS, `/api/*` proxy to FastAPI + AiService + Data + FileService) +- `services/AiService/` -- .NET AI (chat, secrets, LLM config, MCP, enrichment; port 8092) +- `services/Data/` -- .NET read service (report payloads, portfolio, issue status; port 8091) - `services/FileService/` -- .NET PDF + Excel workbook export (HTTP-only; see [README](services/FileService/README.md)) - `web/src/` -- React SPA (`AppRoutes.tsx`, `views/`, `components/`); pipeline UI: `PipelineRunnerFab`, `PipelineContext` - `alembic/` -- schema migrations -**Local dev:** `./local-run` (Postgres in Docker `wp-pg`, FileService on `:8080`, FastAPI on `:8001`, BFF on `:8090`, Vite on `:3000`; default `DATABASE_URL`: `postgres://postgres:dev@127.0.0.1:5432/website_profiling`). See `scripts/local-run.sh`. **Local tests:** `./local-test` runs **three** Python coverage gates (core 100%, reporting 100%, tools 100%) plus web checks — mirrors CI **python** and **web** jobs; Docker CI is separate (see `.github/workflows/ci.yml`). `./local-test browser` for `@pytest.mark.browser` integration tests — see `scripts/local-test.sh`. Mocked browser unit tests: `tests/test_browser_fetcher_unit.py`. +**Local dev:** `./local-run` (Postgres in Docker `wp-pg`, FileService `:8080`, Data `:8091`, AiService `:8092`, FastAPI `:8001`, BFF `:8090`, Vite `:3000`; default `DATABASE_URL`: `postgres://postgres:dev@127.0.0.1:5432/website_profiling`). See `scripts/local-run.sh`. **Local tests:** `./local-test` runs **three** Python coverage gates (core 100%, reporting 100%, tools 100%) plus web and .NET checks — mirrors CI; Docker CI is separate (see `.github/workflows/ci.yml`). `./local-test browser` for `@pytest.mark.browser` integration tests — see `scripts/local-test.sh`. Mocked browser unit tests: `tests/test_browser_fetcher_unit.py`. **JavaScript crawl (optional):** Config keys `crawl_render_mode` (`static` | `javascript` | `auto`) and `crawl_js_*` in pipeline config / `pipelineConfigSchema.ts`. JS/auto crawls can capture browser console errors and uncaught exceptions (`crawl_js_capture_console`, stored under `page_analysis.browser`). **Auto mode** uses static-first fetch, pre-parse SPA heuristics (`needs_js_render`), then post-parse low-outlink fallback (`needs_js_render_after_parse`) in `crawler.py`. **Preflight:** `GET /api/crawl/browser-status` (localhost) spawns Python `browser_status()`; Run audit settings/run validation calls it when render mode is `javascript` or `auto`. Browser deps: Playwright from `requirements.txt` (installed by `./local-run setup` and `./local-test`). Runtime needs Chromium on `PATH` or `CHROME_PATH` (Docker sets `CHROME_PATH=/usr/bin/chromium`). Integration tests: `@pytest.mark.browser` — excluded by default in `pytest.ini`; Docker CI runs `tests/test_crawl_fetchers.py` and `tests/test_crawler_browser_e2e.py -m browser`; locally `./local-test browser`. @@ -25,15 +27,15 @@ Developer reference for agents and contributors. User-facing overview: [README.m - Run audit (CLI): `python -m src` — reads config from PostgreSQL (`pipeline_config`); shadow `DATA_DIR/pipeline-config.txt` if table empty. CLI override: `python -m src --config path` - Optional step: `crawl` | `report` | `plot` | `lighthouse` | `keywords` | `warnings` | `enrich` | `google` | `chat` - **`preserve_crawl_history`** (default true): append crawls; `false` truncates crawl tables but restores `report_payload`, Lighthouse, `google_data`, `keyword_data`, `keyword_history`, `keyword_suggest_cache`, and `crawl_runs` -- **`DATABASE_URL`** env: PostgreSQL connection string (required). **`DATA_DIR`**: secrets + shadow config (Docker: `/data`). +- **`DATABASE_URL`** env: PostgreSQL connection string (required). **`DATA_DIR`**: shadow pipeline config and local artifacts (Docker: `/data`); API keys live in Postgres via AiService. - **Pipeline storage** (crawl, edges, nodes, report payload, Lighthouse, keywords, warnings) lives in **PostgreSQL only**. Deliverables use the Export view, `GET /api/report/export`, or MCP `export_*` tools — not files written by the main pipeline step. - **Pool tuning:** `DB_POOL_MIN` / `DB_POOL_MAX` (Python). Bulk crawl writes via `executemany`; optional **`crawl_stream_to_db`** streams rows during fetch. Per-URL raw HTML: `crawl_page_html` table (migration `015`); API `GET/POST /api/crawl/page-html`. -- **Browser API (BFF):** All `/api/*` routes are served by `services/Bff/` (proxied to FastAPI / FileService). Notable: `/api/report/*`, `/api/run`, `/api/jobs/*`, `/api/pipeline-config`, `/api/llm-config`, `/api/chat` (SSE), `/api/integrations/google/*` (OAuth callback on BFF origin). `PipelineRunnerFab` saves pipeline + LLM state before each run. OpenAPI: `web/openapi.json`; BFF client: `services/Bff/src/Bff.Application/Generated/`. -- **MCP:** `python -m website_profiling.mcp` (stdio) or `python -m website_profiling.mcp.http` (remote Streamable HTTP). Configure at **`/mcp`** in the web UI. See `docs/MCP.md`. +- **Browser API (BFF):** All `/api/*` routes are served by `services/Bff/`. **FastAPI:** `/api/run`, `/api/jobs/*`, `/api/pipeline-config`, crawl, integrations (OAuth reads), properties, content drafts, etc. **AiService:** `/api/chat` (SSE), `/api/llm-config`, `/api/secrets`, `/api/ollama/status`, `/api/issues/fix-suggestion`, `/api/issues/action-plan`, `/api/dashboards/ai-generate`, `/api/content/analyze`, `/api/content/wizard`, `/api/links/page-coach`, `/api/mcp-tools`, `/api/report/audit-tool`. **Data:** report payload reads, portfolio, issue status, saved filters (see `DATA_ROUTES`). **FileService:** PDF/workbook export. `PipelineRunnerFab` saves pipeline config (FastAPI) and LLM state (`PUT /api/llm-config` → AiService) before each run. OpenAPI: `web/openapi.json` (FastAPI routes only — AiService routes are not in this spec); BFF client: `services/Bff/src/Bff.Application/Generated/`. +- **MCP:** AiService (.NET) — stdio host or HTTP at `/mcp` when `WP_MCP_HTTP=1` on `:8092`. Configure at **`/mcp`** in the web UI. See `docs/MCP.md` and [services/AiService/README.md](services/AiService/README.md). - **AI Chat UI:** `/chat` — property-scoped chat with saved sessions (`chat_sessions`, `chat_messages`; migration `012_chat_sessions`). - **Job store:** PostgreSQL `pipeline_jobs` (FastAPI); live job status via `/api/jobs/*` through the BFF. - **Schema head:** `015_crawl_page_html` (recent: `013` link_edges/discovery, `014` job log truncation, `015` per-URL HTML storage). -- **Docker:** Root `Dockerfile` (Python backend); `web/Dockerfile` (Vite SPA + nginx); `docker-compose.yml` (postgres + fastapi + worker + bff + web + FileService); **`docker-compose.prod.yml`** (production + optional MCP on `:8000`); **`docker-compose.pull.yml`** for pre-built images (`BACKEND_IMAGE`, `WEB_IMAGE`); **`LIGHTHOUSE_CHROME_FLAGS`** +- **Docker:** Root `Dockerfile` (Python backend); `web/Dockerfile` (Vite SPA + nginx); `docker-compose.yml` (postgres + fastapi + worker + ai + data + bff + web + FileService); **`docker-compose.prod.yml`** (production + optional MCP profile mapping host `:8000` → AiService `:8092`); **`docker-compose.pull.yml`** for pre-built images (`BACKEND_IMAGE`, `WEB_IMAGE`); **`LIGHTHOUSE_CHROME_FLAGS`** **Where to edit** @@ -44,12 +46,13 @@ Developer reference for agents and contributors. User-facing overview: [README.m | PDF / workbook export | `services/FileService/` (rendering); BFF routes `/api/report/export` and `/api/report/export-workbook` to FileService | | DB schema | `alembic/versions/` | | Local analysis | `analysis/local.py`, `requirements.txt` | -| AI insights (LLM) | `llm/enrich.py`, `llm/agent.py`, `llm_config.py`, `requirements.txt` | -| Audit query tools (MCP + chat) | `tools/audit_tools/`, `mcp/server.py`, `mcp/http_server.py`, `commands/chat_cmd.py` | +| AI insights (LLM) | `services/AiService/` (browser-facing), `llm_client_http.py` (worker/CLI), `llm_config.py` | +| Audit query tools (MCP + chat) | `services/AiService/src/AiService.Tools/`, `services/AiService/src/AiService.Mcp/`, `tools/audit_tools/`, `commands/chat_cmd.py` | | Agent readiness checks | `tools/audit_tools/geo/agent_readiness.py`, `tools/audit_tools/_aeo_helpers.py` | | Config / CLI | `config.py` (`load_config`, `load_config_from_db`), `cli.py`, `input.txt.example` | | UI pipeline schema | `web/src/lib/pipelineConfigSchema.ts` | | UI LLM schema | `web/src/lib/llmConfigSchema.ts` | +| UI secrets schema | `web/src/lib/secretsConfigSchema.ts`, `web/src/hooks/useSecrets.ts` | | Browser API client | `web/src/lib/publicBase.ts` (`apiUrl`, `apiFetch`, `VITE_BFF_BASE_URL`) | | D3 charts (custom / compare / overview) | `web/src/components/charts/d3/`, `web/src/lib/viz/` | | Chart.js charts (standard bar/line/doughnut) | `web/src/utils/chartJsDefaults.ts`, `react-chartjs-2` in views under `web/src/views/`, `web/src/components/searchPerformance/`, `web/src/components/traffic/` | diff --git a/AGENTS.md b/AGENTS.md index 797c472b..d7e40319 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,15 +4,17 @@ This file is the canonical entry point for agents. For full detail see [AGENT.md](AGENT.md). -**What it is:** Self-hosted SEO crawl and technical audit platform — `python -m src` from repo root. Stack: Python (crawl + analysis + MCP + FastAPI), Vite + React SPA (web UI), .NET BFF (browser API), .NET Data (report reads), PostgreSQL. +**What it is:** Self-hosted SEO crawl and technical audit platform — `python -m src` from repo root. Stack: Python (crawl + analysis + FastAPI), Vite + React SPA (web UI), .NET BFF (browser API), .NET Data (reads), .NET AiService (AI/LLM/MCP), .NET IntegrationsService (Google/Bing I/O), PostgreSQL. **Key paths** - `src/website_profiling/` — core Python package - - `cli.py`, `config.py`, `api/`, `worker/`, `crawl/`, `db/`, `reporting/`, `analysis/`, `llm/`, `tools/` + - `cli.py`, `config.py`, `api/`, `worker/`, `crawl/`, `db/`, `reporting/`, `analysis/`, `llm_client_http.py`, `tools/` - `web/` — Vite + React SPA (static nginx in prod); browser calls `services/Bff/` for all `/api/*` -- `services/Bff/` — .NET BFF (auth, CORS, proxy to FastAPI + Data + FileService) +- `services/Bff/` — .NET BFF (auth, CORS, proxy to FastAPI + IntegrationsService + Data + AiService + FileService) - `services/Data/` — .NET read service (report payloads, portfolio, issue status, filters; port 8091) +- `services/AiService/` — .NET AI service (Microsoft.Extensions.AI, chat, enrichment, MCP, **secrets/llm-config writes**; port 8092). See [services/AiService/README.md](services/AiService/README.md) +- `services/IntegrationsService/` — .NET Google/Bing integrations (GSC/GA4 fetch, OAuth, page-live, keyword reads; port 8093). See [services/IntegrationsService/README.md](services/IntegrationsService/README.md) - `services/FileService/` — .NET PDF + Excel workbook export (port 8080). HTTP-only via `REPORT_API_URL`; no Postgres. Profiles: `executive|standard|full|premium`. Details: [services/FileService/README.md](services/FileService/README.md). Env: `FILE_SERVICE_URL` (MCP), `REPORT_API_URL` (FileService). - `alembic/` — DB migrations - `docs/` — documentation index @@ -21,13 +23,15 @@ This file is the canonical entry point for agents. For full detail see [AGENT.md **Run / dev** ```bash -./local-run # Start Postgres + FileService + Data + worker + FastAPI + BFF + Vite dev server +./local-run # Start Postgres + FileService + Data + AiService + IntegrationsService + worker + FastAPI + BFF + Vite ./local-test # Python + web + .NET tests (CI parity) python -m src # Run audit pipeline -python -m website_profiling.mcp # Start MCP server (stdio) +# MCP: AiService stdio/HTTP — see services/AiService/README.md and docs/MCP.md ``` -**MCP:** 340 read-only audit tools via Model Context Protocol. See [docs/MCP.md](docs/MCP.md). +**MCP:** 369 read-only audit tools via Model Context Protocol (AiService). See [docs/MCP.md](docs/MCP.md). + +**Secrets / credentials:** Browser writes go BFF → AiService only (`PUT /api/secrets`, `PUT /api/llm-config`). Python FastAPI keeps `pipeline-config` and read-only integration routes; worker/crawl reads `llm_config` / `google_app_settings` from Postgres at runtime. **Edit targets** diff --git a/README.md b/README.md index 070bfd7f..c465f7a1 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ # Site Audit -**Developer-friendly SEO audit platform** — open-source crawl and technical audit tooling built with **React, Python, PostgreSQL, and .NET**. The stack is split into focused services: a **Python FastAPI** backend (crawl, pipeline, chat, integrations), a **.NET BFF** as the browser-facing API gateway, a **.NET Data** read service (report payloads, portfolio, issue status), and a **.NET FileService** for PDF/Excel export. +**Developer-friendly SEO audit platform** — open-source crawl and technical audit tooling built with **React, Python, PostgreSQL, and .NET**. The stack is split into focused services: **Python FastAPI** (crawl, pipeline, pipeline-config), **.NET IntegrationsService** (Google/Bing OAuth, GSC/GA4 fetch, keywords), **.NET AiService** (AI chat, LLM config, secrets, MCP), **.NET Data** (report reads, portfolio, issue status), **.NET FileService** (PDF/Excel export), and a **.NET BFF** as the single browser-facing API gateway. ## Overview @@ -71,7 +71,7 @@ Site Audit is a **developer-friendly SEO audit** tool: self-hosted, transparent, ## SEO feedback loop -Site Audit is built for a **continuous improve-and-verify cycle**, not one-off dashboard checks. Crawl your site, generate reports, expose audit data to AI agents in **Cursor, Claude Code, or Copilot** via [340 MCP tools](docs/MCP.md), fix issues in your repository, then **review** the next run to compare health scores and issue deltas. +Site Audit is built for a **continuous improve-and-verify cycle**, not one-off dashboard checks. Crawl your site, generate reports, expose audit data to AI agents in **Cursor, Claude Code, or Copilot** via [369 MCP tools](docs/MCP.md), fix issues in your repository, then **review** the next run to compare health scores and issue deltas. ```text Audit → Report → MCP → Fix → Review → (repeat) @@ -87,7 +87,7 @@ Audit → Report → MCP → Fix → Review → (repeat) |------|-------------|---------------| | **Audit** | Crawl and score the site | Pipeline (`python -m src`), Lighthouse, on-page checks | | **Report** | Export and prioritize fixes | PDF, Excel workbook, CSV, and JSON exports; issue board; fix roadmap | -| **MCP** | Pull audit context into your IDE | `python -m website_profiling.mcp` — read-only tools for Cursor / Claude Desktop | +| **MCP** | Pull audit context into your IDE | AiService MCP (stdio or HTTP on `:8092`) — see [docs/MCP.md](docs/MCP.md) | | **Fix** | Ship changes in your codebase | Your PR workflow (MCP does not write to the site) | | **Review** | Prove improvement | Compare runs, category deltas, GSC metric changes | @@ -134,18 +134,27 @@ Site Audit focuses on **honest, self-hosted technical SEO**. It is not a drop-in -Also included: **AI chat** over audit data (optional), **Content studio** (write & optimize with live SEO scoring), **340 MCP tools** (local stdio or remote Streamable HTTP), image SEO, GEO/AEO readiness, keyword explorer (GSC + on-site), backlinks (GSC Links import), compare runs, portfolio management for agencies, and the **agent-driven feedback loop** above. +Also included: **AI chat** over audit data (optional), **Content studio** (write & optimize with live SEO scoring), **369 MCP tools** (AiService stdio or remote Streamable HTTP), image SEO, GEO/AEO readiness, keyword explorer (GSC + on-site), backlinks (GSC Links import), compare runs, portfolio management for agencies, and the **agent-driven feedback loop** above. Site Audit — developer-friendly SEO audit preview ## Architecture -```text -Browser → web (:3000) → bff (:8090) → fastapi (:8001) crawl, pipeline, chat, integrations - │ data (:8091) report reads, portfolio, issue status, filters - │ files (:8080) PDF + Excel export - worker background pipeline jobs (same Python image) - postgres audit data store +```mermaid +flowchart TB + Browser([Browser]) --> Web["web :3000
React SPA"] + Web --> BFF["bff :8090
.NET API gateway"] + + BFF --> FastAPI["fastapi :8001
Crawl · pipeline · pipeline-config"] + BFF --> Integrations["integrations :8093
Google/Bing OAuth · GSC/GA4 · keywords"] + BFF --> Ai["ai :8092
Chat · LLM config · secrets · MCP"] + BFF --> Data["data :8091
Report reads · portfolio · issue status"] + BFF --> Files["files :8080
PDF · Excel export"] + + subgraph Background["Background"] + Worker["worker
Pipeline jobs (same Python image)"] + Postgres[("postgres
Audit data store")] + end ``` ``` @@ -159,9 +168,8 @@ WebsiteProfiling/ │ ├── content_studio/ # Content writing + live SEO scoring │ ├── lighthouse/ # Lighthouse runner │ ├── integrations/ # Google Search Console, GA4, Bing, CrUX -│ ├── llm/ # AI enrich + chat agent -│ ├── tools/ # Exports, audit query tools, MCP helpers -│ ├── mcp/ # MCP server (stdio + remote HTTP, domain bundles) +│ ├── llm_client_http.py # Worker/CLI → AiService HTTP client (enrichment, page coach, …) +│ ├── tools/ # Exports, audit query tools, audit-tool bridge │ ├── db/ # PostgreSQL storage layer │ ├── commands/ # CLI subcommands │ ├── cli.py # Pipeline entrypoint @@ -173,6 +181,8 @@ WebsiteProfiling/ │ ├── src/lib/ # Client helpers, BFF apiUrl/apiFetch │ └── public/ # Static assets (logo, favicon) ├── services/Bff/ # .NET BFF — auth + /api/* proxy (port 8090) +├── services/IntegrationsService/ # .NET Google/Bing integrations — OAuth, GSC/GA4, keywords (port 8093) +├── services/AiService/ # .NET AI — chat, secrets, LLM config, MCP, enrichment (port 8092) ├── services/Data/ # .NET read service — report/portfolio/issue reads (port 8091) ├── services/FileService/ # .NET PDF + Excel workbook export (port 8080) ├── alembic/versions/ # PostgreSQL schema migrations @@ -194,9 +204,11 @@ WebsiteProfiling/ | Path | Purpose | | ------------------------------------- | ------------------------------------------------------------------------------ | -| `src/website_profiling/` | Crawl, analyze, report, Lighthouse, integrations, AI — run via `python -m src` | -| `src/website_profiling/api/` | FastAPI HTTP layer — pipeline, chat, integrations, crawl control | -| `services/Bff/` | Browser API gateway — auth, CORS, routes `/api/*` to FastAPI, Data, FileService | +| `src/website_profiling/` | Crawl, analyze, report, Lighthouse — run via `python -m src` | +| `src/website_profiling/api/` | FastAPI — pipeline, crawl, pipeline-config (not secrets/chat/integrations) | +| `services/Bff/` | Browser API gateway — auth, CORS, routes `/api/*` to FastAPI, IntegrationsService, AiService, Data, FileService | +| `services/IntegrationsService/` | Google/Bing OAuth, GSC/GA4 fetch, keywords, page-live — see [services/IntegrationsService/README.md](services/IntegrationsService/README.md) | +| `services/AiService/` | AI chat, secrets, LLM config, MCP, enrichment — see [services/AiService/README.md](services/AiService/README.md) | | `services/Data/` | .NET read/mutation service for report payloads, portfolio, issue status, saved filters | | `services/FileService/` | PDF and Excel workbook export — see [services/FileService/README.md](services/FileService/README.md) | | `web/src/lib/publicBase.ts` | BFF base URL (`VITE_BFF_BASE_URL`) and `apiFetch` / `apiUrl` | @@ -204,7 +216,7 @@ WebsiteProfiling/ | `alembic/versions/` | Database migrations — run `./local-run migrate` | | `tests/` | Backend tests; `./local-test browser` for Playwright crawl integration | | `docs/MCP.md` | MCP server setup for IDE and agent integrations | -| `data/` | Local secrets and shadow `pipeline-config.txt` (gitignored) | +| `data/` | Shadow `pipeline-config.txt` and local artifacts (gitignored; secrets live in Postgres) | For layout details and common development patterns, see [AGENT.md](AGENT.md). @@ -218,7 +230,7 @@ For layout details and common development patterns, see [AGENT.md](AGENT.md). | **Docker** | Postgres container (local dev) and full-stack compose | | **Python 3.12+** | Audit engine, FastAPI, pipeline worker, tests | | **Node 20+** | Vite + React SPA | -| **.NET SDK 10+** | BFF, Data, and FileService (required for `./local-run`; optional if you only use Docker) | +| **.NET SDK 10+** | BFF, IntegrationsService, AiService, Data, and FileService (required for `./local-run`; optional if you only use Docker) | ### Docker @@ -228,9 +240,9 @@ Build and run the full dev stack from source: docker compose up --build ``` -Services: **postgres**, **fastapi** (`:8001`, internal), **worker**, **data** (`:8091`, internal), **bff** (`:8090`), **web** (`:3000`), **files** (`:8080`, internal). +Services: **postgres**, **fastapi** (`:8001`, internal), **worker**, **integrations** (`:8093`, internal), **ai** (`:8092`, internal), **data** (`:8091`, internal), **bff** (`:8090`), **web** (`:3000`), **files** (`:8080`, internal). -Open [http://localhost:3000/home](http://localhost:3000/home). The browser talks only to the **BFF** (`:8090`); the BFF proxies to FastAPI, the Data service (report reads and portfolio routes), and FileService (PDF/workbook export). +Open [http://localhost:3000/home](http://localhost:3000/home). The browser talks only to the **BFF** (`:8090`); the BFF proxies to FastAPI (crawl/pipeline), IntegrationsService (Google/Bing), AiService (AI/secrets), Data (report reads), and FileService (PDF/workbook export). Production deployment: `docker-compose.prod.yml` — set `POSTGRES_USER`, `POSTGRES_PASSWORD`, `AUTH_SECRET`, `BFF_ALLOWED_ORIGINS`, and `BFF_PUBLIC_URL`. Optional remote MCP: `docker compose -f docker-compose.prod.yml --profile mcp up`. Pre-built images: `docker-compose.pull.yml` (`BACKEND_IMAGE`, `WEB_IMAGE`). @@ -245,7 +257,7 @@ Production deployment: `docker-compose.prod.yml` — set `POSTGRES_USER`, `POSTG ./local-prod # Same DB, Vite production build + preview (no hot reload) ``` -`./local-run` starts (in order): **FileService** `:8080`, **Data** `:8091`, **pipeline worker**, **FastAPI** `:8001`, **BFF** `:8090`, and **Vite** `:3000`. Use `localhost` (not `127.0.0.1`) for pipeline APIs so CORS and cookies match the BFF origin. +`./local-run` starts (in order): **FileService** `:8080`, **Data** `:8091`, **AiService** `:8092` (MCP HTTP enabled), **IntegrationsService** `:8093`, **pipeline worker**, **FastAPI** `:8001`, **BFF** `:8090`, and **Vite** `:3000`. Use `localhost` (not `127.0.0.1`) for pipeline APIs so CORS and cookies match the BFF origin. Default local `DATABASE_URL`: `postgres://postgres:dev@127.0.0.1:5432/website_profiling` (Docker Compose dev stack uses `profiling:profiling`). @@ -265,16 +277,16 @@ Increase `PIPELINE_JOB_STALE_HOURS` for crawls that routinely exceed one hour. ### Testing ```bash -./local-test # Full CI parity: Python + web + .NET (Data, Bff, FileService) +./local-test # Full CI parity: Python + web + .NET (Data, AiService, Bff, FileService) ./local-test python # Backend: three 100% coverage gates + browser pytest + CLI smoke ./local-test browser # JS crawl integration tests (skips if Chromium unavailable) ./local-test web # Frontend: build, typecheck, lint, vitest -./local-test dotnet # dotnet test Data + Bff + FileService + BFF OpenAPI drift gate +./local-test dotnet # dotnet test Data + AiService + Bff + FileService + BFF OpenAPI drift gate ./local-test quick # Fast loop; requires DB already running (no coverage gate) ./local-test all --no-cov # Full run without pytest coverage gate ``` -CI runs separate jobs for **Python** (coverage gates), **web**, **Data**, **Bff**, **FileService**, and **Docker** (image build, browser pytest in container, compose smoke). See [.github/workflows/ci.yml](.github/workflows/ci.yml). +CI runs separate jobs for **Python** (coverage gates), **web**, **Data**, **AiService**, **IntegrationsService**, **Bff**, **FileService**, and **Docker** (image build, browser pytest in container, compose smoke). See [.github/workflows/ci.yml](.github/workflows/ci.yml). ## Configuration @@ -298,9 +310,13 @@ The overlay enriches keywords that have no Search Console impressions with `plan In Audit settings, set **Crawl rendering** to `javascript` (always headless Chromium) or `auto` (static first, browser when SPA heuristics match). Requires Playwright from `requirements.txt` and Chromium on `PATH` or `CHROME_PATH` (included in Docker). The UI preflights via `GET /api/crawl/browser-status` before runs when JS or auto mode is selected. +### Secrets and AI settings + +API keys, Google OAuth app credentials, and remote MCP tokens are saved on the **Secrets** page (`/secrets`) — browser writes go **BFF → AiService** (`PUT /api/secrets`). LLM provider/model toggles and risk settings use **`PUT /api/llm-config`** (also AiService). Audit pipeline keys remain on **`PUT /api/pipeline-config`** (FastAPI). The pipeline run FAB saves LLM state via `/api/llm-config` before each run. + ### AI chat (optional) -Ask questions about audit data at [http://localhost:3000/chat](http://localhost:3000/chat). Enable a provider under **Run audit → AI settings** (`llm_enabled`, provider, model). `./local-run setup` installs Python deps from `requirements.txt` (including `httpx`, OpenAI, Anthropic, and Groq SDKs; Gemini uses `httpx` via REST). +Ask questions about audit data at [http://localhost:3000/chat](http://localhost:3000/chat). Enable a provider under **Run audit → AI settings** (`llm_enabled`, provider, model) or on **Secrets**. AiService handles chat, tool dispatch, and streaming; `./local-run` starts it on `:8092`. | Provider | Notes | @@ -311,7 +327,7 @@ Ask questions about audit data at [http://localhost:3000/chat](http://localhost: | **Groq** | API key in AI settings or `GROQ_API_KEY`; official Groq Python SDK; native tool calling with streaming. Default model `openai/gpt-oss-120b`. | -The agent uses the same **340 read-only audit tools** as the MCP server ([docs/MCP.md](docs/MCP.md)), with **dynamic routing** (~45 tools per turn). Responses stream over SSE (`POST /api/chat`). Sessions persist per property (`chat_sessions` / `chat_messages`). +The agent uses the same **369 read-only audit tools** as the MCP server ([docs/MCP.md](docs/MCP.md)), with **dynamic routing** (~45 tools per turn). Responses stream over SSE (`POST /api/chat` via BFF → AiService). Sessions persist per property (`chat_sessions` / `chat_messages`). **Read-only SQL chat tool (opt-in):** Set `CHAT_SQL_TOOL_ENABLED=true` to expose `get_sql_schema` and `run_sql_query` to the LLM. The agent can then answer arbitrary data questions by generating and executing a single read-only SELECT. Queries are validated by a four-layer guard (regex pre-filter → `sqlglot` AST + table allowlist → `BEGIN TRANSACTION READ ONLY` → optional least-privilege DB role); DELETE/UPDATE/INSERT/DDL and non-allowlisted tables are always blocked. In multi-property deployments, scope-binding CTEs are automatically injected to enforce tenant isolation. See [docs/OPS.md](docs/OPS.md#read-only-sql-chat-tool) for setup including the recommended `audit_readonly` Postgres role and optional RLS configuration. @@ -337,6 +353,8 @@ Contributions are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for setup and | [docs/COMPANY_STANDARDS.md](docs/COMPANY_STANDARDS.md) | Data and security policy | | [docs/MCP.md](docs/MCP.md) | MCP server setup | | [docs/OPS.md](docs/OPS.md) | Scheduled audits, alerts, production ops | +| [services/AiService/README.md](services/AiService/README.md) | AI chat, secrets, LLM config, MCP | +| [services/IntegrationsService/README.md](services/IntegrationsService/README.md) | Google/Bing OAuth, GSC/GA4, keywords | ## Star History diff --git a/SECURITY.md b/SECURITY.md index fb009999..1f4ef05c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -26,4 +26,4 @@ If you find a vulnerability **in Site Audit itself** (e.g. remote code execution - Run production deployments with strong `POSTGRES_PASSWORD` and `AUTH_SECRET` (see `docker-compose.prod.yml`). - For client-facing dashboards, set `AUTH_DEFAULT_ROLE=client-readonly` (view reports and use chat) or `viewer` (view reports only). API enforces 403 on mutations; UI hides **Run audit** for read-only roles. -- Do not commit `.env`, `.secrets/`, or OAuth client secrets. Google credentials are stored in PostgreSQL (`google_app_settings` and per-property columns on `properties`). +- Do not commit `.env`, `.secrets/`, or OAuth client secrets. Google credentials and API keys are stored in PostgreSQL (`google_app_settings`, `llm_config`, and per-property columns on `properties`). Browser writes to these stores go through the BFF → **AiService** (`PUT /api/secrets`, `PUT /api/llm-config`) — not directly to FastAPI. diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index ee323c2b..a8050b18 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -32,6 +32,7 @@ services: CHROME_PATH: /usr/bin/chromium LIGHTHOUSE_PATH: /usr/local/bin/lighthouse LIGHTHOUSE_CHROME_FLAGS: --headless --no-sandbox --disable-dev-shm-usage --disable-gpu + DEPRECATE_PYTHON_INTEGRATIONS: "1" volumes: - profiling-data:/data healthcheck: @@ -60,12 +61,15 @@ services: LIGHTHOUSE_PATH: /usr/local/bin/lighthouse LIGHTHOUSE_CHROME_FLAGS: --headless --no-sandbox --disable-dev-shm-usage --disable-gpu FILE_SERVICE_URL: http://files:8080 + AI_SERVICE_URL: http://ai:8092 + INTEGRATIONS_SERVICE_URL: http://integrations:8093 volumes: - profiling-data:/data data: build: - context: ./services/Data + context: ./services + dockerfile: Data/Dockerfile environment: DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-website_profiling} ASPNETCORE_URLS: http://+:8091 @@ -79,9 +83,56 @@ services: retries: 3 start_period: 15s + ai: + build: + context: ./services + dockerfile: AiService/Dockerfile + environment: + DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-website_profiling} + FASTAPI_URL: http://fastapi:8001 + ASPNETCORE_URLS: http://+:8092 + WP_MCP_HTTP: "1" + depends_on: + postgres: + condition: service_healthy + fastapi: + condition: service_started + healthcheck: + test: ['CMD', 'curl', '-fsS', 'http://127.0.0.1:8092/health'] + interval: 30s + timeout: 5s + retries: 3 + start_period: 20s + + integrations: + build: + context: ./services + dockerfile: IntegrationsService/Dockerfile + environment: + DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-website_profiling} + ASPNETCORE_URLS: http://+:8093 + FASTAPI_URL: http://fastapi:8001 + USE_FASTAPI_PYTHON_BRIDGE: "1" + ASPNETCORE_ENVIRONMENT: Production + AUTH_SECRET: ${AUTH_SECRET:?set AUTH_SECRET} + APP_PUBLIC_URL: ${APP_PUBLIC_URL:-} + GOOGLE_REDIRECT_URI: ${GOOGLE_REDIRECT_URI:-} + depends_on: + postgres: + condition: service_healthy + fastapi: + condition: service_started + healthcheck: + test: ['CMD', 'curl', '-fsS', 'http://127.0.0.1:8093/health'] + interval: 30s + timeout: 5s + retries: 3 + start_period: 20s + files: build: - context: ./services/FileService + context: ./services + dockerfile: FileService/Dockerfile environment: DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-website_profiling} REPORT_API_URL: http://fastapi:8001 @@ -101,13 +152,21 @@ services: condition: service_started data: condition: service_healthy + ai: + condition: service_healthy + integrations: + condition: service_healthy ports: - '${BFF_PORT:-8090}:8090' environment: FASTAPI_URL: http://fastapi:8001 FILE_SERVICE_URL: http://files:8080 DATA_SERVICE_URL: http://data:8091 + AI_SERVICE_URL: http://ai:8092 + INTEGRATIONS_SERVICE_URL: http://integrations:8093 DATA_ROUTES: "/api/report/meta,/api/report/payload,/api/report/history,/api/report/crawl-payload,/api/report/mobile-delta,/api/report/portfolio,/api/portfolio,/api/issues/status,/api/filters" + AI_ROUTES: "/api/chat,/api/links/page-coach,/api/issues/fix-suggestion,/api/issues/action-plan,/api/ai/fix-suggestion,/api/dashboards/ai-generate,/api/content/analyze,/api/content/wizard,/api/llm-config,/api/secrets,/api/ollama/status,/api/report/audit-tool,/api/mcp-tools" + INTEGRATIONS_ROUTES: "/api/integrations/google,/api/integrations/bing" AUTH_SECRET: ${AUTH_SECRET:?set AUTH_SECRET} AUTH_PASSWORD: ${AUTH_PASSWORD:-} BFF_ALLOWED_ORIGINS: ${BFF_ALLOWED_ORIGINS:?set BFF_ALLOWED_ORIGINS (the public UI origin)} @@ -134,28 +193,23 @@ services: start_period: 30s mcp: - image: website-profiling:latest + build: + context: ./services + dockerfile: AiService/Dockerfile depends_on: - postgres: + ai: condition: service_healthy - files: - condition: service_started - command: ['/opt/venv/bin/python', '-m', 'website_profiling.mcp.http'] environment: - WEBSITE_PROFILING_ROOT: /app DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-website_profiling} - FILE_SERVICE_URL: http://files:8080 - WP_MCP_HTTP_HOST: 0.0.0.0 - WP_MCP_HTTP_PORT: 8000 - WP_MCP_TOKEN: ${WP_MCP_TOKEN:?set WP_MCP_TOKEN} - WP_MCP_ALLOWED_HOSTS: ${WP_MCP_ALLOWED_HOSTS:-} - WP_MCP_ALLOWED_ORIGINS: ${WP_MCP_ALLOWED_ORIGINS:-} + FASTAPI_URL: http://fastapi:8001 + ASPNETCORE_URLS: http://+:8092 + WP_MCP_HTTP: "1" WP_MCP_DOMAIN: ${WP_MCP_DOMAIN:-core} WP_PROPERTY_ID: ${WP_PROPERTY_ID:-} profiles: - mcp ports: - - '${MCP_PORT:-8000}:8000' + - '${MCP_PORT:-8000}:8092' volumes: pg-data: diff --git a/docker-compose.pull.yml b/docker-compose.pull.yml index 21976f64..246eb6df 100644 --- a/docker-compose.pull.yml +++ b/docker-compose.pull.yml @@ -34,6 +34,7 @@ services: LIGHTHOUSE_PATH: /usr/local/bin/lighthouse LIGHTHOUSE_CHROME_FLAGS: --headless --no-sandbox --disable-dev-shm-usage --disable-gpu FASTAPI_ALLOWED_ORIGINS: "http://localhost:8090" + DEPRECATE_PYTHON_INTEGRATIONS: "1" volumes: - profiling-data:/data healthcheck: @@ -62,12 +63,15 @@ services: LIGHTHOUSE_PATH: /usr/local/bin/lighthouse LIGHTHOUSE_CHROME_FLAGS: --headless --no-sandbox --disable-dev-shm-usage --disable-gpu FILE_SERVICE_URL: http://files:8080 + AI_SERVICE_URL: http://ai:8092 + INTEGRATIONS_SERVICE_URL: http://integrations:8093 volumes: - profiling-data:/data data: build: - context: ./services/Data + context: ./services + dockerfile: Data/Dockerfile environment: DATABASE_URL: postgres://profiling:profiling@postgres:5432/website_profiling ASPNETCORE_URLS: http://+:8091 @@ -81,9 +85,54 @@ services: retries: 3 start_period: 15s + ai: + build: + context: ./services + dockerfile: AiService/Dockerfile + environment: + DATABASE_URL: postgres://profiling:profiling@postgres:5432/website_profiling + FASTAPI_URL: http://fastapi:8001 + ASPNETCORE_URLS: http://+:8092 + depends_on: + postgres: + condition: service_healthy + fastapi: + condition: service_started + healthcheck: + test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8092/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 20s + + integrations: + build: + context: ./services + dockerfile: IntegrationsService/Dockerfile + environment: + DATABASE_URL: postgres://profiling:profiling@postgres:5432/website_profiling + ASPNETCORE_URLS: http://+:8093 + FASTAPI_URL: http://fastapi:8001 + USE_FASTAPI_PYTHON_BRIDGE: "1" + AUTH_SECRET: ${AUTH_SECRET:-} + GOOGLE_REDIRECT_URI: ${GOOGLE_REDIRECT_URI:-http://localhost:8090/api/integrations/google/callback} + APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://localhost:3000} + depends_on: + postgres: + condition: service_healthy + fastapi: + condition: service_started + healthcheck: + test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8093/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 20s + files: build: - context: ./services/FileService + context: ./services + dockerfile: FileService/Dockerfile environment: DATABASE_URL: postgres://profiling:profiling@postgres:5432/website_profiling REPORT_API_URL: http://fastapi:8001 @@ -102,8 +151,12 @@ services: FASTAPI_URL: http://fastapi:8001 FILE_SERVICE_URL: http://files:8080 DATA_SERVICE_URL: http://data:8091 + AI_SERVICE_URL: http://ai:8092 + INTEGRATIONS_SERVICE_URL: http://integrations:8093 BFF_ALLOWED_ORIGINS: "http://localhost:3000" DATA_ROUTES: "/api/report/meta,/api/report/payload,/api/report/history,/api/report/crawl-payload,/api/report/mobile-delta,/api/report/portfolio,/api/portfolio,/api/issues/status,/api/filters" + AI_ROUTES: "/api/chat,/api/links/page-coach,/api/issues/fix-suggestion,/api/issues/action-plan,/api/ai/fix-suggestion,/api/dashboards/ai-generate,/api/content/analyze,/api/content/wizard,/api/llm-config,/api/secrets,/api/ollama/status,/api/report/audit-tool,/api/mcp-tools" + INTEGRATIONS_ROUTES: "/api/integrations/google,/api/integrations/bing" depends_on: fastapi: condition: service_started @@ -111,6 +164,10 @@ services: condition: service_started data: condition: service_healthy + ai: + condition: service_healthy + integrations: + condition: service_healthy web: image: ${WEB_IMAGE:-website-profiling-web:latest} diff --git a/docker-compose.yml b/docker-compose.yml index 21adbe90..329a8a20 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -33,6 +33,7 @@ services: LIGHTHOUSE_PATH: /usr/local/bin/lighthouse LIGHTHOUSE_CHROME_FLAGS: --headless --no-sandbox --disable-dev-shm-usage --disable-gpu FASTAPI_ALLOWED_ORIGINS: "http://localhost:8090" + DEPRECATE_PYTHON_INTEGRATIONS: "1" volumes: - profiling-data:/data healthcheck: @@ -61,6 +62,8 @@ services: LIGHTHOUSE_PATH: /usr/local/bin/lighthouse LIGHTHOUSE_CHROME_FLAGS: --headless --no-sandbox --disable-dev-shm-usage --disable-gpu FILE_SERVICE_URL: http://files:8080 + AI_SERVICE_URL: http://ai:8092 + INTEGRATIONS_SERVICE_URL: http://integrations:8093 volumes: - profiling-data:/data @@ -68,7 +71,8 @@ services: # Internal: only the BFF reaches it (no published host port). data: build: - context: ./services/Data + context: ./services + dockerfile: Data/Dockerfile environment: DATABASE_URL: postgres://profiling:profiling@postgres:5432/website_profiling ASPNETCORE_URLS: http://+:8091 @@ -82,6 +86,51 @@ services: retries: 3 start_period: 15s + ai: + build: + context: ./services + dockerfile: AiService/Dockerfile + environment: + DATABASE_URL: postgres://profiling:profiling@postgres:5432/website_profiling + FASTAPI_URL: http://fastapi:8001 + ASPNETCORE_URLS: http://+:8092 + WP_MCP_HTTP: "1" + depends_on: + postgres: + condition: service_healthy + fastapi: + condition: service_started + healthcheck: + test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8092/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 20s + + integrations: + build: + context: ./services + dockerfile: IntegrationsService/Dockerfile + environment: + DATABASE_URL: postgres://profiling:profiling@postgres:5432/website_profiling + ASPNETCORE_URLS: http://+:8093 + FASTAPI_URL: http://fastapi:8001 + USE_FASTAPI_PYTHON_BRIDGE: "1" + AUTH_SECRET: ${AUTH_SECRET:-} + GOOGLE_REDIRECT_URI: ${GOOGLE_REDIRECT_URI:-http://localhost:8090/api/integrations/google/callback} + APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://localhost:3000} + depends_on: + postgres: + condition: service_healthy + fastapi: + condition: service_started + healthcheck: + test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8093/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 20s + # .NET BFF — the single browser-facing API surface (owns auth + CORS). bff: build: @@ -93,10 +142,12 @@ services: FILE_SERVICE_URL: http://files:8080 BFF_ALLOWED_ORIGINS: "http://localhost:3000" DATA_SERVICE_URL: http://data:8091 - # Comma-separated /api path prefixes served by the Data service (reads + issue/portfolio mutations). - # Rollback: remove a prefix here and restart bff. + AI_SERVICE_URL: http://ai:8092 + INTEGRATIONS_SERVICE_URL: http://integrations:8093 + AUTH_SECRET: ${AUTH_SECRET:-} DATA_ROUTES: "/api/report/meta,/api/report/payload,/api/report/history,/api/report/crawl-payload,/api/report/mobile-delta,/api/report/portfolio,/api/portfolio,/api/issues/status,/api/filters" - # AUTH_SECRET: set to enable wp_session auth (must match the web service) + AI_ROUTES: "/api/chat,/api/links/page-coach,/api/issues/fix-suggestion,/api/issues/action-plan,/api/ai/fix-suggestion,/api/dashboards/ai-generate,/api/content/analyze,/api/content/wizard,/api/llm-config,/api/secrets,/api/ollama/status,/api/report/audit-tool,/api/mcp-tools" + INTEGRATIONS_ROUTES: "/api/integrations/google,/api/integrations/bing" depends_on: fastapi: condition: service_started @@ -104,6 +155,10 @@ services: condition: service_started data: condition: service_healthy + ai: + condition: service_healthy + integrations: + condition: service_healthy # Vite SPA (nginx). The browser calls the BFF (:8090) for all /api/*. web: @@ -126,7 +181,8 @@ services: # File export service (PDF/Excel). Internal: only the BFF reaches it. files: build: - context: ./services/FileService + context: ./services + dockerfile: FileService/Dockerfile environment: DATABASE_URL: postgres://profiling:profiling@postgres:5432/website_profiling REPORT_API_URL: http://fastapi:8001 @@ -135,6 +191,12 @@ services: condition: service_healthy fastapi: condition: service_started + healthcheck: + test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8080/health || exit 1"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 15s volumes: pg-data: diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md index f6d11f2d..865ca21e 100644 --- a/docs/GLOSSARY.md +++ b/docs/GLOSSARY.md @@ -50,7 +50,7 @@ This glossary maps agency-facing UI terms to internal keys, database tables, and | Content brief | Keywords Brief button, `/api/keywords/content-brief` | LLM or deterministic | Content planning | | AI fix suggestions | `llm_recommendation`, `/api/ai/fix-suggestion` | LLM on demand + report build | Actionable remediation | | AI Chat | `/chat`, `/api/chat`, `chat_sessions` | LLM + read-only audit tools | Conversational audit queries | -| MCP tools | `python -m website_profiling.mcp` | Same `audit_tools` as chat | IDE integration — see [MCP.md](MCP.md) | +| MCP tools | AiService MCP (stdio or HTTP) | Same audit tool catalog as chat | IDE integration — see [MCP.md](MCP.md) | | Read-only session | `AUTH_DEFAULT_ROLE=client-readonly` or `viewer`; `/api/auth/session` returns role and mutation flags | Session cookie | `client-readonly`: view + chat; `viewer`: view only (no chat) | | Export executive summary | Export view; MCP `export_audit_report` (pdf/csv/json); workbook via Export view or FileService | Report payload + optional AI | Client deliverable | | ads.txt / security.txt | `site_level`, `get_ads_txt_status`, `get_security_txt_status` | Root file fetch at report build | Publisher / contact file hygiene | diff --git a/docs/MCP.md b/docs/MCP.md index 7a41f19d..ea22fcee 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -1,8 +1,11 @@ # MCP Server Reference -Site Audit exposes **340 read-only tools** via the [Model Context Protocol (MCP)](https://modelcontextprotocol.io). Connect from Cursor, Claude Desktop, or any MCP-compatible client to query audit data programmatically. +Site Audit exposes **369 read-only tools** via the [Model Context Protocol (MCP)](https://modelcontextprotocol.io). The MCP server runs in **AiService** (.NET) — see [services/AiService/README.md](../services/AiService/README.md). -The same tool catalog powers in-app **AI Chat** at `/chat`. +- **stdio:** `dotnet run --project services/AiService/src/AiService.Api` with stdio MCP host (see AiService.Mcp) +- **HTTP:** set `WP_MCP_HTTP=1` on AiService → endpoint `/mcp` (port 8092 by default) + +The same tool catalog powers in-app **AI Chat** at `/chat` (also served by AiService via the BFF). **Related documentation:** [GLOSSARY.md](GLOSSARY.md) · [Documentation index](README.md) @@ -25,26 +28,36 @@ The same tool catalog powers in-app **AI Chat** at `/chat`. ## Prerequisites +[.NET SDK 10+](https://dotnet.microsoft.com/download), Postgres, and `./local-run` (or AiService + FastAPI manually). AiService needs FastAPI on `:8001` for the audit-tool bridge until all tools are native C#. + ```bash -pip install -r requirements.txt export DATABASE_URL=postgres://profiling:profiling@localhost:5432/website_profiling # Docker default # ./local-run default: postgres://postgres:dev@127.0.0.1:5432/website_profiling -export PYTHONPATH=src +export FASTAPI_URL=http://127.0.0.1:8001 ``` -Start the local stdio server: +### Local stdio (IDE subprocess) + +From the repo root, with Postgres running: ```bash -python -m website_profiling.mcp +cd services/AiService +export DATABASE_URL=postgres://postgres:dev@127.0.0.1:5432/website_profiling +export FASTAPI_URL=http://127.0.0.1:8001 +dotnet run --project src/AiService.Api ``` +For a dedicated stdio MCP host, use `AddAiServiceMcpStdioHost()` (see `AiService.Mcp/McpServerExtensions.cs`). `./local-run` starts AiService with **`WP_MCP_HTTP=1`** so HTTP MCP is available at **http://localhost:8092/mcp**. + For remote access over HTTP, see [Remote Streamable HTTP](#remote-streamable-http). +> **Legacy:** `python -m website_profiling.mcp` still exists under `src/website_profiling/mcp/` but is no longer the recommended path — use AiService. + --- ## Domain-scoped servers -Rather than loading all 340 tools in a single server, Site Audit supports **domain-scoped bundles**. Connect only the domains relevant to your workflow. +Rather than loading all 369 tools in a single server, Site Audit supports **domain-scoped bundles**. Connect only the domains relevant to your workflow. | `WP_MCP_DOMAIN` | Tool count | Scope | Recommended use | |-----------------|------------|-------|-----------------| @@ -52,9 +65,9 @@ Rather than loading all 340 tools in a single server, Site Audit supports **doma | `crawl` | Domain subset | Crawl, on-page, schema, accessibility | Technical crawl audits | | `google` | Domain subset | Google, insight, CTR, keywords | GSC/GA4 analysis | | `links` | Domain subset | Links, backlinks, indexation | Link architecture | -| `full` | 340 | All tools | Debugging, legacy single-server setup | +| `full` | 369 | All tools | Debugging, legacy single-server setup | -Tier 0 alone includes 16 router/insight tools (`TIER_0_TOOLS` in `tool_domains.py`). Use the `audit://tools` resource or `WP_MCP_DOMAIN=full` for the complete catalog. +Tier 0 alone includes 16 router/insight tools. Use the `audit://tools` resource or `WP_MCP_DOMAIN=full` for the complete catalog. Set `WP_PROPERTY_ID` to the default property when tools omit an explicit `property_id` argument. @@ -64,27 +77,27 @@ Set `WP_PROPERTY_ID` to the default property when tools omit an explicit `proper ### Multi-domain setup (recommended) -Add to `.cursor/mcp.json` or your MCP client settings: +Add to `.cursor/mcp.json` or your MCP client settings (stdio — spawn AiService; ensure Postgres and FastAPI are up): ```json { "mcpServers": { "site-audit-core": { - "command": "python", - "args": ["-m", "website_profiling.mcp"], + "command": "dotnet", + "args": ["run", "--project", "services/AiService/src/AiService.Api", "--no-launch-profile"], "env": { - "DATABASE_URL": "postgres://profiling:profiling@localhost:5432/website_profiling", - "PYTHONPATH": "src", + "DATABASE_URL": "postgres://postgres:dev@127.0.0.1:5432/website_profiling", + "FASTAPI_URL": "http://127.0.0.1:8001", "WP_MCP_DOMAIN": "core", "WP_PROPERTY_ID": "1" } }, "site-audit-google": { - "command": "python", - "args": ["-m", "website_profiling.mcp"], + "command": "dotnet", + "args": ["run", "--project", "services/AiService/src/AiService.Api", "--no-launch-profile"], "env": { - "DATABASE_URL": "postgres://profiling:profiling@localhost:5432/website_profiling", - "PYTHONPATH": "src", + "DATABASE_URL": "postgres://postgres:dev@127.0.0.1:5432/website_profiling", + "FASTAPI_URL": "http://127.0.0.1:8001", "WP_MCP_DOMAIN": "google", "WP_PROPERTY_ID": "1" } @@ -99,11 +112,11 @@ Add to `.cursor/mcp.json` or your MCP client settings: { "mcpServers": { "site-audit": { - "command": "python", - "args": ["-m", "website_profiling.mcp"], + "command": "dotnet", + "args": ["run", "--project", "services/AiService/src/AiService.Api", "--no-launch-profile"], "env": { - "DATABASE_URL": "postgres://profiling:profiling@localhost:5432/website_profiling", - "PYTHONPATH": "src", + "DATABASE_URL": "postgres://postgres:dev@127.0.0.1:5432/website_profiling", + "FASTAPI_URL": "http://127.0.0.1:8001", "WP_MCP_DOMAIN": "full", "WP_PROPERTY_ID": "1" } @@ -120,40 +133,38 @@ Use this when Site Audit runs on a hosted server and your MCP client (Cursor, Cl ### Start the HTTP server -Configure access on **MCP settings** (`/mcp`) in the web UI (recommended), or set environment variables. UI changes apply on the next MCP request without restarting the service. +Configure access on **MCP settings** (`/mcp`) in the web UI (recommended), or set environment variables on **AiService**. `./local-run` and Docker Compose set `WP_MCP_HTTP=1` on AiService (`:8092`). Production compose can publish host port `8000` → AiService `8092` via the `mcp` profile. ```bash export DATABASE_URL=postgres://profiling:profiling@localhost:5432/website_profiling -export PYTHONPATH=src -export WP_MCP_HTTP_HOST=0.0.0.0 -export WP_MCP_HTTP_PORT=8000 +export FASTAPI_URL=http://127.0.0.1:8001 +export ASPNETCORE_URLS=http://0.0.0.0:8092 +export WP_MCP_HTTP=1 export WP_MCP_DOMAIN=core export WP_PROPERTY_ID=1 -python -m website_profiling.mcp.http +cd services/AiService && dotnet run --project src/AiService.Api ``` Set **MCP bearer token** and **Allowed hostnames** on the Secrets page (or via `WP_MCP_TOKEN` / `WP_MCP_ALLOWED_HOSTS`). Environment variables override saved values when set. -The MCP endpoint is `http://:8000/mcp` by default (`WP_MCP_HTTP_PATH=/mcp`). +The MCP endpoint is **`http://:8092/mcp`** locally (`8092` is AiService's default port). With `docker-compose.prod.yml --profile mcp`, the host port defaults to **`8000`** mapped to AiService `8092`. ### Environment variables | Variable | Default | Purpose | |----------|---------|---------| -| `WP_MCP_HTTP_HOST` | `127.0.0.1` | Bind address (`0.0.0.0` for Docker) | -| `WP_MCP_HTTP_PORT` | `8000` | Listen port | -| `WP_MCP_HTTP_PATH` | `/mcp` | Mount path | +| `WP_MCP_HTTP` | unset | Set `1` on AiService to expose `/mcp` | +| `ASPNETCORE_URLS` | `http://+:8092` | AiService bind address | | `WP_MCP_TOKEN` | unset | Bearer token (**required** when not binding localhost). Save on **Secrets → Remote MCP** or set here (env wins). | | `WP_MCP_ALLOWED_HOSTS` | unset | Comma-separated `Host` allowlist (**required** for non-localhost bind). Save on **Secrets → Remote MCP** or set here. | | `WP_MCP_ALLOWED_ORIGINS` | unset | Comma-separated `Origin` allowlist for browser clients | -| `WP_MCP_JSON_RESPONSE` | `false` | JSON responses instead of SSE streams | | `WP_MCP_DOMAIN` | `core` | Tool bundle (same as stdio) | | `WP_PROPERTY_ID` | unset | Default property (same as stdio) | -**Security:** `WP_MCP_TOKEN` is required when `WP_MCP_HTTP_HOST` is not localhost. Tools are read-only but expose audit, GSC, and GA4 data — treat the token like a database credential. +**Security:** `WP_MCP_TOKEN` is required when AiService is reachable outside localhost. Tools are read-only but expose audit, GSC, and GA4 data — treat the token like a database credential. -**DNS rebinding protection:** Whenever a token **and** allowed hosts are configured — via the UI **or** environment variables — the HTTP service enforces the bearer token plus the Host/Origin allowlist in its own middleware, and the MCP SDK's built-in DNS-rebinding check is turned off (the middleware supersedes it). The SDK check only applies as a fallback on a non-localhost bind that has no remote access configured (a state the startup validation otherwise refuses to boot in). Either way, set `WP_MCP_ALLOWED_HOSTS` to the public hostname clients use (e.g. `audit.example.com`). +**DNS rebinding protection:** When a token **and** allowed hosts are configured — via the UI **or** environment variables — AiService enforces the bearer token plus the Host/Origin allowlist. Set `WP_MCP_ALLOWED_HOSTS` to the public hostname clients use (e.g. `audit.example.com`). ### Cursor / Claude Desktop (remote) diff --git a/docs/README.md b/docs/README.md index e8503538..8393a7d9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,8 +15,26 @@ This directory contains product, integration, and operations documentation for * | [COMPANY_STANDARDS.md](COMPANY_STANDARDS.md) | Agencies / operators | Data classification, crawl scope, security policy | | [MCP.md](MCP.md) | Integrators | Model Context Protocol server configuration and tool reference | | [OPS.md](OPS.md) | Operators | Scheduled audits, alerts, migrations, production notes | +| [services/AiService/README.md](../services/AiService/README.md) | Developers | AI chat, secrets, LLM config, MCP, enrichment (port 8092) | +| [services/IntegrationsService/README.md](../services/IntegrationsService/README.md) | Developers | Google/Bing OAuth, GSC/GA4 fetch, keywords (port 8093) | | [services/FileService/README.md](../services/FileService/README.md) | Developers / operators | PDF and Excel workbook export service | -| `services/Data/` | Developers | .NET read service — report payloads, portfolio, issue status, saved filters | +| `services/Data/` | Developers | .NET read service — report payloads, portfolio, issue status, saved filters (port 8091) | + +--- + +## API routing (browser) + +All `/api/*` calls from the SPA go to the **BFF** (`:8090`). The BFF forwards subsets to: + +| Upstream | Examples | +|----------|----------| +| **FastAPI** (`:8001`) | `/api/run`, `/api/pipeline-config`, crawl, properties | +| **IntegrationsService** (`:8093`) | `/api/integrations/google/*`, `/api/integrations/bing/*`, property Google config | +| **AiService** (`:8092`) | `/api/chat`, `/api/secrets`, `/api/llm-config`, MCP-related APIs | +| **Data** (`:8091`) | Report payload reads, portfolio, issue status, saved filters | +| **FileService** (`:8080`) | PDF and Excel export | + +See [AGENT.md](../AGENT.md) for the full route split and [services/AiService/README.md](../services/AiService/README.md) for `AI_ROUTES`. --- diff --git a/scripts/cleanup_junk_properties.py b/scripts/cleanup_junk_properties.py new file mode 100755 index 00000000..50c35482 --- /dev/null +++ b/scripts/cleanup_junk_properties.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +"""Remove junk property rows created while typing a Site URL (partial domains).""" +from __future__ import annotations + +import os +import sys + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +sys.path.insert(0, os.path.join(ROOT, "src")) + +from website_profiling.db.pool import db_session +from website_profiling.db.property_store import delete_property, is_valid_canonical_domain, list_properties_public + + +def main() -> int: + dry_run = "--dry-run" in sys.argv + with db_session() as conn: + props = list_properties_public(conn) + junk = [p for p in props if not is_valid_canonical_domain(str(p.get("canonical_domain") or ""))] + if not junk: + print("No junk properties found.") + return 0 + print(f"{'Would delete' if dry_run else 'Deleting'} {len(junk)} junk propert{'y' if len(junk) == 1 else 'ies'}:") + for p in junk: + print(f" id={p['id']} domain={p.get('canonical_domain')!r}") + if dry_run: + return 0 + deleted = 0 + for p in junk: + if delete_property(conn, int(p["id"])): + deleted += 1 + print(f"Deleted {deleted} properties.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/local-prod.sh b/scripts/local-prod.sh index eceb7ef3..655f5104 100755 --- a/scripts/local-prod.sh +++ b/scripts/local-prod.sh @@ -1,13 +1,15 @@ #!/usr/bin/env bash -# Local prod: same Postgres as ./local-run, Vite build + preview (NODE_ENV=production). +# Local prod: same Postgres as ./local-run, full stack + Vite build + preview (NODE_ENV=production). # Usage: ./local-prod [command] -# (default) start — DB, migrations, npm run build, npm run preview +# (default) start — DB, migrations, .NET stack, worker, FastAPI, vite preview # build — npm run build only # help — show commands set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT" +# shellcheck source=scripts/local-run-common.sh +source "$ROOT/scripts/local-run-common.sh" PG_CONTAINER="${WP_PG_CONTAINER:-wp-pg}" PG_PORT="${WP_PG_PORT:-5432}" @@ -21,6 +23,9 @@ export PYTHON="${PYTHON:-$ROOT/.venv/bin/python}" export WEBSITE_PROFILING_ROOT="$ROOT" export PYTHONPATH="${PYTHONPATH:+$PYTHONPATH:}$ROOT/src" export NODE_ENV=production +export VITE_BFF_BASE_URL="${VITE_BFF_BASE_URL:-http://localhost:8090}" +export DEPRECATE_PYTHON_INTEGRATIONS="${DEPRECATE_PYTHON_INTEGRATIONS:-1}" +export USE_FASTAPI_PYTHON_BRIDGE="${USE_FASTAPI_PYTHON_BRIDGE:-1}" WEB="$ROOT/web" LOCAL_RUN="$ROOT/scripts/local-run.sh" @@ -55,10 +60,12 @@ wait_for_pid() { stop_service() { local name="$1" local pid="$2" + local port="${3:-}" [[ -z "$pid" ]] && return 0 if ! kill -0 "$pid" 2>/dev/null; then wait "$pid" 2>/dev/null || true log "$name already stopped." + [[ -n "$port" ]] && free_port "$port" return 0 fi log "Stopping $name (PID $pid)..." @@ -70,6 +77,7 @@ stop_service() { fi wait "$pid" 2>/dev/null || true log "$name stopped." + [[ -n "$port" ]] && free_port "$port" } disown_bg() { @@ -107,8 +115,8 @@ cmd_web_deps() { cmd_build() { cmd_web_deps - log "Building Vite SPA (production)" - (cd "$WEB" && npm run build) + log "Building Vite SPA (production, VITE_BFF_BASE_URL=$VITE_BFF_BASE_URL)" + (cd "$WEB" && VITE_BFF_BASE_URL="$VITE_BFF_BASE_URL" npm run build) } cmd_start() { @@ -119,6 +127,8 @@ cmd_start() { esac done + need_cmd dotnet + mkdir -p "$DATA_DIR" log "Ensuring Postgres and migrations (via ./local-run migrate)" "$LOCAL_RUN" migrate @@ -128,13 +138,14 @@ cmd_start() { cmd_web_deps log "Skipping build (--skip-build)" fi - log "Starting Vite preview server (Ctrl+C stops all services including Postgres)" + log "Starting local prod stack (Ctrl+C stops all services including Postgres)" log "DATABASE_URL=$DATABASE_URL" log "DATA_DIR=$DATA_DIR" - log "PYTHON=$PYTHON" - log "NODE_ENV=$NODE_ENV" + log "VITE_BFF_BASE_URL=$VITE_BFF_BASE_URL" + log "INTEGRATIONS_SERVICE_URL=${INTEGRATIONS_SERVICE_URL:-http://127.0.0.1:8093}" cd "$ROOT" export DATABASE_URL DATA_DIR PYTHON WEBSITE_PROFILING_ROOT PYTHONPATH NODE_ENV + export VITE_BFF_BASE_URL DEPRECATE_PYTHON_INTEGRATIONS USE_FASTAPI_PYTHON_BRIDGE WORKER_PID="" UVICORN_PID="" @@ -153,7 +164,8 @@ cmd_start() { log "Shutting down local prod stack..." stop_service "Vite preview" "$NPM_PID" NPM_PID="" - stop_service "FastAPI" "$UVICORN_PID" + stop_host_dotnet_stack stop_service + stop_service "FastAPI" "$UVICORN_PID" 8001 UVICORN_PID="" stop_service "pipeline worker" "$WORKER_PID" WORKER_PID="" @@ -163,6 +175,15 @@ cmd_start() { } trap cleanup_prod EXIT INT TERM + start_host_dotnet_base "$ROOT" Production + disown_bg "$FILE_SERVICE_PID" + disown_bg "$DATA_PID" + disown_bg "$AI_PID" + + export AI_SERVICE_URL="${AI_SERVICE_URL:-http://127.0.0.1:8092}" + export INTEGRATIONS_SERVICE_URL="${INTEGRATIONS_SERVICE_URL:-http://127.0.0.1:8093}" + export FILE_SERVICE_URL="${FILE_SERVICE_URL:-http://127.0.0.1:8080}" + log "Starting pipeline worker" "$ROOT/.venv/bin/python" -m website_profiling.worker & WORKER_PID=$! @@ -170,10 +191,16 @@ cmd_start() { log "Starting FastAPI on port 8001" export FASTAPI_URL="http://127.0.0.1:8001" + export FASTAPI_ALLOWED_ORIGINS="http://localhost:8090" "$ROOT/.venv/bin/uvicorn" website_profiling.api.main:app \ --host 0.0.0.0 --port 8001 --workers 1 & UVICORN_PID=$! disown_bg "$UVICORN_PID" + wait_for_http "http://127.0.0.1:8001/api/health" "FastAPI" 90 || die "FastAPI failed to start" + + start_host_integrations_bff "$ROOT" Production + disown_bg "$INTEGRATIONS_PID" + disown_bg "$BFF_PID" cd "$WEB" npm run preview -- --host 0.0.0.0 --port 3000 & @@ -186,10 +213,10 @@ cmd_start() { cmd_help() { cat </data) - AUTH_SECRET (optional — enables login when set) + VITE_BFF_BASE_URL (default: http://localhost:8090 — baked into the SPA at build time) + AUTH_SECRET (optional — required for Google OAuth in Production ASP.NET mode) + GOOGLE_REDIRECT_URI, APP_PUBLIC_URL (Google OAuth callback + post-login redirect) WP_PG_CONTAINER, WP_PG_PORT, WP_PG_PASSWORD, WP_PG_DB After start, open: http://localhost:3000/home Use localhost (not 127.0.0.1) for pipeline APIs. Dev mode with hot reload: ./local-run start +Docker prod layout: docker compose -f docker-compose.prod.yml up EOF } diff --git a/scripts/local-run-common.sh b/scripts/local-run-common.sh new file mode 100644 index 00000000..855b3d44 --- /dev/null +++ b/scripts/local-run-common.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash +# Shared helpers for local-run.sh and local-prod.sh (source, do not execute directly). + +free_port() { + local port="$1" + local pids + pids="$(lsof -nP -tiTCP:"$port" -sTCP:LISTEN 2>/dev/null || true)" + if [[ -n "$pids" ]]; then + printf '\033[1;33m!\033[0m Stopping stale listener on port %s (PID(s): %s)\n' "$port" "${pids//$'\n'/ }" >&2 + # shellcheck disable=SC2086 + kill $pids 2>/dev/null || true + sleep 0.3 + fi +} + +wait_for_http() { + local url="$1" + local name="$2" + local timeout="${3:-60}" + local i + for ((i = 0; i < timeout; i++)); do + if curl -fsS "$url" >/dev/null 2>&1; then + return 0 + fi + sleep 1 + done + printf '\033[1;31m✗\033[0m %s did not become ready at %s\n' "$name" "$url" >&2 + return 1 +} + +start_host_dotnet_base() { + local root="$1" + local mode="${2:-Development}" + + export REPORT_API_URL="${REPORT_API_URL:-http://127.0.0.1:8001}" + export AI_SERVICE_URL="${AI_SERVICE_URL:-http://127.0.0.1:8092}" + export INTEGRATIONS_SERVICE_URL="${INTEGRATIONS_SERVICE_URL:-http://127.0.0.1:8093}" + export FILE_SERVICE_URL="${FILE_SERVICE_URL:-http://127.0.0.1:8080}" + + free_port 8080 + printf '\033[1;36m→\033[0m Starting FileService on port 8080\n' + (cd "$root/services/FileService" && \ + ASPNETCORE_URLS="http://127.0.0.1:8080" \ + ASPNETCORE_ENVIRONMENT="$mode" \ + dotnet run --project src/FileService.Api --no-launch-profile) & + FILE_SERVICE_PID=$! + + free_port 8091 + printf '\033[1;36m→\033[0m Starting Data service on port 8091\n' + (cd "$root/services/Data" && \ + DATABASE_URL="$DATABASE_URL" \ + ASPNETCORE_URLS="http://127.0.0.1:8091" \ + ASPNETCORE_ENVIRONMENT="$mode" \ + dotnet run --project src/Data.Api --no-launch-profile) & + DATA_PID=$! + + free_port 8092 + printf '\033[1;36m→\033[0m Starting AiService on port 8092\n' + (cd "$root/services/AiService" && \ + DATABASE_URL="$DATABASE_URL" \ + FASTAPI_URL="http://127.0.0.1:8001" \ + ASPNETCORE_URLS="http://127.0.0.1:8092" \ + ASPNETCORE_ENVIRONMENT="$mode" \ + WP_MCP_HTTP=1 \ + dotnet run --project src/AiService.Api --no-launch-profile) & + AI_PID=$! + + wait_for_http "http://127.0.0.1:8080/health" "FileService" + wait_for_http "http://127.0.0.1:8091/health" "Data service" + wait_for_http "http://127.0.0.1:8092/health" "AiService" +} + +start_host_integrations_bff() { + local root="$1" + local mode="${2:-Development}" + + free_port 8093 + printf '\033[1;36m→\033[0m Starting IntegrationsService on port 8093\n' + (cd "$root/services/IntegrationsService" && \ + DATABASE_URL="$DATABASE_URL" \ + FASTAPI_URL="http://127.0.0.1:8001" \ + USE_FASTAPI_PYTHON_BRIDGE="${USE_FASTAPI_PYTHON_BRIDGE:-1}" \ + ASPNETCORE_URLS="http://127.0.0.1:8093" \ + ASPNETCORE_ENVIRONMENT="$mode" \ + AUTH_SECRET="${AUTH_SECRET:-}" \ + SESSION_SECRET="${SESSION_SECRET:-}" \ + GOOGLE_REDIRECT_URI="${GOOGLE_REDIRECT_URI:-http://localhost:8090/api/integrations/google/callback}" \ + APP_PUBLIC_URL="${APP_PUBLIC_URL:-http://localhost:3000}" \ + dotnet run --project src/IntegrationsService.Api --no-launch-profile) & + INTEGRATIONS_PID=$! + wait_for_http "http://127.0.0.1:8093/health" "IntegrationsService" + + free_port 8090 + printf '\033[1;36m→\033[0m Starting BFF on port 8090\n' + (cd "$root/services/Bff" && \ + FASTAPI_URL="http://127.0.0.1:8001" \ + FILE_SERVICE_URL="$FILE_SERVICE_URL" \ + DATA_SERVICE_URL="http://127.0.0.1:8091" \ + AI_SERVICE_URL="$AI_SERVICE_URL" \ + INTEGRATIONS_SERVICE_URL="$INTEGRATIONS_SERVICE_URL" \ + DATA_ROUTES="${DATA_ROUTES:-/api/report/meta,/api/report/payload,/api/report/history,/api/report/crawl-payload,/api/report/mobile-delta,/api/report/portfolio,/api/portfolio,/api/issues/status,/api/filters}" \ + AI_ROUTES="${AI_ROUTES:-/api/chat,/api/links/page-coach,/api/issues/fix-suggestion,/api/issues/action-plan,/api/ai/fix-suggestion,/api/dashboards/ai-generate,/api/content/analyze,/api/content/wizard,/api/llm-config,/api/secrets,/api/ollama/status,/api/report/audit-tool,/api/mcp-tools}" \ + INTEGRATIONS_ROUTES="${INTEGRATIONS_ROUTES:-/api/integrations/google,/api/integrations/bing}" \ + BFF_ALLOWED_ORIGINS="${BFF_ALLOWED_ORIGINS:-http://localhost:3000}" \ + AUTH_SECRET="${AUTH_SECRET:-}" \ + SESSION_SECRET="${SESSION_SECRET:-}" \ + AUTH_PASSWORD="${AUTH_PASSWORD:-}" \ + AUTH_USER="${AUTH_USER:-}" \ + ASPNETCORE_URLS="http://127.0.0.1:8090" \ + ASPNETCORE_ENVIRONMENT="$mode" \ + dotnet run --project src/Bff.Api --no-launch-profile) & + BFF_PID=$! + wait_for_http "http://127.0.0.1:8090/health" "BFF" +} + +start_host_dotnet_stack() { + local root="$1" + local mode="${2:-Development}" + start_host_dotnet_base "$root" "$mode" + start_host_integrations_bff "$root" "$mode" +} + +stop_host_dotnet_stack() { + local stop_service_fn="$1" + "$stop_service_fn" "BFF" "${BFF_PID:-}" 8090 + BFF_PID="" + "$stop_service_fn" "IntegrationsService" "${INTEGRATIONS_PID:-}" 8093 + INTEGRATIONS_PID="" + "$stop_service_fn" "AiService" "${AI_PID:-}" 8092 + AI_PID="" + "$stop_service_fn" "Data" "${DATA_PID:-}" 8091 + DATA_PID="" + "$stop_service_fn" "FileService" "${FILE_SERVICE_PID:-}" 8080 + FILE_SERVICE_PID="" +} diff --git a/scripts/local-run.sh b/scripts/local-run.sh index b911562b..81580ba3 100755 --- a/scripts/local-run.sh +++ b/scripts/local-run.sh @@ -12,6 +12,8 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT" +# shellcheck source=scripts/local-run-common.sh +source "$ROOT/scripts/local-run-common.sh" PG_CONTAINER="${WP_PG_CONTAINER:-wp-pg}" PG_IMAGE="${WP_PG_IMAGE:-postgres:16-alpine}" @@ -33,19 +35,6 @@ log() { printf '\033[1;36m→\033[0m %s\n' "$*"; } warn() { printf '\033[1;33m!\033[0m %s\n' "$*" >&2; } die() { printf '\033[1;31m✗\033[0m %s\n' "$*" >&2; exit 1; } -# Kill any process still listening on a TCP port (stale dev servers after Ctrl+C). -free_port() { - local port="$1" - local pids - pids="$(lsof -nP -tiTCP:"$port" -sTCP:LISTEN 2>/dev/null || true)" - if [[ -n "$pids" ]]; then - warn "Stopping stale listener on port $port (PID(s): ${pids//$'\n'/ })" - # shellcheck disable=SC2086 - kill $pids 2>/dev/null || true - sleep 0.3 - fi -} - # Send signal to a process and its descendants (dotnet/npm subshell trees). kill_process_tree() { local pid="$1" @@ -122,6 +111,23 @@ wait_for_postgres() { die "Postgres did not become ready in time (container: $PG_CONTAINER)" } +wait_for_http_logged() { + local url="$1" + local label="$2" + local timeout="${3:-90}" + local i + need_cmd curl + log "Waiting for $label ($url)" + for ((i = 0; i < timeout; i++)); do + if curl -fsS "$url" >/dev/null 2>&1; then + log "$label ready" + return 0 + fi + sleep 1 + done + die "$label did not become ready in ${timeout}s ($url)" +} + cmd_db() { ensure_docker if docker ps -a --format '{{.Names}}' | grep -qx "$PG_CONTAINER"; then @@ -209,7 +215,9 @@ cmd_start() { UVICORN_PID="" FILE_SERVICE_PID="" DATA_PID="" + AI_PID="" BFF_PID="" + INTEGRATIONS_PID="" _CLEANUP_DONE=0 set +m @@ -223,16 +231,13 @@ cmd_start() { log "Shutting down local dev stack..." # Reverse startup order; Vite (foreground) is already exiting from Ctrl+C. - stop_service "BFF" "$BFF_PID" 8090 - BFF_PID="" - stop_service "Data" "$DATA_PID" 8091 - DATA_PID="" + if command -v dotnet >/dev/null 2>&1; then + stop_host_dotnet_stack stop_service + fi stop_service "FastAPI" "$UVICORN_PID" 8001 UVICORN_PID="" stop_service "pipeline worker" "$WORKER_PID" WORKER_PID="" - stop_service "FileService" "$FILE_SERVICE_PID" 8080 - FILE_SERVICE_PID="" stop_postgres log "All services stopped." exit 0 @@ -240,30 +245,21 @@ cmd_start() { trap cleanup_local EXIT INT TERM if command -v dotnet >/dev/null 2>&1; then - free_port 8080 - log "Starting FileService on port 8080" - export REPORT_API_URL="http://127.0.0.1:8001" - (cd "$ROOT/services/FileService" && \ - ASPNETCORE_URLS="http://127.0.0.1:8080" \ - ASPNETCORE_ENVIRONMENT=Development \ - dotnet run --project src/FileService.Api --no-launch-profile) & - FILE_SERVICE_PID=$! + start_host_dotnet_base "$ROOT" Development disown_bg "$FILE_SERVICE_PID" - - free_port 8091 - log "Starting Data service on port 8091" - (cd "$ROOT/services/Data" && \ - DATABASE_URL="$DATABASE_URL" \ - ASPNETCORE_URLS="http://127.0.0.1:8091" \ - ASPNETCORE_ENVIRONMENT=Development \ - dotnet run --project src/Data.Api --no-launch-profile) & - DATA_PID=$! disown_bg "$DATA_PID" + disown_bg "$AI_PID" else warn "dotnet not found — PDF export requires FileService (see services/FileService/README.md)" warn "dotnet not found — Data service unavailable on port 8091" + warn "dotnet not found — AiService unavailable on port 8092" + warn "dotnet not found — IntegrationsService unavailable on port 8093" fi + export AI_SERVICE_URL="${AI_SERVICE_URL:-http://127.0.0.1:8092}" + export INTEGRATIONS_SERVICE_URL="${INTEGRATIONS_SERVICE_URL:-http://127.0.0.1:8093}" + export FILE_SERVICE_URL="${FILE_SERVICE_URL:-http://127.0.0.1:8080}" + log "Starting pipeline worker" "$VENV/bin/python" -m website_profiling.worker & WORKER_PID=$! @@ -273,24 +269,16 @@ cmd_start() { log "Starting FastAPI on port 8001" export FASTAPI_URL="http://127.0.0.1:8001" export FASTAPI_ALLOWED_ORIGINS="http://localhost:8090" + export DEPRECATE_PYTHON_INTEGRATIONS="${DEPRECATE_PYTHON_INTEGRATIONS:-1}" "$VENV/bin/uvicorn" website_profiling.api.main:app \ --host 0.0.0.0 --port 8001 --workers 1 & UVICORN_PID=$! disown_bg "$UVICORN_PID" + wait_for_http_logged "http://127.0.0.1:8001/api/health" "FastAPI" if command -v dotnet >/dev/null 2>&1; then - free_port 8090 - log "Starting BFF on port 8090" - (cd "$ROOT/services/Bff" && \ - FASTAPI_URL="http://127.0.0.1:8001" \ - FILE_SERVICE_URL="${FILE_SERVICE_URL:-http://127.0.0.1:8080}" \ - DATA_SERVICE_URL="http://127.0.0.1:8091" \ - DATA_ROUTES="${DATA_ROUTES:-/api/report/meta,/api/report/payload,/api/report/history,/api/report/crawl-payload,/api/report/mobile-delta,/api/report/portfolio,/api/portfolio,/api/issues/status,/api/filters}" \ - BFF_ALLOWED_ORIGINS="http://localhost:3000" \ - ASPNETCORE_URLS="http://127.0.0.1:8090" \ - ASPNETCORE_ENVIRONMENT=Development \ - dotnet run --project src/Bff.Api --no-launch-profile) & - BFF_PID=$! + start_host_integrations_bff "$ROOT" Development + disown_bg "$INTEGRATIONS_PID" disown_bg "$BFF_PID" else warn "dotnet not found — browser API calls need the BFF (see services/Bff/)" @@ -359,6 +347,9 @@ Environment overrides (optional): DATABASE_URL (default: postgres://postgres:dev@127.0.0.1:5432/website_profiling) DATA_DIR (default: /data) DATA_ROUTES (default: report reads, portfolio, issues status, saved filters) + INTEGRATIONS_ROUTES (default: /api/integrations/google,/api/integrations/bing) + AUTH_SECRET, GOOGLE_REDIRECT_URI, APP_PUBLIC_URL (Google OAuth) + DEPRECATE_PYTHON_INTEGRATIONS (default: 1 — Python integration routes return 410) WP_PG_CONTAINER, WP_PG_PORT, WP_PG_PASSWORD, WP_PG_DB After start, open: http://localhost:3000/home diff --git a/scripts/local-test.sh b/scripts/local-test.sh index dd2c1d7c..240d7c7d 100755 --- a/scripts/local-test.sh +++ b/scripts/local-test.sh @@ -332,6 +332,7 @@ steps_dotnet() { return 0 fi run_step "dotnet test Data (services/Data/Data.slnx)" dotnet_test_sln "Data" "Data.slnx" + run_step "dotnet test AiService (services/AiService/AiService.slnx)" dotnet_test_sln "AiService" "AiService.slnx" run_step "dotnet test Bff (services/Bff/Bff.slnx)" dotnet_test_sln "Bff" "Bff.slnx" run_step_or_skip_openapi run_step "dotnet test FileService (services/FileService/FileService.slnx)" dotnet_test_sln "FileService" "FileService.slnx" diff --git a/services/.dockerignore b/services/.dockerignore new file mode 100644 index 00000000..ab41d99e --- /dev/null +++ b/services/.dockerignore @@ -0,0 +1,4 @@ +**/bin/ +**/obj/ +**/.vs/ +**/TestResults/ diff --git a/services/AiService/.dockerignore b/services/AiService/.dockerignore new file mode 100644 index 00000000..ab41d99e --- /dev/null +++ b/services/AiService/.dockerignore @@ -0,0 +1,4 @@ +**/bin/ +**/obj/ +**/.vs/ +**/TestResults/ diff --git a/services/AiService/AiService.slnx b/services/AiService/AiService.slnx new file mode 100644 index 00000000..f4558068 --- /dev/null +++ b/services/AiService/AiService.slnx @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/services/AiService/Dockerfile b/services/AiService/Dockerfile new file mode 100644 index 00000000..54711f16 --- /dev/null +++ b/services/AiService/Dockerfile @@ -0,0 +1,24 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY Shared/WebsiteProfiling.Contracts/WebsiteProfiling.Contracts.csproj Shared/WebsiteProfiling.Contracts/ +COPY AiService/AiService.slnx AiService/ +COPY AiService/src/AiService.Domain/AiService.Domain.csproj AiService/src/AiService.Domain/ +COPY AiService/src/AiService.Providers/AiService.Providers.csproj AiService/src/AiService.Providers/ +COPY AiService/src/AiService.Tools/AiService.Tools.csproj AiService/src/AiService.Tools/ +COPY AiService/src/AiService.Application/AiService.Application.csproj AiService/src/AiService.Application/ +COPY AiService/src/AiService.Mcp/AiService.Mcp.csproj AiService/src/AiService.Mcp/ +COPY AiService/src/AiService.Api/AiService.Api.csproj AiService/src/AiService.Api/ +RUN dotnet restore AiService/src/AiService.Api/AiService.Api.csproj +COPY Shared/WebsiteProfiling.Contracts/ Shared/WebsiteProfiling.Contracts/ +COPY AiService/src/ AiService/src/ +RUN dotnet publish AiService/src/AiService.Api/AiService.Api.csproj -c Release -o /app/publish --no-restore + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime +WORKDIR /app +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* +ENV ASPNETCORE_URLS=http://+:8092 +EXPOSE 8092 +COPY --from=build /app/publish . +ENTRYPOINT ["dotnet", "AiService.Api.dll"] diff --git a/services/AiService/README.md b/services/AiService/README.md new file mode 100644 index 00000000..b79bfc0b --- /dev/null +++ b/services/AiService/README.md @@ -0,0 +1,87 @@ +# AiService + +Standalone .NET microservice for **all AI/LLM functionality** in Site Audit, built on [Microsoft.Extensions.AI](https://www.nuget.org/packages/Microsoft.Extensions.AI). + +Python FastAPI retains crawl, pipeline, and integrations. AiService owns chat, structured completions, enrichment, **secrets and LLM config writes**, MCP, and audit-tool dispatch for the chat agent. + +## Run locally + +Prerequisites: [.NET SDK 10+](https://dotnet.microsoft.com/download), Postgres, FastAPI on port 8001 (audit-tool bridge for tools not yet native in C#). + +```bash +cd services/AiService +export DATABASE_URL=postgres://postgres:dev@127.0.0.1:5432/website_profiling +export FASTAPI_URL=http://127.0.0.1:8001 +dotnet run --project src/AiService.Api +``` + +Service listens on **http://localhost:8092**. Swagger UI in Development: **http://localhost:8092/docs**. + +`./local-run` starts AiService automatically (with `WP_MCP_HTTP=1`). The BFF routes browser AI requests here via `AI_SERVICE_URL` and `AI_ROUTES`. + +## BFF routing (`AI_ROUTES`) + +Browser clients never call AiService directly. The BFF proxies these paths (see `docker-compose.yml` / `services/Bff/src/Bff.Api/appsettings.Development.json`): + +| Path | Purpose | +|------|---------| +| `/api/chat` | Property-scoped chat (SSE) | +| `/api/llm-config` | LLM provider/model and risk toggles | +| `/api/secrets` | API keys, Google OAuth app credentials, MCP token | +| `/api/ollama/status` | Local Ollama catalog | +| `/api/issues/fix-suggestion`, `/api/issues/action-plan` | Issue AI helpers | +| `/api/ai/fix-suggestion` | Legacy fix-suggestion alias | +| `/api/dashboards/ai-generate` | Dashboard AI | +| `/api/content/analyze`, `/api/content/wizard` | Content studio AI | +| `/api/links/page-coach` | Page coach | +| `/api/report/audit-tool` | Internal audit-tool dispatch (also used by tool bridge) | +| `/api/mcp-tools` | MCP tool catalog API | + +## Secrets and config stores + +`SecretsService` unifies **GET/PUT `/api/secrets`** across Postgres tables: + +| Store | Examples | +|-------|----------| +| `llm_config` | `OPENAI_API_KEY`, `llm_enabled`, provider/model | +| `pipeline_config` | Integration keys routed from Secrets UI (e.g. `PERPLEXITY_API_KEY`) | +| `google_app_settings` | Google OAuth client ID/secret (app-wide) | + +`LlmConfigRepository` **merges** partial PUT payloads so Risk Settings toggles do not wipe API keys. Per-property Google tokens remain on `properties` (Integrations UI). + +## Environment variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `DATABASE_URL` | — | Postgres (`llm_config`, `llm_cache`, `chat_sessions`, `pipeline_config`, `google_app_settings`, `report_payload`) | +| `FASTAPI_URL` | `http://127.0.0.1:8001` | Python audit-tool bridge for unported tools | +| `AI_SERVICE_URL` | — | Used by Python worker (`llm_client_http.py`) | +| `ASPNETCORE_URLS` | `http://+:8092` | Bind address | +| `WP_MCP_HTTP` | — | Set `1` to expose MCP at `/mcp` | +| `WP_MCP_DOMAIN` | `core` | MCP tool bundle: core, crawl, google, links, full | + +## Architecture + +``` +BFF (:8090) → AiService (:8092) + ├─ Microsoft.Extensions.AI (OpenAI, Groq, Ollama, Anthropic, Gemini) + ├─ SecretsService → llm_config / pipeline_config / google_app_settings + ├─ ToolDispatcher → native C# handlers + Python bridge (369 tools) + └─ MCP (stdio or HTTP on /mcp) +Python worker → POST /internal/enrichment/* and llm_client_http → AiService +``` + +## Projects + +| Project | Role | +|---------|------| +| `AiService.Api` | HTTP controllers (paths match former FastAPI routes) | +| `AiService.Application` | Services, repos, chat agent, enrichment, secrets | +| `AiService.Providers` | `IChatClientFactory`, structured JSON completions | +| `AiService.Tools` | Audit tool catalog, dispatch, payload slices | +| `AiService.Mcp` | Model Context Protocol server | +| `AiService.Domain` | Entities and repository interfaces | + +## OpenAPI + +AiService exposes Swagger at `/docs` in Development. The web app's `web/openapi.json` is generated from **FastAPI only** — it does not include AiService routes. Use AiService Swagger or BFF proxy paths when integrating. diff --git a/services/AiService/src/AiService.Api/AiService.Api.csproj b/services/AiService/src/AiService.Api/AiService.Api.csproj new file mode 100644 index 00000000..444cc79c --- /dev/null +++ b/services/AiService/src/AiService.Api/AiService.Api.csproj @@ -0,0 +1,14 @@ + + + + net10.0 + enable + enable + + + + + + + + diff --git a/services/AiService/src/AiService.Api/Controllers/AuditToolController.cs b/services/AiService/src/AiService.Api/Controllers/AuditToolController.cs new file mode 100644 index 00000000..dab3a721 --- /dev/null +++ b/services/AiService/src/AiService.Api/Controllers/AuditToolController.cs @@ -0,0 +1,56 @@ +using System.Text.Json.Nodes; +using AiService.Tools.Registry; +using Microsoft.AspNetCore.Mvc; + +namespace AiService.Api.Controllers; + +/// Audit tool dispatch — POST /api/report/audit-tool. +[ApiController] +[Route("api/report")] +[Tags("Report Audit Tool")] +public sealed class AuditToolController : ControllerBase +{ + private readonly ToolDispatcher _dispatcher; + + public AuditToolController(ToolDispatcher dispatcher) => _dispatcher = dispatcher; + + [HttpPost("audit-tool")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task Run([FromBody] AuditToolBody body, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(body.ToolName) || body.PropertyId == 0) + { + return BadRequest(new { detail = "toolName and propertyId required" }); + } + + try + { + var args = body.Args ?? []; + var result = await _dispatcher.DispatchAsync( + body.ToolName, + body.PropertyId, + body.ReportId, + args, + cancellationToken); + + return Ok(new { result }); + } + catch (Exception ex) + { + return StatusCode(StatusCodes.Status500InternalServerError, new { detail = ex.Message }); + } + } + + public sealed class AuditToolBody + { + public string ToolName { get; set; } = ""; + + public int PropertyId { get; set; } + + public int? ReportId { get; set; } + + public JsonObject? Args { get; set; } + } +} diff --git a/services/AiService/src/AiService.Api/Controllers/ChatController.cs b/services/AiService/src/AiService.Api/Controllers/ChatController.cs new file mode 100644 index 00000000..0baf2335 --- /dev/null +++ b/services/AiService/src/AiService.Api/Controllers/ChatController.cs @@ -0,0 +1,258 @@ +using System.Text.Json.Nodes; +using System.Threading.Channels; +using AiService.Application.Chat; +using AiService.Application.Dto; +using AiService.Application.Services; +using AiService.Domain.Repositories; +using AiService.Tools.Context; +using AiService.Tools.Options; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; + +namespace AiService.Api.Controllers; + +/// +/// Chat agent and session endpoints ported from FastAPI's /api/chat/*. +/// +[ApiController] +[Route("api/chat")] +[Tags("Chat")] +public sealed class ChatController : ControllerBase +{ + private readonly IChatSessionRepository _sessions; + private readonly ChatAgentService _agent; + private readonly IHttpClientFactory _httpClientFactory; + private readonly IOptions _fastApiOptions; + private readonly ILogger _logger; + + public ChatController( + IChatSessionRepository sessions, + ChatAgentService agent, + IHttpClientFactory httpClientFactory, + IOptions fastApiOptions, + ILogger logger) + { + _sessions = sessions; + _agent = agent; + _httpClientFactory = httpClientFactory; + _fastApiOptions = fastApiOptions; + _logger = logger; + } + + /// Run one chat turn with SSE streaming (status, tool_start, tool_end, token, narrative_partial, narrative, error, done). + [HttpPost("")] + [Produces("text/event-stream")] + public async Task PostChat([FromBody] ChatRequest body, CancellationToken cancellationToken) + { + if (body.SessionId == 0 || body.PropertyId == 0 || string.IsNullOrWhiteSpace(body.Message)) + { + Response.StatusCode = StatusCodes.Status400BadRequest; + await Response.WriteAsJsonAsync(new { detail = "sessionId, propertyId, and message required" }, cancellationToken); + return; + } + + var session = await _sessions.GetSessionAsync(body.SessionId, cancellationToken); + if (session is null || session.PropertyId != body.PropertyId) + { + Response.StatusCode = StatusCodes.Status404NotFound; + await Response.WriteAsJsonAsync(new { detail = "session not found" }, cancellationToken); + return; + } + + await _sessions.AppendMessageAsync(body.SessionId, "user", body.Message.Trim(), cancellationToken: cancellationToken); + + var history = await _sessions.GetMessagesAsync(body.SessionId, cancellationToken: cancellationToken); + var agentMessages = ChatHelpers.MessagesForAgentContext(history); + var context = new AuditToolContext + { + PropertyId = (int)body.PropertyId, + ReportId = body.ReportId, + }; + + Response.ContentType = "text/event-stream"; + Response.Headers.CacheControl = "no-cache"; + + var channel = Channel.CreateUnbounded(); + ChatTurnResult? agentResult = null; + + var agentTask = Task.Run(async () => + { + try + { + agentResult = await _agent.RunTurnAsync( + agentMessages, + context, + evt => channel.Writer.TryWrite(ChatSseSerializer.ToJson(evt)), + cancellationToken); + } + catch (Exception ex) + { + channel.Writer.TryWrite(ChatSseSerializer.ToJson(new ChatErrorStreamEvent(ex.Message))); + } + finally + { + channel.Writer.Complete(); + } + }, cancellationToken); + + await foreach (var item in channel.Reader.ReadAllAsync(cancellationToken)) + { + var eventType = item["type"]?.GetValue() ?? "message"; + await Response.WriteAsync($"event: {eventType}\n", cancellationToken); + await Response.WriteAsync($"data: {item.ToJsonString()}\n\n", cancellationToken); + await Response.Body.FlushAsync(cancellationToken); + } + + await agentTask; + + var toolResultJson = agentResult is null ? null : ChatPersistenceMapper.ToToolResultJson(agentResult); + if (toolResultJson is not null) + { + try + { + await _sessions.AppendMessageAsync( + body.SessionId, + "assistant", + content: "", + toolResultJson: toolResultJson, + cancellationToken: cancellationToken); + if (session.Title is "New chat" or "" or null) + { + var derived = ChatHelpers.DeriveTitle(body.Message) + ?? ChatHelpers.DeriveTitle(ChatPersistenceMapper.FirstNarrativeInsight(agentResult!)); + if (!string.IsNullOrWhiteSpace(derived)) + { + await _sessions.UpdateSessionTitleAsync(body.SessionId, derived, cancellationToken); + } + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to persist chat assistant message for session {SessionId}", body.SessionId); + } + } + } + + [HttpGet("sessions")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task ListSessions([FromQuery] long propertyId, CancellationToken cancellationToken) + { + if (propertyId == 0) + { + return BadRequest(new { detail = "propertyId required" }); + } + + var sessions = await _sessions.ListSessionsAsync(propertyId, cancellationToken: cancellationToken); + return Ok(new { sessions = sessions.Select(ChatHelpers.FormatSession).ToList() }); + } + + [HttpPost("sessions")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task CreateSession([FromBody] ChatSessionCreate body, CancellationToken cancellationToken) + { + if (body.PropertyId == 0) + { + return BadRequest(new { detail = "propertyId required" }); + } + + var title = string.IsNullOrWhiteSpace(body.Title) ? "New chat" : body.Title.Trim(); + var id = await _sessions.CreateSessionAsync(body.PropertyId, title, cancellationToken); + return Ok(new { id, propertyId = body.PropertyId, title }); + } + + [HttpGet("sessions/{sessionId:long}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetSession(long sessionId, CancellationToken cancellationToken) + { + var session = await _sessions.GetSessionAsync(sessionId, cancellationToken); + if (session is null) + { + return NotFound(new { detail = "session not found" }); + } + + return Ok(new { session = ChatHelpers.FormatSession(session) }); + } + + [HttpDelete("sessions/{sessionId:long}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task DeleteSession( + long sessionId, + [FromQuery] long propertyId, + CancellationToken cancellationToken) + { + var session = await _sessions.GetSessionAsync(sessionId, cancellationToken); + if (session is null || session.PropertyId != propertyId) + { + return NotFound(new { detail = "session not found" }); + } + + var deleted = await _sessions.DeleteSessionAsync(sessionId, cancellationToken); + if (!deleted) + { + return NotFound(new { detail = "session not found" }); + } + + return Ok(new { ok = true }); + } + + [HttpGet("sessions/{sessionId:long}/messages")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetMessages( + long sessionId, + [FromQuery] long propertyId, + CancellationToken cancellationToken) + { + var session = await _sessions.GetSessionAsync(sessionId, cancellationToken); + if (session is null || session.PropertyId != propertyId) + { + return NotFound(new { detail = "session not found" }); + } + + var messages = await _sessions.GetMessagesAsync(sessionId, cancellationToken: cancellationToken); + return Ok(new { messages = ChatHelpers.FormatMessages(messages) }); + } + + [HttpGet("artifacts/{artifactId}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetArtifact(string artifactId, CancellationToken cancellationToken) + { + if (!System.Text.RegularExpressions.Regex.IsMatch(artifactId, @"^[a-f0-9\-]{36}$")) + { + return BadRequest(new { detail = "Invalid artifact id" }); + } + + var baseUrl = _fastApiOptions.Value.BaseUrl.Trim().TrimEnd('/'); + var client = _httpClientFactory.CreateClient(); + using var response = await client.GetAsync( + $"{baseUrl}/api/chat/artifacts/{artifactId}", + HttpCompletionOption.ResponseHeadersRead, + cancellationToken); + + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) + { + return NotFound(new { detail = "Artifact not found" }); + } + + if (!response.IsSuccessStatusCode) + { + return StatusCode((int)response.StatusCode, new { detail = "Artifact fetch failed" }); + } + + var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken); + var contentType = response.Content.Headers.ContentType?.MediaType ?? "application/octet-stream"; + var fileResult = File(bytes, contentType); + if (response.Content.Headers.ContentDisposition is { } disposition) + { + fileResult.FileDownloadName = disposition.FileName?.Trim('"'); + } + + return fileResult; + } +} diff --git a/services/AiService/src/AiService.Api/Controllers/ContentController.cs b/services/AiService/src/AiService.Api/Controllers/ContentController.cs new file mode 100644 index 00000000..7da106e1 --- /dev/null +++ b/services/AiService/src/AiService.Api/Controllers/ContentController.cs @@ -0,0 +1,101 @@ +using System.Text.Json.Nodes; +using AiService.Application.Dto; +using AiService.Application.Services; +using Microsoft.AspNetCore.Mvc; + +namespace AiService.Api.Controllers; + +/// Content studio endpoints — POST /api/content/analyze and wizard. +[ApiController] +[Route("api/content")] +[Tags("Content")] +public sealed class ContentController : ControllerBase +{ + private static readonly HashSet ValidWizardSteps = new(StringComparer.Ordinal) + { + "intents", "content_types", "tones", "titles", "outline", "draft", "research", + }; + + private readonly ContentAnalyzeService _analyze; + private readonly ContentWizardService _wizard; + + public ContentController(ContentAnalyzeService analyze, ContentWizardService wizard) + { + _analyze = analyze; + _wizard = wizard; + } + + [HttpPost("analyze")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task Analyze([FromBody] JsonObject body, CancellationToken cancellationToken) + { + var keyword = (body["keyword"]?.GetValue() ?? "").Trim(); + if (string.IsNullOrEmpty(keyword)) + { + return BadRequest(new { detail = "keyword required" }); + } + + int? propertyId = body["propertyId"]?.GetValue(); + try + { + var analysis = await _analyze.AnalyzeAsync( + propertyId, + keyword, + body["bodyHtml"]?.GetValue() ?? "", + body["titleTag"]?.GetValue() ?? "", + body["metaDescription"]?.GetValue() ?? "", + body["landingUrl"]?.GetValue(), + body["useAi"]?.GetValue() == true, + body.GetRefresh(), + body["title"]?.GetValue() ?? "", + cancellationToken); + + return Ok(new { analysis }); + } + catch (Exception ex) + { + return StatusCode(StatusCodes.Status500InternalServerError, new { detail = $"Content analyze failed: {ex.Message}" }); + } + } + + [HttpPost("wizard")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task Wizard([FromBody] JsonObject body, CancellationToken cancellationToken) + { + var step = (body["step"]?.GetValue() ?? "").Trim(); + if (!ValidWizardSteps.Contains(step)) + { + return BadRequest(new { detail = "Invalid wizard step" }); + } + + var payload = new JsonObject + { + ["keyword"] = body["keyword"]?.GetValue() ?? "", + ["locale"] = body["locale"]?.GetValue() ?? "en-US", + ["intent"] = body["intent"]?.GetValue() ?? "", + ["contentType"] = body["contentType"]?.GetValue() ?? "", + ["tone"] = body["tone"]?.GetValue() ?? "", + ["title"] = body["title"]?.GetValue() ?? "", + ["outline"] = body["outline"] is JsonArray outline ? outline.DeepClone() : new JsonArray(), + }; + + try + { + var result = await _wizard.RunStepAsync(step, payload, cancellationToken); + if (result["ok"]?.GetValue() == false) + { + return BadRequest(new { detail = result["error"]?.GetValue() ?? "Wizard step failed" }); + } + + return Ok(new { result }); + } + catch (Exception ex) + { + return StatusCode(StatusCodes.Status500InternalServerError, new { detail = $"Wizard step failed: {ex.Message}" }); + } + } +} diff --git a/services/AiService/src/AiService.Api/Controllers/ControllerHelpers.cs b/services/AiService/src/AiService.Api/Controllers/ControllerHelpers.cs new file mode 100644 index 00000000..da8c2a00 --- /dev/null +++ b/services/AiService/src/AiService.Api/Controllers/ControllerHelpers.cs @@ -0,0 +1,118 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using AiService.Application.Chat; +using AiService.Domain.Entities; + +namespace AiService.Api.Controllers; + +internal static class ChatHelpers +{ + private static readonly Regex FirstSentenceRe = new(@"^(.{8,80}[.!?])", RegexOptions.Singleline); + + internal static object FormatSession(ChatSession session) => new + { + id = session.Id, + propertyId = session.PropertyId, + title = session.Title, + createdAt = session.CreatedAt, + updatedAt = session.UpdatedAt, + }; + + internal static List FormatMessages(IReadOnlyList rows) + { + var outList = new List(rows.Count); + foreach (var row in rows) + { + object? toolArgs = ParseJsonField(row.ToolArgs); + object? toolResult = ParseJsonField(row.ToolResult); + outList.Add(new + { + id = row.Id, + role = row.Role, + content = row.Content ?? "", + tool_name = row.ToolName, + tool_args = toolArgs, + tool_result = toolResult, + created_at = row.CreatedAt, + }); + } + + return outList; + } + + internal static List MessagesForAgentContext( + IReadOnlyList rows, + int maxTurns = 20) + { + var relevant = rows.Where(m => m.Role is "user" or "assistant").ToList(); + var sliced = relevant.TakeLast(maxTurns * 2); + return sliced.Select(m => new ChatMessageRecord(m.Role, m.Content ?? "")).ToList(); + } + + internal static string? DeriveTitle(string text) + { + text = text.Trim(); + if (string.IsNullOrEmpty(text)) + { + return null; + } + + var match = FirstSentenceRe.Match(text); + var raw = match.Success ? match.Groups[1].Value.Trim() : text[..Math.Min(text.Length, 60)].Trim(); + return string.IsNullOrEmpty(raw) ? null : raw[..Math.Min(raw.Length, 80)]; + } + + internal static string SerializeNarrative(JsonObject narrative) + => narrative.ToJsonString(new JsonSerializerOptions { WriteIndented = false }); + + private static object? ParseJsonField(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + { + return null; + } + + try + { + return JsonNode.Parse(raw); + } + catch (JsonException) + { + return raw; + } + } +} + +internal static class SecretHelpers +{ + internal const string Mask = "*"; + + internal static bool IsSecretKey(string key) + { + var keyLower = key.ToLowerInvariant(); + return keyLower.EndsWith("_secret", StringComparison.Ordinal) + || keyLower.EndsWith("_api_key", StringComparison.Ordinal) + || keyLower.EndsWith("_key", StringComparison.Ordinal) + || keyLower.Contains("api_key", StringComparison.Ordinal) + || keyLower.Contains("secret", StringComparison.Ordinal) + || keyLower.Contains("password", StringComparison.Ordinal) + || keyLower.Contains("token", StringComparison.Ordinal); + } + + internal static bool IsMaskedSentinel(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + var trimmed = value.Trim(); + if (trimmed is Mask or "••••") + { + return true; + } + + return trimmed.StartsWith('*') && trimmed.Length <= 4; + } +} diff --git a/services/AiService/src/AiService.Api/Controllers/DashboardsController.cs b/services/AiService/src/AiService.Api/Controllers/DashboardsController.cs new file mode 100644 index 00000000..277d7b14 --- /dev/null +++ b/services/AiService/src/AiService.Api/Controllers/DashboardsController.cs @@ -0,0 +1,53 @@ +using System.Text.Json.Nodes; +using AiService.Application.Services; +using Microsoft.AspNetCore.Mvc; + +namespace AiService.Api.Controllers; + +/// Dashboard AI generation — POST /api/dashboards/ai-generate. +[ApiController] +[Route("api/dashboards")] +[Tags("Dashboards")] +public sealed class DashboardsController : ControllerBase +{ + private readonly DashboardAiService _dashboardAi; + + public DashboardsController(DashboardAiService dashboardAi) => _dashboardAi = dashboardAi; + + [HttpPost("ai-generate")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task AiGenerate([FromBody] JsonObject body, CancellationToken cancellationToken) + { + var mode = (body["mode"]?.GetValue() ?? "widget").Trim().ToLowerInvariant(); + if (mode is not ("script" or "widget" or "dashboard")) + { + return BadRequest(new { detail = "mode must be script, widget, or dashboard" }); + } + + var prompt = (body["prompt"]?.GetValue() ?? "").Trim(); + if (string.IsNullOrEmpty(prompt)) + { + return BadRequest(new { detail = "prompt required" }); + } + + try + { + var result = await _dashboardAi.GenerateAsync(body, cancellationToken); + if (result["ok"]?.GetValue() == false) + { + var status = result.ContainsKey("missing") + ? StatusCodes.Status503ServiceUnavailable + : StatusCodes.Status500InternalServerError; + return StatusCode(status, result); + } + + return Ok(result); + } + catch (Exception ex) + { + return StatusCode(StatusCodes.Status500InternalServerError, new { detail = ex.Message }); + } + } +} diff --git a/services/AiService/src/AiService.Api/Controllers/HealthController.cs b/services/AiService/src/AiService.Api/Controllers/HealthController.cs new file mode 100644 index 00000000..f5a0248b --- /dev/null +++ b/services/AiService/src/AiService.Api/Controllers/HealthController.cs @@ -0,0 +1,13 @@ +using Microsoft.AspNetCore.Mvc; + +namespace AiService.Api.Controllers; + +[ApiController] +[Route("")] +[Tags("Health")] +public sealed class HealthController : ControllerBase +{ + [HttpGet("health")] + [ProducesResponseType(StatusCodes.Status200OK)] + public IActionResult Get() => Ok(new { status = "ok" }); +} diff --git a/services/AiService/src/AiService.Api/Controllers/Internal/CompletionController.cs b/services/AiService/src/AiService.Api/Controllers/Internal/CompletionController.cs new file mode 100644 index 00000000..85c8d548 --- /dev/null +++ b/services/AiService/src/AiService.Api/Controllers/Internal/CompletionController.cs @@ -0,0 +1,24 @@ +using System.Text.Json.Nodes; +using AiService.Domain.Repositories; +using AiService.Providers.Chat; +using Microsoft.AspNetCore.Mvc; + +namespace AiService.Api.Controllers.Internal; + +[ApiController] +[Route("internal/completion")] +[Tags("Internal Completion")] +public sealed class CompletionController( + StructuredCompletionService completionService, + ILlmConfigRepository configRepository) : ControllerBase +{ + [HttpPost("json")] + public async Task CompleteJson([FromBody] JsonObject body, CancellationToken cancellationToken) + { + var system = body["system"]?.GetValue() ?? ""; + var user = body["user"]?.GetValue() ?? ""; + var cfg = await configRepository.LoadAsync(cancellationToken); + var result = await completionService.CompleteJsonAsync(system, user, cfg, cancellationToken); + return Ok(result); + } +} diff --git a/services/AiService/src/AiService.Api/Controllers/Internal/EnrichmentController.cs b/services/AiService/src/AiService.Api/Controllers/Internal/EnrichmentController.cs new file mode 100644 index 00000000..2abb9e32 --- /dev/null +++ b/services/AiService/src/AiService.Api/Controllers/Internal/EnrichmentController.cs @@ -0,0 +1,63 @@ +using System.Text.Json.Nodes; +using AiService.Application.Dto; +using AiService.Application.Services; +using Microsoft.AspNetCore.Mvc; + +namespace AiService.Api.Controllers.Internal; + +/// Internal enrichment endpoints — POST /internal/enrichment/*. +[ApiController] +[Route("internal/enrichment")] +[Tags("Internal Enrichment")] +public sealed class EnrichmentController : ControllerBase +{ + private readonly EnrichmentService _enrichment; + + public EnrichmentController(EnrichmentService enrichment) => _enrichment = enrichment; + + [HttpPost("run")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task Run([FromBody] JsonObject body, CancellationToken cancellationToken) + { + var pages = body["pages"] as JsonArray ?? []; + var result = await _enrichment.RunEnrichmentAsync(pages, cancellationToken); + return Ok(result); + } + + [HttpPost("cluster-keywords")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task ClusterKeywords([FromBody] JsonObject body, CancellationToken cancellationToken) + { + var keywords = new List(); + if (body["keywords"] is JsonArray arr) + { + foreach (var node in arr) + { + var kw = (node?.GetValue() ?? "").Trim(); + if (!string.IsNullOrEmpty(kw)) + { + keywords.Add(kw); + } + } + } + + var result = await _enrichment.ClusterKeywordsAsync(keywords, cancellationToken); + return Ok(result); + } + + [HttpPost("issue-fixes")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task IssueFixes([FromBody] JsonObject body, CancellationToken cancellationToken) + { + var result = await _enrichment.GenerateIssueFixAsync(body, body.GetRefresh(), cancellationToken); + return Ok(result); + } + + [HttpPost("audit-summary")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task AuditSummary([FromBody] JsonObject body, CancellationToken cancellationToken) + { + var result = await _enrichment.GenerateAuditSummaryAsync(body, cancellationToken); + return Ok(result); + } +} diff --git a/services/AiService/src/AiService.Api/Controllers/IssuesController.cs b/services/AiService/src/AiService.Api/Controllers/IssuesController.cs new file mode 100644 index 00000000..05ad6ad3 --- /dev/null +++ b/services/AiService/src/AiService.Api/Controllers/IssuesController.cs @@ -0,0 +1,148 @@ +using System.Text.Json.Nodes; +using AiService.Application.Dto; +using AiService.Application.Services; +using Microsoft.AspNetCore.Mvc; + +namespace AiService.Api.Controllers; + +/// +/// Issue LLM endpoints ported from FastAPI's /api/issues/* and /api/ai/fix-suggestion. +/// +[ApiController] +[Route("api/issues")] +[Tags("Issues")] +public sealed class IssuesController : ControllerBase +{ + private readonly FixSuggestionService _fixSuggestions; + private readonly IssuesActionPlanService _actionPlan; + + public IssuesController(FixSuggestionService fixSuggestions, IssuesActionPlanService actionPlan) + { + _fixSuggestions = fixSuggestions; + _actionPlan = actionPlan; + } + + [HttpPost("fix-suggestion")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task FixSuggestion([FromBody] JsonObject body, CancellationToken cancellationToken) + { + var message = (body["message"]?.GetValue() ?? "").Trim(); + if (string.IsNullOrEmpty(message)) + { + return BadRequest(new { detail = "message required" }); + } + + var payload = new JsonObject + { + ["source"] = "issue", + ["message"] = message, + ["url"] = body["url"]?.DeepClone(), + ["priority"] = body["priority"]?.DeepClone(), + ["category"] = body["category"]?.DeepClone(), + ["recommendation"] = body["recommendation"]?.DeepClone(), + ["type"] = body["type"]?.DeepClone(), + ["refresh"] = body["refresh"]?.DeepClone(), + }; + + try + { + var result = await _fixSuggestions.GenerateAsync(payload, body.GetRefresh(), cancellationToken); + if (result["ok"]?.GetValue() == false) + { + return StatusCode(StatusCodes.Status500InternalServerError, new { detail = result["error"]?.GetValue() ?? "Fix suggestion failed" }); + } + + return Ok(result); + } + catch (Exception ex) + { + return StatusCode(StatusCodes.Status500InternalServerError, new { detail = $"Fix suggestion failed: {ex.Message}" }); + } + } + + [HttpPost("action-plan")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task ActionPlan([FromBody] JsonObject body, CancellationToken cancellationToken) + { + var domain = (body["domain"]?.GetValue() ?? "").Trim(); + if (string.IsNullOrEmpty(domain)) + { + return BadRequest(new { detail = "domain required" }); + } + + if (body["issues"] is not JsonArray issues || issues.Count == 0) + { + return BadRequest(new { detail = "issues required" }); + } + + try + { + var result = await _actionPlan.GenerateAsync(body, body.GetRefresh(), cancellationToken); + if (result["ok"]?.GetValue() == false) + { + return StatusCode(StatusCodes.Status500InternalServerError, new { detail = result["error"]?.GetValue() ?? "Action plan failed" }); + } + + return Ok(result); + } + catch (Exception ex) + { + return StatusCode(StatusCodes.Status500InternalServerError, new { detail = $"Action plan failed: {ex.Message}" }); + } + } +} + +[ApiController] +[Route("api/ai")] +[Tags("Issues")] +public sealed class AiFixSuggestionController : ControllerBase +{ + private readonly FixSuggestionService _fixSuggestions; + + public AiFixSuggestionController(FixSuggestionService fixSuggestions) => _fixSuggestions = fixSuggestions; + + [HttpPost("fix-suggestion")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task FixSuggestion([FromBody] JsonObject body, CancellationToken cancellationToken) + { + var message = (body["message"]?.GetValue() ?? "").Trim(); + if (string.IsNullOrEmpty(message)) + { + return BadRequest(new { detail = "message required" }); + } + + var payload = new JsonObject + { + ["source"] = body["source"]?.GetValue() ?? "issue", + ["message"] = message, + ["url"] = body["url"]?.DeepClone(), + ["refresh"] = body["refresh"]?.DeepClone(), + ["context"] = body["context"]?.DeepClone(), + ["priority"] = body["priority"]?.DeepClone(), + ["category"] = body["category"]?.DeepClone(), + ["recommendation"] = body["recommendation"]?.DeepClone(), + ["type"] = body["type"]?.DeepClone(), + }; + + try + { + var result = await _fixSuggestions.GenerateAsync(payload, body.GetRefresh(), cancellationToken); + if (result["ok"]?.GetValue() == false) + { + return StatusCode(StatusCodes.Status500InternalServerError, new { detail = result["error"]?.GetValue() ?? "Fix suggestion failed" }); + } + + return Ok(result); + } + catch (Exception ex) + { + return StatusCode(StatusCodes.Status500InternalServerError, new { detail = $"Fix suggestion failed: {ex.Message}" }); + } + } +} diff --git a/services/AiService/src/AiService.Api/Controllers/LlmConfigController.cs b/services/AiService/src/AiService.Api/Controllers/LlmConfigController.cs new file mode 100644 index 00000000..b2e2feae --- /dev/null +++ b/services/AiService/src/AiService.Api/Controllers/LlmConfigController.cs @@ -0,0 +1,65 @@ +using System.Text.Json.Nodes; +using AiService.Application.Repositories; +using AiService.Domain.Repositories; +using AiService.Providers.Chat; +using Microsoft.AspNetCore.Mvc; + +namespace AiService.Api.Controllers; + +/// LLM configuration — GET/PUT /api/llm-config. +[ApiController] +[Route("api/llm-config")] +[Tags("Config")] +public sealed class LlmConfigController : ControllerBase +{ + private readonly ILlmConfigRepository _config; + + public LlmConfigController(ILlmConfigRepository config) => _config = config; + + [HttpGet("")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task Get(CancellationToken cancellationToken) + { + var rows = await _config.LoadFullAsync(cancellationToken); + var state = new JsonObject(); + foreach (var row in rows) + { + state[row.Key] = row.Value; + } + + var resolved = await _config.LoadAsync(cancellationToken); + return Ok(new + { + state, + source = "db", + apiKeyConfigured = IsApiKeyConfigured(resolved), + }); + } + + [HttpPut("")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task Put([FromBody] JsonObject body, CancellationToken cancellationToken) + { + var state = body["state"] as JsonObject ?? []; + var entries = LlmConfigPutHelpers.ParsePutEntries(state); + await _config.SaveAsync(entries, cancellationToken); + var resolved = await _config.LoadAsync(cancellationToken); + return Ok(new { ok = true, apiKeyConfigured = IsApiKeyConfigured(resolved) }); + } + + private static bool IsApiKeyConfigured(IReadOnlyDictionary cfg) + { + var provider = (cfg.GetValueOrDefault("llm_provider") ?? "none").Trim().ToLowerInvariant(); + if (provider is "" or "none") + { + return false; + } + + if (provider == "ollama") + { + return true; + } + + return !string.IsNullOrWhiteSpace(LlmConfigHelpers.ResolveApiKey(cfg)); + } +} diff --git a/services/AiService/src/AiService.Api/Controllers/McpToolsController.cs b/services/AiService/src/AiService.Api/Controllers/McpToolsController.cs new file mode 100644 index 00000000..e9175344 --- /dev/null +++ b/services/AiService/src/AiService.Api/Controllers/McpToolsController.cs @@ -0,0 +1,30 @@ +using AiService.Mcp; +using Microsoft.AspNetCore.Mvc; + +namespace AiService.Api.Controllers; + +/// MCP audit tool catalog — GET /api/mcp-tools. +[ApiController] +[Route("api/mcp-tools")] +[Tags("MCP Tools")] +public sealed class McpToolsController : ControllerBase +{ + private readonly McpToolCatalogService _catalog; + + public McpToolsController(McpToolCatalogService catalog) => _catalog = catalog; + + [HttpGet("")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public IActionResult List() + { + try + { + return Ok(_catalog.ListTools()); + } + catch (Exception ex) + { + return StatusCode(StatusCodes.Status500InternalServerError, new { detail = ex.Message }); + } + } +} diff --git a/services/AiService/src/AiService.Api/Controllers/OllamaController.cs b/services/AiService/src/AiService.Api/Controllers/OllamaController.cs new file mode 100644 index 00000000..650826c0 --- /dev/null +++ b/services/AiService/src/AiService.Api/Controllers/OllamaController.cs @@ -0,0 +1,74 @@ +using System.Text.Json.Nodes; +using AiService.Application.Services; +using AiService.Domain.Repositories; +using Microsoft.AspNetCore.Mvc; + +namespace AiService.Api.Controllers; + +/// Ollama runtime status — GET /api/ollama/status. +[ApiController] +[Route("api/ollama")] +[Tags("Ollama")] +public sealed class OllamaController : ControllerBase +{ + private const string DefaultBase = "http://127.0.0.1:11434"; + + private readonly ILlmConfigRepository _config; + private readonly OllamaCatalogService _catalog; + + public OllamaController(ILlmConfigRepository config, OllamaCatalogService catalog) + { + _config = config; + _catalog = catalog; + } + + [HttpGet("status")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task Status(CancellationToken cancellationToken) + { + var cfg = await _config.LoadAsync(cancellationToken); + var baseUrl = (cfg.GetValueOrDefault("llm_base_url") ?? DefaultBase).Trim().TrimEnd('/'); + var configuredModel = (cfg.GetValueOrDefault("llm_model") ?? "").Trim(); + + var result = await _catalog.FetchModelsAsync(baseUrl, cancellationToken); + if (result["ok"]?.GetValue() != true) + { + return Ok(new JsonObject + { + ["ok"] = false, + ["baseUrl"] = result["baseUrl"]?.GetValue() ?? baseUrl, + ["configuredModel"] = configuredModel, + ["error"] = result["error"]?.GetValue() ?? "Cannot reach Ollama. Is it running?", + ["models"] = new JsonArray(), + ["cloudCatalogOk"] = false, + ["localOk"] = false, + }); + } + + var models = (result["models"] as JsonArray ?? []) + .OfType() + .ToList(); + + var modelInstalled = OllamaCatalogService.ModelIsConfigured(models, configuredModel); + var configuredEntry = models.FirstOrDefault(m => + string.Equals(m["name"]?.GetValue(), configuredModel, StringComparison.OrdinalIgnoreCase)); + + var supportsTools = configuredEntry?["capabilities"] is JsonArray caps && caps.Any(c => c?.GetValue() == "tools") + ? true + : OllamaCatalogService.ModelsSupportTools(models); + + return Ok(new JsonObject + { + ["ok"] = true, + ["baseUrl"] = result["baseUrl"]?.GetValue() ?? baseUrl, + ["configuredModel"] = configuredModel, + ["modelInstalled"] = modelInstalled, + ["supportsTools"] = supportsTools, + ["cloudCatalogOk"] = result["cloudCatalogOk"]?.GetValue() ?? false, + ["localOk"] = result["localOk"]?.GetValue() ?? false, + ["catalogSource"] = "live", + ["cloudModelCount"] = models.Count(m => m["source"]?.GetValue() == "cloud"), + ["models"] = result["models"]?.DeepClone(), + }); + } +} diff --git a/services/AiService/src/AiService.Api/Controllers/PageCoachController.cs b/services/AiService/src/AiService.Api/Controllers/PageCoachController.cs new file mode 100644 index 00000000..2c6572d8 --- /dev/null +++ b/services/AiService/src/AiService.Api/Controllers/PageCoachController.cs @@ -0,0 +1,60 @@ +using AiService.Application.Services; +using Microsoft.AspNetCore.Mvc; + +namespace AiService.Api.Controllers; + +/// Internal link page coach — POST /api/links/page-coach. +[ApiController] +[Route("api/links")] +[Tags("Page Coach")] +public sealed class PageCoachController : ControllerBase +{ + private readonly PageCoachService _pageCoach; + + public PageCoachController(PageCoachService pageCoach) => _pageCoach = pageCoach; + + [HttpPost("page-coach")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task Run([FromBody] PageCoachBody body, CancellationToken cancellationToken) + { + var url = (body.Url ?? "").Trim(); + if (string.IsNullOrEmpty(url)) + { + return BadRequest(new { detail = "url required" }); + } + + try + { + var result = await _pageCoach.RunAsync(url, body.Refresh, cancellationToken); + if (result["ok"]?.GetValue() == false) + { + return StatusCode(StatusCodes.Status500InternalServerError, new { detail = result["error"]?.GetValue() ?? "Page coach failed" }); + } + + return Ok(result); + } + catch (Exception ex) + { + return StatusCode(StatusCodes.Status500InternalServerError, new { detail = ex.Message }); + } + } + + public sealed class PageCoachBody + { + public string? Url { get; set; } + + public bool Refresh { get; set; } + + public string? CurrentType { get; set; } + + public int? CurrentId { get; set; } + + public string? BaselineType { get; set; } + + public int? BaselineId { get; set; } + + public int? PropertyId { get; set; } + } +} diff --git a/services/AiService/src/AiService.Api/Controllers/SecretsController.cs b/services/AiService/src/AiService.Api/Controllers/SecretsController.cs new file mode 100644 index 00000000..e16ea857 --- /dev/null +++ b/services/AiService/src/AiService.Api/Controllers/SecretsController.cs @@ -0,0 +1,39 @@ +using System.Text.Json.Nodes; +using AiService.Application.Services; +using Microsoft.AspNetCore.Mvc; + +namespace AiService.Api.Controllers; + +/// Unified secrets — GET/PUT /api/secrets (LLM, pipeline, Google). +[ApiController] +[Route("api/secrets")] +[Tags("Config")] +public sealed class SecretsController(SecretsService secrets) : ControllerBase +{ + [HttpGet("")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task Get(CancellationToken cancellationToken) + { + var state = await secrets.GetStateAsync(cancellationToken); + return Ok(new { state, source = "db" }); + } + + [HttpPut("")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task Put([FromBody] JsonObject body, CancellationToken cancellationToken) + { + var state = body["state"] as JsonObject ?? []; + try + { + await secrets.PutStateAsync(state, cancellationToken); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { error = ex.Message }); + } + + var refreshed = await secrets.GetStateAsync(cancellationToken); + return Ok(new { ok = true, state = refreshed, source = "db" }); + } +} diff --git a/services/AiService/src/AiService.Api/Program.cs b/services/AiService/src/AiService.Api/Program.cs new file mode 100644 index 00000000..5a8b0bce --- /dev/null +++ b/services/AiService/src/AiService.Api/Program.cs @@ -0,0 +1,35 @@ +using AiService.Application; +using AiService.Mcp; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddControllers(); +builder.Services.AddAiServiceApplication(); +builder.Services.AddAiServiceMcpCatalog(); + +if (McpServerExtensions.IsMcpHttpEnabled()) +{ + builder.Services + .AddAiServiceMcp() + .WithHttpTransport(options => options.Stateless = true); +} + +var app = builder.Build(); + +app.MapControllers(); + +app.MapGet("/", () => Results.Ok(new +{ + service = "AiService", + mcp_http = McpServerExtensions.IsMcpHttpEnabled(), + mcp_path = McpServerExtensions.IsMcpHttpEnabled() ? "/mcp" : null, + mcp_domain = Environment.GetEnvironmentVariable("WP_MCP_DOMAIN") ?? "core", + mcp_stdio = "Use a separate console host: Host.CreateApplicationBuilder(args).AddAiServiceMcpStdioHost().Build().RunAsync()", +})); + +if (McpServerExtensions.IsMcpHttpEnabled()) +{ + app.MapAiServiceMcp("/mcp"); +} + +app.Run(); diff --git a/services/AiService/src/AiService.Api/Properties/launchSettings.json b/services/AiService/src/AiService.Api/Properties/launchSettings.json new file mode 100644 index 00000000..ac3405cd --- /dev/null +++ b/services/AiService/src/AiService.Api/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:8092", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7230;http://localhost:8092", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/services/AiService/src/AiService.Api/appsettings.Development.json b/services/AiService/src/AiService.Api/appsettings.Development.json new file mode 100644 index 00000000..ff66ba6b --- /dev/null +++ b/services/AiService/src/AiService.Api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/services/AiService/src/AiService.Api/appsettings.json b/services/AiService/src/AiService.Api/appsettings.json new file mode 100644 index 00000000..ed5a72cd --- /dev/null +++ b/services/AiService/src/AiService.Api/appsettings.json @@ -0,0 +1,20 @@ +{ + "Urls": "http://+:8092", + "Database": { + "ConnectionString": "", + "MinPoolSize": 2, + "MaxPoolSize": 20, + "CommandTimeoutSeconds": 30 + }, + "FastApi": { + "BaseUrl": "http://127.0.0.1:8001" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/services/AiService/src/AiService.Application/AiService.Application.csproj b/services/AiService/src/AiService.Application/AiService.Application.csproj new file mode 100644 index 00000000..470fbe22 --- /dev/null +++ b/services/AiService/src/AiService.Application/AiService.Application.csproj @@ -0,0 +1,29 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + + + + + + + + + + diff --git a/services/AiService/src/AiService.Application/Chat/AuditChatToolsBuilder.cs b/services/AiService/src/AiService.Application/Chat/AuditChatToolsBuilder.cs new file mode 100644 index 00000000..7e73f1e3 --- /dev/null +++ b/services/AiService/src/AiService.Application/Chat/AuditChatToolsBuilder.cs @@ -0,0 +1,47 @@ +using System.Text.Json.Nodes; +using AiService.Tools.Context; +using AiService.Tools.Registry; +using Microsoft.Extensions.AI; + +namespace AiService.Application.Chat; + +/// Builds MEAI tools from the audit catalog for a chat turn. +public sealed class AuditChatToolsBuilder(ToolCatalog toolCatalog, ToolDispatcher toolDispatcher) +{ + public IReadOnlyList Build( + AuditToolContext context, + IReadOnlySet selectedTools, + ChatTurnProgress progress) + { + var tools = new List(); + foreach (var definition in toolCatalog.ToolDefinitions) + { + var fn = definition["function"] as JsonObject; + var toolName = fn?["name"]?.GetValue(); + if (string.IsNullOrWhiteSpace(toolName) || !selectedTools.Contains(toolName)) + { + continue; + } + + var description = fn?["description"]?.GetValue() ?? toolName; + var capturedName = toolName; + tools.Add(AIFunctionFactory.Create( + async (AIFunctionArguments args, CancellationToken ct) => + { + var callId = Guid.NewGuid().ToString("N"); + var outcome = await progress.DispatchToolAsync( + callId, + capturedName, + args, + context, + toolDispatcher, + ct); + return ToolResultCompactor.CompactForLlm(capturedName, outcome.ResultObject).ToJsonString(); + }, + capturedName, + description)); + } + + return tools; + } +} diff --git a/services/AiService/src/AiService.Application/Chat/ChatAgentConfig.cs b/services/AiService/src/AiService.Application/Chat/ChatAgentConfig.cs new file mode 100644 index 00000000..c2e2d4e3 --- /dev/null +++ b/services/AiService/src/AiService.Application/Chat/ChatAgentConfig.cs @@ -0,0 +1,129 @@ +using AiService.Application.Prompts; +using AiService.Providers.Chat; +using System.Text.Json.Nodes; + +namespace AiService.Application.Chat; + +/// Chat agent constants and config resolution ported from Python llm/agent.py. +public static class ChatAgentConfig +{ + public const int MaxToolRoundsDefault = 10; + public const int MaxToolRoundsExtended = 100; + public const string ChatCrawlTool = "prepare_audit_run"; + public const string NarrativeFailedMessage = "Could not generate a summary. Tool results are shown below."; + + public static int ResolveMaxToolRounds(IReadOnlyDictionary cfg) + { + if (LlmConfigHelpers.IsTruthy(cfg.GetValueOrDefault("llm_chat_unlimited_tool_rounds") ?? "false")) + { + var extendedRaw = Environment.GetEnvironmentVariable("CHAT_MAX_TOOL_ROUNDS_EXTENDED")?.Trim(); + if (int.TryParse(extendedRaw, out var extended) && extended > 0) + { + return extended; + } + + return MaxToolRoundsExtended; + } + + var raw = Environment.GetEnvironmentVariable("CHAT_MAX_TOOL_ROUNDS")?.Trim(); + if (int.TryParse(raw, out var parsed) && parsed > 0) + { + return parsed; + } + + return MaxToolRoundsDefault; + } + + public static string ResolveSystemPrompt(IReadOnlyDictionary cfg) + { + var prompt = LlmPrompts.ChatAgentSystemBase; + if (LlmConfigHelpers.IsTruthy(cfg.GetValueOrDefault("llm_chat_allow_crawl") ?? "false")) + { + return prompt + LlmPrompts.ChatAgentCrawlSuffix; + } + + return prompt + LlmPrompts.ChatAgentReadOnlySuffix; + } + + public static bool ChatAllowCrawl(IReadOnlyDictionary cfg) + => LlmConfigHelpers.IsTruthy(cfg.GetValueOrDefault("llm_chat_allow_crawl") ?? "false"); + + public static bool FastNarrativeEnabled(IReadOnlyDictionary? cfg = null) + { + var env = Environment.GetEnvironmentVariable("CHAT_FAST_NARRATIVE")?.Trim(); + if (!string.IsNullOrEmpty(env)) + { + return LlmConfigHelpers.IsTruthy(env); + } + + return LlmConfigHelpers.IsTruthy(cfg?.GetValueOrDefault("llm_chat_fast_narrative") ?? "false"); + } + + private static readonly HashSet WorkflowToolNames = new(StringComparer.Ordinal) + { + "run_insight_workflow", + "run_technical_workflow", + "run_keyword_workflow", + "run_domain_agent", + }; + + public static bool ShouldFastFinishAfterBatch(IReadOnlyList batch, IReadOnlyDictionary cfg) + { + if (!FastNarrativeEnabled(cfg) || batch.Count == 0) + { + return false; + } + + return batch.All(ev => + { + if (!WorkflowToolNames.Contains(ev.Name)) + { + return false; + } + + if (JsonNode.Parse(ev.ResultJson) is not JsonObject parsed) + { + return false; + } + + return parsed["error"] is null; + }); + } + + public static string MapAgentError(Exception ex, IReadOnlyDictionary cfg) + { + var msg = ex.Message.Trim(); + if (string.IsNullOrEmpty(msg)) + { + msg = ex.GetType().Name; + } + + var provider = (cfg.GetValueOrDefault("llm_provider") ?? "").Trim().ToLowerInvariant(); + if (msg.Contains("Connection error", StringComparison.OrdinalIgnoreCase) && provider == "groq") + { + return + "Could not reach Groq. Check your Groq API key on the Secrets page and " + + "that outbound HTTPS to api.groq.com is allowed. " + + $"Details: {msg}"; + } + + if (msg.Contains("httpx", StringComparison.OrdinalIgnoreCase) || + msg.Contains("requirements.txt", StringComparison.OrdinalIgnoreCase)) + { + return + "LLM dependencies are missing. Run: pip install -r requirements.txt " + + $"(or restart with ./local-run setup). Details: {msg}"; + } + + if (msg.Contains("tool_use_failed", StringComparison.OrdinalIgnoreCase) || + msg.Contains("not in request.tools", StringComparison.OrdinalIgnoreCase)) + { + return + "The assistant tried to use an audit tool that was not loaded for this chat step. " + + "Try your question again, use search_audit_tools first, or set chat tool mode to full in AI settings. " + + $"Details: {msg}"; + } + + return msg; + } +} diff --git a/services/AiService/src/AiService.Application/Chat/ChatAgentLoop.cs b/services/AiService/src/AiService.Application/Chat/ChatAgentLoop.cs new file mode 100644 index 00000000..653b6ca3 --- /dev/null +++ b/services/AiService/src/AiService.Application/Chat/ChatAgentLoop.cs @@ -0,0 +1,179 @@ +using System.Text.Json.Nodes; +using AiService.Tools.Context; +using AiService.Tools.Registry; +using AiService.Tools.Selection; +using Microsoft.Extensions.AI; + +namespace AiService.Application.Chat; + +/// +/// Manual multi-round tool loop (ports Python run_agent_turn inner loop) with parallel +/// tool dispatch and mid-turn tool expansion after search/domain-agent results. +/// +public sealed class ChatAgentLoop(AuditChatToolsBuilder toolsBuilder, ToolDispatcher toolDispatcher) +{ + public async Task RunAsync( + IChatClient client, + List messages, + HashSet activeTools, + IReadOnlySet allowedTools, + IReadOnlyDictionary cfg, + AuditToolContext context, + ChatTurnProgress progress, + int maxRounds, + CancellationToken cancellationToken) + { + var gated = ChatToolSelector.ResolveChatToolMode(cfg) != "full"; + var sessionTools = new HashSet(activeTools, StringComparer.Ordinal); + var finished = false; + + for (var round = 0; round < maxRounds; round++) + { + progress.EmitStatus("model", $"Planning step {round + 1} of {maxRounds}…"); + var chatOptions = BuildChatOptions(context, sessionTools, progress); + var response = await client.GetResponseAsync(messages, chatOptions, cancellationToken); + var assistantMessage = response.Messages.LastOrDefault(m => m.Role == ChatRole.Assistant); + var toolCalls = assistantMessage?.Contents.OfType().ToList() ?? []; + if (toolCalls.Count == 0) + { + finished = true; + break; + } + + foreach (var call in toolCalls) + { + sessionTools.Add(call.Name); + } + + messages.Add(assistantMessage!); + var preRoundActive = activeTools.ToHashSet(StringComparer.Ordinal); + var batchStart = progress.ToolEvents.Count; + var dispatchResults = await DispatchToolBatchAsync( + toolCalls, + preRoundActive, + gated, + context, + progress, + cancellationToken); + + foreach (var result in dispatchResults) + { + sessionTools.Add(result.Name); + activeTools = ChatToolSelector.ExpandActiveToolsFromResult( + result.Name, + result.ResultObject, + activeTools, + allowedTools, + cfg); + sessionTools.UnionWith(activeTools); + + messages.Add(new ChatMessage( + ChatRole.Tool, + [new FunctionResultContent(result.CallId, result.LlmResultJson)])); + } + + var batchEvents = progress.ToolEvents.Skip(batchStart).ToList(); + if (ChatAgentConfig.ShouldFastFinishAfterBatch(batchEvents, cfg)) + { + finished = true; + break; + } + } + + string? partialNote = null; + if (!finished && progress.ToolEvents.Count > 0) + { + partialNote = + $"The agent completed {progress.ToolEvents.Count} tool step(s) but did not finish " + + "all planned steps. Tool results are preserved below."; + } + + return new ChatAgentLoopResult(finished, partialNote); + } + + private ChatOptions BuildChatOptions( + AuditToolContext context, + IReadOnlySet activeTools, + ChatTurnProgress progress) + => new() + { + Tools = toolsBuilder.Build(context, activeTools, progress).Cast().ToList(), + }; + + private async Task> DispatchToolBatchAsync( + IReadOnlyList toolCalls, + HashSet preRoundActive, + bool gated, + AuditToolContext context, + ChatTurnProgress progress, + CancellationToken cancellationToken) + { + var factories = toolCalls.Select(call => (Func>)(() => + DispatchOneToolAsync(call, preRoundActive, gated, context, progress, cancellationToken))).ToList(); + + return (await ToolConcurrency.MapParallelAsync( + factories, + ToolConcurrency.ResolveMaxWorkers(), + cancellationToken)).ToList(); + } + + private async Task DispatchOneToolAsync( + FunctionCallContent call, + HashSet preRoundActive, + bool gated, + AuditToolContext context, + ChatTurnProgress progress, + CancellationToken cancellationToken) + { + var callId = string.IsNullOrWhiteSpace(call.CallId) ? Guid.NewGuid().ToString("N") : call.CallId; + var args = new AIFunctionArguments(call.Arguments ?? new Dictionary()); + ToolDispatchOutcome outcome; + + if (gated && !preRoundActive.Contains(call.Name)) + { + var gatedJson = progress.RecordGatedTool(callId, call.Name, args); + var gatedObject = JsonNode.Parse(gatedJson) as JsonObject ?? []; + outcome = new ToolDispatchOutcome(gatedJson, gatedJson, gatedObject); + } + else + { + EmitWorkflowProgress(callId, call.Name, progress); + outcome = await progress.DispatchToolAsync( + callId, + call.Name, + args, + context, + toolDispatcher, + cancellationToken); + } + + var llmObject = ToolResultCompactor.CompactForLlm(call.Name, outcome.ResultObject); + return new ToolDispatchResult( + callId, + call.Name, + outcome.FullResultJson, + llmObject.ToJsonString(), + outcome.ResultObject); + } + + private static void EmitWorkflowProgress(string callId, string toolName, ChatTurnProgress progress) + { + if (toolName is "run_insight_workflow" or "run_technical_workflow" or "run_keyword_workflow") + { + progress.EmitToolProgress(callId, toolName, "Running workflow steps…"); + } + else if (toolName == "run_domain_agent") + { + progress.EmitToolProgress(callId, toolName, "Exploring domain tools…"); + } + } + + private sealed record ToolDispatchResult( + string CallId, + string Name, + string FullResultJson, + string LlmResultJson, + JsonObject ResultObject); +} + +public sealed record ChatAgentLoopResult(bool Finished, string? PartialNote); diff --git a/services/AiService/src/AiService.Application/Chat/ChatModels.cs b/services/AiService/src/AiService.Application/Chat/ChatModels.cs new file mode 100644 index 00000000..c265f5c3 --- /dev/null +++ b/services/AiService/src/AiService.Application/Chat/ChatModels.cs @@ -0,0 +1,43 @@ +namespace AiService.Application.Chat; + +public sealed record ChatMessageRecord(string Role, string Content); + +public sealed record ChatNarrative( + IReadOnlyList PowerInsights, + IReadOnlyList RecommendedActions); + +/// Immutable tool invocation captured as JSON strings — safe to reuse across SSE and persistence. +public sealed record ChatToolEvent(string Name, string ArgsJson, string ResultJson); + +public sealed record ChatTurnResult( + bool Ok, + ChatNarrative? Narrative, + IReadOnlyList ToolEvents, + string? Error); + +public abstract record ChatStreamEvent(string Type); + +public sealed record ChatStatusEvent(string Phase, string Detail) : ChatStreamEvent("status"); + +public sealed record ChatToolStartEvent(string CallId, string Name, string ArgsJson) : ChatStreamEvent("tool_start"); + +public sealed record ChatToolEndEvent( + string CallId, + string Name, + string ResultJson, + bool Truncated = false, + int? ResultBytes = null) : ChatStreamEvent("tool_end"); + +public sealed record ChatToolProgressEvent(string CallId, string Name, string Detail) : ChatStreamEvent("tool_progress"); + +public sealed record ChatNarrativeStreamEvent(ChatNarrative Narrative) : ChatStreamEvent("narrative"); + +public sealed record ChatNarrativePartialStreamEvent(ChatNarrative Narrative) : ChatStreamEvent("narrative_partial"); + +public sealed record ChatTokenStreamEvent(string Text) : ChatStreamEvent("token"); + +public sealed record ChatDoneStreamEvent() : ChatStreamEvent("done"); + +public sealed record ChatErrorStreamEvent(string Message) : ChatStreamEvent("error"); + +public sealed record ChatPartialDoneStreamEvent(string Message) : ChatStreamEvent("partial_done"); diff --git a/services/AiService/src/AiService.Application/Chat/ChatNarrativeFallback.cs b/services/AiService/src/AiService.Application/Chat/ChatNarrativeFallback.cs new file mode 100644 index 00000000..706bc0cb --- /dev/null +++ b/services/AiService/src/AiService.Application/Chat/ChatNarrativeFallback.cs @@ -0,0 +1,158 @@ +using System.Text.Json.Nodes; + +namespace AiService.Application.Chat; + +/// +/// Builds a user-facing narrative from tool results when LLM synthesis fails. +/// +public static class ChatNarrativeFallback +{ + private static readonly HashSet WorkflowTools = new(StringComparer.Ordinal) + { + "run_insight_workflow", + "run_technical_workflow", + "run_keyword_workflow", + "run_domain_agent", + }; + + public static ChatNarrative? TryFromToolEvents( + IReadOnlyList toolEvents, + string userMessage) + { + var insights = new List(); + var actions = new List(); + + foreach (var toolEvent in toolEvents) + { + if (JsonNode.Parse(toolEvent.ResultJson) is not JsonObject result) + { + continue; + } + + if (WorkflowTools.Contains(toolEvent.Name)) + { + CollectFromWorkflow(toolEvent.Name, result, insights, actions); + continue; + } + + CollectFromResult(toolEvent.Name, result, insights, actions); + } + + if (insights.Count == 0 && actions.Count == 0 && !string.IsNullOrWhiteSpace(userMessage)) + { + insights.Add($"Review the tool results for “{Trim(userMessage, 120)}”."); + } + + if (insights.Count == 0 && actions.Count == 0) + { + return null; + } + + return new ChatNarrative( + insights.Take(5).ToList(), + actions.Take(5).ToList()); + } + + private static void CollectFromWorkflow( + string workflowName, + JsonObject result, + List insights, + List actions) + { + if (JsonScalar.AsString(result["error"]) is { Length: > 0 } error) + { + insights.Add($"The {Label(workflowName)} could not finish: {error}"); + actions.Add("Check that a recent audit exists for this property and try again."); + return; + } + + var wfType = JsonScalar.AsString(result["type"]) ?? JsonScalar.AsString(result["workflow"]) ?? "default"; + if (result["steps"] is JsonArray steps) + { + var stepCount = 0; + foreach (var stepNode in steps) + { + if (stepNode is not JsonObject step) + { + continue; + } + + stepCount++; + var stepTool = JsonScalar.AsString(step["tool"]) ?? "tool"; + if (step["result"] is JsonObject stepResult) + { + CollectFromResult(stepTool, stepResult, insights, actions); + } + } + + if (stepCount > 0 && insights.Count == 0) + { + insights.Add($"Completed {Label(workflowName)} ({wfType}) with {stepCount} data step(s)."); + } + } + else + { + insights.Add($"Completed {Label(workflowName)} ({wfType})."); + } + } + + private static void CollectFromResult( + string toolName, + JsonObject result, + List insights, + List actions) + { + if (JsonScalar.AsString(result["error"]) is { Length: > 0 } error) + { + insights.Add($"{Label(toolName)}: {error}"); + return; + } + + if (JsonScalar.AsString(result["summary"]) is { Length: > 0 } summary) + { + insights.Add(summary); + } + + if (JsonScalar.AsString(result["message"]) is { Length: > 0 } message) + { + insights.Add(message); + } + + if (JsonScalar.AsDouble(result["health_score"]) is { } health) + { + insights.Add($"Health score is {health:0}."); + } + + if (JsonScalar.AsString(result["site_name"]) is { Length: > 0 } site) + { + insights.Add($"Report data loaded for {site}."); + } + + if (JsonScalar.AsInt(result["total"]) is { } total && total >= 0) + { + insights.Add($"{Label(toolName)} returned {total} item(s)."); + } + + if (result["issues"] is JsonArray issues && issues.Count > 0) + { + insights.Add($"Found {issues.Count} issue(s) in {Label(toolName)}."); + actions.Add("Prioritize critical and high-severity issues first."); + } + + if (result["items"] is JsonArray items && items.Count > 0 && insights.Count < 5) + { + insights.Add($"{Label(toolName)} returned {items.Count} row(s) to review."); + } + + if (JsonScalar.AsString(result["recommendation"]) is { Length: > 0 } rec) + { + actions.Add(rec); + } + } + + private static string Label(string toolName) + => toolName.Replace("_", " ", StringComparison.Ordinal); + + private static string Trim(string text, int max) + => text.Length <= max ? text : text[..max].Trim() + "…"; +} diff --git a/services/AiService/src/AiService.Application/Chat/ChatNarrativeParser.cs b/services/AiService/src/AiService.Application/Chat/ChatNarrativeParser.cs new file mode 100644 index 00000000..dc550729 --- /dev/null +++ b/services/AiService/src/AiService.Application/Chat/ChatNarrativeParser.cs @@ -0,0 +1,233 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using AiService.Providers.Chat; +using WebsiteProfiling.Contracts.Chat; + +namespace AiService.Application.Chat; + +/// Parse and validate chat narrative JSON from LLM output. +public static class ChatNarrativeParser +{ + public static JsonObject UnwrapNarrativeObject(JsonObject parsed) + { + if (parsed["power_insights"] is JsonArray || parsed["recommended_actions"] is JsonArray) + { + return parsed; + } + + if (parsed["data"] is JsonObject data) + { + return data; + } + + return parsed; + } + + public static (ChatNarrative Narrative, List Errors) ValidateNarrative(JsonObject raw) + { + var errors = new List(); + LlmNarrativeResponse? typed = null; + try + { + typed = JsonSerializer.Deserialize(raw.ToJsonString()); + } + catch (JsonException) + { + // fall through to manual normalization + } + + var insights = typed?.PowerInsights?.Where(s => !string.IsNullOrWhiteSpace(s)).Take(5).ToList() + ?? NormalizeStringList(raw["power_insights"], "power_insights", errors, partial: false); + var actions = typed?.RecommendedActions?.Where(s => !string.IsNullOrWhiteSpace(s)).Take(5).ToList() + ?? NormalizeStringList(raw["recommended_actions"], "recommended_actions", errors, partial: false); + if (insights.Count == 0 && actions.Count == 0) + { + errors.Add("both power_insights and recommended_actions are empty after normalization"); + } + + return (new ChatNarrative(insights, actions), errors); + } + + /// Lenient parse for streaming partial JSON — skips missing-key errors. + public static ChatNarrative? TryParsePartial(string buffer) + { + if (string.IsNullOrWhiteSpace(buffer)) + { + return null; + } + + var insights = ExtractCompleteStrings(buffer, "power_insights"); + var actions = ExtractCompleteStrings(buffer, "recommended_actions"); + if (insights.Count > 0 || actions.Count > 0) + { + return new ChatNarrative(insights, actions); + } + + var parsed = TryParsePartialObject(buffer); + if (parsed is null) + { + return null; + } + + var unwrapped = UnwrapNarrativeObject(parsed); + insights = NormalizeStringList(unwrapped["power_insights"], "power_insights", [], partial: true); + actions = NormalizeStringList(unwrapped["recommended_actions"], "recommended_actions", [], partial: true); + if (insights.Count == 0 && actions.Count == 0) + { + return null; + } + + return new ChatNarrative(insights, actions); + } + + private static List ExtractCompleteStrings(string buffer, string field) + { + var marker = $"\"{field}\""; + var idx = buffer.IndexOf(marker, StringComparison.Ordinal); + if (idx < 0) + { + return []; + } + + var arrayStart = buffer.IndexOf('[', idx); + if (arrayStart < 0) + { + return []; + } + + var results = new List(); + var i = arrayStart + 1; + while (i < buffer.Length && results.Count < 5) + { + while (i < buffer.Length && (char.IsWhiteSpace(buffer[i]) || buffer[i] == ',')) + { + i++; + } + + if (i >= buffer.Length || buffer[i] == ']') + { + break; + } + + if (buffer[i] != '"') + { + break; + } + + i++; + var sb = new StringBuilder(); + var closed = false; + while (i < buffer.Length) + { + if (buffer[i] == '\\' && i + 1 < buffer.Length) + { + sb.Append(buffer[i]); + sb.Append(buffer[i + 1]); + i += 2; + continue; + } + + if (buffer[i] == '"') + { + closed = true; + i++; + break; + } + + sb.Append(buffer[i++]); + } + + if (!closed) + { + break; + } + + var text = sb.ToString().Trim(); + if (!string.IsNullOrEmpty(text)) + { + results.Add(text); + } + } + + return results; + } + + private static JsonObject? TryParsePartialObject(string buffer) + { + var trimmed = buffer.Trim(); + var direct = JsonResponseParser.Parse(trimmed); + if (direct.Count > 0 && (direct["power_insights"] is JsonArray || direct["recommended_actions"] is JsonArray)) + { + return direct; + } + + foreach (var suffix in PartialJsonSuffixes) + { + try + { + var node = JsonNode.Parse(trimmed + suffix); + if (node is JsonObject obj) + { + return obj; + } + } + catch (JsonException) + { + // try next suffix + } + } + + return null; + } + + private static readonly string[] PartialJsonSuffixes = + [ + "\"]}", + "\"]}]}", + "]}", + "]}]}", + "\"}", + "}", + ]; + + private static List NormalizeStringList( + JsonNode? value, + string field, + List errors, + bool partial) + { + var outList = new List(); + if (value is not JsonArray list) + { + if (!partial) + { + errors.Add($"missing key {field}"); + } + + return outList; + } + + foreach (var item in list) + { + if (item is not JsonValue scalar || !scalar.TryGetValue(out var raw)) + { + continue; + } + + var text = (raw ?? "").Trim(); + if (string.IsNullOrEmpty(text)) + { + continue; + } + + outList.Add(text); + if (outList.Count >= 5) + { + break; + } + } + + return outList; + } +} diff --git a/services/AiService/src/AiService.Application/Chat/ChatNarrativeSynthesizer.cs b/services/AiService/src/AiService.Application/Chat/ChatNarrativeSynthesizer.cs new file mode 100644 index 00000000..364f39aa --- /dev/null +++ b/services/AiService/src/AiService.Application/Chat/ChatNarrativeSynthesizer.cs @@ -0,0 +1,139 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using AiService.Application.Prompts; +using AiService.Providers.Chat; + +namespace AiService.Application.Chat; + +public sealed class ChatNarrativeSynthesizer(StructuredCompletionService completionService) +{ + private const string NarrativeFailedMsg = "Could not generate a summary. Tool results are shown below."; + + public string NarrativeFailedMessage => NarrativeFailedMsg; + + public async Task SynthesizeAsync( + IReadOnlyDictionary cfg, + string userMessage, + IReadOnlyList toolEvents, + Action? onStatus = null, + Action? onToken = null, + Action? onPartialNarrative = null, + CancellationToken cancellationToken = default) + { + try + { + return await SynthesizeCoreAsync( + cfg, + userMessage, + toolEvents, + onStatus, + onToken, + onPartialNarrative, + cancellationToken); + } + catch (Exception ex) when (ex is not InvalidOperationException) + { + return ResolveFallbackOrThrow(toolEvents, userMessage, ex); + } + } + + private async Task SynthesizeCoreAsync( + IReadOnlyDictionary cfg, + string userMessage, + IReadOnlyList toolEvents, + Action? onStatus, + Action? onToken, + Action? onPartialNarrative, + CancellationToken cancellationToken) + { + var payload = BuildSynthesisPayload(userMessage, toolEvents); + var extractor = onPartialNarrative is not null ? new StreamingNarrativeExtractor() : null; + + var parsed = await completionService.CompleteJsonStreamingAsync( + LlmPrompts.ChatNarrativeSystem, + payload, + cfg, + delta => + { + onToken?.Invoke(delta); + if (extractor is null) + { + return; + } + + extractor.Append(delta); + var partial = extractor.TryExtractPartial(); + if (partial is not null) + { + onPartialNarrative!(partial); + } + }, + cancellationToken); + + var (narrative, errors) = ChatNarrativeParser.ValidateNarrative( + ChatNarrativeParser.UnwrapNarrativeObject(parsed)); + if (errors.Count == 0) + { + return narrative; + } + + var repairPayload = JsonSerializer.Serialize(new + { + original_data = JsonNode.Parse(payload), + previous_response = parsed.ToJsonString(), + errors, + required_schema = new { power_insights = new[] { "string" }, recommended_actions = new[] { "string" } }, + }); + + onStatus?.Invoke("retrying"); + var repaired = await completionService.CompleteJsonAsync( + LlmPrompts.ChatNarrativeRepairSystem, + repairPayload, + cfg, + cancellationToken); + + var (narrative2, errors2) = ChatNarrativeParser.ValidateNarrative( + ChatNarrativeParser.UnwrapNarrativeObject(repaired)); + if (errors2.Count == 0) + { + return narrative2; + } + + return ResolveFallbackOrThrow( + toolEvents, + userMessage, + new InvalidOperationException(string.Join("; ", errors.Concat(errors2)))); + } + + private static ChatNarrative ResolveFallbackOrThrow( + IReadOnlyList toolEvents, + string userMessage, + Exception ex) + { + var fallback = ChatNarrativeFallback.TryFromToolEvents(toolEvents, userMessage); + if (fallback is not null) + { + return fallback; + } + + throw ex; + } + + private static string BuildSynthesisPayload(string userMessage, IReadOnlyList toolEvents) + { + var compact = toolEvents.Select(ev => new + { + name = ev.Name, + args = JsonNode.Parse(ev.ArgsJson), + result = JsonNode.Parse(ev.ResultJson), + }); + + var payload = JsonSerializer.Serialize(new + { + user_question = userMessage, + tool_results = compact, + }); + + return payload.Length > 10_000 ? payload[..10_000] + "\n…(truncated)" : payload; + } +} diff --git a/services/AiService/src/AiService.Application/Chat/ChatPersistenceMapper.cs b/services/AiService/src/AiService.Application/Chat/ChatPersistenceMapper.cs new file mode 100644 index 00000000..3e976874 --- /dev/null +++ b/services/AiService/src/AiService.Application/Chat/ChatPersistenceMapper.cs @@ -0,0 +1,63 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using WebsiteProfiling.Contracts.Chat; +using WebsiteProfiling.Contracts.Json; + +namespace AiService.Application.Chat; + +/// Maps a completed chat turn to the JSON stored in chat_messages.tool_result. +public static class ChatPersistenceMapper +{ + public static string? ToToolResultJson(ChatTurnResult result) + { + if (!result.Ok && result.Narrative is null && result.ToolEvents.Count == 0) + { + return null; + } + + var dto = new PersistedToolResultDto + { + Narrative = result.Narrative is { } narrative + ? new PersistedNarrativeDto + { + PowerInsights = narrative.PowerInsights, + RecommendedActions = narrative.RecommendedActions, + } + : null, + ToolEvents = result.ToolEvents.Count > 0 + ? result.ToolEvents.Select(ToPersistedToolEvent).ToList() + : null, + AgentError = string.IsNullOrWhiteSpace(result.Error) ? null : result.Error, + }; + + if (dto.Narrative is null && dto.ToolEvents is null && dto.AgentError is null) + { + return null; + } + + return JsonSerializer.Serialize(dto, ContractJsonOptions.Options); + } + + public static string FirstNarrativeInsight(ChatTurnResult result) + { + if (result.Narrative is not { } narrative) + { + return ""; + } + + if (narrative.PowerInsights.Count > 0) + { + return narrative.PowerInsights[0]; + } + + return narrative.RecommendedActions.Count > 0 ? narrative.RecommendedActions[0] : ""; + } + + private static PersistedToolEventDto ToPersistedToolEvent(ChatToolEvent toolEvent) + => new() + { + Name = toolEvent.Name, + Args = JsonNode.Parse(toolEvent.ArgsJson), + Result = JsonNode.Parse(toolEvent.ResultJson), + }; +} diff --git a/services/AiService/src/AiService.Application/Chat/ChatSseSerializer.cs b/services/AiService/src/AiService.Application/Chat/ChatSseSerializer.cs new file mode 100644 index 00000000..4476db2c --- /dev/null +++ b/services/AiService/src/AiService.Application/Chat/ChatSseSerializer.cs @@ -0,0 +1,69 @@ +using System.Text.Json.Nodes; + +namespace AiService.Application.Chat; + +/// Maps typed chat events to wire-format JSON at the HTTP boundary only. +public static class ChatSseSerializer +{ + public static JsonObject ToJson(ChatStreamEvent evt) => evt switch + { + ChatStatusEvent s => new() + { + ["type"] = s.Type, + ["phase"] = s.Phase, + ["detail"] = s.Detail, + }, + ChatToolStartEvent t => new() + { + ["type"] = t.Type, + ["call_id"] = t.CallId, + ["name"] = t.Name, + ["args"] = ParseJsonObject(t.ArgsJson), + }, + ChatToolEndEvent t => new() + { + ["type"] = t.Type, + ["call_id"] = t.CallId, + ["name"] = t.Name, + ["result"] = ParseJsonObject(t.ResultJson), + ["truncated"] = t.Truncated, + ["result_bytes"] = t.ResultBytes, + }, + ChatToolProgressEvent p => new() + { + ["type"] = p.Type, + ["call_id"] = p.CallId, + ["name"] = p.Name, + ["detail"] = p.Detail, + }, + ChatNarrativeStreamEvent n => new() + { + ["type"] = n.Type, + ["narrative"] = ToNarrativeJson(n.Narrative), + }, + ChatNarrativePartialStreamEvent n => new() + { + ["type"] = n.Type, + ["narrative"] = ToNarrativeJson(n.Narrative), + }, + ChatTokenStreamEvent t => new() + { + ["type"] = t.Type, + ["text"] = t.Text, + }, + ChatDoneStreamEvent d => new() { ["type"] = d.Type }, + ChatErrorStreamEvent e => new() { ["type"] = e.Type, ["message"] = e.Message }, + ChatPartialDoneStreamEvent p => new() { ["type"] = p.Type, ["message"] = p.Message }, + _ => new JsonObject { ["type"] = evt.Type }, + }; + + private static JsonObject ToNarrativeJson(ChatNarrative narrative) + => new() + { + ["power_insights"] = new JsonArray(narrative.PowerInsights.Select(x => JsonValue.Create(x)).ToArray()), + ["recommended_actions"] = new JsonArray(narrative.RecommendedActions.Select(x => JsonValue.Create(x)).ToArray()), + }; + + private static JsonNode ParseJsonObject(string json) + => JsonNode.Parse(json) ?? new JsonObject(); +} diff --git a/services/AiService/src/AiService.Application/Chat/ChatTextSanitize.cs b/services/AiService/src/AiService.Application/Chat/ChatTextSanitize.cs new file mode 100644 index 00000000..d81ffe0f --- /dev/null +++ b/services/AiService/src/AiService.Application/Chat/ChatTextSanitize.cs @@ -0,0 +1,43 @@ +using System.Text; + +namespace AiService.Application.Chat; + +/// Unicode sanitization for chat messages (ports Python text_sanitize.strip_surrogates). +public static class ChatTextSanitize +{ + /// + /// Drops lone (unpaired) UTF-16 surrogates while preserving valid surrogate pairs, so + /// emoji and other non-BMP characters survive. Matches the Python original, which only + /// strips surrogates that cannot encode to UTF-8. + /// + public static string StripSurrogates(string? text) + { + if (string.IsNullOrEmpty(text)) + { + return ""; + } + + var sb = new StringBuilder(text.Length); + for (var i = 0; i < text.Length; i++) + { + var ch = text[i]; + if (char.IsHighSurrogate(ch) && i + 1 < text.Length && char.IsLowSurrogate(text[i + 1])) + { + // Valid surrogate pair — keep both halves. + sb.Append(ch); + sb.Append(text[i + 1]); + i++; + } + else if (char.IsSurrogate(ch)) + { + // Lone surrogate — drop (invalid in UTF-8). + } + else + { + sb.Append(ch); + } + } + + return sb.Length == text.Length ? text : sb.ToString(); + } +} diff --git a/services/AiService/src/AiService.Application/Chat/ChatTurnProgress.cs b/services/AiService/src/AiService.Application/Chat/ChatTurnProgress.cs new file mode 100644 index 00000000..a7d1c151 --- /dev/null +++ b/services/AiService/src/AiService.Application/Chat/ChatTurnProgress.cs @@ -0,0 +1,128 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using AiService.Tools.Context; +using AiService.Tools.Registry; +using Microsoft.Extensions.AI; + +namespace AiService.Application.Chat; + +/// Collects tool invocations and emits typed SSE events for a single chat turn. +public sealed class ChatTurnProgress(Action? emit) +{ + private readonly List _toolEvents = []; + private readonly Lock _toolLock = new(); + + public IReadOnlyList ToolEvents => _toolEvents; + + public void EmitStatus(string phase, string detail) + => emit?.Invoke(new ChatStatusEvent(phase, detail)); + + public void EmitToolProgress(string callId, string toolName, string detail) + => emit?.Invoke(new ChatToolProgressEvent(callId, toolName, detail)); + + public void EmitPartialDone(string message) + => emit?.Invoke(new ChatPartialDoneStreamEvent(message)); + + public void EmitNarrative(ChatNarrative narrative) + => emit?.Invoke(new ChatNarrativeStreamEvent(narrative)); + + public void EmitNarrativePartial(ChatNarrative narrative) + => emit?.Invoke(new ChatNarrativePartialStreamEvent(narrative)); + + public void EmitToken(string text) + => emit?.Invoke(new ChatTokenStreamEvent(text)); + + public void EmitDone() + => emit?.Invoke(new ChatDoneStreamEvent()); + + public void EmitError(string message) + => emit?.Invoke(new ChatErrorStreamEvent(message)); + + public string RecordGatedTool(string callId, string toolName, AIFunctionArguments args) + { + var argsJson = SerializeArgs(args); + emit?.Invoke(new ChatToolStartEvent(callId, toolName, argsJson)); + var result = new JsonObject + { + ["error"] = $"tool not loaded this turn: {toolName}", + ["hint"] = "Call search_audit_tools to load specialized tools, or rephrase your request.", + }; + var fullJson = CompleteTool(callId, toolName, argsJson, result); + emit?.Invoke(new ChatToolEndEvent(callId, toolName, fullJson, false, fullJson.Length)); + return fullJson; + } + + public async Task DispatchToolAsync( + string callId, + string toolName, + AIFunctionArguments args, + AuditToolContext context, + ToolDispatcher dispatcher, + CancellationToken cancellationToken) + { + var argsJson = SerializeArgs(args); + emit?.Invoke(new ChatToolStartEvent(callId, toolName, argsJson)); + + JsonObject result; + try + { + if (context.PropertyId is not int propertyId) + { + result = new JsonObject { ["error"] = "property_id required" }; + } + else + { + var jsonArgs = ParseArgs(args); + result = await dispatcher.DispatchAsync( + toolName, + propertyId, + context.ReportId, + jsonArgs, + cancellationToken); + } + } + catch (Exception ex) + { + result = new JsonObject { ["error"] = ex.Message }; + } + + var fullJson = CompleteTool(callId, toolName, argsJson, result); + var uiObject = ToolResultCompactor.CompactForUi(toolName, result); + var uiJson = uiObject.ToJsonString(); + var truncated = ToolResultCompactor.WasTruncated(result, uiObject); + emit?.Invoke(new ChatToolEndEvent( + callId, + toolName, + uiJson, + truncated, + result.ToJsonString().Length)); + + return new ToolDispatchOutcome(fullJson, uiJson, result); + } + + private string CompleteTool(string callId, string toolName, string argsJson, JsonObject result) + { + var resultJson = result.ToJsonString(); + lock (_toolLock) + { + _toolEvents.Add(new ChatToolEvent(toolName, argsJson, resultJson)); + } + + return resultJson; + } + + private static string SerializeArgs(AIFunctionArguments args) + => args.Count == 0 ? "{}" : JsonSerializer.Serialize(args.ToDictionary(x => x.Key, x => x.Value)); + + private static JsonObject ParseArgs(AIFunctionArguments args) + { + if (args.Count == 0) + { + return []; + } + + return JsonNode.Parse(SerializeArgs(args)) as JsonObject ?? []; + } +} + +public sealed record ToolDispatchOutcome(string FullResultJson, string UiResultJson, JsonObject ResultObject); diff --git a/services/AiService/src/AiService.Application/Chat/JsonScalar.cs b/services/AiService/src/AiService.Application/Chat/JsonScalar.cs new file mode 100644 index 00000000..fcdeb10c --- /dev/null +++ b/services/AiService/src/AiService.Application/Chat/JsonScalar.cs @@ -0,0 +1,17 @@ +using System.Text.Json.Nodes; +using WebsiteProfiling.Contracts.Json; + +namespace AiService.Application.Chat; + +/// +/// Safe scalar extraction from values. Delegates to +/// in WebsiteProfiling.Contracts. +/// +internal static class JsonScalar +{ + public static string? AsString(JsonNode? node) => JsonCoercion.AsString(node); + + public static double? AsDouble(JsonNode? node) => JsonCoercion.AsDouble(node); + + public static int? AsInt(JsonNode? node) => JsonCoercion.AsInt(node); +} diff --git a/services/AiService/src/AiService.Application/Chat/StreamingNarrativeExtractor.cs b/services/AiService/src/AiService.Application/Chat/StreamingNarrativeExtractor.cs new file mode 100644 index 00000000..06df330c --- /dev/null +++ b/services/AiService/src/AiService.Application/Chat/StreamingNarrativeExtractor.cs @@ -0,0 +1,40 @@ +using System.Text; + +namespace AiService.Application.Chat; + +/// Extracts incremental narrative bullets from a streaming JSON buffer. +public sealed class StreamingNarrativeExtractor +{ + private readonly StringBuilder _buffer = new(); + private int _lastInsightCount; + private int _lastActionCount; + + public void Append(string delta) + { + if (!string.IsNullOrEmpty(delta)) + { + _buffer.Append(delta); + } + } + + /// Returns a partial narrative when new complete bullets appear; otherwise null. + public ChatNarrative? TryExtractPartial() + { + var partial = ChatNarrativeParser.TryParsePartial(_buffer.ToString()); + if (partial is null) + { + return null; + } + + var insightCount = partial.PowerInsights.Count; + var actionCount = partial.RecommendedActions.Count; + if (insightCount <= _lastInsightCount && actionCount <= _lastActionCount) + { + return null; + } + + _lastInsightCount = insightCount; + _lastActionCount = actionCount; + return partial; + } +} diff --git a/services/AiService/src/AiService.Application/Chat/ToolConcurrency.cs b/services/AiService/src/AiService.Application/Chat/ToolConcurrency.cs new file mode 100644 index 00000000..819ca34b --- /dev/null +++ b/services/AiService/src/AiService.Application/Chat/ToolConcurrency.cs @@ -0,0 +1,70 @@ +namespace AiService.Application.Chat; + +/// +/// Bounded parallel tool dispatch (ports Python website_profiling.concurrency.tool_concurrency). +/// +public static class ToolConcurrency +{ + public const int DefaultMaxWorkers = 6; + + public static int ResolveMaxWorkers(int? overrideValue = null) + { + if (overrideValue is int explicitValue && explicitValue > 0) + { + return explicitValue; + } + + var raw = Environment.GetEnvironmentVariable("WP_TOOL_CONCURRENCY")?.Trim(); + if (int.TryParse(raw, out var parsed) && parsed > 0) + { + return parsed; + } + + return DefaultMaxWorkers; + } + + /// + /// Runs async work items with bounded concurrency, preserving input order in results. + /// + public static async Task> MapParallelAsync( + IReadOnlyList>> tasks, + int maxWorkers, + CancellationToken cancellationToken = default) + { + if (tasks.Count == 0) + { + return []; + } + + var workers = Math.Max(1, Math.Min(maxWorkers, tasks.Count)); + if (workers == 1 || tasks.Count == 1) + { + var sequential = new List(tasks.Count); + foreach (var task in tasks) + { + cancellationToken.ThrowIfCancellationRequested(); + sequential.Add(await task()); + } + + return sequential; + } + + using var gate = new SemaphoreSlim(workers, workers); + var results = new T[tasks.Count]; + var inFlight = tasks.Select(async (factory, index) => + { + await gate.WaitAsync(cancellationToken); + try + { + results[index] = await factory(); + } + finally + { + gate.Release(); + } + }); + + await Task.WhenAll(inFlight); + return results; + } +} diff --git a/services/AiService/src/AiService.Application/Chat/ToolResultCompactor.cs b/services/AiService/src/AiService.Application/Chat/ToolResultCompactor.cs new file mode 100644 index 00000000..141cd623 --- /dev/null +++ b/services/AiService/src/AiService.Application/Chat/ToolResultCompactor.cs @@ -0,0 +1,268 @@ +using System.Text.Json.Nodes; +using AiService.Tools.Slice; + +namespace AiService.Application.Chat; + +/// +/// Produces compact tool result JSON for LLM follow-up rounds and UI-sized slices for SSE. +/// Full results are persisted separately in . +/// +public static class ToolResultCompactor +{ + public const int DefaultLlmListLimit = 20; + public const int DefaultUiListLimit = 50; + + private static readonly HashSet ExportTools = new(StringComparer.Ordinal) + { + "export_audit_report", + "export_list_as_csv", + "export_compare_csv", + "export_sitemap_xml", + }; + + private static readonly HashSet WorkflowTools = new(StringComparer.Ordinal) + { + "run_insight_workflow", + "run_technical_workflow", + "run_keyword_workflow", + "run_domain_agent", + }; + + private static readonly HashSet ListKeys = new(StringComparer.Ordinal) + { + "items", "issues", "pages", "queries", "rows", "results", "tools", "steps", + "links", "urls", "entries", "records", "domains", "keywords", + }; + + public static JsonObject CompactForLlm(string toolName, JsonObject full) + => Compact(full, DefaultLlmListLimit, toolName, forUi: false); + + public static JsonObject CompactForUi(string toolName, JsonObject full) + => Compact(full, DefaultUiListLimit, toolName, forUi: true); + + public static bool WasTruncated(JsonObject full, JsonObject compact) + => full.ToJsonString().Length > compact.ToJsonString().Length; + + private static JsonObject Compact(JsonObject full, int listLimit, string toolName, bool forUi) + { + if (full["error"] is JsonValue || full["hint"] is JsonValue) + { + return full.DeepClone() as JsonObject ?? []; + } + + if (ExportTools.Contains(toolName) || full["artifact_id"] is not null) + { + return CompactExport(full); + } + + if (WorkflowTools.Contains(toolName)) + { + return CompactWorkflow(full, listLimit); + } + + if (toolName == "search_audit_tools") + { + return CompactSearchTools(full, forUi ? 12 : 8); + } + + var output = new JsonObject(); + foreach (var (key, value) in full) + { + if (value is JsonArray array && ListKeys.Contains(key)) + { + var items = array.Select(x => x).ToList(); + var capped = PayloadSliceHelpers.CapList(items, listLimit, listLimit); + output[key] = capped["items"]?.DeepClone(); + output["total"] = capped["total"]?.DeepClone(); + output["truncated"] = capped["truncated"]?.DeepClone(); + continue; + } + + if (value is JsonArray nestedArray && nestedArray.Count > listLimit) + { + var capped = PayloadSliceHelpers.CapList( + nestedArray.Select(x => x).ToList(), + listLimit, + listLimit); + output[key] = capped["items"]?.DeepClone(); + continue; + } + + if (value is JsonObject obj && obj.Count > 24) + { + output[key] = ShallowObject(obj, maxFields: forUi ? 16 : 12); + continue; + } + + output[key] = value?.DeepClone(); + } + + return output; + } + + private static JsonObject CompactExport(JsonObject full) + { + var compact = new JsonObject(); + foreach (var key in new[] { "artifact_id", "format", "filename", "mime_type", "ready", "url", "error", "hint", "message" }) + { + if (full.TryGetPropertyValue(key, out var value) && value is not null) + { + compact[key] = value.DeepClone(); + } + } + + if (compact.Count == 0) + { + return ShallowObject(full, maxFields: 8); + } + + return compact; + } + + private static JsonObject CompactWorkflow(JsonObject full, int listLimit) + { + var compact = new JsonObject(); + foreach (var (key, value) in full) + { + if (key is "workflow" or "type" or "error" or "hint") + { + compact[key] = value?.DeepClone(); + } + } + + if (full["steps"] is JsonArray steps) + { + var stepSummaries = new JsonArray(); + var count = 0; + foreach (var stepNode in steps) + { + if (count >= listLimit) + { + break; + } + + if (stepNode is not JsonObject step) + { + continue; + } + + var tool = JsonScalar.AsString(step["tool"]) ?? JsonScalar.AsString(step["name"]) ?? ""; + var result = step["result"] as JsonObject; + var summary = new JsonObject + { + ["tool"] = tool, + ["ok"] = result?["error"] is null, + }; + if (JsonScalar.AsString(result?["error"]) is { Length: > 0 } err) + { + summary["error"] = err; + } + else if (result is not null) + { + summary["summary"] = SummarizeResult(result); + } + + stepSummaries.Add(summary); + count++; + } + + compact["steps"] = stepSummaries; + compact["step_count"] = steps.Count; + compact["truncated"] = steps.Count > count; + } + + return compact; + } + + private static JsonObject CompactSearchTools(JsonObject full, int nameLimit) + { + var compact = new JsonObject(); + foreach (var (key, value) in full) + { + if (key is "query" or "total" or "error") + { + compact[key] = value?.DeepClone(); + } + } + + if (full["tool_names"] is JsonArray names) + { + var slice = new JsonArray(); + for (var i = 0; i < Math.Min(nameLimit, names.Count); i++) + { + slice.Add(names[i]?.DeepClone()); + } + + compact["tool_names"] = slice; + } + + if (full["tools"] is JsonArray tools) + { + var slice = new JsonArray(); + for (var i = 0; i < Math.Min(nameLimit, tools.Count); i++) + { + var entry = tools[i] as JsonObject; + if (entry is null) + { + continue; + } + + slice.Add(new JsonObject + { + ["name"] = entry["name"]?.DeepClone(), + ["description"] = entry["description"]?.DeepClone(), + ["domain"] = entry["domain"]?.DeepClone(), + }); + } + + compact["tools"] = slice; + } + + return compact; + } + + private static JsonObject ShallowObject(JsonObject obj, int maxFields) + { + var shallow = new JsonObject(); + var count = 0; + foreach (var (key, value) in obj) + { + if (count >= maxFields) + { + shallow["_truncated"] = true; + break; + } + + shallow[key] = value switch + { + JsonObject => JsonValue.Create("[object]"), + JsonArray array => JsonValue.Create($"[array:{array.Count}]"), + _ => value?.DeepClone(), + }; + count++; + } + + return shallow; + } + + private static string SummarizeResult(JsonObject result) + { + if (JsonScalar.AsInt(result["total"]) is int total) + { + return $"total={total}"; + } + + if (result["health_score"] is JsonValue healthScore) + { + return $"health_score={healthScore.ToJsonString()}"; + } + + if (JsonScalar.AsString(result["error"]) is { Length: > 0 } err) + { + return err.Length > 120 ? err[..120] : err; + } + + var json = result.ToJsonString(); + return json.Length > 160 ? json[..160] + "…" : json; + } +} diff --git a/services/AiService/src/AiService.Application/DependencyInjection.cs b/services/AiService/src/AiService.Application/DependencyInjection.cs new file mode 100644 index 00000000..9afdf434 --- /dev/null +++ b/services/AiService/src/AiService.Application/DependencyInjection.cs @@ -0,0 +1,91 @@ +using AiService.Application.Chat; +using AiService.Application.Options; +using AiService.Application.Persistence; +using AiService.Application.Repositories; +using AiService.Application.Services; +using AiService.Domain.Repositories; +using AiService.Providers; +using AiService.Tools; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Npgsql; + +namespace AiService.Application; + +public static class DependencyInjection +{ + public static IServiceCollection AddAiServiceApplication(this IServiceCollection services) + { + services.AddAiServiceProviders(); + services.AddAiServiceTools(); + + services.AddOptions() + .BindConfiguration(DatabaseOptions.SectionName) + .PostConfigure(o => + { + var url = Environment.GetEnvironmentVariable("DATABASE_URL"); + if (!string.IsNullOrWhiteSpace(url)) + { + o.ConnectionString = url.Trim(); + } + }); + + services.AddOptions() + .BindConfiguration(UpstreamOptions.SectionName) + .PostConfigure(o => + { + var fastApi = Environment.GetEnvironmentVariable("FASTAPI_URL"); + if (!string.IsNullOrWhiteSpace(fastApi)) + { + o.FastApiUrl = fastApi.Trim(); + } + }); + + services.AddSingleton(sp => + { + var o = sp.GetRequiredService>().Value; + var builder = new NpgsqlDataSourceBuilder(NpgsqlDsn.ToNpgsql(o.ConnectionString)); + builder.ConnectionStringBuilder.MinPoolSize = o.MinPoolSize; + builder.ConnectionStringBuilder.MaxPoolSize = o.MaxPoolSize; + builder.ConnectionStringBuilder.CommandTimeout = o.CommandTimeoutSeconds; + return builder.Build(); + }); + + services.AddDbContextPool((sp, options) => + { + var o = sp.GetRequiredService>().Value; + var dataSource = sp.GetRequiredService(); + options.UseNpgsql(dataSource, npg => npg.CommandTimeout(o.CommandTimeoutSeconds)); + }); + + services.AddHttpClient(nameof(OllamaCatalogService), client => + { + client.Timeout = TimeSpan.FromSeconds(15); + }); + + services.AddScoped(); + // Singleton: stateless Npgsql reads; consumed by singleton AuditToolSelectionService (MCP/chat). + services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(sp => sp.GetRequiredService()); + services.AddScoped(); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + return services; + } +} diff --git a/services/AiService/src/AiService.Application/Dto/ChatDtos.cs b/services/AiService/src/AiService.Application/Dto/ChatDtos.cs new file mode 100644 index 00000000..26e77394 --- /dev/null +++ b/services/AiService/src/AiService.Application/Dto/ChatDtos.cs @@ -0,0 +1,19 @@ +namespace AiService.Application.Dto; + +public sealed class ChatRequest +{ + public long SessionId { get; set; } + + public long PropertyId { get; set; } + + public int? ReportId { get; set; } + + public string Message { get; set; } = ""; +} + +public sealed class ChatSessionCreate +{ + public long PropertyId { get; set; } + + public string? Title { get; set; } +} diff --git a/services/AiService/src/AiService.Application/Dto/JsonRefreshExtensions.cs b/services/AiService/src/AiService.Application/Dto/JsonRefreshExtensions.cs new file mode 100644 index 00000000..49fde951 --- /dev/null +++ b/services/AiService/src/AiService.Application/Dto/JsonRefreshExtensions.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Nodes; + +namespace AiService.Application.Dto; + +public static class JsonRefreshExtensions +{ + public static bool GetRefresh(this JsonObject body) + => body["refresh"]?.GetValue() == true; +} diff --git a/services/AiService/src/AiService.Application/Dto/RefreshRequestBody.cs b/services/AiService/src/AiService.Application/Dto/RefreshRequestBody.cs new file mode 100644 index 00000000..21cb476d --- /dev/null +++ b/services/AiService/src/AiService.Application/Dto/RefreshRequestBody.cs @@ -0,0 +1,7 @@ +namespace AiService.Application.Dto; + +/// Shared refresh flag for enrichment/issue endpoints. +public sealed class RefreshRequestBody +{ + public bool Refresh { get; set; } +} diff --git a/services/AiService/src/AiService.Application/Json/JsonNodeCopy.cs b/services/AiService/src/AiService.Application/Json/JsonNodeCopy.cs new file mode 100644 index 00000000..2325775c --- /dev/null +++ b/services/AiService/src/AiService.Application/Json/JsonNodeCopy.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Nodes; + +namespace AiService.Application.Json; + +/// +/// Safe JsonNode patterns — each JsonNode has a single parent; copy at boundaries. +/// +public static class JsonNodeCopy +{ + public static JsonObject CloneObject(JsonObject? node) + => node?.DeepClone() as JsonObject ?? []; + + public static JsonArray CloneArray(JsonArray? node) + => node?.DeepClone() as JsonArray ?? []; + + public static JsonObject DetachedCopy(JsonObject source) + => CloneObject(source); +} diff --git a/services/AiService/src/AiService.Application/Options/DatabaseOptions.cs b/services/AiService/src/AiService.Application/Options/DatabaseOptions.cs new file mode 100644 index 00000000..a65e5ed0 --- /dev/null +++ b/services/AiService/src/AiService.Application/Options/DatabaseOptions.cs @@ -0,0 +1,18 @@ +namespace AiService.Application.Options; + +/// +/// Postgres connection settings. is the libpq URI from env +/// DATABASE_URL; converted by . +/// +public sealed class DatabaseOptions +{ + public const string SectionName = "Database"; + + public string ConnectionString { get; set; } = ""; + + public int MinPoolSize { get; set; } = 2; + + public int MaxPoolSize { get; set; } = 20; + + public int CommandTimeoutSeconds { get; set; } = 30; +} diff --git a/services/AiService/src/AiService.Application/Options/UpstreamOptions.cs b/services/AiService/src/AiService.Application/Options/UpstreamOptions.cs new file mode 100644 index 00000000..ffda71da --- /dev/null +++ b/services/AiService/src/AiService.Application/Options/UpstreamOptions.cs @@ -0,0 +1,9 @@ +namespace AiService.Application.Options; + +/// Upstream FastAPI bridge for Python audit tools (FASTAPI_URL). +public sealed class UpstreamOptions +{ + public const string SectionName = "Upstream"; + + public string FastApiUrl { get; set; } = "http://127.0.0.1:8000"; +} diff --git a/services/AiService/src/AiService.Application/Persistence/AiDbContext.cs b/services/AiService/src/AiService.Application/Persistence/AiDbContext.cs new file mode 100644 index 00000000..9716015e --- /dev/null +++ b/services/AiService/src/AiService.Application/Persistence/AiDbContext.cs @@ -0,0 +1,79 @@ +using AiService.Domain.Entities; +using Microsoft.EntityFrameworkCore; + +namespace AiService.Application.Persistence; + +/// +/// EF Core context over the Alembic-owned schema. Never calls Migrate() or EnsureCreated(). +/// +public sealed class AiDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet LlmConfig => Set(); + + public DbSet LlmCache => Set(); + + public DbSet ChatSessions => Set(); + + public DbSet ChatMessages => Set(); + + public DbSet ReportPayloads => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(e => + { + e.ToTable("llm_config"); + e.HasKey(x => x.Key); + e.Property(x => x.Key).HasColumnName("key"); + e.Property(x => x.Value).HasColumnName("value"); + e.Property(x => x.IsSecret).HasColumnName("is_secret"); + e.Property(x => x.UpdatedAt).HasColumnName("updated_at"); + }); + + modelBuilder.Entity(e => + { + e.ToTable("llm_cache"); + e.HasKey(x => x.CacheKey); + e.Property(x => x.CacheKey).HasColumnName("cache_key"); + e.Property(x => x.ResponseJson).HasColumnName("response_json").HasColumnType("jsonb"); + e.Property(x => x.CreatedAt).HasColumnName("created_at"); + }); + + modelBuilder.Entity(e => + { + e.ToTable("chat_sessions"); + e.HasKey(x => x.Id); + e.Property(x => x.Id).HasColumnName("id"); + e.Property(x => x.PropertyId).HasColumnName("property_id"); + e.Property(x => x.Title).HasColumnName("title"); + e.Property(x => x.CreatedAt).HasColumnName("created_at"); + e.Property(x => x.UpdatedAt).HasColumnName("updated_at"); + e.HasMany(x => x.Messages).WithOne(x => x.Session).HasForeignKey(x => x.SessionId); + }); + + modelBuilder.Entity(e => + { + e.ToTable("chat_messages"); + e.HasKey(x => x.Id); + e.Property(x => x.Id).HasColumnName("id"); + e.Property(x => x.SessionId).HasColumnName("session_id"); + e.Property(x => x.Role).HasColumnName("role"); + e.Property(x => x.Content).HasColumnName("content"); + e.Property(x => x.ToolName).HasColumnName("tool_name"); + e.Property(x => x.ToolArgs).HasColumnName("tool_args").HasColumnType("jsonb"); + e.Property(x => x.ToolResult).HasColumnName("tool_result").HasColumnType("jsonb"); + e.Property(x => x.CreatedAt).HasColumnName("created_at"); + }); + + modelBuilder.Entity(e => + { + e.ToTable("report_payload"); + e.HasKey(x => x.Id); + e.Property(x => x.Id).HasColumnName("id"); + e.Property(x => x.GeneratedAt).HasColumnName("generated_at"); + e.Property(x => x.SiteName).HasColumnName("site_name"); + e.Property(x => x.CanonicalDomain).HasColumnName("canonical_domain"); + e.Property(x => x.Data).HasColumnName("data").HasColumnType("jsonb"); + }); + } +} diff --git a/services/AiService/src/AiService.Application/Persistence/NpgsqlDsn.cs b/services/AiService/src/AiService.Application/Persistence/NpgsqlDsn.cs new file mode 100644 index 00000000..a55382a8 --- /dev/null +++ b/services/AiService/src/AiService.Application/Persistence/NpgsqlDsn.cs @@ -0,0 +1,90 @@ +using Npgsql; + +namespace AiService.Application.Persistence; + +/// +/// Converts a libpq connection URI into an Npgsql keyword connection string. +/// +public static class NpgsqlDsn +{ + public static string ToNpgsql(string raw) + { + if (string.IsNullOrWhiteSpace(raw)) + { + throw new InvalidOperationException( + "DATABASE_URL is not set. Example: postgres://user:pass@host:5432/website_profiling"); + } + + var s = raw.Trim(); + var isUri = s.StartsWith("postgres://", StringComparison.OrdinalIgnoreCase) + || s.StartsWith("postgresql://", StringComparison.OrdinalIgnoreCase); + if (!isUri) + { + return s; + } + + var uri = new Uri(s); + var b = new NpgsqlConnectionStringBuilder + { + Host = Uri.UnescapeDataString(uri.Host), + Port = uri.IsDefaultPort || uri.Port <= 0 ? 5432 : uri.Port, + Database = Uri.UnescapeDataString(uri.AbsolutePath.TrimStart('/')), + }; + + var userInfo = uri.UserInfo.Split(':', 2); + if (userInfo.Length > 0 && userInfo[0].Length > 0) + { + b.Username = Uri.UnescapeDataString(userInfo[0]); + } + + if (userInfo.Length > 1) + { + b.Password = Uri.UnescapeDataString(userInfo[1]); + } + + foreach (var (key, value) in ParseQuery(uri.Query)) + { + switch (key.ToLowerInvariant()) + { + case "connect_timeout": + if (int.TryParse(value, out var t)) + { + b.Timeout = t; + } + + break; + case "sslmode": + if (Enum.TryParse(value, ignoreCase: true, out var mode)) + { + b.SslMode = mode; + } + + break; + case "application_name": + b.ApplicationName = value; + break; + } + } + + return b.ConnectionString; + } + + private static IEnumerable> ParseQuery(string query) + { + var q = query.TrimStart('?'); + if (q.Length == 0) + { + yield break; + } + + foreach (var part in q.Split('&', StringSplitOptions.RemoveEmptyEntries)) + { + var idx = part.IndexOf('='); + yield return idx < 0 + ? new KeyValuePair(Uri.UnescapeDataString(part), "") + : new KeyValuePair( + Uri.UnescapeDataString(part[..idx]), + Uri.UnescapeDataString(part[(idx + 1)..])); + } + } +} diff --git a/services/AiService/src/AiService.Application/Prompts/Prompts.cs b/services/AiService/src/AiService.Application/Prompts/Prompts.cs new file mode 100644 index 00000000..508595b1 --- /dev/null +++ b/services/AiService/src/AiService.Application/Prompts/Prompts.cs @@ -0,0 +1,252 @@ +namespace AiService.Application.Prompts; + +/// Versioned prompts ported from src/website_profiling/llm/prompts.py. +public static class LlmPrompts +{ + public const string Version = "v2"; + + public const string NerSystem = + """ + You extract named entities from web page text for SEO analysis. + Return JSON: {"pages": [{"url": "...", "entity_count": N, "top_entity_labels": [["ORG", 2], ["PERSON", 1]]}]} + Use standard NER labels (ORG, PERSON, GPE, PRODUCT, etc.). Count occurrences per label. + """; + + public const string KeyphrasesSystem = + """ + You extract SEO keyphrases from web page content. + Return JSON: {"pages": [{"url": "...", "phrases": [["phrase text", 0.95], ...]}]} + Provide 3-8 phrases per page with scores 0-1. + """; + + public const string SimilarSystem = + """ + You find semantically similar internal pages for SEO deduplication review. + Return JSON: {"pages": [{"url": "...", "similar": [{"url": "...", "score": 0.87}, ...]}]} + Scores 0-1; only include URLs from the provided candidate list. + """; + + public const string KeywordClusterSystem = + """ + You group related SEO keywords into semantic clusters. + Return JSON: {"clusters": [{"top_keyword": "...", "keywords": ["a","b"], "cluster_score": 0.9}]} + Only merge clearly related terms; omit singletons. + """; + + public const string PageCoachSystem = + """ + You are an SEO and UX retention analyst for a single web page. + Use ONLY the metrics and crawl facts provided. Do not invent traffic numbers. + Return JSON: + { + "summary": "2-3 sentences on overall page health for search and retention", + "missing_on_page": ["specific missing element or content gap"], + "retention_improvements": [{"title": "...", "why": "...", "priority": "high|medium|low"}], + "seo_improvements": [{"title": "...", "why": "...", "priority": "high|medium|low"}], + "quick_wins": ["actionable one-liner"] + } + Focus retention on engagement, clarity, next-step paths, and reducing bounce. Reference compare trends when present. + """; + + public const string ContentStudioAnalyzeSystem = + """ + You are an SEO content editor coaching a writer on a draft article. + Use ONLY the keyword, score metrics, missing terms, and draft excerpt provided. Do not invent SERP data. + Return JSON: + { + "summary": "2-3 sentences on draft quality and top priority", + "suggestions": [{"text": "specific actionable suggestion", "priority": "high|medium|low", "type": "term|structure|seo|readability"}], + "outline": ["optional H2 heading ideas"], + "title_ideas": ["optional title tag ideas"] + } + Prioritize missing high-importance GSC terms, failed on-page checks, and clarity improvements. + """; + + public const string IssueFixSystem = + """ + You are a technical SEO consultant. Given one audit issue, return a concise, actionable fix. + Use ONLY the facts provided. Do not invent URLs or metrics. + Return JSON: {"fix": "2-4 sentences with specific steps", "effort": "low|medium|high"} + """; + + public const string FixSuggestionLighthouseSystem = + """ + You are a web performance and Lighthouse audit specialist. + Given one Lighthouse finding (quick win, diagnostic, or audit), return a concise actionable fix. + Use ONLY the facts provided. Reference audit IDs and evidence when present. Do not invent URLs or savings. + Return JSON: {"fix": "2-4 sentences with specific steps (config snippets welcome)", "effort": "low|medium|high"} + """; + + public const string FixSuggestionSecuritySystem = + """ + You are a web security and HTTP headers consultant. + Given one security finding or missing header, return a concise actionable fix with server config guidance when relevant. + Use ONLY the facts provided. Do not invent URLs or CVEs. + Return JSON: {"fix": "2-4 sentences with specific steps", "effort": "low|medium|high"} + """; + + public const string FixSuggestionBrowserSystem = + """ + You are a frontend debugging specialist. + Given one browser console error, page exception, or on-page warning, return a concise root-cause fix. + Use ONLY the facts provided (message, URL, source file, line, stack). Do not invent stack frames. + Return JSON: {"fix": "2-4 sentences with specific steps", "effort": "low|medium|high"} + """; + + public const string FixSuggestionSeoContentSystem = + """ + You are an SEO content strategist. + Given one content/keyword/structured-data issue (misalignment, cannibalisation, rich results, duplicates, etc.), + return a concise actionable fix with clear next steps (canonical, merge, redirect, schema, internal links). + Use ONLY the facts provided. Do not invent traffic numbers. + Return JSON: {"fix": "2-4 sentences with specific steps", "effort": "low|medium|high"} + """; + + public const string FixSuggestionTechnicalSystem = + """ + You are a technical SEO engineer. + Given one technical crawl issue (broken link, redirect chain, mixed content, headers, indexing flags), + return a concise actionable fix. + Use ONLY the facts provided. Do not invent URLs. + Return JSON: {"fix": "2-4 sentences with specific steps", "effort": "low|medium|high"} + """; + + public static IReadOnlyDictionary FixSuggestionPrompts { get; } = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["issue"] = IssueFixSystem, + ["lighthouse"] = FixSuggestionLighthouseSystem, + ["security"] = FixSuggestionSecuritySystem, + ["browser"] = FixSuggestionBrowserSystem, + ["seo_content"] = FixSuggestionSeoContentSystem, + ["technical"] = FixSuggestionTechnicalSystem, + }; + + public const string AuditExecutiveSystem = + """ + You write a short executive summary for a site audit report for agency clients. + Use ONLY the scores and issues provided. Be direct and prioritize by traffic impact. + Return JSON: {"summary": "3-5 sentences in plain language", "priorities": ["bullet 1", "bullet 2", "bullet 3"]} + """; + + public const string IssuesActionPlanSystem = + """ + You are a senior SEO/technical audit consultant. + Given a deduplicated list of site audit issues, return a prioritized remediation plan. + Use ONLY the issues provided. Group by root cause where possible. + Return JSON: { + "summary": "2-3 sentence overview", + "phases": [{"name": "...", "effort": "low|medium|high", "actions": ["..."]}], + "quick_wins": ["..."], + "notes": "optional caveats" + } + """; + + public const string ChatNarrativeSystem = + """ + You write the user-facing narrative for a site-audit chat turn. + Use ONLY the user question and tool results provided. Do not invent metrics, URLs, or scores. + The chat UI already renders charts, tables, and score cards from tool data — do not repeat those numbers. + Return JSON only: {"power_insights": ["..."], "recommended_actions": ["..."]} + Max 5 items per array. Plain language. No internal tool names. No emoji. + """; + + public const string ChatNarrativeRepairSystem = + """ + Your previous response was not valid JSON matching the required schema. + Return ONLY a JSON object with exactly these keys: + {"power_insights": ["string", ...], "recommended_actions": ["string", ...]} + Each value must be a non-empty array of non-empty strings (max 5 each). + Use ONLY the original user question and tool data provided. Do not invent metrics. + """; + + public const string DashboardAiSystem = + """ + You are a dashboard-configuration assistant for a site-audit analytics platform. + You generate DashScript formulas, widget configurations, and full dashboard layouts from natural-language requests. + Use ONLY toolName values from the provided catalog and viz values from viz_types or custom-chart. + Return ONLY valid JSON matching the requested mode (script, widget, or dashboard). + """; + + public const string ContentWizardJsonSystem = + "You are an expert SEO content strategist. Respond with valid JSON only — no prose, no markdown fences."; + + public const string ChatAgentSystemBase = + """ + You are Site Audit AI, a technical SEO assistant for a self-hosted site audit platform. + You help users understand crawl results, audit issues, Lighthouse scores, keywords, and Search Console data. + + Tool routing (only a subset of tools is loaded each turn): + - Always available: search_audit_tools, list_tool_domains, get_data_coverage_report, run_insight_workflow, run_technical_workflow, run_keyword_workflow, run_domain_agent, plus top insight tools (get_report_summary, get_opportunity_matrix, get_traffic_health_check, etc.) + - Use search_audit_tools(query) to discover specialized tools by topic (e.g. "broken links", "GSC CTR", "export PDF"). + - Use list_tool_domains to see domain groupings and example prompts. + - Use run_*_workflow for common multi-step analyses (insight, technical, keyword). + - Use run_domain_agent(task, domain) for deep exploration within one domain. + - Use get_data_coverage_report when tools return empty or missing data. + + Image playbook: + - Overview: get_image_audit_summary first — the UI renders summary cards, page preview lists (alt/lazy/OG/dimensions), and Lighthouse image findings. Call tools only; the app generates user-facing narrative separately. + - Missing alt / lazy / OG / dimensions: get_image_audit_summary includes previews; call list_pages_* only if the user wants the full exportable list + - All image URLs: list_site_image_urls (optional kind filter) + - Lighthouse image issues: list_lighthouse_image_opportunities + - Largest / heavy files: list_largest_images (requires probe_image_inventory=true on report build) + - Unoptimized format/size: list_unoptimized_images (requires image inventory probe) + - What needs attention: list_images_needing_attention + - Export lists: export_list_as_csv with the matching list tool + + Export playbook (chat UI shows download buttons after export tools — do not paste file contents): + - Full audit PDF/CSV/JSON: export_audit_report with format pdf|csv|json (PDF via FileService) + - Compare issue diff CSV: export_compare_csv with baseline_report_id + - Export a list as CSV: export_list_as_csv with tool_name and tool_args (e.g. list_broken_links) + - After export tools succeed, tell the user their download is ready; the UI renders file buttons automatically + + Visualization playbook (chat UI renders charts and tables from tool JSON automatically): + - Category scores / health: get_category_scores, list_audit_categories, or get_report_summary + - Issue breakdown: get_report_summary, get_issue_priority_breakdown (priority chart), and list_issues or get_critical_issues for the table + - Top critical issues (required trio): get_report_summary, get_issue_priority_breakdown, get_critical_issues — then only write recommendations, never enumerate issues in prose + - Audit overview / site health recap: get_report_summary (health, crawl, categories, issue counts). Keep prose to interpretation and next steps only — never repeat health score, URL counts, success rate, category scores, or priority counts in markdown; the UI renders those as cards and charts. + - Distributions: get_mime_type_breakdown, get_title_length_distribution, get_domain_link_distribution, get_status_code_breakdown, get_depth_distribution + - Trends over time: get_health_history, get_category_health_history + - Compare drift: compare_category_deltas, compare_issue_deltas, compare_google_metrics, compare_security_deltas + - Lighthouse: get_lighthouse_summary + - Google/GSC: get_google_summary, get_gsc_top_queries + + SQL playbook (only when get_sql_schema / run_sql_query are available): + - SQL is a fallback for custom questions not answerable by the named audit tools above. Always prefer a named tool first. + - When SQL is needed: call get_sql_schema first to discover tables and foreign keys, then run_sql_query with a single read-only SELECT. + - Only SELECT is allowed — the tool rejects INSERT/UPDATE/DELETE/DDL. + - The tool automatically scopes queries to the active property; you do not need to add a property_id filter manually. For crawl data, scope is applied through crawl_runs. + - Use row_cap intentionally: set a small value (10-50) for row listings and omit it (default 200) for aggregates. + - Keep results concise — use LIMIT, GROUP BY, and aggregate functions. Avoid SELECT *. + - Never tell the user you cannot run SQL if run_sql_query is loaded — use it. + + Rules: + - Use the provided tools to query real audit data. Do not invent URLs, scores, or metrics. + - When citing issues, include the URL when available. + - The chat UI automatically renders charts, gauges, and tables from tool results. Never tell the user you cannot show graphs or charts, and never send them to other app pages for data you can fetch with tools. + - For visual or chart requests, always call the appropriate tools first, then give a short interpretation (2–4 sentences) with recommendations. + - When tools return issue lists, scores, or breakdowns, do not re-list them in prose—the UI renders structured blocks from tool data. + - Do not emit markdown headings, bullet lists, or pipe tables for the user. The app synthesizes the final narrative from tool results. + - After gathering enough data via tools, stop calling tools. A brief internal acknowledgment is enough; user-facing text is generated separately. + - Do not repeat health scores, URL counts, success rates, category scores, priority counts, or URL lists when the UI already shows them in cards or tables. + - Never mention internal tool names (e.g. run_technical_workflow, export_audit_report) in user-facing text. + - Do not pass property_id or report_id in tool calls — they are injected from the active chat property. + - If data is missing, say what integration or crawl step is needed (briefly; narrative will be expanded separately). + """; + + public const string ChatAgentReadOnlySuffix = + """ + - You are read-only: you cannot run crawls or change settings. + """; + + public const string ChatAgentCrawlSuffix = + """ + Crawl playbook (when user asks to crawl, audit, or re-run a site): + - Clarify: new vs existing property, default vs custom configuration. + - Default: pick crawl preset (starter, spa, ecommerce, performance) and pipeline mode (full-audit or crawl-only). + - Custom: ask only high-impact overrides — max_pages, crawl_render_mode (static/auto/javascript), run_lighthouse_on_pages, concurrency. + - After collecting answers, always call prepare_audit_run to build a preview — never claim a crawl has started. + - The chat UI shows a confirm card; wait for the user to authorize and click Run before assuming the audit began. + - If prepare_audit_run returns job_running, tell the user an audit is already in progress. + """; +} diff --git a/services/AiService/src/AiService.Application/Repositories/ChatSessionRepository.cs b/services/AiService/src/AiService.Application/Repositories/ChatSessionRepository.cs new file mode 100644 index 00000000..4a55bb42 --- /dev/null +++ b/services/AiService/src/AiService.Application/Repositories/ChatSessionRepository.cs @@ -0,0 +1,115 @@ +using AiService.Application.Persistence; +using AiService.Domain.Entities; +using AiService.Domain.Repositories; +using Microsoft.EntityFrameworkCore; + +namespace AiService.Application.Repositories; + +public sealed class ChatSessionRepository(AiDbContext db) : IChatSessionRepository +{ + public async Task CreateSessionAsync(long propertyId, string title, CancellationToken cancellationToken = default) + { + var now = DateTimeOffset.UtcNow; + var session = new ChatSession + { + PropertyId = propertyId, + Title = string.IsNullOrWhiteSpace(title) ? "New chat" : title.Trim(), + CreatedAt = now, + UpdatedAt = now, + }; + db.ChatSessions.Add(session); + await db.SaveChangesAsync(cancellationToken); + return session.Id; + } + + public async Task> ListSessionsAsync( + long propertyId, + int limit = 30, + CancellationToken cancellationToken = default) + { + var capped = Math.Clamp(limit, 1, 100); + return await db.ChatSessions.AsNoTracking() + .Where(x => x.PropertyId == propertyId) + .OrderByDescending(x => x.UpdatedAt) + .Take(capped) + .ToListAsync(cancellationToken); + } + + public async Task GetSessionAsync(long sessionId, CancellationToken cancellationToken = default) + => await db.ChatSessions.AsNoTracking().FirstOrDefaultAsync(x => x.Id == sessionId, cancellationToken); + + public async Task DeleteSessionAsync(long sessionId, CancellationToken cancellationToken = default) + { + var session = await db.ChatSessions.FirstOrDefaultAsync(x => x.Id == sessionId, cancellationToken); + if (session is null) + { + return false; + } + + db.ChatSessions.Remove(session); + await db.SaveChangesAsync(cancellationToken); + return true; + } + + public async Task> GetMessagesAsync( + long sessionId, + int limit = 200, + CancellationToken cancellationToken = default) + { + var capped = Math.Clamp(limit, 1, 500); + return await db.ChatMessages.AsNoTracking() + .Where(x => x.SessionId == sessionId) + .OrderBy(x => x.CreatedAt) + .Take(capped) + .ToListAsync(cancellationToken); + } + + public async Task AppendMessageAsync( + long sessionId, + string role, + string content = "", + string? toolName = null, + string? toolArgsJson = null, + string? toolResultJson = null, + CancellationToken cancellationToken = default) + { + var message = new ChatMessage + { + SessionId = sessionId, + Role = role, + Content = content ?? "", + ToolName = toolName, + ToolArgs = toolArgsJson, + ToolResult = toolResultJson, + CreatedAt = DateTimeOffset.UtcNow, + }; + db.ChatMessages.Add(message); + await TouchSessionAsync(sessionId, cancellationToken); + await db.SaveChangesAsync(cancellationToken); + return message.Id; + } + + public async Task UpdateSessionTitleAsync(long sessionId, string title, CancellationToken cancellationToken = default) + { + var session = await db.ChatSessions.FirstOrDefaultAsync(x => x.Id == sessionId, cancellationToken); + if (session is null) + { + return; + } + + session.Title = string.IsNullOrWhiteSpace(title) ? "New chat" : title.Trim(); + session.UpdatedAt = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(cancellationToken); + } + + public async Task TouchSessionAsync(long sessionId, CancellationToken cancellationToken = default) + { + var session = await db.ChatSessions.FirstOrDefaultAsync(x => x.Id == sessionId, cancellationToken); + if (session is null) + { + return; + } + + session.UpdatedAt = DateTimeOffset.UtcNow; + } +} diff --git a/services/AiService/src/AiService.Application/Repositories/GoogleAppSettingsRepository.cs b/services/AiService/src/AiService.Application/Repositories/GoogleAppSettingsRepository.cs new file mode 100644 index 00000000..caeb60f8 --- /dev/null +++ b/services/AiService/src/AiService.Application/Repositories/GoogleAppSettingsRepository.cs @@ -0,0 +1,138 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using AiService.Domain.Repositories; +using Npgsql; +using NpgsqlTypes; + +namespace AiService.Application.Repositories; + +public sealed class GoogleAppSettingsRepository(NpgsqlDataSource dataSource) : IGoogleAppSettingsRepository +{ + private const int SingletonId = 1; + + public async Task LoadAsync(CancellationToken cancellationToken = default) + { + await using var conn = await dataSource.OpenConnectionAsync(cancellationToken); + await using var cmd = new NpgsqlCommand( + """ + SELECT client_id, client_secret, service_account_json, + default_date_range_days, developer_token, login_customer_id + FROM google_app_settings WHERE id = $1 + """, + conn); + cmd.Parameters.AddWithValue(SingletonId); + await using var reader = await cmd.ExecuteReaderAsync(cancellationToken); + + if (!await reader.ReadAsync(cancellationToken)) + { + return new GoogleAppSettings(); + } + + return new GoogleAppSettings + { + ClientId = reader.IsDBNull(0) ? "" : reader.GetString(0).Trim(), + ClientSecret = reader.IsDBNull(1) ? "" : reader.GetString(1).Trim(), + ServiceAccountJson = ParseServiceAccountJson(reader, 2), + DefaultDateRangeDays = reader.IsDBNull(3) ? 28 : reader.GetInt32(3), + DeveloperToken = reader.IsDBNull(4) ? "" : (reader.GetString(4) ?? "").Trim(), + LoginCustomerId = reader.IsDBNull(5) ? "" : (reader.GetString(5) ?? "").Trim(), + }; + } + + public async Task MergeAsync(GoogleAppSettingsPatch patch, CancellationToken cancellationToken = default) + { + var sets = new List { "updated_at = now()" }; + var cmd = new NpgsqlCommand(); + var paramIndex = 1; + + if (patch.ClientId is not null) + { + sets.Add($"client_id = ${paramIndex++}"); + cmd.Parameters.AddWithValue(patch.ClientId); + } + + if (patch.ClientSecret is not null) + { + sets.Add($"client_secret = ${paramIndex++}"); + cmd.Parameters.AddWithValue(patch.ClientSecret); + } + + if (patch.ServiceAccountJson is not null) + { + sets.Add($"service_account_json = ${paramIndex++}"); + cmd.Parameters.Add(new NpgsqlParameter + { + Value = patch.ServiceAccountJson, + NpgsqlDbType = NpgsqlDbType.Jsonb, + }); + } + + if (patch.DefaultDateRangeDays is not null) + { + sets.Add($"default_date_range_days = ${paramIndex++}"); + cmd.Parameters.AddWithValue(patch.DefaultDateRangeDays.Value); + } + + if (patch.DeveloperToken is not null) + { + sets.Add($"developer_token = ${paramIndex++}"); + cmd.Parameters.AddWithValue( + string.IsNullOrWhiteSpace(patch.DeveloperToken) ? DBNull.Value : patch.DeveloperToken); + } + + if (patch.LoginCustomerId is not null) + { + sets.Add($"login_customer_id = ${paramIndex++}"); + cmd.Parameters.AddWithValue( + string.IsNullOrWhiteSpace(patch.LoginCustomerId) ? DBNull.Value : patch.LoginCustomerId); + } + + if (cmd.Parameters.Count == 0) + { + return; + } + + cmd.Parameters.AddWithValue(SingletonId); + cmd.CommandText = $"UPDATE google_app_settings SET {string.Join(", ", sets)} WHERE id = ${paramIndex}"; + + await using var conn = await dataSource.OpenConnectionAsync(cancellationToken); + cmd.Connection = conn; + await cmd.ExecuteNonQueryAsync(cancellationToken); + } + + private static JsonObject? ParseServiceAccountJson(NpgsqlDataReader reader, int ordinal) + { + if (reader.IsDBNull(ordinal)) + { + return null; + } + + if (reader.GetFieldType(ordinal) == typeof(string)) + { + var raw = reader.GetString(ordinal); + if (string.IsNullOrWhiteSpace(raw)) + { + return null; + } + + try + { + return JsonNode.Parse(raw) as JsonObject; + } + catch (JsonException) + { + return null; + } + } + + try + { + var obj = reader.GetFieldValue(ordinal); + return obj; + } + catch + { + return null; + } + } +} diff --git a/services/AiService/src/AiService.Application/Repositories/LlmCacheRepository.cs b/services/AiService/src/AiService.Application/Repositories/LlmCacheRepository.cs new file mode 100644 index 00000000..bf8fd2f4 --- /dev/null +++ b/services/AiService/src/AiService.Application/Repositories/LlmCacheRepository.cs @@ -0,0 +1,93 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using AiService.Application.Json; +using AiService.Application.Persistence; +using AiService.Domain.Entities; +using AiService.Domain.Repositories; +using Microsoft.EntityFrameworkCore; + +namespace AiService.Application.Repositories; + +public sealed class LlmCacheRepository(AiDbContext db) : ILlmCacheRepository +{ + public async Task ReadAsync(string cacheKey, CancellationToken cancellationToken = default) + { + var row = await db.LlmCache.AsNoTracking() + .FirstOrDefaultAsync(x => x.CacheKey == cacheKey, cancellationToken); + return row?.ResponseJson; + } + + public async Task WriteAsync(string cacheKey, string responseJson, CancellationToken cancellationToken = default) + { + var normalized = NormalizeJson(responseJson); + var now = DateTimeOffset.UtcNow; + var existing = await db.LlmCache.FirstOrDefaultAsync(x => x.CacheKey == cacheKey, cancellationToken); + if (existing is null) + { + db.LlmCache.Add(new LlmCacheEntry + { + CacheKey = cacheKey, + ResponseJson = normalized, + CreatedAt = now, + }); + } + else + { + existing.ResponseJson = normalized; + existing.CreatedAt = now; + } + + await db.SaveChangesAsync(cancellationToken); + } + + public async Task> ReadBatchAsync( + IReadOnlyList cacheKeys, + CancellationToken cancellationToken = default) + { + if (cacheKeys.Count == 0) + { + return new Dictionary(); + } + + var rows = await db.LlmCache.AsNoTracking() + .Where(x => cacheKeys.Contains(x.CacheKey)) + .ToListAsync(cancellationToken); + + return rows.ToDictionary(x => x.CacheKey, x => x.ResponseJson, StringComparer.Ordinal); + } + + public async Task ReadObjectAsync(string cacheKey, CancellationToken cancellationToken = default) + { + var raw = await ReadAsync(cacheKey, cancellationToken); + if (string.IsNullOrWhiteSpace(raw)) + { + return null; + } + + try + { + var parsed = JsonNode.Parse(raw) as JsonObject; + return parsed is null ? null : JsonNodeCopy.CloneObject(parsed); + } + catch (JsonException) + { + return null; + } + } + + public async Task WriteObjectAsync(string cacheKey, JsonObject data, CancellationToken cancellationToken = default) + => await WriteAsync(cacheKey, JsonNodeCopy.CloneObject(data).ToJsonString(), cancellationToken); + + private static string NormalizeJson(string responseJson) + { + try + { + var node = JsonNode.Parse(responseJson); + return node?.ToJsonString() ?? responseJson; + } + catch (JsonException) + { + return JsonSerializer.Serialize(responseJson); + } + } +} diff --git a/services/AiService/src/AiService.Application/Repositories/LlmConfigPutHelpers.cs b/services/AiService/src/AiService.Application/Repositories/LlmConfigPutHelpers.cs new file mode 100644 index 00000000..ce61e92b --- /dev/null +++ b/services/AiService/src/AiService.Application/Repositories/LlmConfigPutHelpers.cs @@ -0,0 +1,45 @@ +using System.Text.Json.Nodes; + +namespace AiService.Application.Repositories; + +public static class LlmConfigPutHelpers +{ + public static Dictionary ParsePutEntries(JsonObject state) + { + var entries = new Dictionary(StringComparer.Ordinal); + foreach (var prop in state) + { + if (prop.Key.EndsWith("_masked", StringComparison.Ordinal)) + { + continue; + } + + entries[prop.Key] = CoerceValue(prop.Value); + } + + return entries; + } + + private static string CoerceValue(JsonNode? node) + { + if (node is null) + { + return ""; + } + + if (node is JsonValue value) + { + if (value.TryGetValue(out var boolean)) + { + return boolean ? "true" : "false"; + } + + if (value.TryGetValue(out var text)) + { + return text; + } + } + + return node.ToString(); + } +} diff --git a/services/AiService/src/AiService.Application/Repositories/LlmConfigRepository.cs b/services/AiService/src/AiService.Application/Repositories/LlmConfigRepository.cs new file mode 100644 index 00000000..11764a48 --- /dev/null +++ b/services/AiService/src/AiService.Application/Repositories/LlmConfigRepository.cs @@ -0,0 +1,111 @@ +using AiService.Application.Persistence; +using AiService.Domain.Entities; +using AiService.Domain.Repositories; +using Microsoft.EntityFrameworkCore; + +namespace AiService.Application.Repositories; + +public sealed class LlmConfigRepository(AiDbContext db) : ILlmConfigRepository +{ + public const string Mask = "*"; + private static readonly HashSet MaskSentinels = new(StringComparer.Ordinal) { Mask, "••••" }; + + public async Task> LoadAsync(CancellationToken cancellationToken = default) + { + var rows = await db.LlmConfig.AsNoTracking().OrderBy(x => x.Key).ToListAsync(cancellationToken); + var dict = new Dictionary(StringComparer.Ordinal); + foreach (var row in rows) + { + dict[row.Key] = row.Value; + } + + return LlmConfigSecrets.WithResolvedApiKey(dict); + } + + public async Task> LoadFullAsync(CancellationToken cancellationToken = default) + { + var rows = await db.LlmConfig.AsNoTracking().OrderBy(x => x.Key).ToListAsync(cancellationToken); + return rows.Select(row => new LlmConfigEntry + { + Key = row.Key, + Value = row.IsSecret && !string.IsNullOrEmpty(row.Value) ? Mask : row.Value, + IsSecret = row.IsSecret, + UpdatedAt = row.UpdatedAt, + }).ToList(); + } + + public async Task SaveAsync(IReadOnlyDictionary entries, CancellationToken cancellationToken = default) + { + var existingRows = await db.LlmConfig.AsNoTracking().ToListAsync(cancellationToken); + var existing = existingRows.ToDictionary(x => x.Key, x => x.Value, StringComparer.Ordinal); + var existingSecrets = existingRows.Where(x => x.IsSecret).Select(x => x.Key).ToHashSet(StringComparer.Ordinal); + + var merged = new Dictionary(existing, StringComparer.Ordinal); + foreach (var (key, rawValue) in entries) + { + var val = rawValue ?? ""; + if (IsMaskedSentinel(val) && existing.TryGetValue(key, out var prior)) + { + val = prior; + } + + merged[key] = val; + } + + var now = DateTimeOffset.UtcNow; + var normalized = new Dictionary(StringComparer.Ordinal); + + foreach (var (key, val) in merged) + { + var isSecret = existingSecrets.Contains(key) || LlmConfigSecrets.IsSecretKey(key); + normalized[key] = (val, isSecret); + } + + await using var tx = await db.Database.BeginTransactionAsync(cancellationToken); + db.LlmConfig.RemoveRange(await db.LlmConfig.ToListAsync(cancellationToken)); + foreach (var (key, (value, isSecret)) in normalized) + { + db.LlmConfig.Add(new LlmConfigEntry + { + Key = key, + Value = value, + IsSecret = isSecret, + UpdatedAt = now, + }); + } + + await db.SaveChangesAsync(cancellationToken); + await tx.CommitAsync(cancellationToken); + } + + private static bool IsMaskedSentinel(string value) + { + var trimmed = value.Trim(); + if (MaskSentinels.Contains(trimmed)) + { + return true; + } + + return trimmed.StartsWith("*", StringComparison.Ordinal) && trimmed.Length <= 4; + } +} + +internal static class LlmConfigSecrets +{ + public static bool IsSecretKey(string key) + { + var keyLower = key.ToLowerInvariant(); + return keyLower.EndsWith("_secret", StringComparison.Ordinal) + || keyLower.EndsWith("_api_key", StringComparison.Ordinal) + || keyLower.EndsWith("_key", StringComparison.Ordinal) + || keyLower.Contains("api_key", StringComparison.Ordinal) + || keyLower.Contains("secret", StringComparison.Ordinal) + || keyLower.Contains("password", StringComparison.Ordinal) + || keyLower.Contains("token", StringComparison.Ordinal); + } + + public static IReadOnlyDictionary WithResolvedApiKey(IReadOnlyDictionary cfg) + { + return AiService.Providers.Chat.LlmConfigHelpers.WithResolvedApiKey(cfg); + } +} diff --git a/services/AiService/src/AiService.Application/Repositories/PipelineConfigRepository.cs b/services/AiService/src/AiService.Application/Repositories/PipelineConfigRepository.cs new file mode 100644 index 00000000..6fb44e06 --- /dev/null +++ b/services/AiService/src/AiService.Application/Repositories/PipelineConfigRepository.cs @@ -0,0 +1,95 @@ +using AiService.Domain.Repositories; +using Npgsql; + +namespace AiService.Application.Repositories; + +public sealed class PipelineConfigRepository(NpgsqlDataSource dataSource) : IPipelineConfigRepository +{ + public async Task> LoadAsync(CancellationToken cancellationToken = default) + { + var (known, unknown) = await LoadFullAsync(cancellationToken); + var combined = new Dictionary(known, StringComparer.Ordinal); + foreach (var entry in unknown) + { + combined[entry.Key] = entry.Value; + } + + return combined; + } + + public async Task<(IReadOnlyDictionary Known, IReadOnlyList Unknown)> LoadFullAsync( + CancellationToken cancellationToken = default) + { + await using var conn = await dataSource.OpenConnectionAsync(cancellationToken); + await using var cmd = new NpgsqlCommand( + "SELECT key, value, is_unknown FROM pipeline_config ORDER BY key", + conn); + await using var reader = await cmd.ExecuteReaderAsync(cancellationToken); + + var known = new Dictionary(StringComparer.Ordinal); + var unknown = new List(); + + while (await reader.ReadAsync(cancellationToken)) + { + var key = reader.GetString(0); + var value = reader.IsDBNull(1) ? "" : reader.GetString(1); + var isUnknown = !reader.IsDBNull(2) && reader.GetBoolean(2); + if (isUnknown) + { + unknown.Add(new PipelineConfigUnknownEntry(key, value)); + } + else + { + known[key] = value; + } + } + + return (known, unknown); + } + + public async Task SaveAsync( + IReadOnlyDictionary known, + IReadOnlyList unknown, + CancellationToken cancellationToken = default) + { + await using var conn = await dataSource.OpenConnectionAsync(cancellationToken); + await using var tx = await conn.BeginTransactionAsync(cancellationToken); + + await using (var delete = new NpgsqlCommand("DELETE FROM pipeline_config", conn, tx)) + { + await delete.ExecuteNonQueryAsync(cancellationToken); + } + + var now = DateTimeOffset.UtcNow; + + foreach (var (key, value) in known) + { + await using var insert = new NpgsqlCommand( + "INSERT INTO pipeline_config (key, value, is_unknown, updated_at) VALUES ($1, $2, false, $3)", + conn, + tx); + insert.Parameters.AddWithValue(key); + insert.Parameters.AddWithValue(value ?? ""); + insert.Parameters.AddWithValue(now); + await insert.ExecuteNonQueryAsync(cancellationToken); + } + + foreach (var entry in unknown) + { + await using var insert = new NpgsqlCommand( + """ + INSERT INTO pipeline_config (key, value, is_unknown, updated_at) + VALUES ($1, $2, true, $3) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, is_unknown = true, updated_at = EXCLUDED.updated_at + """, + conn, + tx); + insert.Parameters.AddWithValue(entry.Key); + insert.Parameters.AddWithValue(entry.Value ?? ""); + insert.Parameters.AddWithValue(now); + await insert.ExecuteNonQueryAsync(cancellationToken); + } + + await tx.CommitAsync(cancellationToken); + } +} diff --git a/services/AiService/src/AiService.Application/Services/ChatAgentService.cs b/services/AiService/src/AiService.Application/Services/ChatAgentService.cs new file mode 100644 index 00000000..89d16ee7 --- /dev/null +++ b/services/AiService/src/AiService.Application/Services/ChatAgentService.cs @@ -0,0 +1,164 @@ +using AiService.Application.Chat; +using AiService.Domain.Repositories; +using AiService.Providers.Chat; +using AiService.Tools.Context; +using AiService.Tools.Selection; +using Microsoft.Extensions.AI; + +namespace AiService.Application.Services; + +/// +/// Chat agent turn orchestration — ports Python run_agent_turn using a manual tool loop. +/// +public sealed class ChatAgentService( + ILlmConfigRepository configRepository, + IChatClientFactory chatClientFactory, + ChatAgentLoop agentLoop, + ChatNarrativeSynthesizer narrativeSynthesizer, + AuditToolSelectionService toolSelection) +{ + public async Task RunTurnAsync( + IReadOnlyList history, + AuditToolContext context, + Action? onEvent = null, + CancellationToken cancellationToken = default) + { + var progress = new ChatTurnProgress(onEvent); + var cfg = await configRepository.LoadAsync(cancellationToken); + if (!LlmConfigHelpers.IsEnabled(cfg)) + { + const string err = "AI is disabled. Enable AI insights in the AI settings tab and configure a provider."; + progress.EmitError(err); + return new ChatTurnResult(false, null, progress.ToolEvents, err); + } + + IChatClient client; + try + { + client = chatClientFactory.CreateClient(cfg); + } + catch (Exception ex) + { + progress.EmitError(ex.Message); + return new ChatTurnResult(false, null, progress.ToolEvents, ex.Message); + } + + var systemPrompt = ChatAgentConfig.ResolveSystemPrompt(cfg); + var messages = BuildMessages(history, systemPrompt); + var lastUser = LastUserMessage(history); + var maxRounds = ChatAgentConfig.ResolveMaxToolRounds(cfg); + + var enabledTools = (await toolSelection.GetEnabledToolNamesAsync(cancellationToken)) + .ToHashSet(StringComparer.Ordinal); + if (ChatAgentConfig.ChatAllowCrawl(cfg)) + { + enabledTools.Add(ChatAgentConfig.ChatCrawlTool); + } + + var priorUserMessages = history + .Where(m => m.Role == "user") + .Select(m => m.Content) + .ToList(); + + HashSet? extraTools = ChatAgentConfig.ChatAllowCrawl(cfg) + ? [ChatAgentConfig.ChatCrawlTool] + : null; + + var activeTools = ChatToolSelector.SelectToolsForTurn( + lastUser, + priorUserMessages, + enabledTools, + cfg, + extraNames: extraTools); + + ChatAgentLoopResult loopResult; + try + { + loopResult = await agentLoop.RunAsync( + client, + messages, + activeTools, + enabledTools, + cfg, + context, + progress, + maxRounds, + cancellationToken); + } + catch (Exception ex) + { + var msg = ChatAgentConfig.MapAgentError(ex, cfg); + progress.EmitError(msg); + return new ChatTurnResult(false, null, progress.ToolEvents, msg); + } + + return await FinishWithNarrativeAsync(cfg, lastUser, progress, loopResult.PartialNote, cancellationToken); + } + + private async Task FinishWithNarrativeAsync( + IReadOnlyDictionary cfg, + string userMessage, + ChatTurnProgress progress, + string? partialNote, + CancellationToken cancellationToken) + { + if (!string.IsNullOrWhiteSpace(partialNote)) + { + progress.EmitPartialDone(partialNote); + } + + progress.EmitStatus("synthesizing", "Summarizing insights…"); + + try + { + var narrative = await narrativeSynthesizer.SynthesizeAsync( + cfg, + userMessage, + progress.ToolEvents, + phase => + { + var detail = phase == "retrying" ? "Retrying summary…" : "Summarizing insights…"; + progress.EmitStatus("synthesizing", detail); + }, + onToken: progress.EmitToken, + onPartialNarrative: progress.EmitNarrativePartial, + cancellationToken); + progress.EmitNarrative(narrative); + progress.EmitDone(); + return new ChatTurnResult(true, narrative, progress.ToolEvents, null); + } + catch (Exception ex) + { + progress.EmitError(narrativeSynthesizer.NarrativeFailedMessage); + return new ChatTurnResult(false, null, progress.ToolEvents, ex.Message); + } + } + + private static List BuildMessages(IReadOnlyList history, string systemPrompt) + { + var messages = new List { new(ChatRole.System, systemPrompt) }; + foreach (var msg in history) + { + if (msg.Role is "user" or "assistant") + { + var content = ChatTextSanitize.StripSurrogates(msg.Content); + messages.Add(new ChatMessage(msg.Role == "user" ? ChatRole.User : ChatRole.Assistant, content)); + } + } + + return messages; + } + + private static string LastUserMessage(IReadOnlyList history) + { + for (var i = history.Count - 1; i >= 0; i--) + { + if (history[i].Role == "user") + { + return ChatTextSanitize.StripSurrogates(history[i].Content); + } + } + + return ""; + } +} diff --git a/services/AiService/src/AiService.Application/Services/ContentAnalyzeService.cs b/services/AiService/src/AiService.Application/Services/ContentAnalyzeService.cs new file mode 100644 index 00000000..8884aa54 --- /dev/null +++ b/services/AiService/src/AiService.Application/Services/ContentAnalyzeService.cs @@ -0,0 +1,340 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using AiService.Application.Json; +using AiService.Application.Prompts; +using AiService.Application.Repositories; +using AiService.Domain.Repositories; +using AiService.Providers.Chat; + +namespace AiService.Application.Services; + +public sealed class ContentAnalyzeService( + ILlmConfigRepository configRepository, + LlmCacheRepository cacheRepository, + StructuredCompletionService completionService) +{ + public async Task AnalyzeAsync( + int? propertyId, + string keyword, + string bodyHtml, + string titleTag = "", + string metaDescription = "", + string? landingUrl = null, + bool useAi = false, + bool refresh = false, + string title = "", + CancellationToken cancellationToken = default) + { + var score = ScoreDraft(keyword, bodyHtml, titleTag, metaDescription); + var rule = RuleSuggestions(score); + var result = new JsonObject + { + ["ok"] = true, + ["score"] = score, + ["suggestions"] = rule, + ["summary"] = DefaultSummary(score, keyword), + ["outline"] = new JsonArray(), + ["title_ideas"] = new JsonArray(), + ["ai_used"] = false, + ["tools_used"] = new JsonArray(), + ["tool_events"] = new JsonArray(), + ["provenance"] = "Search Console + on-site heuristics", + }; + + if (!useAi) + { + result["provenance"] = $"{result["provenance"]?.GetValue()} · Rule-based tips"; + return result; + } + + var cfg = await configRepository.LoadAsync(cancellationToken); + if (!LlmConfigHelpers.IsEnabled(cfg) + || !LlmConfigHelpers.IsTruthy(cfg.GetValueOrDefault("llm_enable_content_studio") ?? "true")) + { + result["provenance"] = $"{result["provenance"]?.GetValue()} · AI off (enable in Run audit → AI settings)"; + result["ai_error"] = "AI insights disabled in settings."; + return result; + } + + var model = (cfg.GetValueOrDefault("llm_model") ?? cfg.GetValueOrDefault("llm_provider") ?? "unknown").Trim(); + var bodyHash = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(bodyHtml ?? "")))[..16]; + var cachePayload = new JsonObject + { + ["keyword"] = keyword, + ["title"] = title, + ["title_tag"] = titleTag, + ["meta_description"] = metaDescription, + ["landing_url"] = landingUrl, + ["grade_score"] = score["grade_score"]?.GetValue() ?? 0, + ["body_hash"] = bodyHash, + }; + var cacheKey = SHA256.HashData( + Encoding.UTF8.GetBytes($"content_studio:v2-tools:{LlmPrompts.Version}:{model}:{cachePayload.ToJsonString()}")); + var cacheKeyHex = Convert.ToHexStringLower(cacheKey); + + JsonObject? aiBlock = null; + if (!refresh) + { + var cached = await cacheRepository.ReadObjectAsync(cacheKeyHex, cancellationToken); + aiBlock = cached?["ai_block"] is JsonObject block ? JsonNodeCopy.CloneObject(block) : null; + } + + if (aiBlock is null) + { + var userPayload = new JsonObject + { + ["keyword"] = keyword, + ["title"] = title, + ["title_tag"] = titleTag, + ["meta_description"] = metaDescription, + ["landing_url"] = landingUrl, + ["score"] = JsonNodeCopy.CloneObject(score), + ["body_excerpt"] = StripHtml(bodyHtml)[..Math.Min(StripHtml(bodyHtml).Length, 6000)], + }; + + try + { + aiBlock = await completionService.CompleteJsonAsync( + LlmPrompts.ContentStudioAnalyzeSystem, + userPayload.ToJsonString(), + cfg, + cancellationToken); + + if (aiBlock.Count > 0) + { + await cacheRepository.WriteObjectAsync( + cacheKeyHex, + new JsonObject { ["ai_block"] = aiBlock.DeepClone(), ["tool_events"] = new JsonArray() }, + cancellationToken); + } + } + catch (Exception ex) + { + result["ai_error"] = ex.Message; + result["provenance"] = $"{result["provenance"]?.GetValue()} · Rule-based tips (AI failed)"; + return result; + } + } + + if (aiBlock is null || aiBlock.Count == 0) + { + result["ai_error"] = "No structured output from analyze agent."; + result["provenance"] = $"{result["provenance"]?.GetValue()} · Rule-based tips (AI failed)"; + return result; + } + + aiBlock = JsonNodeCopy.CloneObject(aiBlock); + + var aiSuggestions = aiBlock["suggestions"] as JsonArray ?? []; + foreach (var node in aiSuggestions) + { + if (node is JsonObject obj) + { + obj["source"] = "ai"; + } + } + + result["suggestions"] = MergeSuggestions(rule, aiSuggestions); + if (aiBlock["summary"] is not null) + { + result["summary"] = aiBlock["summary"]!.GetValue(); + } + + if (aiBlock["outline"] is JsonArray outline) + { + var outLines = new JsonArray(); + foreach (var item in outline.Take(8)) + { + outLines.Add(item?.GetValue() ?? ""); + } + + result["outline"] = outLines; + } + + if (aiBlock["title_ideas"] is JsonArray titles) + { + var outTitles = new JsonArray(); + foreach (var item in titles.Take(5)) + { + outTitles.Add(item?.GetValue() ?? ""); + } + + result["title_ideas"] = outTitles; + } + + result["ai_used"] = true; + result["provenance"] = "Tool-based AI analyze + Search Console heuristics"; + return result; + } + + private static JsonObject ScoreDraft(string keyword, string bodyHtml, string titleTag, string metaDescription) + { + var plain = StripHtml(bodyHtml); + var words = plain.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var wordCount = words.Length; + var kw = (keyword ?? "").Trim(); + var kwCount = string.IsNullOrEmpty(kw) + ? 0 + : Regex.Matches(plain, Regex.Escape(kw), RegexOptions.IgnoreCase).Count; + + var grade = wordCount >= 800 && kwCount >= 2 ? 75 + : wordCount >= 400 && kwCount >= 1 ? 60 + : 45; + + var terms = new JsonArray(); + if (!string.IsNullOrEmpty(kw)) + { + terms.Add(new JsonObject + { + ["term"] = kw, + ["status"] = kwCount >= 2 ? "included" : kwCount == 1 ? "partial" : "missing", + ["importance"] = "high", + ["count"] = kwCount, + ["target"] = 2, + }); + } + + var checks = new JsonArray(); + if (string.IsNullOrWhiteSpace(titleTag)) + { + checks.Add(new JsonObject { ["pass"] = false, ["hint"] = "Add a title tag." }); + } + + if (string.IsNullOrWhiteSpace(metaDescription)) + { + checks.Add(new JsonObject { ["pass"] = false, ["hint"] = "Add a meta description." }); + } + + return new JsonObject + { + ["grade_score"] = grade, + ["grade_label"] = grade >= 75 ? "B" : grade >= 60 ? "C" : "D", + ["word_count"] = wordCount, + ["terms"] = terms, + ["checks"] = checks, + ["provenance"] = "Search Console + on-site heuristics", + }; + } + + private static JsonArray RuleSuggestions(JsonObject score) + { + var items = new JsonArray(); + if (score["terms"] is JsonArray terms) + { + foreach (var node in terms) + { + if (node is not JsonObject term) + { + continue; + } + + var status = term["status"]?.GetValue(); + var termText = term["term"]?.GetValue() ?? ""; + if (status == "missing") + { + items.Add(new JsonObject + { + ["text"] = $"Work the term “{termText}” into a heading or paragraph.", + ["priority"] = term["importance"]?.GetValue() ?? "medium", + ["type"] = "term", + ["source"] = "rule", + }); + } + else if (status == "partial") + { + items.Add(new JsonObject + { + ["text"] = $"Use the full phrase “{termText}” (related words appear but not the exact query).", + ["priority"] = "medium", + ["type"] = "term", + ["source"] = "rule", + }); + } + } + } + + if (score["checks"] is JsonArray checks) + { + foreach (var node in checks) + { + if (node is JsonObject check && check["pass"]?.GetValue() == false) + { + items.Add(new JsonObject + { + ["text"] = check["hint"]?.GetValue() ?? "Fix an on-page check.", + ["priority"] = "high", + ["type"] = "seo", + ["source"] = "rule", + }); + } + } + } + + var wordCount = score["word_count"]?.GetValue() ?? 0; + if (wordCount is > 0 and < 400) + { + items.Add(new JsonObject + { + ["text"] = "Expand the body with examples, FAQs, or subsections to reach a competitive word count.", + ["priority"] = "medium", + ["type"] = "structure", + ["source"] = "rule", + }); + } + + return new JsonArray(items.Take(15).Select(x => x!.DeepClone()).ToArray()); + } + + private static JsonArray MergeSuggestions(JsonArray rule, JsonArray ai) + { + var seen = new HashSet(StringComparer.Ordinal); + var merged = new JsonArray(); + foreach (var node in ai.Concat(rule)) + { + if (node is not JsonObject item) + { + continue; + } + + var text = Regex.Replace(item["text"]?.GetValue() ?? "", @"\s+", " ").Trim().ToLowerInvariant(); + if (string.IsNullOrEmpty(text) || !seen.Add(text)) + { + continue; + } + + merged.Add(new JsonObject + { + ["text"] = item["text"]?.GetValue() ?? "", + ["priority"] = item["priority"]?.GetValue() ?? "medium", + ["type"] = item["type"]?.GetValue() ?? "seo", + ["source"] = item["source"]?.GetValue() ?? "ai", + }); + + if (merged.Count >= 20) + { + break; + } + } + + return merged; + } + + private static string DefaultSummary(JsonObject score, string keyword) + { + var grade = score["grade_label"]?.GetValue() ?? "?"; + var pts = score["grade_score"]?.GetValue() ?? 0; + var kw = string.IsNullOrWhiteSpace(keyword) ? "your target keyword" : keyword.Trim(); + var missing = 0; + if (score["terms"] is JsonArray terms) + { + missing = terms.Count(x => x is JsonObject o && o["status"]?.GetValue() == "missing"); + } + + return $"Draft scores {grade} ({pts}/100) for “{kw}”. {missing} priority term(s) still missing from the body."; + } + + private static string StripHtml(string html) + => Regex.Replace(html ?? "", "<[^>]+>", " "); +} diff --git a/services/AiService/src/AiService.Application/Services/ContentWizardService.cs b/services/AiService/src/AiService.Application/Services/ContentWizardService.cs new file mode 100644 index 00000000..f064eacf --- /dev/null +++ b/services/AiService/src/AiService.Application/Services/ContentWizardService.cs @@ -0,0 +1,511 @@ +using System.Net.Http.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using AiService.Application.Prompts; +using AiService.Domain.Repositories; +using AiService.Providers.Chat; + +namespace AiService.Application.Services; + +public sealed class ContentWizardService( + ILlmConfigRepository configRepository, + StructuredCompletionService completionService) +{ + private const int MaxOptions = 6; + private const int MaxTitles = 6; + private const int MaxOutline = 24; + + private static readonly (string Label, string Description)[] FallbackContentTypes = + [ + ("How-to guide", "Step-by-step instructions that walk the reader through a task."), + ("Listicle", "A scannable numbered or bulleted list of items, tips, or examples."), + ("Comparison", "Weighs two or more options against each other to aid a decision."), + ("Explainer / overview", "Defines the topic and covers the essentials for newcomers."), + ("FAQ", "Answers the common questions searchers ask about the topic."), + ("Opinion / editorial", "A point-of-view piece backed by reasoning and examples."), + ]; + + private static readonly (string Label, string Description)[] FallbackTones = + [ + ("Professional", "Polished and credible, suitable for a business audience."), + ("Conversational", "Warm and approachable, like talking to a knowledgeable friend."), + ("Authoritative", "Confident and expert, establishing trust and depth."), + ("Friendly", "Casual and encouraging, easy for beginners to follow."), + ("Informative", "Neutral and fact-forward, prioritising clarity over flair."), + ("Persuasive", "Action-oriented, building toward a clear call to action."), + ]; + + public async Task RunStepAsync(string step, JsonObject payload, CancellationToken cancellationToken = default) + { + return step switch + { + "intents" => await SuggestIntentsAsync(payload, cancellationToken), + "content_types" => await SuggestContentTypesAsync(payload, cancellationToken), + "tones" => await SuggestTonesAsync(payload, cancellationToken), + "titles" => await SuggestTitlesAsync(payload, cancellationToken), + "research" => await ResearchPanelAsync(payload, cancellationToken), + "outline" => await SuggestOutlineAsync(payload, cancellationToken), + "draft" => await GenerateDraftAsync(payload, cancellationToken), + _ => new JsonObject { ["ok"] = false, ["error"] = $"unknown step: {step}" }, + }; + } + + private async Task<(StructuredCompletionService Client, JsonObject? Error)> GetClientAsync(CancellationToken cancellationToken) + { + var cfg = await configRepository.LoadAsync(cancellationToken); + if (!LlmConfigHelpers.IsEnabled(cfg) + || !LlmConfigHelpers.IsTruthy(cfg.GetValueOrDefault("llm_enable_content_studio") ?? "true")) + { + return (completionService, new JsonObject { ["ok"] = false, ["error"] = "AI is disabled. Enable it in Run audit → AI settings." }); + } + + try + { + return (completionService, null); + } + catch (Exception ex) + { + return (completionService, new JsonObject { ["ok"] = false, ["error"] = ex.Message }); + } + } + + private async Task SuggestIntentsAsync(JsonObject payload, CancellationToken cancellationToken) + { + var (_, err) = await GetClientAsync(cancellationToken); + if (err is not null) + { + return err; + } + + var kw = Clean(payload["keyword"]?.GetValue()); + if (string.IsNullOrEmpty(kw)) + { + return new JsonObject { ["ok"] = false, ["error"] = "keyword required" }; + } + + var locale = payload["locale"]?.GetValue() ?? "en-US"; + var user = + $"For the search keyword \"{kw}\" (locale {locale}), list up to {MaxOptions} distinct " + + "search intents a reader might have. Return JSON: " + + "{\"intents\":[{\"label\":\"short intent label\",\"description\":\"one sentence\"}]}"; + + var cfg = await configRepository.LoadAsync(cancellationToken); + var data = await SafeCompleteAsync(user, cfg, cancellationToken); + var options = NormalizeOptions(data["intents"]) ?? FallbackIntents(kw); + return new JsonObject { ["ok"] = true, ["options"] = options }; + } + + private async Task SuggestContentTypesAsync(JsonObject payload, CancellationToken cancellationToken) + { + var (_, err) = await GetClientAsync(cancellationToken); + if (err is not null) + { + return err; + } + + var keyword = Clean(payload["keyword"]?.GetValue()); + var intent = Clean(payload["intent"]?.GetValue()); + var user = + $"A writer is creating content for the keyword \"{keyword}\" with the intent \"{intent}\". " + + $"Recommend up to {MaxOptions} content types that best serve this, best first. " + + "Return JSON: {\"content_types\":[{\"label\":\"type\",\"description\":\"why it fits\"}]}"; + + var cfg = await configRepository.LoadAsync(cancellationToken); + var data = await SafeCompleteAsync(user, cfg, cancellationToken); + var options = NormalizeOptions(data["content_types"]) ?? OptionsFromPairs(FallbackContentTypes); + return new JsonObject { ["ok"] = true, ["options"] = options }; + } + + private async Task SuggestTonesAsync(JsonObject payload, CancellationToken cancellationToken) + { + var (_, err) = await GetClientAsync(cancellationToken); + if (err is not null) + { + return err; + } + + var user = + $"For a \"{Clean(payload["contentType"]?.GetValue())}\" about \"{Clean(payload["keyword"]?.GetValue())}\" " + + $"(intent: \"{Clean(payload["intent"]?.GetValue())}\"), recommend up to {MaxOptions} writing tones, best first. " + + "Return JSON: {\"tones\":[{\"label\":\"tone\",\"description\":\"when to use it\"}]}"; + + var cfg = await configRepository.LoadAsync(cancellationToken); + var data = await SafeCompleteAsync(user, cfg, cancellationToken); + var options = NormalizeOptions(data["tones"]) ?? OptionsFromPairs(FallbackTones); + return new JsonObject { ["ok"] = true, ["options"] = options }; + } + + private async Task SuggestTitlesAsync(JsonObject payload, CancellationToken cancellationToken) + { + var (_, err) = await GetClientAsync(cancellationToken); + if (err is not null) + { + return err; + } + + var kw = Clean(payload["keyword"]?.GetValue()); + var user = + $"Write up to {MaxTitles} compelling, SEO-friendly article titles for the keyword \"{kw}\". " + + $"Content type: \"{Clean(payload["contentType"]?.GetValue())}\". " + + $"Intent: \"{Clean(payload["intent"]?.GetValue())}\". " + + $"Tone: \"{Clean(payload["tone"]?.GetValue())}\". " + + "Keep each under 60 characters where possible and include the keyword naturally. " + + "Return JSON: {\"titles\":[\"title one\",\"title two\"]}"; + + var cfg = await configRepository.LoadAsync(cancellationToken); + var data = await SafeCompleteAsync(user, cfg, cancellationToken); + var titles = NormalizeStringList(data["titles"]) ?? FallbackTitles(kw); + return new JsonObject { ["ok"] = true, ["titles"] = titles }; + } + + private async Task ResearchPanelAsync(JsonObject payload, CancellationToken cancellationToken) + { + var (_, err) = await GetClientAsync(cancellationToken); + if (err is not null) + { + return err; + } + + var kw = Clean(payload["keyword"]?.GetValue()); + if (string.IsNullOrEmpty(kw)) + { + return new JsonObject { ["ok"] = false, ["error"] = "keyword required" }; + } + + var title = Clean(payload["title"]?.GetValue()); + var intent = Clean(payload["intent"]?.GetValue()); + var context = !string.IsNullOrEmpty(title) || !string.IsNullOrEmpty(intent) + ? $" The article is \"{title}\" (intent \"{intent}\")." + : ""; + + var user = + $"For the search keyword \"{kw}\", help an author research the topic.{context} Return JSON with: " + + "\"questions\" = up to 8 \"People Also Ask\" style questions real searchers ask; " + + "\"sources\" = up to 6 authoritative reference types to cite, each " + + "{\"label\":\"source name or type\",\"description\":\"what to cite it for\"}. " + + "Return JSON: {\"questions\":[\"...\"],\"sources\":[{\"label\":\"...\",\"description\":\"...\"}]}"; + + var cfg = await configRepository.LoadAsync(cancellationToken); + var data = await SafeCompleteAsync(user, cfg, cancellationToken); + var questions = NormalizeStringList(data["questions"]) ?? FallbackQuestions(kw); + var sources = NormalizeOptions(data["sources"]) ?? FallbackSources(); + return new JsonObject + { + ["ok"] = true, + ["questions"] = questions, + ["sources"] = sources, + }; + } + + private async Task SuggestOutlineAsync(JsonObject payload, CancellationToken cancellationToken) + { + var (_, err) = await GetClientAsync(cancellationToken); + if (err is not null) + { + return err; + } + + var title = Clean(payload["title"]?.GetValue()); + var user = + $"Create a heading outline for an article titled \"{title}\" " + + $"(keyword \"{Clean(payload["keyword"]?.GetValue())}\", {Clean(payload["contentType"]?.GetValue())}, " + + $"intent \"{Clean(payload["intent"]?.GetValue())}\", tone \"{Clean(payload["tone"]?.GetValue())}\"). " + + "Use h2 for main sections and h3 for sub-points. Do not include the title as a heading. " + + "Return JSON: {\"outline\":[{\"level\":\"h2\",\"text\":\"Section heading\"},{\"level\":\"h3\",\"text\":\"Sub-point\"}]}"; + + var cfg = await configRepository.LoadAsync(cancellationToken); + var data = await SafeCompleteAsync(user, cfg, cancellationToken); + var outline = NormalizeOutline(data["outline"], title); + return new JsonObject { ["ok"] = true, ["outline"] = outline }; + } + + private async Task GenerateDraftAsync(JsonObject payload, CancellationToken cancellationToken) + { + var (_, err) = await GetClientAsync(cancellationToken); + if (err is not null) + { + return err; + } + + var keyword = Clean(payload["keyword"]?.GetValue()); + var title = Clean(payload["title"]?.GetValue()); + var outlineRaw = payload["outline"] as JsonArray ?? []; + var normalized = NormalizeOutline(outlineRaw, title); + var h1Text = normalized.FirstOrDefault(x => x["level"]?.GetValue() == "h1")?["text"]?.GetValue() + ?? title + ?? keyword; + var headings = normalized + .OfType() + .Where(x => x["level"]?.GetValue() != "h1") + .ToList(); + var headingsText = string.Join('\n', headings.Select(x => $"{x["level"]}: {x["text"]}")); + + var user = + $"Write the body of a \"{Clean(payload["contentType"]?.GetValue())}\" titled \"{h1Text}\" for the keyword " + + $"\"{keyword}\" (intent \"{Clean(payload["intent"]?.GetValue())}\", tone \"{Clean(payload["tone"]?.GetValue())}\"). " + + $"Write 2-4 sentences of plain-text prose for each heading below, in order:\n{headingsText}\n\n" + + "Return JSON: {\"title_tag\":\"SEO title under 60 chars\",\"meta_description\":\"under 160 chars\"," + + "\"sections\":[\"prose for heading 1\",\"prose for heading 2\", ...]} " + + "with one sections entry per heading, in the same order."; + + var cfg = await configRepository.LoadAsync(cancellationToken); + var data = await SafeCompleteAsync(user, cfg, cancellationToken); + var titleTag = Clean(data["title_tag"]?.GetValue()); + if (string.IsNullOrEmpty(titleTag)) + { + titleTag = h1Text; + } + + titleTag = titleTag[..Math.Min(titleTag.Length, 70)]; + var meta = Clean(data["meta_description"]?.GetValue()); + if (string.IsNullOrEmpty(meta)) + { + meta = $"{h1Text}. Learn about {keyword}."; + } + + meta = meta[..Math.Min(meta.Length, 170)]; + var bodyHtml = AssembleBody(h1Text, headings, data["sections"]); + + return new JsonObject + { + ["ok"] = true, + ["title_tag"] = titleTag, + ["meta_description"] = meta, + ["body_html"] = bodyHtml, + ["outline"] = normalized, + }; + } + + private async Task SafeCompleteAsync( + string user, + IReadOnlyDictionary cfg, + CancellationToken cancellationToken) + { + try + { + return await completionService.CompleteJsonAsync(LlmPrompts.ContentWizardJsonSystem, user, cfg, cancellationToken); + } + catch (Exception) + { + return []; + } + } + + private static string Clean(string? value) + => Regex.Replace((value ?? "").Trim(), @"\s+", " "); + + private static JsonArray OptionsFromPairs(IEnumerable<(string Label, string Description)> pairs) + { + var arr = new JsonArray(); + foreach (var (label, desc) in pairs) + { + arr.Add(new JsonObject { ["label"] = label, ["description"] = desc }); + } + + return arr; + } + + private static JsonArray? NormalizeOptions(JsonNode? raw) + { + if (raw is not JsonArray list) + { + return null; + } + + var outArr = new JsonArray(); + foreach (var item in list) + { + string label; + string desc; + if (item is JsonObject obj) + { + label = Clean(obj["label"]?.GetValue() ?? obj["name"]?.GetValue() ?? obj["title"]?.GetValue()); + desc = Clean(obj["description"]?.GetValue() ?? obj["summary"]?.GetValue()); + } + else + { + label = Clean(item?.GetValue()); + desc = ""; + } + + if (!string.IsNullOrEmpty(label)) + { + outArr.Add(new JsonObject + { + ["label"] = label[..Math.Min(label.Length, 120)], + ["description"] = desc[..Math.Min(desc.Length, 240)], + }); + } + } + + return outArr.Count > 0 ? outArr : null; + } + + private static JsonArray? NormalizeStringList(JsonNode? raw) + { + if (raw is not JsonArray list) + { + return null; + } + + var outArr = new JsonArray(); + foreach (var item in list) + { + var text = item is JsonObject obj + ? Clean(obj["text"]?.GetValue() ?? obj["title"]?.GetValue()) + : Clean(item?.GetValue()); + if (!string.IsNullOrEmpty(text)) + { + outArr.Add(text[..Math.Min(text.Length, 160)]); + } + } + + return outArr.Count > 0 ? outArr : null; + } + + private static JsonArray NormalizeOutline(JsonNode? raw, string title) + { + var items = new List(); + if (raw is JsonArray list) + { + foreach (var it in list) + { + string level; + string text; + if (it is JsonObject obj) + { + level = (obj["level"]?.GetValue() ?? "h2").Trim().ToLowerInvariant(); + text = Clean(obj["text"]?.GetValue() ?? obj["title"]?.GetValue() ?? obj["heading"]?.GetValue()); + } + else + { + level = "h2"; + text = Clean(it?.GetValue()); + } + + if (level is not ("h1" or "h2" or "h3")) + { + level = "h2"; + } + + if (!string.IsNullOrEmpty(text)) + { + items.Add(new JsonObject { ["level"] = level, ["text"] = text[..Math.Min(text.Length, 200)] }); + } + + if (items.Count >= MaxOutline) + { + break; + } + } + } + + var titleText = Clean(title); + if (string.IsNullOrEmpty(titleText)) + { + titleText = items.FirstOrDefault()?["text"]?.GetValue() ?? "Untitled"; + } + + var bodyItems = items.Where(x => x["level"]?.GetValue() != "h1").ToList(); + if (bodyItems.Count == 0) + { + return FallbackOutline(titleText); + } + + var result = new JsonArray { new JsonObject { ["level"] = "h1", ["text"] = titleText } }; + foreach (var item in bodyItems.Take(MaxOutline - 1)) + { + result.Add(item.DeepClone()); + } + + return result; + } + + private static JsonArray FallbackIntents(string keyword) + { + var kw = keyword.Trim(); + return OptionsFromPairs([ + ($"Learn about {kw}", $"Understand what {kw} is and why it matters."), + ($"How to use {kw}", $"Practical, step-by-step guidance for {kw}."), + ($"Best {kw} options", $"Compare the top {kw} choices available."), + ($"{kw} reviews & comparisons", $"Evaluate {kw} against the alternatives."), + ]); + } + + private static JsonArray FallbackTitles(string keyword) + { + var t = string.IsNullOrWhiteSpace(keyword) ? "Your Topic" : keyword.Trim().ToUpperInvariant(); + return new JsonArray + { + $"{t}: A Complete Guide", + $"What Is {t}? Everything You Need to Know", + $"The Beginner's Guide to {t}", + $"{t}: Tips, Examples, and Best Practices", + }; + } + + private static JsonArray FallbackOutline(string title) + { + var h1 = string.IsNullOrWhiteSpace(title) ? "Untitled" : title.Trim(); + var sections = new[] { "Introduction", "Key concepts", "How it works", "Practical tips", "Common mistakes", "Conclusion" }; + var arr = new JsonArray { new JsonObject { ["level"] = "h1", ["text"] = h1 } }; + foreach (var section in sections) + { + arr.Add(new JsonObject { ["level"] = "h2", ["text"] = section }); + } + + return arr; + } + + private static JsonArray FallbackQuestions(string keyword) + { + var kw = keyword.Trim(); + return new JsonArray + { + $"What is {kw}?", + $"How does {kw} work?", + $"Why is {kw} important?", + $"What are examples of {kw}?", + $"How do you use {kw}?", + }; + } + + private static JsonArray FallbackSources() + => OptionsFromPairs([ + ("Wikipedia", "Background, definitions, and a neutral overview."), + ("Official site or documentation", "Authoritative first-party specifics."), + ("Industry publications", "Expert analysis, trends, and commentary."), + ("Academic or research sources", "Evidence for data-backed claims."), + ("Reputable news coverage", "Recent developments and real-world context."), + ]); + + private static string AssembleBody(string h1Text, IReadOnlyList headings, JsonNode? sections) + { + var sectionList = sections as JsonArray ?? []; + var parts = new List { $"

{System.Net.WebUtility.HtmlEncode(h1Text)}

" }; + for (var i = 0; i < headings.Count; i++) + { + var heading = headings[i]; + var level = heading["level"]?.GetValue() ?? "h2"; + var text = heading["text"]?.GetValue() ?? ""; + var prose = ""; + if (i < sectionList.Count) + { + var raw = sectionList[i]; + prose = raw is JsonObject obj + ? Clean(obj["text"]?.GetValue()) + : Clean(raw?.GetValue()); + } + + if (string.IsNullOrEmpty(prose)) + { + prose = $"Add details about {text.ToLowerInvariant()} here."; + } + + parts.Add($"<{level}>{System.Net.WebUtility.HtmlEncode(text)}"); + parts.Add($"

{System.Net.WebUtility.HtmlEncode(prose)}

"); + } + + return string.Join('\n', parts); + } +} diff --git a/services/AiService/src/AiService.Application/Services/DashboardAiService.cs b/services/AiService/src/AiService.Application/Services/DashboardAiService.cs new file mode 100644 index 00000000..85205039 --- /dev/null +++ b/services/AiService/src/AiService.Application/Services/DashboardAiService.cs @@ -0,0 +1,70 @@ +using System.Text.Json.Nodes; +using AiService.Application.Prompts; +using AiService.Domain.Repositories; +using AiService.Providers.Chat; + +namespace AiService.Application.Services; + +public sealed class DashboardAiService( + ILlmConfigRepository configRepository, + StructuredCompletionService completionService) +{ + private static readonly HashSet ValidModes = new(StringComparer.OrdinalIgnoreCase) + { + "script", "widget", "dashboard", + }; + + public async Task GenerateAsync( + JsonObject payload, + CancellationToken cancellationToken = default) + { + var cfg = await configRepository.LoadAsync(cancellationToken); + if (!LlmConfigHelpers.IsEnabled(cfg)) + { + return new JsonObject { ["ok"] = false, ["error"] = "AI insights are disabled.", ["missing"] = true }; + } + + if (!LlmConfigHelpers.IsTruthy(cfg.GetValueOrDefault("llm_enable_dashboards") ?? "true")) + { + return new JsonObject { ["ok"] = false, ["error"] = "Dashboard AI is disabled in task settings.", ["missing"] = true }; + } + + var mode = (payload["mode"]?.GetValue() ?? "widget").Trim().ToLowerInvariant(); + if (!ValidModes.Contains(mode)) + { + return new JsonObject { ["ok"] = false, ["error"] = $"Unknown mode: '{mode}'. Must be one of: script, widget, dashboard." }; + } + + var prompt = (payload["prompt"]?.GetValue() ?? "").Trim(); + if (string.IsNullOrEmpty(prompt)) + { + return new JsonObject { ["ok"] = false, ["error"] = "prompt is required." }; + } + + try + { + var user = payload.ToJsonString()[..Math.Min(payload.ToJsonString().Length, 10_000)]; + var result = await completionService.CompleteJsonAsync( + LlmPrompts.DashboardAiSystem, + user, + cfg, + cancellationToken); + + if (result.Count == 0) + { + return new JsonObject { ["ok"] = false, ["error"] = "AI returned no parseable output." }; + } + + if (!result.ContainsKey("ok")) + { + result["ok"] = true; + } + + return result; + } + catch (Exception ex) + { + return new JsonObject { ["ok"] = false, ["error"] = ex.Message }; + } + } +} diff --git a/services/AiService/src/AiService.Application/Services/EnrichmentService.cs b/services/AiService/src/AiService.Application/Services/EnrichmentService.cs new file mode 100644 index 00000000..aabef51a --- /dev/null +++ b/services/AiService/src/AiService.Application/Services/EnrichmentService.cs @@ -0,0 +1,604 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using AiService.Application.Json; +using AiService.Application.Prompts; +using AiService.Application.Repositories; +using AiService.Domain.Repositories; +using AiService.Providers.Chat; + +namespace AiService.Application.Services; + +/// +/// LLM enrichment endpoints for /internal/enrichment/* — ports enrich.py, issue_fixes.py, audit_summary.py. +/// +public sealed class EnrichmentService( + ILlmConfigRepository configRepository, + LlmCacheRepository cacheRepository, + StructuredCompletionService completionService, + FixSuggestionService fixSuggestionService) +{ + public async Task ClusterKeywordsAsync( + IReadOnlyList keywords, + CancellationToken cancellationToken = default) + { + var cfg = await configRepository.LoadAsync(cancellationToken); + if (keywords.Count < 2 || !LlmConfigHelpers.IsEnabled(cfg) + || !LlmConfigHelpers.IsTruthy(cfg.GetValueOrDefault("llm_enable_keyword_clusters") ?? "false")) + { + return new JsonObject { ["clusters"] = new JsonArray() }; + } + + var kws = keywords.Take(200).ToList(); + var model = (cfg.GetValueOrDefault("llm_model") ?? cfg.GetValueOrDefault("llm_provider") ?? "").Trim(); + var payload = new JsonObject { ["keywords"] = new JsonArray(kws.Select(x => JsonValue.Create(x)).ToArray()) }; + var cacheKey = LlmTaskCache.CacheKey("kw_clusters", model, payload); + + var cached = await cacheRepository.ReadObjectAsync(cacheKey, cancellationToken); + JsonObject data; + if (cached is not null) + { + data = cached; + } + else + { + data = await completionService.CompleteJsonAsync( + LlmPrompts.KeywordClusterSystem, + payload.ToJsonString(), + cfg, + cancellationToken); + await cacheRepository.WriteObjectAsync(cacheKey, data, cancellationToken); + } + + var clusters = new JsonArray(); + if (data["clusters"] is JsonArray rawClusters) + { + foreach (var node in rawClusters) + { + if (node is not JsonObject c || c["keywords"] is not JsonArray words || words.Count < 2) + { + continue; + } + + var keywordList = words.Select(w => w?.GetValue() ?? "").Where(w => !string.IsNullOrEmpty(w)).OrderBy(w => w).ToList(); + clusters.Add(new JsonObject + { + ["top_keyword"] = c["top_keyword"]?.GetValue() ?? keywordList[0], + ["keywords"] = new JsonArray(keywordList.Select(x => JsonValue.Create(x)).ToArray()), + ["cluster_score"] = Math.Round(c["cluster_score"]?.GetValue() ?? 0.9, 4), + }); + } + } + + var sorted = new JsonArray(clusters.OrderByDescending(x => x?["cluster_score"]?.GetValue() ?? 0).Select(x => x!.DeepClone()).ToArray()); + return new JsonObject { ["clusters"] = sorted }; + } + + public async Task RunEnrichmentAsync( + JsonArray pages, + CancellationToken cancellationToken = default) + { + var cfg = await configRepository.LoadAsync(cancellationToken); + var bundle = new JsonObject + { + ["spacy_by_url"] = new JsonObject(), + ["similar_internal_by_url"] = new JsonObject(), + ["ner_site_summary"] = new JsonObject(), + ["keyphrases_by_url"] = new JsonObject(), + ["ml_errors"] = new JsonArray(), + }; + + if (pages.Count == 0 || !LlmConfigHelpers.IsEnabled(cfg)) + { + return bundle; + } + + var maxPages = ParseInt(cfg.GetValueOrDefault("llm_max_pages"), 60); + var items = BuildPageItems(pages, maxPages); + if (items.Count == 0) + { + return bundle; + } + + try + { + if (LlmConfigHelpers.IsTruthy(cfg.GetValueOrDefault("llm_enable_ner") ?? "true")) + { + bundle["spacy_by_url"] = await RunBatchedTaskAsync("ner", LlmPrompts.NerSystem, items, cfg, ApplyNerBatch, cancellationToken); + } + + if (LlmConfigHelpers.IsTruthy(cfg.GetValueOrDefault("llm_enable_keyphrases") ?? "true")) + { + bundle["keyphrases_by_url"] = await RunBatchedTaskAsync( + "keyphrases", + LlmPrompts.KeyphrasesSystem, + items, + cfg, + ApplyKeyphraseBatch, + cancellationToken); + } + + if (LlmConfigHelpers.IsTruthy(cfg.GetValueOrDefault("llm_enable_similar_internal") ?? "true")) + { + bundle["similar_internal_by_url"] = await RunSimilarInternalAsync(items, cfg, cancellationToken); + } + + bundle["ner_site_summary"] = AggregateNerSiteSummary(bundle["spacy_by_url"] as JsonObject ?? []); + bundle["llm_meta"] = new JsonObject + { + ["model"] = (cfg.GetValueOrDefault("llm_model") ?? "unknown").Trim(), + ["prompt_version"] = LlmPrompts.Version, + ["generated_at"] = DateTimeOffset.UtcNow.ToString("O"), + }; + } + catch (Exception ex) + { + (bundle["ml_errors"] as JsonArray)?.Add(ex.Message); + } + + return bundle; + } + + public Task GenerateIssueFixAsync(JsonObject issue, bool refresh = false, CancellationToken cancellationToken = default) + { + var payload = JsonNodeCopy.CloneObject(issue); + payload["source"] = "issue"; + return fixSuggestionService.GenerateAsync(payload, refresh, cancellationToken); + } + + public async Task GenerateAuditSummaryAsync( + JsonObject reportPayload, + CancellationToken cancellationToken = default) + { + var cfg = await configRepository.LoadAsync(cancellationToken); + var categories = reportPayload["categories"] as JsonArray ?? []; + var gsc = (reportPayload["google"] as JsonObject)?["gsc"] as JsonObject; + var gscPages = gsc?["top_pages"] as JsonArray; + var topIssues = RankIssuesByTraffic(categories, gscPages).Take(5).ToList(); + var avg = AverageCategoryScore(categories); + + var fallback = DeterministicSummaryText(avg, topIssues); + var source = "deterministic"; + var priorities = new JsonArray(); + + if (LlmConfigHelpers.IsEnabled(cfg) && LlmConfigHelpers.IsTruthy(cfg.GetValueOrDefault("llm_enable_audit_summary") ?? "true")) + { + source = "ai_insights"; + var llmResult = await GenerateLlmExecutiveSummaryAsync(reportPayload, topIssues, cfg, cancellationToken); + var summary = llmResult["summary"]?.GetValue(); + if (!string.IsNullOrWhiteSpace(summary)) + { + fallback = summary!; + if (llmResult["priorities"] is JsonArray p) + { + priorities = p; + } + } + else + { + fallback = DeterministicSummaryText(avg, topIssues, llmUnavailable: true); + } + } + else if (LlmConfigHelpers.IsEnabled(cfg)) + { + fallback = DeterministicSummaryText(avg, topIssues, hintEnableLlm: true); + } + + return new JsonObject + { + ["ok"] = true, + ["source"] = source, + ["summary"] = fallback, + ["top_issues"] = new JsonArray(topIssues.Select(x => x.DeepClone()).ToArray()), + ["priorities"] = priorities, + }; + } + + private async Task RunSimilarInternalAsync( + IReadOnlyList items, + IReadOnlyDictionary cfg, + CancellationToken cancellationToken) + { + var topK = Math.Min(ParseInt(cfg.GetValueOrDefault("llm_similar_top_k"), 5), 15); + var allUrls = items.Select(x => x["url"]?.GetValue() ?? "").Where(x => !string.IsNullOrEmpty(x)).ToList(); + var batchSize = Math.Max(1, Math.Min(ParseInt(cfg.GetValueOrDefault("llm_batch_size"), 5), 3)); + var batches = new List(); + for (var i = 0; i < items.Count; i += batchSize) + { + var slice = items.Skip(i).Take(batchSize).ToList(); + batches.Add(new JsonObject + { + ["pages"] = new JsonArray(slice.Select(x => x.DeepClone()).ToArray()), + ["candidate_urls"] = new JsonArray(allUrls.Take(80).Select(x => JsonValue.Create(x)).ToArray()), + ["top_k"] = topK, + }); + } + + var outObj = new JsonObject(); + foreach (var batch in batches) + { + var result = await RunSingleBatchAsync("similar", LlmPrompts.SimilarSystem, batch, cfg, cancellationToken); + if (result["pages"] is JsonArray pages) + { + foreach (var pageNode in pages) + { + if (pageNode is not JsonObject page) + { + continue; + } + + var url = (page["url"]?.GetValue() ?? "").Trim().TrimEnd('/'); + if (string.IsNullOrEmpty(url)) + { + continue; + } + + var sim = new JsonArray(); + if (page["similar"] is JsonArray similar) + { + foreach (var s in similar.Take(topK)) + { + if (s is JsonObject so && !string.IsNullOrEmpty(so["url"]?.GetValue())) + { + sim.Add(new JsonObject + { + ["url"] = so["url"]!.GetValue(), + ["score"] = Math.Round(so["score"]?.GetValue() ?? 0, 4), + }); + } + } + } + + if (sim.Count > 0) + { + outObj[url] = sim; + } + } + } + } + + return outObj; + } + + private async Task RunBatchedTaskAsync( + string task, + string system, + IReadOnlyList items, + IReadOnlyDictionary cfg, + Action applyBatch, + CancellationToken cancellationToken) + { + var batchSize = Math.Max(1, ParseInt(cfg.GetValueOrDefault("llm_batch_size"), 5)); + var outObj = new JsonObject(); + for (var i = 0; i < items.Count; i += batchSize) + { + var batch = new JsonObject + { + ["pages"] = new JsonArray(items.Skip(i).Take(batchSize).Select(x => x.DeepClone()).ToArray()), + }; + var result = await RunSingleBatchAsync(task, system, batch, cfg, cancellationToken); + applyBatch(outObj, result); + } + + return outObj; + } + + private async Task RunSingleBatchAsync( + string task, + string system, + JsonObject batch, + IReadOnlyDictionary cfg, + CancellationToken cancellationToken) + { + var model = (cfg.GetValueOrDefault("llm_model") ?? cfg.GetValueOrDefault("llm_provider") ?? "").Trim(); + var cacheKey = LlmTaskCache.CacheKey(task, model, batch); + var cached = await cacheRepository.ReadObjectAsync(cacheKey, cancellationToken); + if (cached is not null) + { + return cached; + } + + var result = await completionService.CompleteJsonAsync(system, batch.ToJsonString(), cfg, cancellationToken); + await cacheRepository.WriteObjectAsync(cacheKey, result, cancellationToken); + return result; + } + + private static void ApplyNerBatch(JsonObject output, JsonObject data) + { + if (data["pages"] is not JsonArray pages) + { + return; + } + + foreach (var pageNode in pages) + { + if (pageNode is not JsonObject page) + { + continue; + } + + var url = (page["url"]?.GetValue() ?? "").Trim().TrimEnd('/'); + if (string.IsNullOrEmpty(url)) + { + continue; + } + + output[url] = new JsonObject + { + ["entity_count"] = page["entity_count"]?.GetValue() ?? 0, + ["top_entity_labels"] = page["top_entity_labels"]?.DeepClone() ?? new JsonArray(), + }; + } + } + + private static void ApplyKeyphraseBatch(JsonObject output, JsonObject data) + { + if (data["pages"] is not JsonArray pages) + { + return; + } + + foreach (var pageNode in pages) + { + if (pageNode is not JsonObject page) + { + continue; + } + + var url = (page["url"]?.GetValue() ?? "").Trim().TrimEnd('/'); + if (string.IsNullOrEmpty(url)) + { + continue; + } + + var pairs = new JsonArray(); + if (page["phrases"] is JsonArray phrases) + { + foreach (var phraseNode in phrases) + { + if (phraseNode is JsonArray pair && pair.Count >= 2) + { + pairs.Add(new JsonArray(pair[0]?.DeepClone(), JsonValue.Create(pair[1]?.GetValue() ?? 0))); + } + } + } + + output[url] = new JsonObject { ["phrases"] = pairs }; + } + } + + private static List BuildPageItems(JsonArray pages, int maxPages) + { + var items = new List(); + foreach (var node in pages) + { + if (node is not JsonObject row) + { + continue; + } + + var url = (row["url"]?.GetValue() ?? "").Trim().TrimEnd('/'); + var text = (row["text"]?.GetValue() ?? "").Trim(); + if (string.IsNullOrEmpty(url) || text.Length < 40) + { + continue; + } + + items.Add(new JsonObject { ["url"] = url, ["text"] = text[..Math.Min(text.Length, 4000)] }); + if (items.Count >= maxPages) + { + break; + } + } + + return items; + } + + private static JsonObject AggregateNerSiteSummary(JsonObject spacyByUrl) + { + var labelTotals = new Dictionary(StringComparer.Ordinal); + var totalEntities = 0; + foreach (var prop in spacyByUrl) + { + if (prop.Value is not JsonObject info) + { + continue; + } + + totalEntities += info["entity_count"]?.GetValue() ?? 0; + if (info["top_entity_labels"] is JsonArray labels) + { + foreach (var labelNode in labels) + { + if (labelNode is JsonArray pair && pair.Count >= 2) + { + var label = pair[0]?.GetValue() ?? ""; + var count = pair[1]?.GetValue() ?? 0; + labelTotals[label] = labelTotals.GetValueOrDefault(label) + count; + } + } + } + } + + var labelCounts = new JsonObject(); + foreach (var (label, count) in labelTotals.OrderByDescending(x => x.Value).Take(40)) + { + labelCounts[label] = count; + } + + return new JsonObject + { + ["label_counts"] = labelCounts, + ["pages_with_ner"] = spacyByUrl.Count, + ["total_entities"] = totalEntities, + }; + } + + private async Task GenerateLlmExecutiveSummaryAsync( + JsonObject reportPayload, + IReadOnlyList topIssues, + IReadOnlyDictionary cfg, + CancellationToken cancellationToken) + { + var categories = reportPayload["categories"] as JsonArray ?? []; + var avg = AverageCategoryScore(categories); + var payload = new JsonObject + { + ["health_score"] = avg, + ["category_scores"] = new JsonArray(categories.Take(12).OfType().Select(c => new JsonObject + { + ["name"] = c["name"]?.DeepClone(), + ["score"] = c["score"]?.DeepClone(), + }).ToArray()), + ["top_issues"] = new JsonArray(topIssues.Select(i => new JsonObject + { + ["priority"] = i["priority"]?.DeepClone(), + ["message"] = i["message"]?.DeepClone(), + ["url"] = i["url"]?.DeepClone(), + ["gsc_clicks"] = i["gsc_clicks"]?.DeepClone(), + }).ToArray()), + ["total_urls"] = (reportPayload["summary"] as JsonObject)?["total_urls"]?.DeepClone(), + }; + + try + { + var user = payload.ToJsonString()[..Math.Min(payload.ToJsonString().Length, 10_000)]; + return await completionService.CompleteJsonAsync(LlmPrompts.AuditExecutiveSystem, user, cfg, cancellationToken); + } + catch (Exception) + { + return []; + } + } + + private static List RankIssuesByTraffic(JsonArray categories, JsonArray? gscPages) + { + var clicksByUrl = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (gscPages is not null) + { + foreach (var rowNode in gscPages) + { + if (rowNode is not JsonObject row) + { + continue; + } + + var url = (row["page"]?.GetValue() ?? "").Trim().ToLowerInvariant(); + if (string.IsNullOrEmpty(url)) + { + continue; + } + + clicksByUrl[url] = row["clicks"]?.GetValue() ?? 0; + } + } + + var ranked = new List(); + var priorityRank = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["Critical"] = 0, + ["High"] = 1, + ["Medium"] = 2, + ["Low"] = 3, + }; + + foreach (var catNode in categories) + { + if (catNode is not JsonObject cat) + { + continue; + } + + var catName = cat["name"]?.GetValue() ?? cat["id"]?.GetValue() ?? ""; + if (cat["issues"] is not JsonArray issues) + { + continue; + } + + foreach (var issueNode in issues) + { + if (issueNode is not JsonObject issue) + { + continue; + } + + var url = (issue["url"]?.GetValue() ?? "").Trim().ToLowerInvariant(); + var clicks = clicksByUrl.GetValueOrDefault(url); + ranked.Add(new JsonObject + { + ["message"] = issue["message"]?.DeepClone(), + ["url"] = issue["url"]?.DeepClone(), + ["priority"] = issue["priority"]?.DeepClone(), + ["category"] = catName, + ["gsc_clicks"] = clicks, + ["traffic_weight"] = clicks, + }); + } + } + + return ranked + .OrderByDescending(x => x["traffic_weight"]?.GetValue() ?? 0) + .ThenBy(x => priorityRank.GetValueOrDefault(x["priority"]?.GetValue() ?? "Medium", 99)) + .ToList(); + } + + private static int? AverageCategoryScore(JsonArray categories) + { + var scores = categories.OfType() + .Select(c => c["score"]?.GetValue()) + .Where(s => s.HasValue) + .Select(s => s!.Value) + .ToList(); + if (scores.Count == 0) + { + return null; + } + + return (int)Math.Round(scores.Average(), MidpointRounding.AwayFromZero); + } + + private static string DeterministicSummaryText( + int? avg, + IReadOnlyList topIssues, + bool llmUnavailable = false, + bool hintEnableLlm = false) + { + string msg; + if (topIssues.Count > 0) + { + msg = "Prioritize fixes below by severity and Search Console traffic impact."; + } + else if (avg is >= 80) + { + msg = "Site health looks strong. Keep monitoring crawl and Search Console trends."; + } + else if (avg is not null) + { + msg = "Review category scores and address high-priority issues to improve overall health."; + } + else + { + msg = "No major issues detected in this audit run."; + } + + if (llmUnavailable) + { + msg += " (AI summary unavailable — showing structured overview only.)"; + } + else if (hintEnableLlm) + { + msg += " Enable audit executive summary in AI settings for an AI narrative."; + } + + return msg; + } + + private static int ParseInt(string? raw, int defaultValue) + { + if (string.IsNullOrWhiteSpace(raw)) + { + return defaultValue; + } + + return int.TryParse(raw.Trim(), out var value) ? value : defaultValue; + } +} diff --git a/services/AiService/src/AiService.Application/Services/FixSuggestionService.cs b/services/AiService/src/AiService.Application/Services/FixSuggestionService.cs new file mode 100644 index 00000000..4a44f8f2 --- /dev/null +++ b/services/AiService/src/AiService.Application/Services/FixSuggestionService.cs @@ -0,0 +1,149 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using AiService.Application.Prompts; +using AiService.Application.Repositories; +using AiService.Domain.Repositories; +using AiService.Providers.Chat; + +namespace AiService.Application.Services; + +public sealed class FixSuggestionService( + ILlmConfigRepository configRepository, + LlmCacheRepository cacheRepository, + StructuredCompletionService completionService) +{ + private static readonly JsonObject DefaultFix = new() + { + ["fix"] = "Review the issue on the affected URL and apply standard remediation.", + ["effort"] = "medium", + }; + + public async Task GenerateAsync( + JsonObject payload, + bool refresh = false, + CancellationToken cancellationToken = default) + { + var cfg = await configRepository.LoadAsync(cancellationToken); + if (!LlmConfigHelpers.IsEnabled(cfg)) + { + return new JsonObject { ["ok"] = false, ["error"] = "AI insights are disabled." }; + } + + if (!FixSuggestionSupport.FixSuggestionsEnabled(cfg)) + { + return new JsonObject { ["ok"] = false, ["error"] = "Issue fix suggestions are disabled in AI task settings." }; + } + + var userPayload = BuildUserPayload(payload); + var message = userPayload["message"]?.GetValue() ?? ""; + if (string.IsNullOrWhiteSpace(message)) + { + return new JsonObject { ["ok"] = false, ["error"] = "message required." }; + } + + var source = userPayload["source"]?.GetValue() ?? "issue"; + var model = (cfg.GetValueOrDefault("llm_model") ?? cfg.GetValueOrDefault("llm_provider") ?? "unknown").Trim(); + var cacheKey = SHA256.HashData( + Encoding.UTF8.GetBytes($"fix_suggestion:{LlmPrompts.Version}:{model}:{source}:{userPayload.ToJsonString()}")); + var cacheKeyHex = Convert.ToHexStringLower(cacheKey); + + if (!refresh) + { + var cached = await cacheRepository.ReadObjectAsync(cacheKeyHex, cancellationToken); + if (cached is not null) + { + return new JsonObject + { + ["ok"] = true, + ["cached"] = true, + ["fix"] = cached.DeepClone(), + ["provenance"] = "AI insights", + }; + } + } + + if (!LlmPrompts.FixSuggestionPrompts.TryGetValue(source, out var system)) + { + system = LlmPrompts.IssueFixSystem; + } + + try + { + var user = Truncate(userPayload.ToJsonString(), 8000); + var fix = await completionService.CompleteJsonAsync(system, user, cfg, cancellationToken); + if (fix.Count == 0 || string.IsNullOrWhiteSpace(fix["fix"]?.GetValue())) + { + fix = DefaultFix.DeepClone() as JsonObject ?? []; + } + + await cacheRepository.WriteObjectAsync(cacheKeyHex, fix, cancellationToken); + return new JsonObject + { + ["ok"] = true, + ["cached"] = false, + ["fix"] = fix.DeepClone(), + ["provenance"] = "AI insights", + }; + } + catch (Exception ex) + { + return new JsonObject { ["ok"] = false, ["error"] = ex.Message }; + } + } + + private static JsonObject BuildUserPayload(JsonObject payload) + { + var source = NormalizeSource(payload["source"]?.GetValue()); + var result = new JsonObject + { + ["source"] = source, + ["message"] = (payload["message"]?.GetValue() ?? "").Trim(), + }; + + if (payload.TryGetPropertyValue("url", out var urlNode) && urlNode is not null) + { + result["url"] = urlNode.DeepClone(); + } + + if (payload["context"] is JsonObject context && context.Count > 0) + { + result["context"] = context.DeepClone(); + } + + if (source == "issue") + { + var legacy = new JsonObject(); + foreach (var key in new[] { "priority", "category", "type", "finding_type", "recommendation", "existing_recommendation" }) + { + if (payload.TryGetPropertyValue(key, out var node) && node is not null) + { + legacy[key] = node.DeepClone(); + } + } + + if (legacy.Count > 0) + { + var ctx = result["context"] as JsonObject ?? new JsonObject(); + foreach (var prop in legacy) + { + ctx[prop.Key] = prop.Value?.DeepClone(); + } + + result["context"] = ctx; + } + } + + return result; + } + + private static string NormalizeSource(string? raw) + { + var source = (raw ?? "issue").Trim().ToLowerInvariant(); + return LlmPrompts.FixSuggestionPrompts.ContainsKey(source) ? source : "issue"; + } + + private static string Truncate(string text, int max) + => text.Length <= max ? text : text[..max]; +} diff --git a/services/AiService/src/AiService.Application/Services/IssuesActionPlanService.cs b/services/AiService/src/AiService.Application/Services/IssuesActionPlanService.cs new file mode 100644 index 00000000..8861ad3f --- /dev/null +++ b/services/AiService/src/AiService.Application/Services/IssuesActionPlanService.cs @@ -0,0 +1,253 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using AiService.Application.Json; +using AiService.Application.Prompts; +using AiService.Application.Repositories; +using AiService.Domain.Repositories; +using AiService.Providers.Chat; + +namespace AiService.Application.Services; + +public sealed class IssuesActionPlanService( + ILlmConfigRepository configRepository, + LlmCacheRepository cacheRepository, + StructuredCompletionService completionService) +{ + private const int MaxIssues = 80; + + public async Task GenerateAsync( + JsonObject payload, + bool refresh = false, + CancellationToken cancellationToken = default) + { + var cfg = await configRepository.LoadAsync(cancellationToken); + if (!LlmConfigHelpers.IsEnabled(cfg)) + { + return new JsonObject { ["ok"] = false, ["error"] = "AI insights are disabled." }; + } + + if (!FixSuggestionSupport.FixSuggestionsEnabled(cfg)) + { + return new JsonObject { ["ok"] = false, ["error"] = "Issue fix suggestions are disabled in AI task settings." }; + } + + var domain = (payload["domain"]?.GetValue() ?? "").Trim(); + var issues = CompactIssues(payload["issues"]); + if (string.IsNullOrWhiteSpace(domain)) + { + return new JsonObject { ["ok"] = false, ["error"] = "domain required." }; + } + + if (issues.Count == 0) + { + return new JsonObject { ["ok"] = false, ["error"] = "issues required." }; + } + + var model = (cfg.GetValueOrDefault("llm_model") ?? cfg.GetValueOrDefault("llm_provider") ?? "unknown").Trim(); + var cachePayload = new JsonObject + { + ["domain"] = domain, + ["issues"] = JsonNodeCopy.CloneArray(issues), + }; + var cacheKey = SHA256.HashData( + Encoding.UTF8.GetBytes($"issues_action_plan:{LlmPrompts.Version}:{model}:{cachePayload.ToJsonString()}")); + var cacheKeyHex = Convert.ToHexStringLower(cacheKey); + + if (!refresh) + { + var cached = await cacheRepository.ReadObjectAsync(cacheKeyHex, cancellationToken); + if (cached is not null) + { + return BuildSuccess(cached, cached: true); + } + } + + var userPayload = new JsonObject + { + ["domain"] = domain, + ["issue_count"] = issues.Count, + ["issues"] = issues, + }; + + try + { + var user = userPayload.ToJsonString()[..Math.Min(userPayload.ToJsonString().Length, 12000)]; + var parsed = await completionService.CompleteJsonAsync( + LlmPrompts.IssuesActionPlanSystem, + user, + cfg, + cancellationToken); + + if (parsed.Count == 0) + { + parsed = new JsonObject { ["summary"] = "No plan returned." }; + } + + await cacheRepository.WriteObjectAsync(cacheKeyHex, parsed, cancellationToken); + return BuildSuccess(parsed, cached: false); + } + catch (Exception ex) + { + return new JsonObject { ["ok"] = false, ["error"] = ex.Message }; + } + } + + private static JsonObject BuildSuccess(JsonObject parsed, bool cached) + { + var planMd = FormatPlanMarkdown(parsed); + return new JsonObject + { + ["ok"] = true, + ["cached"] = cached, + ["plan"] = planMd, + ["summary"] = parsed["summary"]?.DeepClone(), + ["phases"] = parsed["phases"]?.DeepClone(), + ["quick_wins"] = parsed["quick_wins"]?.DeepClone(), + ["notes"] = parsed["notes"]?.DeepClone(), + ["provenance"] = "AI insights", + }; + } + + private static JsonArray CompactIssues(JsonNode? raw) + { + var outArr = new JsonArray(); + if (raw is not JsonArray rows) + { + return outArr; + } + + foreach (var rowNode in rows) + { + if (rowNode is not JsonObject row) + { + continue; + } + + var message = (row["message"]?.GetValue() ?? "").Trim(); + if (string.IsNullOrEmpty(message)) + { + continue; + } + + var item = new JsonObject + { + ["category"] = row["category"]?.GetValue() ?? "", + ["message"] = message, + ["priority"] = row["priority"]?.GetValue() ?? "Medium", + ["url_count"] = row["url_count"]?.GetValue() + ?? row["urlCount"]?.GetValue() + ?? 0, + }; + + var sampleUrls = new JsonArray(); + var samples = row["sample_urls"] as JsonArray ?? row["sampleUrls"] as JsonArray; + if (samples is not null) + { + foreach (var urlNode in samples.Take(5)) + { + var url = (urlNode?.GetValue() ?? "").Trim(); + if (!string.IsNullOrEmpty(url)) + { + sampleUrls.Add(url); + } + } + } + + item["sample_urls"] = sampleUrls; + + if (row.TryGetPropertyValue("recommendation", out var rec) && rec is not null) + { + item["recommendation"] = rec.GetValue(); + } + + foreach (var (src, dst) in new[] { ("impact_score", "impact_score"), ("gsc_clicks", "gsc_clicks") }) + { + JsonNode? val = row[src] ?? row[src == "impact_score" ? "impactScore" : "gscClicks"]; + if (val is JsonValue jv && jv.TryGetValue(out double d)) + { + item[dst] = d; + } + } + + outArr.Add(item); + if (outArr.Count >= MaxIssues) + { + break; + } + } + + return outArr; + } + + private static string FormatPlanMarkdown(JsonObject data) + { + var lines = new List(); + var summary = (data["summary"]?.GetValue() ?? "").Trim(); + if (!string.IsNullOrEmpty(summary)) + { + lines.Add(summary); + lines.Add(""); + } + + if (data["quick_wins"] is JsonArray quickWins && quickWins.Count > 0) + { + lines.Add("### Quick wins"); + foreach (var item in quickWins.Take(8)) + { + var text = (item?.GetValue() ?? "").Trim(); + if (!string.IsNullOrEmpty(text)) + { + lines.Add($"- {text}"); + } + } + + lines.Add(""); + } + + if (data["phases"] is JsonArray phases && phases.Count > 0) + { + lines.Add("### Phased plan"); + foreach (var phaseNode in phases.Take(6)) + { + if (phaseNode is not JsonObject phase) + { + continue; + } + + var name = (phase["name"]?.GetValue() ?? "Phase").Trim(); + var effort = (phase["effort"]?.GetValue() ?? "").Trim(); + var header = $"**{name}**"; + if (!string.IsNullOrEmpty(effort)) + { + header += $" (effort: {effort})"; + } + + lines.Add(header); + if (phase["actions"] is JsonArray actions) + { + foreach (var action in actions.Take(8)) + { + var text = (action?.GetValue() ?? "").Trim(); + if (!string.IsNullOrEmpty(text)) + { + lines.Add($"- {text}"); + } + } + } + + lines.Add(""); + } + } + + var notes = (data["notes"]?.GetValue() ?? "").Trim(); + if (!string.IsNullOrEmpty(notes)) + { + lines.Add("### Notes"); + lines.Add(notes); + } + + return string.Join('\n', lines).Trim(); + } +} diff --git a/services/AiService/src/AiService.Application/Services/LlmTaskCache.cs b/services/AiService/src/AiService.Application/Services/LlmTaskCache.cs new file mode 100644 index 00000000..5730a8a2 --- /dev/null +++ b/services/AiService/src/AiService.Application/Services/LlmTaskCache.cs @@ -0,0 +1,41 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using AiService.Application.Prompts; +using AiService.Application.Repositories; +using AiService.Domain.Repositories; +using AiService.Providers.Chat; + +namespace AiService.Application.Services; + +internal static class LlmTaskCache +{ + public static string CacheKey(string task, string model, object payload) + { + var body = JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = false }); + var digest = SHA256.HashData(Encoding.UTF8.GetBytes($"{LlmPrompts.Version}:{task}:{model}:{body}")); + return Convert.ToHexStringLower(digest); + } + + public static async Task ReadAsync( + LlmCacheRepository cache, + string key, + CancellationToken cancellationToken) + { + return await cache.ReadObjectAsync(key, cancellationToken); + } + + public static async Task WriteAsync( + LlmCacheRepository cache, + string key, + JsonObject data, + CancellationToken cancellationToken) + => await cache.WriteObjectAsync(key, data, cancellationToken); +} + +internal static class FixSuggestionSupport +{ + public static bool FixSuggestionsEnabled(IReadOnlyDictionary cfg) + => LlmConfigHelpers.IsTruthy(cfg.GetValueOrDefault("llm_enable_issue_fixes") ?? "true"); +} diff --git a/services/AiService/src/AiService.Application/Services/OllamaCatalogService.cs b/services/AiService/src/AiService.Application/Services/OllamaCatalogService.cs new file mode 100644 index 00000000..31ec5b77 --- /dev/null +++ b/services/AiService/src/AiService.Application/Services/OllamaCatalogService.cs @@ -0,0 +1,243 @@ +using System.Net.Http.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using AiService.Application.Json; + +namespace AiService.Application.Services; + +/// Port of ollama_catalog.py — local + cloud Ollama model catalog. +public sealed class OllamaCatalogService(IHttpClientFactory httpClientFactory) +{ + public const string OllamaCloudCatalogUrl = "https://ollama.com/api/tags"; + + private static readonly Regex[] ProCloudModelPatterns = + [ + new("671b", RegexOptions.IgnoreCase), + new("480b", RegexOptions.IgnoreCase), + new(":1t(?:-cloud|:cloud)?$", RegexOptions.IgnoreCase), + new("v4-pro", RegexOptions.IgnoreCase), + new("nemotron-3-ultra", RegexOptions.IgnoreCase), + new("nemotron-3-super", RegexOptions.IgnoreCase), + new("mistral-large", RegexOptions.IgnoreCase), + new("397b", RegexOptions.IgnoreCase), + new("cogito-2\\.1:671b", RegexOptions.IgnoreCase), + new("deepseek-v4-pro", RegexOptions.IgnoreCase), + new("qwen3-coder:480b", RegexOptions.IgnoreCase), + new("gpt-oss:120b", RegexOptions.IgnoreCase), + ]; + + public async Task FetchModelsAsync(string? baseUrl, CancellationToken cancellationToken = default) + { + var normalizedBase = (baseUrl ?? "http://127.0.0.1:11434").Trim().TrimEnd('/'); + if (string.IsNullOrEmpty(normalizedBase)) + { + normalizedBase = "http://127.0.0.1:11434"; + } + + var http = httpClientFactory.CreateClient(nameof(OllamaCatalogService)); + var localData = await FetchJsonAsync(http, $"{normalizedBase}/api/tags", TimeSpan.FromSeconds(8), cancellationToken); + var cloudData = await FetchJsonAsync(http, OllamaCloudCatalogUrl, TimeSpan.FromSeconds(12), cancellationToken); + + var localOk = localData is not null; + var cloudCatalogOk = cloudData is not null; + + var localModels = (localData?["models"] as JsonArray ?? []) + .Select(NormalizeLocalModel) + .Where(x => x is not null) + .Cast() + .ToList(); + + var cloudModels = (cloudData?["models"] as JsonArray ?? []) + .Select(NormalizeCatalogModel) + .Where(x => x is not null) + .Cast() + .ToList(); + + var models = MergeOllamaModels(localModels, cloudModels); + + if (!localOk && !cloudCatalogOk) + { + return new JsonObject + { + ["ok"] = false, + ["baseUrl"] = normalizedBase, + ["models"] = new JsonArray(), + ["cloudCatalogOk"] = false, + ["localOk"] = false, + ["error"] = "Cannot reach Ollama or the cloud model catalog.", + }; + } + + return new JsonObject + { + ["ok"] = localOk || cloudCatalogOk, + ["baseUrl"] = normalizedBase, + ["models"] = new JsonArray(models.Select(x => x.DeepClone()).ToArray()), + ["cloudCatalogOk"] = cloudCatalogOk, + ["localOk"] = localOk, + }; + } + + public static bool ModelIsConfigured(IEnumerable models, string configuredModel) + { + var target = configuredModel.Trim(); + if (string.IsNullOrEmpty(target)) + { + return models.Any(); + } + + var key = ModelKey(target); + return models.Any(m => ModelKey(m["name"]?.GetValue() ?? "") == key); + } + + public static bool ModelsSupportTools(IEnumerable models) + => models.Any(m => m["capabilities"] is JsonArray caps && caps.Any(c => c?.GetValue() == "tools")); + + private static async Task FetchJsonAsync( + HttpClient http, + string url, + TimeSpan timeout, + CancellationToken cancellationToken) + { + try + { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(timeout); + using var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.TryAddWithoutValidation("Accept", "application/json"); + using var response = await http.SendAsync(request, cts.Token); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadFromJsonAsync(cancellationToken: cts.Token); + } + catch (Exception) + { + return null; + } + } + + private static JsonObject? NormalizeLocalModel(JsonNode? raw) + { + if (raw is not JsonObject obj) + { + return null; + } + + var name = (obj["name"]?.GetValue() ?? "").Trim(); + if (string.IsNullOrEmpty(name)) + { + return null; + } + + var cloud = obj["remote_host"] is not null || IsCloudModelRef(name); + var details = obj["details"] as JsonObject; + JsonArray? capabilities = obj["capabilities"] as JsonArray; + return WithBilling(new JsonObject + { + ["name"] = name, + ["source"] = cloud ? "cloud" : "local", + ["installed"] = true, + ["capabilities"] = capabilities?.DeepClone(), + ["context_length"] = details?["context_length"]?.DeepClone(), + }); + } + + private static JsonObject? NormalizeCatalogModel(JsonNode? raw) + { + if (raw is not JsonObject obj) + { + return null; + } + + var baseName = (obj["name"]?.GetValue() ?? "").Trim(); + if (string.IsNullOrEmpty(baseName)) + { + return null; + } + + return WithBilling(new JsonObject + { + ["name"] = ToCloudModelRef(baseName), + ["source"] = "cloud", + ["installed"] = false, + }); + } + + private static List MergeOllamaModels(IReadOnlyList local, IReadOnlyList cloudCatalog) + { + var byKey = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var model in cloudCatalog) + { + byKey[ModelKey(model["name"]?.GetValue() ?? "")] = JsonNodeCopy.CloneObject(model); + } + + foreach (var model in local) + { + var key = ModelKey(model["name"]?.GetValue() ?? ""); + if (byKey.TryGetValue(key, out var existing)) + { + var merged = new JsonObject + { + ["name"] = model["name"]?.DeepClone(), + ["source"] = model["source"]?.DeepClone(), + ["installed"] = true, + ["capabilities"] = model["capabilities"]?.DeepClone() ?? existing["capabilities"]?.DeepClone(), + ["context_length"] = model["context_length"]?.DeepClone() ?? existing["context_length"]?.DeepClone(), + }; + byKey[key] = WithBilling(merged); + } + else + { + byKey[key] = JsonNodeCopy.CloneObject(model); + } + } + + return byKey.Values + .OrderBy(m => m["installed"]?.GetValue() == true ? 0 : 1) + .ThenBy(m => (m["source"]?.GetValue() ?? "") == "local" ? 0 : 1) + .ThenBy(m => m["name"]?.GetValue() ?? "", StringComparer.Ordinal) + .ToList(); + } + + private static JsonObject WithBilling(JsonObject entry) + { + var tier = ResolveBillingTier( + entry["name"]?.GetValue() ?? "", + entry["source"]?.GetValue() ?? "local"); + // Copy scalar values — do not assign tier child nodes directly (JsonNode single-parent rule). + entry["billing"] = tier["billing"]?.GetValue(); + entry["requires_subscription"] = tier["requires_subscription"]?.GetValue() ?? false; + return entry; + } + + public static bool IsCloudModelRef(string name) + => name.EndsWith("-cloud", StringComparison.Ordinal) || name.EndsWith(":cloud", StringComparison.Ordinal); + + public static string ToCloudModelRef(string name) + { + var trimmed = name.Trim(); + if (string.IsNullOrEmpty(trimmed) || IsCloudModelRef(trimmed)) + { + return trimmed; + } + + return trimmed.Contains(':') ? $"{trimmed}-cloud" : $"{trimmed}:cloud"; + } + + public static JsonObject ResolveBillingTier(string name, string source) + { + var cloud = source == "cloud" || IsCloudModelRef(name); + if (!cloud) + { + return new JsonObject { ["billing"] = "free_local", ["requires_subscription"] = false }; + } + + if (ProCloudModelPatterns.Any(p => p.IsMatch(name))) + { + return new JsonObject { ["billing"] = "cloud_pro", ["requires_subscription"] = true }; + } + + return new JsonObject { ["billing"] = "cloud_free", ["requires_subscription"] = true }; + } + + private static string ModelKey(string name) => name.ToLowerInvariant(); +} diff --git a/services/AiService/src/AiService.Application/Services/PageCoachService.cs b/services/AiService/src/AiService.Application/Services/PageCoachService.cs new file mode 100644 index 00000000..1a90cb11 --- /dev/null +++ b/services/AiService/src/AiService.Application/Services/PageCoachService.cs @@ -0,0 +1,145 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using AiService.Application.Persistence; +using AiService.Application.Prompts; +using AiService.Application.Repositories; +using AiService.Domain.Repositories; +using AiService.Providers.Chat; +using Microsoft.EntityFrameworkCore; + +namespace AiService.Application.Services; + +public sealed class PageCoachService( + ILlmConfigRepository configRepository, + LlmCacheRepository cacheRepository, + StructuredCompletionService completionService, + AiDbContext db) +{ + public async Task RunAsync( + string pageUrl, + bool refresh = false, + CancellationToken cancellationToken = default) + { + var cfg = await configRepository.LoadAsync(cancellationToken); + if (!LlmConfigHelpers.IsEnabled(cfg)) + { + return new JsonObject + { + ["ok"] = false, + ["error"] = "AI insights are disabled. Enable them in Pipeline → Content & AI.", + }; + } + + if (!LlmConfigHelpers.IsTruthy(cfg.GetValueOrDefault("llm_enable_page_coach") ?? "true")) + { + return new JsonObject { ["ok"] = false, ["error"] = "Page coach is disabled in AI task settings." }; + } + + var context = await BuildPageContextAsync(pageUrl, cancellationToken); + var model = (cfg.GetValueOrDefault("llm_model") ?? cfg.GetValueOrDefault("llm_provider") ?? "unknown").Trim(); + var payloadStr = context.ToJsonString(); + var cacheKey = SHA256.HashData( + Encoding.UTF8.GetBytes($"page_coach:v2:{LlmPrompts.Version}:{model}:{pageUrl}:{payloadStr}")); + var cacheKeyHex = Convert.ToHexStringLower(cacheKey); + + if (!refresh) + { + var cached = await cacheRepository.ReadObjectAsync(cacheKeyHex, cancellationToken); + if (cached is not null) + { + return new JsonObject + { + ["ok"] = true, + ["cached"] = true, + ["coach"] = cached.DeepClone(), + ["context"] = context.DeepClone(), + }; + } + } + + try + { + var user = payloadStr[..Math.Min(payloadStr.Length, 12000)]; + var coach = await completionService.CompleteJsonAsync( + LlmPrompts.PageCoachSystem, + user, + cfg, + cancellationToken); + + if (coach.Count == 0) + { + coach = new JsonObject { ["summary"] = "No structured coach output returned." }; + } + + await cacheRepository.WriteObjectAsync(cacheKeyHex, coach, cancellationToken); + return new JsonObject + { + ["ok"] = true, + ["cached"] = false, + ["coach"] = coach.DeepClone(), + ["context"] = context.DeepClone(), + }; + } + catch (Exception ex) + { + return new JsonObject + { + ["ok"] = false, + ["error"] = ex.Message, + ["context"] = context.DeepClone(), + }; + } + } + + private async Task BuildPageContextAsync(string pageUrl, CancellationToken cancellationToken) + { + var ctx = new JsonObject { ["page_url"] = pageUrl, ["link"] = null, ["current"] = null, ["baseline"] = null, ["compare"] = new JsonArray() }; + + var report = await db.ReportPayloads.AsNoTracking() + .OrderByDescending(x => x.Id) + .Select(x => x.Data) + .FirstOrDefaultAsync(cancellationToken); + + if (!string.IsNullOrWhiteSpace(report)) + { + try + { + if (JsonNode.Parse(report) is JsonObject reportObj + && reportObj["links"] is JsonArray links) + { + ctx["link"] = FindLink(links, pageUrl)?.DeepClone(); + } + } + catch (JsonException) + { + // ignore malformed report payload + } + } + + return ctx; + } + + private static JsonObject? FindLink(JsonArray links, string pageUrl) + { + var norm = NormalizeUrl(pageUrl); + foreach (var node in links) + { + if (node is not JsonObject rec) + { + continue; + } + + if (NormalizeUrl(rec["url"]?.GetValue() ?? "") == norm) + { + return rec; + } + } + + return null; + } + + private static string NormalizeUrl(string url) + => url.Trim().TrimEnd('/').ToLowerInvariant(); +} diff --git a/services/AiService/src/AiService.Application/Services/SecretsKeyCatalog.cs b/services/AiService/src/AiService.Application/Services/SecretsKeyCatalog.cs new file mode 100644 index 00000000..47163ac0 --- /dev/null +++ b/services/AiService/src/AiService.Application/Services/SecretsKeyCatalog.cs @@ -0,0 +1,134 @@ +namespace AiService.Application.Services; + +/// Key routing for unified secrets API — mirrors web/src/lib/secretsConfigSchema.ts. +internal static class SecretsKeyCatalog +{ + internal const string Mask = "*"; + + internal static readonly HashSet PipelineSecretKeys = new(StringComparer.Ordinal) + { + "bing_webmaster_api_key", + "serp_api_key", + "google_rich_results_api_key", + "crawl_auth_password", + "crawl_cookies", + "mcp_token", + }; + + internal static readonly HashSet McpManagedKeys = new(StringComparer.Ordinal) + { + "mcp_token", + "mcp_allowed_hosts", + "mcp_allowed_origins", + "mcp_public_url", + "mcp_domain", + "mcp_enabled_domains", + }; + + internal static readonly HashSet RiskSettingsKeys = new(StringComparer.Ordinal) + { + "mcp_disabled_tools", + "mcp_enabled_domains", + "feature_pipeline_enabled", + "feature_write_enabled", + "feature_pages_md_enabled", + "feature_chat_enabled", + "feature_mcp_visible", + "feature_secrets_visible", + }; + + internal static readonly HashSet LlmApiKeyFields = new(StringComparer.Ordinal) + { + "llm_api_key", + "llm_api_key_openai", + "llm_api_key_gemini", + "llm_api_key_anthropic", + "llm_api_key_groq", + }; + + internal static readonly HashSet GoogleStateKeys = new(StringComparer.Ordinal) + { + "google_client_id", + "google_client_secret", + "google_service_account_json", + "google_developer_token", + "google_login_customer_id", + }; + + internal enum SecretsStorage + { + Llm, + Pipeline, + Google, + } + + internal static SecretsStorage? ResolveStorage(string key) + { + if (key.StartsWith("google_", StringComparison.Ordinal)) + { + return SecretsStorage.Google; + } + + if (PipelineSecretKeys.Contains(key) || McpManagedKeys.Contains(key) || RiskSettingsKeys.Contains(key)) + { + return SecretsStorage.Pipeline; + } + + if (LlmApiKeyFields.Contains(key) || ConfigSecretHelpers.IsSecretKey(key)) + { + return SecretsStorage.Llm; + } + + return null; + } + + internal static bool IsPipelineSecretKey(string key) => PipelineSecretKeys.Contains(key); + + internal static bool IsManagedPipelineKey(string key) + => PipelineSecretKeys.Contains(key) + || McpManagedKeys.Contains(key) + || RiskSettingsKeys.Contains(key); + + internal static string? GoogleFieldFromStateKey(string key) + { + if (!key.StartsWith("google_", StringComparison.Ordinal)) + { + return null; + } + + return key["google_".Length..]; + } +} + +internal static class ConfigSecretHelpers +{ + internal const string Mask = SecretsKeyCatalog.Mask; + + internal static bool IsSecretKey(string key) + { + var keyLower = key.ToLowerInvariant(); + return keyLower.EndsWith("_secret", StringComparison.Ordinal) + || keyLower.EndsWith("_api_key", StringComparison.Ordinal) + || keyLower.EndsWith("_key", StringComparison.Ordinal) + || keyLower.Contains("api_key", StringComparison.Ordinal) + || keyLower.Contains("secret", StringComparison.Ordinal) + || keyLower.Contains("password", StringComparison.Ordinal) + || keyLower.Contains("token", StringComparison.Ordinal); + } + + internal static bool IsMaskedSentinel(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + var trimmed = value.Trim(); + if (trimmed is Mask or "••••" or "{configured}") + { + return true; + } + + return trimmed.StartsWith('*') && trimmed.Length <= 4; + } +} diff --git a/services/AiService/src/AiService.Application/Services/SecretsService.cs b/services/AiService/src/AiService.Application/Services/SecretsService.cs new file mode 100644 index 00000000..f3df1edf --- /dev/null +++ b/services/AiService/src/AiService.Application/Services/SecretsService.cs @@ -0,0 +1,225 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using AiService.Domain.Repositories; + +namespace AiService.Application.Services; + +public sealed class SecretsService( + ILlmConfigRepository llmConfig, + IPipelineConfigRepository pipelineConfig, + IGoogleAppSettingsRepository googleSettings) +{ + public async Task GetStateAsync(CancellationToken cancellationToken = default) + { + var state = new JsonObject(); + + var llmRows = await llmConfig.LoadFullAsync(cancellationToken); + foreach (var row in llmRows) + { + var isSecret = row.IsSecret || ConfigSecretHelpers.IsSecretKey(row.Key); + if (!isSecret) + { + continue; + } + + if (string.IsNullOrEmpty(row.Value)) + { + continue; + } + + state[row.Key] = row.IsSecret || !string.Equals(row.Value, ConfigSecretHelpers.Mask, StringComparison.Ordinal) + ? ConfigSecretHelpers.Mask + : row.Value; + if (row.IsSecret || ConfigSecretHelpers.IsSecretKey(row.Key)) + { + state[$"{row.Key}_masked"] = true; + } + } + + var (pipelineKnown, _) = await pipelineConfig.LoadFullAsync(cancellationToken); + foreach (var (key, value) in pipelineKnown) + { + if (!SecretsKeyCatalog.IsManagedPipelineKey(key) || string.IsNullOrEmpty(value)) + { + continue; + } + + if (SecretsKeyCatalog.IsPipelineSecretKey(key)) + { + state[key] = ConfigSecretHelpers.Mask; + state[$"{key}_masked"] = true; + } + else + { + state[key] = value; + } + } + + var google = await googleSettings.LoadAsync(cancellationToken); + if (!string.IsNullOrEmpty(google.ClientId)) + { + state["google_client_id"] = google.ClientId; + } + + if (!string.IsNullOrEmpty(google.ClientSecret)) + { + state["google_client_secret"] = ConfigSecretHelpers.Mask; + state["google_client_secret_masked"] = true; + } + + if (!string.IsNullOrEmpty(google.DeveloperToken)) + { + state["google_developer_token"] = ConfigSecretHelpers.Mask; + state["google_developer_token_masked"] = true; + } + + if (!string.IsNullOrEmpty(google.LoginCustomerId)) + { + state["google_login_customer_id"] = google.LoginCustomerId; + } + + if (google.ServiceAccountJson is not null) + { + state["google_service_account_json_masked"] = true; + } + + state["google_has_service_account"] = google.ServiceAccountJson is not null; + + return state; + } + + public async Task PutStateAsync(JsonObject incoming, CancellationToken cancellationToken = default) + { + var llmUpdates = new Dictionary(StringComparer.Ordinal); + var pipelineUpdates = new Dictionary(StringComparer.Ordinal); + var googlePatch = new GoogleAppSettingsPatchBuilder(); + + foreach (var prop in incoming) + { + var key = prop.Key; + if (key.EndsWith("_masked", StringComparison.Ordinal) || key == "google_has_service_account") + { + continue; + } + + var val = prop.Value?.ToString() ?? ""; + if (ConfigSecretHelpers.IsMaskedSentinel(val)) + { + continue; + } + + var storage = SecretsKeyCatalog.ResolveStorage(key); + switch (storage) + { + case SecretsKeyCatalog.SecretsStorage.Llm: + llmUpdates[key] = val; + break; + case SecretsKeyCatalog.SecretsStorage.Pipeline: + pipelineUpdates[key] = val; + break; + case SecretsKeyCatalog.SecretsStorage.Google: + ApplyGooglePatch(googlePatch, key, val); + break; + } + } + + if (llmUpdates.Count > 0) + { + await llmConfig.SaveAsync(llmUpdates, cancellationToken); + } + + if (pipelineUpdates.Count > 0) + { + var (known, unknown) = await pipelineConfig.LoadFullAsync(cancellationToken); + var mergedKnown = new Dictionary(known, StringComparer.Ordinal); + foreach (var (key, value) in pipelineUpdates) + { + mergedKnown[key] = value; + } + + await pipelineConfig.SaveAsync(mergedKnown, unknown, cancellationToken); + } + + if (googlePatch.HasChanges) + { + await googleSettings.MergeAsync(googlePatch.Build(), cancellationToken); + } + } + + private static void ApplyGooglePatch(GoogleAppSettingsPatchBuilder patch, string key, string val) + { + var field = SecretsKeyCatalog.GoogleFieldFromStateKey(key); + if (field is null) + { + return; + } + + switch (field) + { + case "client_id": + patch.ClientId = val; + break; + case "client_secret": + patch.ClientSecret = val; + break; + case "developer_token": + patch.DeveloperToken = val; + break; + case "login_customer_id": + patch.LoginCustomerId = val; + break; + case "service_account_json": + if (string.IsNullOrWhiteSpace(val)) + { + break; + } + + try + { + var node = JsonNode.Parse(val) as JsonObject; + if (node?["type"]?.GetValue() != "service_account") + { + throw new InvalidOperationException( + "Invalid service account JSON: expected type service_account."); + } + + patch.ServiceAccountJson = node; + } + catch (JsonException ex) + { + throw new InvalidOperationException("Invalid service account JSON.", ex); + } + + break; + } + } + + private sealed class GoogleAppSettingsPatchBuilder + { + public string? ClientId { get; set; } + + public string? ClientSecret { get; set; } + + public JsonObject? ServiceAccountJson { get; set; } + + public string? DeveloperToken { get; set; } + + public string? LoginCustomerId { get; set; } + + public bool HasChanges => + ClientId is not null + || ClientSecret is not null + || ServiceAccountJson is not null + || DeveloperToken is not null + || LoginCustomerId is not null; + + public GoogleAppSettingsPatch Build() => new() + { + ClientId = ClientId, + ClientSecret = ClientSecret, + ServiceAccountJson = ServiceAccountJson, + DeveloperToken = DeveloperToken, + LoginCustomerId = LoginCustomerId, + }; + } +} diff --git a/services/AiService/src/AiService.Domain/AiService.Domain.csproj b/services/AiService/src/AiService.Domain/AiService.Domain.csproj new file mode 100644 index 00000000..b7601447 --- /dev/null +++ b/services/AiService/src/AiService.Domain/AiService.Domain.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/services/AiService/src/AiService.Domain/Entities/ChatMessage.cs b/services/AiService/src/AiService.Domain/Entities/ChatMessage.cs new file mode 100644 index 00000000..cf344b3d --- /dev/null +++ b/services/AiService/src/AiService.Domain/Entities/ChatMessage.cs @@ -0,0 +1,22 @@ +namespace AiService.Domain.Entities; + +public sealed class ChatMessage +{ + public long Id { get; set; } + + public long SessionId { get; set; } + + public ChatSession Session { get; set; } = null!; + + public string Role { get; set; } = "user"; + + public string Content { get; set; } = ""; + + public string? ToolName { get; set; } + + public string? ToolArgs { get; set; } + + public string? ToolResult { get; set; } + + public DateTimeOffset CreatedAt { get; set; } +} diff --git a/services/AiService/src/AiService.Domain/Entities/ChatSession.cs b/services/AiService/src/AiService.Domain/Entities/ChatSession.cs new file mode 100644 index 00000000..5d340ef6 --- /dev/null +++ b/services/AiService/src/AiService.Domain/Entities/ChatSession.cs @@ -0,0 +1,16 @@ +namespace AiService.Domain.Entities; + +public sealed class ChatSession +{ + public long Id { get; set; } + + public long PropertyId { get; set; } + + public string Title { get; set; } = "New chat"; + + public DateTimeOffset CreatedAt { get; set; } + + public DateTimeOffset UpdatedAt { get; set; } + + public ICollection Messages { get; set; } = new List(); +} diff --git a/services/AiService/src/AiService.Domain/Entities/LlmCacheEntry.cs b/services/AiService/src/AiService.Domain/Entities/LlmCacheEntry.cs new file mode 100644 index 00000000..e6b0d75b --- /dev/null +++ b/services/AiService/src/AiService.Domain/Entities/LlmCacheEntry.cs @@ -0,0 +1,10 @@ +namespace AiService.Domain.Entities; + +public sealed class LlmCacheEntry +{ + public string CacheKey { get; set; } = ""; + + public string ResponseJson { get; set; } = "{}"; + + public DateTimeOffset CreatedAt { get; set; } +} diff --git a/services/AiService/src/AiService.Domain/Entities/LlmConfigEntry.cs b/services/AiService/src/AiService.Domain/Entities/LlmConfigEntry.cs new file mode 100644 index 00000000..3f5cda3d --- /dev/null +++ b/services/AiService/src/AiService.Domain/Entities/LlmConfigEntry.cs @@ -0,0 +1,12 @@ +namespace AiService.Domain.Entities; + +public sealed class LlmConfigEntry +{ + public string Key { get; set; } = ""; + + public string Value { get; set; } = ""; + + public bool IsSecret { get; set; } + + public DateTimeOffset UpdatedAt { get; set; } +} diff --git a/services/AiService/src/AiService.Domain/Entities/ReportPayload.cs b/services/AiService/src/AiService.Domain/Entities/ReportPayload.cs new file mode 100644 index 00000000..319da6f7 --- /dev/null +++ b/services/AiService/src/AiService.Domain/Entities/ReportPayload.cs @@ -0,0 +1,15 @@ +namespace AiService.Domain.Entities; + +/// Read/write mapping of the Alembic-owned report_payload table. +public sealed class ReportPayload +{ + public long Id { get; set; } + + public DateTimeOffset GeneratedAt { get; set; } + + public string? SiteName { get; set; } + + public string? CanonicalDomain { get; set; } + + public string Data { get; set; } = "{}"; +} diff --git a/services/AiService/src/AiService.Domain/Repositories/IChatSessionRepository.cs b/services/AiService/src/AiService.Domain/Repositories/IChatSessionRepository.cs new file mode 100644 index 00000000..817e39af --- /dev/null +++ b/services/AiService/src/AiService.Domain/Repositories/IChatSessionRepository.cs @@ -0,0 +1,35 @@ +using AiService.Domain.Entities; + +namespace AiService.Domain.Repositories; + +public interface IChatSessionRepository +{ + Task CreateSessionAsync(long propertyId, string title, CancellationToken cancellationToken = default); + + Task> ListSessionsAsync( + long propertyId, + int limit = 30, + CancellationToken cancellationToken = default); + + Task GetSessionAsync(long sessionId, CancellationToken cancellationToken = default); + + Task DeleteSessionAsync(long sessionId, CancellationToken cancellationToken = default); + + Task> GetMessagesAsync( + long sessionId, + int limit = 200, + CancellationToken cancellationToken = default); + + Task AppendMessageAsync( + long sessionId, + string role, + string content = "", + string? toolName = null, + string? toolArgsJson = null, + string? toolResultJson = null, + CancellationToken cancellationToken = default); + + Task UpdateSessionTitleAsync(long sessionId, string title, CancellationToken cancellationToken = default); + + Task TouchSessionAsync(long sessionId, CancellationToken cancellationToken = default); +} diff --git a/services/AiService/src/AiService.Domain/Repositories/IGoogleAppSettingsRepository.cs b/services/AiService/src/AiService.Domain/Repositories/IGoogleAppSettingsRepository.cs new file mode 100644 index 00000000..5910f724 --- /dev/null +++ b/services/AiService/src/AiService.Domain/Repositories/IGoogleAppSettingsRepository.cs @@ -0,0 +1,41 @@ +using System.Text.Json.Nodes; + +namespace AiService.Domain.Repositories; + +/// Singleton google_app_settings row (OAuth app credentials). +public interface IGoogleAppSettingsRepository +{ + Task LoadAsync(CancellationToken cancellationToken = default); + + Task MergeAsync(GoogleAppSettingsPatch patch, CancellationToken cancellationToken = default); +} + +public sealed class GoogleAppSettings +{ + public string ClientId { get; init; } = ""; + + public string ClientSecret { get; init; } = ""; + + public JsonObject? ServiceAccountJson { get; init; } + + public int DefaultDateRangeDays { get; init; } = 28; + + public string DeveloperToken { get; init; } = ""; + + public string LoginCustomerId { get; init; } = ""; +} + +public sealed class GoogleAppSettingsPatch +{ + public string? ClientId { get; init; } + + public string? ClientSecret { get; init; } + + public JsonObject? ServiceAccountJson { get; init; } + + public int? DefaultDateRangeDays { get; init; } + + public string? DeveloperToken { get; init; } + + public string? LoginCustomerId { get; init; } +} diff --git a/services/AiService/src/AiService.Domain/Repositories/ILlmCacheRepository.cs b/services/AiService/src/AiService.Domain/Repositories/ILlmCacheRepository.cs new file mode 100644 index 00000000..431ec29a --- /dev/null +++ b/services/AiService/src/AiService.Domain/Repositories/ILlmCacheRepository.cs @@ -0,0 +1,12 @@ +namespace AiService.Domain.Repositories; + +public interface ILlmCacheRepository +{ + Task ReadAsync(string cacheKey, CancellationToken cancellationToken = default); + + Task WriteAsync(string cacheKey, string responseJson, CancellationToken cancellationToken = default); + + Task> ReadBatchAsync( + IReadOnlyList cacheKeys, + CancellationToken cancellationToken = default); +} diff --git a/services/AiService/src/AiService.Domain/Repositories/ILlmConfigRepository.cs b/services/AiService/src/AiService.Domain/Repositories/ILlmConfigRepository.cs new file mode 100644 index 00000000..edc66dc4 --- /dev/null +++ b/services/AiService/src/AiService.Domain/Repositories/ILlmConfigRepository.cs @@ -0,0 +1,12 @@ +using AiService.Domain.Entities; + +namespace AiService.Domain.Repositories; + +public interface ILlmConfigRepository +{ + Task> LoadAsync(CancellationToken cancellationToken = default); + + Task> LoadFullAsync(CancellationToken cancellationToken = default); + + Task SaveAsync(IReadOnlyDictionary entries, CancellationToken cancellationToken = default); +} diff --git a/services/AiService/src/AiService.Domain/Repositories/IPipelineConfigRepository.cs b/services/AiService/src/AiService.Domain/Repositories/IPipelineConfigRepository.cs new file mode 100644 index 00000000..2bf96d48 --- /dev/null +++ b/services/AiService/src/AiService.Domain/Repositories/IPipelineConfigRepository.cs @@ -0,0 +1,18 @@ +namespace AiService.Domain.Repositories; + +/// Read/write access to pipeline_config (known + unknown keys). +public interface IPipelineConfigRepository +{ + /// All keys (known + unknown) as a flat dictionary — for MCP/tool selection. + Task> LoadAsync(CancellationToken cancellationToken = default); + + Task<(IReadOnlyDictionary Known, IReadOnlyList Unknown)> LoadFullAsync( + CancellationToken cancellationToken = default); + + Task SaveAsync( + IReadOnlyDictionary known, + IReadOnlyList unknown, + CancellationToken cancellationToken = default); +} + +public sealed record PipelineConfigUnknownEntry(string Key, string Value); diff --git a/services/AiService/src/AiService.Mcp/AiService.Mcp.csproj b/services/AiService/src/AiService.Mcp/AiService.Mcp.csproj new file mode 100644 index 00000000..a9174e10 --- /dev/null +++ b/services/AiService/src/AiService.Mcp/AiService.Mcp.csproj @@ -0,0 +1,21 @@ + + + + net10.0 + enable + enable + AiService.Mcp + + + + + + + + + + + + + + diff --git a/services/AiService/src/AiService.Mcp/McpAuditTools.cs b/services/AiService/src/AiService.Mcp/McpAuditTools.cs new file mode 100644 index 00000000..dcf1603d --- /dev/null +++ b/services/AiService/src/AiService.Mcp/McpAuditTools.cs @@ -0,0 +1,125 @@ +using System.ComponentModel; +using System.Text.Json; +using System.Text.Json.Nodes; +using AiService.Tools.Context; +using AiService.Tools.Domain; +using AiService.Tools.Registry; +using AiService.Tools.Selection; +using ModelContextProtocol.Server; + +namespace AiService.Mcp; + +/// +/// MCP audit tools that delegate to . +/// Exposes catalog tools filtered by pipeline bundle, custom domains, and disabled-tool list. +/// +[McpServerToolType] +public sealed class McpAuditTools +{ + private readonly ToolDispatcher _dispatcher; + private readonly AuditToolSelectionService _selection; + private readonly ToolCatalogEntryLookup _entryLookup; + + public McpAuditTools( + ToolDispatcher dispatcher, + AuditToolSelectionService selection, + ToolCatalogEntryLookup entryLookup) + { + _dispatcher = dispatcher; + _selection = selection; + _entryLookup = entryLookup; + } + + [McpServerTool(Name = "list_audit_tools")] + [Description("List Site Audit tools exposed for the current tool bundle, including tool_count.")] + public async Task ListAuditTools(CancellationToken cancellationToken = default) + { + var snapshot = await _selection.GetSnapshotAsync(cancellationToken); + var payload = McpToolDomains.BuildListToolsPayload( + snapshot.EnabledToolNames, + _entryLookup, + snapshot.BundleKey); + payload["enabled_domains"] = new JsonArray( + snapshot.EnabledDomains.Select(d => JsonValue.Create(d)).ToArray()); + return payload.ToJsonString(new JsonSerializerOptions { WriteIndented = true }); + } + + [McpServerTool(Name = "call_audit_tool")] + [Description("Invoke a Site Audit tool by name. Arguments are passed as a JSON object string.")] + public async Task CallAuditTool( + [Description("Audit tool name from list_audit_tools.")] string name, + [Description("Tool arguments as a JSON object string.")] string? arguments = null, + [Description("Site property id. Falls back to WP_PROPERTY_ID when omitted.")] int? property_id = null, + [Description("Optional report id.")] int? report_id = null, + CancellationToken cancellationToken = default) + => await DispatchNamedToolAsync(name, arguments, property_id, report_id, cancellationToken); + + internal async Task DispatchNamedToolAsync( + string name, + string? arguments, + int? propertyId, + int? reportId, + CancellationToken cancellationToken) + { + var snapshot = await _selection.GetSnapshotAsync(cancellationToken); + if (!snapshot.EnabledToolNames.Contains(name)) + { + var error = new JsonObject + { + ["error"] = $"tool not exposed in bundle {snapshot.BundleKey}: {name}", + ["hint"] = "Adjust mcp_domain / mcp_enabled_domains in Risk Settings or set WP_MCP_DOMAIN=full.", + ["enabled_domains"] = new JsonArray(snapshot.EnabledDomains.Select(d => JsonValue.Create(d)).ToArray()), + }; + return error.ToJsonString(new JsonSerializerOptions { WriteIndented = true }); + } + + var args = ParseArguments(arguments); + MergeContext(args, propertyId, reportId); + var ctx = BuildContext(args); + var result = await _dispatcher.DispatchAsync(name, ctx, args, cancellationToken); + return result.ToJsonString(new JsonSerializerOptions { WriteIndented = true }); + } + + private static JsonObject ParseArguments(string? arguments) + { + if (string.IsNullOrWhiteSpace(arguments)) + { + return []; + } + + try + { + return JsonNode.Parse(arguments) as JsonObject ?? []; + } + catch (JsonException ex) + { + return new JsonObject { ["error"] = $"invalid arguments JSON: {ex.Message}" }; + } + } + + private static void MergeContext(JsonObject args, int? propertyId, int? reportId) + { + if (propertyId is int pid && !args.ContainsKey("property_id")) + { + args["property_id"] = pid; + } + else if (propertyId is null && McpToolDomains.DefaultPropertyId() is int defaultPid && !args.ContainsKey("property_id")) + { + args["property_id"] = defaultPid; + } + + if (reportId is int rid && !args.ContainsKey("report_id")) + { + args["report_id"] = rid; + } + } + + private static AuditToolContext BuildContext(JsonObject args) + { + var ctx = new AuditToolContext + { + PropertyId = McpToolDomains.DefaultPropertyId(), + }; + return ctx.WithArgs(args); + } +} diff --git a/services/AiService/src/AiService.Mcp/McpDependencyInjection.cs b/services/AiService/src/AiService.Mcp/McpDependencyInjection.cs new file mode 100644 index 00000000..db9e3b71 --- /dev/null +++ b/services/AiService/src/AiService.Mcp/McpDependencyInjection.cs @@ -0,0 +1,15 @@ +using AiService.Tools.Registry; +using Microsoft.Extensions.DependencyInjection; + +namespace AiService.Mcp; + +public static class McpDependencyInjection +{ + /// Registers MCP catalog services used by GET /api/mcp-tools. + public static IServiceCollection AddAiServiceMcpCatalog(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + return services; + } +} diff --git a/services/AiService/src/AiService.Mcp/McpServerExtensions.cs b/services/AiService/src/AiService.Mcp/McpServerExtensions.cs new file mode 100644 index 00000000..83d4e09f --- /dev/null +++ b/services/AiService/src/AiService.Mcp/McpServerExtensions.cs @@ -0,0 +1,159 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using AiService.Application; +using AiService.Tools.Domain; +using AiService.Tools.Registry; +using AiService.Tools.Selection; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using ModelContextProtocol; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace AiService.Mcp; + +public static class McpServerExtensions +{ + /// + /// Registers Site Audit MCP server, domain-filtered handlers, and router tools. + /// Chain or + /// after this call. + /// + public static IMcpServerBuilder AddAiServiceMcp(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + + return services.AddMcpServer(options => + { + var domain = McpToolDomains.ResolveMcpDomain(); + options.ServerInfo = new Implementation + { + Name = $"site-audit-{domain}", + Version = "1.0.0", + }; + + options.Handlers.ListToolsHandler = async (request, cancellationToken) => + { + var selection = request.Services!.GetRequiredService(); + var entryLookup = request.Services!.GetRequiredService(); + var snapshot = await selection.GetSnapshotAsync(cancellationToken); + var tools = new List(); + + foreach (var name in snapshot.EnabledToolNames.Order(StringComparer.Ordinal)) + { + if (!entryLookup.TryGetEntry(name, out var entry)) + { + continue; + } + + tools.Add(new Tool + { + Name = name, + Description = entry.Description, + InputSchema = entry.InputSchema is null + ? JsonDocument.Parse("""{"type":"object","properties":{}}""").RootElement + : JsonDocument.Parse(entry.InputSchema.ToJsonString()).RootElement, + }); + } + + return new ListToolsResult { Tools = tools }; + }; + + options.Handlers.CallToolHandler = async (request, cancellationToken) => + { + var auditTools = request.Services!.GetRequiredService(); + var selection = request.Services!.GetRequiredService(); + var toolName = request.Params?.Name + ?? throw new McpException("Tool name is required."); + + var snapshot = await selection.GetSnapshotAsync(cancellationToken); + if (!snapshot.EnabledToolNames.Contains(toolName)) + { + var error = new JsonObject + { + ["error"] = $"tool not exposed in bundle {snapshot.BundleKey}: {toolName}", + ["hint"] = "Adjust mcp_domain / mcp_enabled_domains in Risk Settings or set WP_MCP_DOMAIN=full.", + }; + return new CallToolResult + { + Content = [new TextContentBlock { Text = error.ToJsonString(new JsonSerializerOptions { WriteIndented = true }) }], + IsError = true, + }; + } + + var argsDict = request.Params?.Arguments; + string? argsJson = null; + int? propertyId = null; + int? reportId = null; + + if (argsDict is not null && argsDict.Count > 0) + { + var jsonArgs = new JsonObject(); + foreach (var (key, value) in argsDict) + { + jsonArgs[key] = JsonNode.Parse(value.GetRawText()); + } + + argsJson = jsonArgs.ToJsonString(); + + if (argsDict.TryGetValue("property_id", out var pidProp) + && pidProp.TryGetInt32(out var pid)) + { + propertyId = pid; + } + + if (argsDict.TryGetValue("report_id", out var ridProp) + && ridProp.TryGetInt32(out var rid)) + { + reportId = rid; + } + } + + var text = await auditTools.DispatchNamedToolAsync( + toolName, + argsJson, + propertyId, + reportId, + cancellationToken); + + return new CallToolResult + { + Content = [new TextContentBlock { Text = text }], + }; + }; + }) + .WithTools(); + } + + /// Maps MCP Streamable HTTP endpoints at . + public static IEndpointConventionBuilder MapAiServiceMcp(this IEndpointRouteBuilder endpoints, string pattern = "/mcp") + => endpoints.MapMcp(pattern); + + /// Returns true when WP_MCP_HTTP=1. + public static bool IsMcpHttpEnabled() + => string.Equals(Environment.GetEnvironmentVariable("WP_MCP_HTTP"), "1", StringComparison.Ordinal); + + /// + /// Configures a stdio MCP host. Use from a dedicated console entry point: + /// + /// var builder = Host.CreateApplicationBuilder(args); + /// builder.AddAiServiceMcpStdioHost(); + /// await builder.Build().RunAsync(); + /// + /// + public static IHostApplicationBuilder AddAiServiceMcpStdioHost(this IHostApplicationBuilder builder) + { + builder.Logging.AddConsole(console => + { + console.LogToStandardErrorThreshold = LogLevel.Trace; + }); + + builder.Services.AddAiServiceApplication(); + builder.Services.AddAiServiceMcp().WithStdioServerTransport(); + return builder; + } +} diff --git a/services/AiService/src/AiService.Mcp/McpToolCatalogService.cs b/services/AiService/src/AiService.Mcp/McpToolCatalogService.cs new file mode 100644 index 00000000..c88e901c --- /dev/null +++ b/services/AiService/src/AiService.Mcp/McpToolCatalogService.cs @@ -0,0 +1,72 @@ +using System.Text.Json.Nodes; +using AiService.Tools.Domain; +using AiService.Tools.Registry; +using AiService.Tools.Selection; + +namespace AiService.Mcp; + +/// +/// Presents the audit tool catalog for GET /api/mcp-tools (mirrors Python MCP router). +/// +public sealed class McpToolCatalogService(ToolCatalog catalog, AuditToolSelectionService selection) +{ + private readonly ToolCatalogEntryLookup _lookup = new(catalog); + + public async Task ListToolsAsync(CancellationToken cancellationToken = default) + { + var snapshot = await selection.GetSnapshotAsync(cancellationToken); + var allNames = catalog.ToolNames.ToList(); + var bundleSets = McpToolDomains.McpDomainBundles.Keys.ToDictionary( + bundle => bundle, + bundle => McpToolDomains.ToolNamesForMcpBundle(allNames, bundle), + StringComparer.Ordinal); + bundleSets["custom"] = snapshot.EnabledToolNames.ToHashSet(StringComparer.Ordinal); + + var tools = new JsonArray(); + foreach (var name in allNames.Order(StringComparer.Ordinal)) + { + if (!_lookup.TryGetEntry(name, out var entry)) + { + continue; + } + + var domain = McpToolDomains.ClassifyToolDomain(name); + var inBundles = bundleSets + .Where(pair => pair.Value.Contains(name)) + .Select(pair => pair.Key) + .Order(StringComparer.Ordinal) + .Select(x => JsonValue.Create(x)) + .ToArray(); + + tools.Add(new JsonObject + { + ["name"] = name, + ["description"] = entry.Description, + ["domain"] = domain, + ["bundles"] = new JsonArray(inBundles), + ["enabled"] = snapshot.EnabledToolNames.Contains(name), + }); + } + + var bundleNames = new JsonArray( + McpToolDomains.McpDomainBundles.Keys + .Concat(["custom"]) + .Distinct(StringComparer.Ordinal) + .Order(StringComparer.Ordinal) + .Select(x => JsonValue.Create(x)) + .ToArray()); + + return new JsonObject + { + ["tools"] = tools, + ["bundles"] = bundleNames, + ["domains"] = new JsonArray( + McpToolDomains.CanonicalDomains.Select(d => JsonValue.Create(d)).ToArray()), + ["current_bundle"] = snapshot.BundleKey, + ["enabled_domains"] = new JsonArray(snapshot.EnabledDomains.Select(d => JsonValue.Create(d)).ToArray()), + ["enabled_tool_count"] = snapshot.EnabledToolNames.Count, + }; + } + + public JsonObject ListTools() => ListToolsAsync().GetAwaiter().GetResult(); +} diff --git a/services/AiService/src/AiService.Providers/AiService.Providers.csproj b/services/AiService/src/AiService.Providers/AiService.Providers.csproj new file mode 100644 index 00000000..64fc68f8 --- /dev/null +++ b/services/AiService/src/AiService.Providers/AiService.Providers.csproj @@ -0,0 +1,23 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + + + + diff --git a/services/AiService/src/AiService.Providers/Chat/AnthropicChatClient.cs b/services/AiService/src/AiService.Providers/Chat/AnthropicChatClient.cs new file mode 100644 index 00000000..8bcf48d9 --- /dev/null +++ b/services/AiService/src/AiService.Providers/Chat/AnthropicChatClient.cs @@ -0,0 +1,270 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.Extensions.AI; + +namespace AiService.Providers.Chat; + +/// MEAI adapter over the Anthropic Messages HTTP API. +internal sealed class AnthropicChatClient(string apiKey, string model, TimeSpan timeout) : IChatClient +{ + private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + + private readonly HttpClient _http = new() { Timeout = timeout }; + + public ChatClientMetadata Metadata { get; } = new("anthropic"); + + public void Dispose() => _http.Dispose(); + + public object? GetService(global::System.Type serviceType, object? serviceKey = null) + => serviceType.IsInstanceOfType(this) ? this : null; + + public async Task GetResponseAsync( + IEnumerable chatMessages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + var payload = BuildPayload(chatMessages, options, stream: false); + using var request = CreateRequest(payload, stream: false); + using var response = await _http.SendAsync(request, cancellationToken); + response.EnsureSuccessStatusCode(); + var body = await response.Content.ReadFromJsonAsync(cancellationToken) + ?? throw new InvalidOperationException("Anthropic returned an empty response."); + + return ToChatResponse(body); + } + + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable chatMessages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var payload = BuildPayload(chatMessages, options, stream: true); + using var request = CreateRequest(payload, stream: true); + using var response = await _http.SendAsync( + request, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken); + response.EnsureSuccessStatusCode(); + + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + using var reader = new StreamReader(stream); + + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + var line = await reader.ReadLineAsync(cancellationToken); + if (line is null) + { + break; + } + + if (!line.StartsWith("data: ", StringComparison.Ordinal)) + { + continue; + } + + var data = line["data: ".Length..].Trim(); + if (data.Length == 0 || data == "[DONE]") + { + continue; + } + + JsonObject? eventData; + try + { + eventData = JsonNode.Parse(data) as JsonObject; + } + catch (JsonException) + { + continue; + } + + if (eventData is null) + { + continue; + } + + var eventType = eventData["type"]?.GetValue(); + if (eventType == "content_block_delta" + && eventData["delta"] is JsonObject delta + && delta["type"]?.GetValue() == "text_delta") + { + var text = delta["text"]?.GetValue(); + if (!string.IsNullOrEmpty(text)) + { + yield return new ChatResponseUpdate(ChatRole.Assistant, text); + } + } + } + } + + private JsonObject BuildPayload( + IEnumerable chatMessages, + ChatOptions? options, + bool stream) + { + var (system, messages) = ToAnthropicMessages(chatMessages); + if (options?.ResponseFormat == ChatResponseFormat.Json) + { + system = $"{system}\nRespond with valid JSON only.".Trim(); + } + + var payload = new JsonObject + { + ["model"] = model, + ["max_tokens"] = 4096, + ["system"] = system, + ["messages"] = messages, + }; + + if (stream) + { + payload["stream"] = true; + } + + if (options?.Tools is { Count: > 0 }) + { + payload["tools"] = ToAnthropicTools(options.Tools); + } + + return payload; + } + + private HttpRequestMessage CreateRequest(JsonObject payload, bool stream) + { + var request = new HttpRequestMessage(HttpMethod.Post, "https://api.anthropic.com/v1/messages") + { + Content = JsonContent.Create(payload, options: JsonOptions), + }; + request.Headers.Add("x-api-key", apiKey); + request.Headers.Add("anthropic-version", "2023-06-01"); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue( + stream ? "text/event-stream" : "application/json")); + return request; + } + + private static (string System, JsonArray Messages) ToAnthropicMessages(IEnumerable chatMessages) + { + var systemParts = new List(); + var messages = new JsonArray(); + + foreach (var message in chatMessages) + { + if (message.Role == ChatRole.System) + { + systemParts.Add(message.Text ?? ""); + continue; + } + + if (message.Role == ChatRole.Tool) + { + var toolResult = message.Contents.OfType().FirstOrDefault(); + messages.Add(new JsonObject + { + ["role"] = "user", + ["content"] = new JsonArray + { + new JsonObject + { + ["type"] = "tool_result", + ["tool_use_id"] = toolResult?.CallId ?? "", + ["content"] = toolResult?.Result?.ToString() ?? message.Text ?? "", + }, + }, + }); + continue; + } + + if (message.Role == ChatRole.Assistant) + { + var blocks = new JsonArray(); + if (!string.IsNullOrEmpty(message.Text)) + { + blocks.Add(new JsonObject { ["type"] = "text", ["text"] = message.Text }); + } + + foreach (var call in message.Contents.OfType()) + { + blocks.Add(new JsonObject + { + ["type"] = "tool_use", + ["id"] = call.CallId, + ["name"] = call.Name, + ["input"] = JsonSerializer.SerializeToNode(call.Arguments ?? new Dictionary()) ?? new JsonObject(), + }); + } + + messages.Add(new JsonObject { ["role"] = "assistant", ["content"] = blocks }); + continue; + } + + messages.Add(new JsonObject + { + ["role"] = "user", + ["content"] = message.Text ?? "", + }); + } + + return (string.Join('\n', systemParts), messages); + } + + private static JsonArray ToAnthropicTools(IList tools) + { + var outTools = new JsonArray(); + foreach (var tool in tools.OfType()) + { + outTools.Add(new JsonObject + { + ["name"] = tool.Name, + ["description"] = tool.Description ?? "", + ["input_schema"] = new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonObject(), + }, + }); + } + + return outTools; + } + + private static ChatResponse ToChatResponse(JsonObject body) + { + var contents = new List(); + if (body["content"] is JsonArray blocks) + { + foreach (var blockNode in blocks) + { + if (blockNode is not JsonObject block) + { + continue; + } + + var type = block["type"]?.GetValue(); + if (type == "text") + { + var text = block["text"]?.GetValue() ?? ""; + contents.Add(new TextContent(text)); + } + else if (type == "tool_use") + { + var args = block["input"] as JsonObject ?? []; + var dict = JsonSerializer.Deserialize>(args.ToJsonString()) + ?? new Dictionary(); + contents.Add(new FunctionCallContent( + block["id"]?.GetValue() ?? "", + block["name"]?.GetValue() ?? "", + dict)); + } + } + } + + return new ChatResponse(new ChatMessage(ChatRole.Assistant, contents)) + { + ModelId = body["model"]?.GetValue(), + }; + } +} diff --git a/services/AiService/src/AiService.Providers/Chat/ChatClientFactory.cs b/services/AiService/src/AiService.Providers/Chat/ChatClientFactory.cs new file mode 100644 index 00000000..da9699a8 --- /dev/null +++ b/services/AiService/src/AiService.Providers/Chat/ChatClientFactory.cs @@ -0,0 +1,81 @@ +using System.ClientModel; +using AiService.Domain.Repositories; +using Microsoft.Extensions.AI; +using OpenAI; + +namespace AiService.Providers.Chat; + +public sealed class ChatClientFactory(ILlmConfigRepository configRepository) : IChatClientFactory +{ + public async Task CreateFromConfigAsync(CancellationToken cancellationToken = default) + { + var cfg = await configRepository.LoadAsync(cancellationToken); + return CreateClient(cfg); + } + + public IChatClient CreateClient(IReadOnlyDictionary cfg) + { + var resolved = LlmConfigHelpers.WithResolvedApiKey(cfg); + var provider = (resolved.GetValueOrDefault("llm_provider") ?? "none").Trim().ToLowerInvariant(); + + return provider switch + { + "openai" => CreateOpenAiClient(resolved, endpoint: null, defaultModel: "gpt-4o-mini"), + "groq" => CreateOpenAiClient( + resolved, + endpoint: new Uri(LlmConfigHelpers.OptionalCloudBaseUrl(resolved) ?? "https://api.groq.com/openai/v1"), + defaultModel: "openai/gpt-oss-120b"), + "gemini" => CreateOpenAiClient( + resolved, + endpoint: new Uri(LlmConfigHelpers.OptionalCloudBaseUrl(resolved) + ?? "https://generativelanguage.googleapis.com/v1beta/openai/"), + defaultModel: "gemini-2.0-flash"), + "anthropic" => CreateAnthropicClient(resolved), + "ollama" => CreateOllamaClient(resolved), + _ => throw new InvalidOperationException($"Unknown LLM provider: {provider}"), + }; + } + + private static IChatClient CreateOpenAiClient( + IReadOnlyDictionary cfg, + Uri? endpoint, + string defaultModel) + { + var apiKey = LlmConfigHelpers.ResolveApiKey(cfg); + if (string.IsNullOrWhiteSpace(apiKey)) + { + throw new InvalidOperationException("LLM API key is missing for the configured provider."); + } + + var model = LlmConfigHelpers.ModelOrDefault(cfg, defaultModel); + var options = endpoint is null + ? null + : new OpenAIClientOptions { Endpoint = endpoint }; + + var client = options is null + ? new OpenAIClient(new ApiKeyCredential(apiKey)) + : new OpenAIClient(new ApiKeyCredential(apiKey), options); + + return client.GetChatClient(model).AsIChatClient(); + } + + private static IChatClient CreateAnthropicClient(IReadOnlyDictionary cfg) + { + var apiKey = LlmConfigHelpers.ResolveApiKey(cfg); + if (string.IsNullOrWhiteSpace(apiKey)) + { + throw new InvalidOperationException("Anthropic API key is missing."); + } + + var model = LlmConfigHelpers.ModelOrDefault(cfg, "claude-3-5-haiku-latest"); + var timeout = TimeSpan.FromSeconds(LlmConfigHelpers.TimeoutSeconds(cfg)); + return new AnthropicChatClient(apiKey, model, timeout); + } + + private static IChatClient CreateOllamaClient(IReadOnlyDictionary cfg) + { + var baseUrl = (cfg.GetValueOrDefault("llm_base_url") ?? "http://127.0.0.1:11434").Trim().TrimEnd('/'); + var model = LlmConfigHelpers.ModelOrDefault(cfg, "llama3.2"); + return new OllamaChatClient(new Uri(baseUrl), model); + } +} diff --git a/services/AiService/src/AiService.Providers/Chat/IChatClientFactory.cs b/services/AiService/src/AiService.Providers/Chat/IChatClientFactory.cs new file mode 100644 index 00000000..d9977e49 --- /dev/null +++ b/services/AiService/src/AiService.Providers/Chat/IChatClientFactory.cs @@ -0,0 +1,10 @@ +using Microsoft.Extensions.AI; + +namespace AiService.Providers.Chat; + +public interface IChatClientFactory +{ + IChatClient CreateClient(IReadOnlyDictionary cfg); + + Task CreateFromConfigAsync(CancellationToken cancellationToken = default); +} diff --git a/services/AiService/src/AiService.Providers/Chat/JsonResponseParser.cs b/services/AiService/src/AiService.Providers/Chat/JsonResponseParser.cs new file mode 100644 index 00000000..905d6b3e --- /dev/null +++ b/services/AiService/src/AiService.Providers/Chat/JsonResponseParser.cs @@ -0,0 +1,59 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; + +namespace AiService.Providers.Chat; + +public static partial class JsonResponseParser +{ + public static JsonObject Parse(string? text) + { + var trimmed = (text ?? "").Trim(); + if (trimmed.Length == 0) + { + return []; + } + + try + { + var node = JsonNode.Parse(trimmed); + return node switch + { + JsonObject obj => obj, + JsonValue or JsonArray => new JsonObject { ["data"] = node.DeepClone() }, + _ => [], + }; + } + catch (JsonException) + { + // fall through + } + + var match = JsonObjectPattern().Match(trimmed); + if (match.Success) + { + try + { + var node = JsonNode.Parse(match.Value); + if (node is JsonObject obj) + { + return obj; + } + + if (node is not null) + { + return new JsonObject { ["data"] = node.DeepClone() }; + } + } + catch (JsonException) + { + // fall through + } + } + + return []; + } + + [GeneratedRegex(@"\{[\s\S]*\}", RegexOptions.Singleline)] + private static partial Regex JsonObjectPattern(); +} diff --git a/services/AiService/src/AiService.Providers/Chat/LlmConfigHelpers.cs b/services/AiService/src/AiService.Providers/Chat/LlmConfigHelpers.cs new file mode 100644 index 00000000..1e2d6086 --- /dev/null +++ b/services/AiService/src/AiService.Providers/Chat/LlmConfigHelpers.cs @@ -0,0 +1,108 @@ +namespace AiService.Providers.Chat; + +public static class LlmConfigHelpers +{ + private static readonly string[] CloudProviders = ["openai", "gemini", "anthropic", "groq"]; + + private static readonly Dictionary EnvKeyByProvider = new(StringComparer.OrdinalIgnoreCase) + { + ["openai"] = "OPENAI_API_KEY", + ["gemini"] = "GEMINI_API_KEY", + ["anthropic"] = "ANTHROPIC_API_KEY", + ["groq"] = "GROQ_API_KEY", + }; + + public static bool IsEnabled(IReadOnlyDictionary cfg) + { + if (cfg.Count == 0) + { + return false; + } + + if (!IsTruthy(cfg.GetValueOrDefault("llm_enabled"))) + { + return false; + } + + var provider = (cfg.GetValueOrDefault("llm_provider") ?? "none").Trim().ToLowerInvariant(); + return provider is not "" and not "none"; + } + + public static bool IsTruthy(string? value) + => (value ?? "").Trim().ToLowerInvariant() is "true" or "1" or "yes"; + + public static IReadOnlyDictionary WithResolvedApiKey(IReadOnlyDictionary cfg) + { + var provider = (cfg.GetValueOrDefault("llm_provider") ?? "none").Trim().ToLowerInvariant(); + var resolved = ResolveApiKey(cfg, provider); + if (string.IsNullOrWhiteSpace(resolved)) + { + return cfg; + } + + var copy = new Dictionary(cfg, StringComparer.Ordinal) + { + ["llm_api_key"] = resolved, + }; + return copy; + } + + public static string ResolveApiKey(IReadOnlyDictionary cfg, string? provider = null) + { + provider ??= (cfg.GetValueOrDefault("llm_provider") ?? "none").Trim().ToLowerInvariant(); + + if (CloudProviders.Contains(provider, StringComparer.OrdinalIgnoreCase)) + { + var perProviderKey = $"llm_api_key_{provider}"; + var specific = (cfg.GetValueOrDefault(perProviderKey) ?? "").Trim(); + if (!string.IsNullOrEmpty(specific)) + { + return specific; + } + } + + var generic = (cfg.GetValueOrDefault("llm_api_key") ?? "").Trim(); + if (!string.IsNullOrEmpty(generic)) + { + return generic; + } + + if (EnvKeyByProvider.TryGetValue(provider, out var envVar)) + { + return (Environment.GetEnvironmentVariable(envVar) ?? "").Trim(); + } + + return ""; + } + + public static bool IsOllamaBaseUrl(string? url) + { + var normalized = (url ?? "").Trim().TrimEnd('/').ToLowerInvariant(); + if (normalized is "http://127.0.0.1:11434" or "http://localhost:11434") + { + return true; + } + + return normalized.EndsWith(":11434", StringComparison.Ordinal); + } + + public static string? OptionalCloudBaseUrl(IReadOnlyDictionary cfg) + { + var baseUrl = (cfg.GetValueOrDefault("llm_base_url") ?? "").Trim().TrimEnd('/'); + if (string.IsNullOrEmpty(baseUrl) || IsOllamaBaseUrl(baseUrl)) + { + return null; + } + + return baseUrl; + } + + public static double TimeoutSeconds(IReadOnlyDictionary cfg, double defaultSeconds = 120) + { + var raw = (cfg.GetValueOrDefault("llm_timeout_s") ?? "").Trim(); + return double.TryParse(raw, out var seconds) && seconds > 0 ? seconds : defaultSeconds; + } + + public static string ModelOrDefault(IReadOnlyDictionary cfg, string defaultModel) + => (cfg.GetValueOrDefault("llm_model") ?? defaultModel).Trim(); +} diff --git a/services/AiService/src/AiService.Providers/Chat/StructuredCompletionService.cs b/services/AiService/src/AiService.Providers/Chat/StructuredCompletionService.cs new file mode 100644 index 00000000..9684d297 --- /dev/null +++ b/services/AiService/src/AiService.Providers/Chat/StructuredCompletionService.cs @@ -0,0 +1,60 @@ +using System.Text; +using System.Text.Json.Nodes; +using Microsoft.Extensions.AI; + +namespace AiService.Providers.Chat; + +/// Structured JSON completions via . +public sealed class StructuredCompletionService(IChatClientFactory chatClientFactory) +{ + public Task CompleteJsonAsync( + string system, + string user, + IReadOnlyDictionary cfg, + CancellationToken cancellationToken = default) + => CompleteJsonStreamingAsync(system, user, cfg, onToken: null, cancellationToken); + + public async Task CompleteJsonStreamingAsync( + string system, + string user, + IReadOnlyDictionary cfg, + Action? onToken, + CancellationToken cancellationToken = default) + { + var client = chatClientFactory.CreateClient(cfg); + var messages = new List + { + new(ChatRole.System, system), + new(ChatRole.User, user), + }; + + var options = new ChatOptions + { + Temperature = 0.2f, + ResponseFormat = ChatResponseFormat.Json, + }; + + if (onToken is null) + { + var response = await client.GetResponseAsync(messages, options, cancellationToken); + var text = response.Text ?? ""; + return string.IsNullOrWhiteSpace(text) ? [] : JsonResponseParser.Parse(text); + } + + var accumulated = new StringBuilder(); + await foreach (var update in client.GetStreamingResponseAsync(messages, options, cancellationToken)) + { + var delta = update.Text ?? ""; + if (string.IsNullOrEmpty(delta)) + { + continue; + } + + accumulated.Append(delta); + onToken(delta); + } + + var fullText = accumulated.ToString(); + return string.IsNullOrWhiteSpace(fullText) ? [] : JsonResponseParser.Parse(fullText); + } +} diff --git a/services/AiService/src/AiService.Providers/DependencyInjection.cs b/services/AiService/src/AiService.Providers/DependencyInjection.cs new file mode 100644 index 00000000..076d8478 --- /dev/null +++ b/services/AiService/src/AiService.Providers/DependencyInjection.cs @@ -0,0 +1,14 @@ +using AiService.Providers.Chat; +using Microsoft.Extensions.DependencyInjection; + +namespace AiService.Providers; + +public static class DependencyInjection +{ + public static IServiceCollection AddAiServiceProviders(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + return services; + } +} diff --git a/services/AiService/src/AiService.Tools/AiService.Tools.csproj b/services/AiService/src/AiService.Tools/AiService.Tools.csproj new file mode 100644 index 00000000..e043d001 --- /dev/null +++ b/services/AiService/src/AiService.Tools/AiService.Tools.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + enable + enable + AiService.Tools + + + + + + + + + + + + + + + + + + + + + diff --git a/services/AiService/src/AiService.Tools/Bridge/PythonToolBridgeClient.cs b/services/AiService/src/AiService.Tools/Bridge/PythonToolBridgeClient.cs new file mode 100644 index 00000000..224c6cd1 --- /dev/null +++ b/services/AiService/src/AiService.Tools/Bridge/PythonToolBridgeClient.cs @@ -0,0 +1,46 @@ +using System.Net.Http.Json; +using System.Text.Json.Nodes; +using AiService.Tools.Options; +using Microsoft.Extensions.Options; + +namespace AiService.Tools.Bridge; + +/// +/// HTTP bridge to Python audit tools via POST {FASTAPI_URL}/api/report/audit-tool. +/// +public sealed class PythonToolBridgeClient(HttpClient http, IOptions options) +{ + public async Task InvokeAsync( + string toolName, + JsonObject args, + int propertyId, + int? reportId = null, + CancellationToken cancellationToken = default) + { + var body = new JsonObject + { + ["toolName"] = toolName, + ["propertyId"] = propertyId, + ["reportId"] = reportId, + ["args"] = args, + }; + + using var response = await http.PostAsJsonAsync("api/report/audit-tool", body, cancellationToken); + response.EnsureSuccessStatusCode(); + + var envelope = await response.Content.ReadFromJsonAsync(cancellationToken); + if (envelope is null) + { + return new JsonObject { ["error"] = "empty response from FastAPI audit-tool endpoint" }; + } + + if (envelope.TryGetPropertyValue("result", out var resultNode) && resultNode is JsonObject result) + { + return result; + } + + return envelope; + } + + public Uri BaseAddress => http.BaseAddress ?? new Uri(options.Value.BaseUrl); +} diff --git a/services/AiService/src/AiService.Tools/Context/AuditToolContext.cs b/services/AiService/src/AiService.Tools/Context/AuditToolContext.cs new file mode 100644 index 00000000..0b3eaf24 --- /dev/null +++ b/services/AiService/src/AiService.Tools/Context/AuditToolContext.cs @@ -0,0 +1,401 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Npgsql; + +namespace AiService.Tools.Context; + +/// +/// Execution context for audit tools (property + report scope). Mirrors Python +/// website_profiling.tools.audit_tools.context.AuditToolContext. +/// +public sealed class AuditToolContext +{ + public int? PropertyId { get; init; } + + public int? ReportId { get; init; } + + /// + /// Load the report JSON blob from report_payload. When is null, + /// returns the latest report (ORDER BY id DESC LIMIT 1), matching Python read_report_payload. + /// + public async Task LoadPayloadAsync(NpgsqlConnection conn, CancellationToken cancellationToken = default) + { + await using var cmd = conn.CreateCommand(); + if (ReportId is int reportId) + { + cmd.CommandText = "SELECT data FROM report_payload WHERE id = @id"; + cmd.Parameters.AddWithValue("id", reportId); + } + else + { + cmd.CommandText = "SELECT data FROM report_payload ORDER BY id DESC LIMIT 1"; + } + + var raw = await cmd.ExecuteScalarAsync(cancellationToken); + if (raw is null or DBNull) + { + return []; + } + + var text = raw switch + { + string s => s, + byte[] bytes => System.Text.Encoding.UTF8.GetString(bytes), + _ => raw.ToString() ?? "{}", + }; + + try + { + return JsonNode.Parse(text) as JsonObject ?? []; + } + catch (JsonException) + { + return []; + } + } + + /// + /// Latest Google snapshot for the property as report_payload["google"] shape (gsc_full/ga4_full + /// stripped). Falls back to the embedded payload blob. Mirrors Python load_google. + /// + public async Task LoadGoogleAsync(NpgsqlConnection conn, CancellationToken cancellationToken = default) + { + var latest = await ReadLatestGoogleAsync(conn, offset: 0, cancellationToken); + if (latest is not null) + { + return StripFullBlobs(latest); + } + + var payload = await LoadPayloadAsync(conn, cancellationToken); + return payload["google"] as JsonObject; + } + + /// Latest Google snapshot including gsc_full/ga4_full. Mirrors Python load_google_full. + public async Task LoadGoogleFullAsync(NpgsqlConnection conn, CancellationToken cancellationToken = default) + { + var latest = await ReadLatestGoogleAsync(conn, offset: 0, cancellationToken); + if (latest is not null) + { + return latest; + } + + var payload = await LoadPayloadAsync(conn, cancellationToken); + return payload["google"] as JsonObject; + } + + /// (current, prior) full Google snapshots for decay/compare tools. Mirrors load_google_pair. + public async Task<(JsonObject? Current, JsonObject? Prior)> LoadGooglePairAsync( + NpgsqlConnection conn, + CancellationToken cancellationToken = default) + { + var current = await ReadLatestGoogleAsync(conn, offset: 0, cancellationToken); + var prior = await ReadLatestGoogleAsync(conn, offset: 1, cancellationToken); + if (current is null) + { + var payload = await LoadPayloadAsync(conn, cancellationToken); + current = payload["google"] as JsonObject; + } + + return (current, prior); + } + + /// Latest keyword snapshot for the property (rows capped at 1000). Mirrors load_keywords. + public async Task LoadKeywordsAsync(NpgsqlConnection conn, CancellationToken cancellationToken = default) + { + if (PropertyId is int pid) + { + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT data FROM keyword_data WHERE property_id = @pid ORDER BY id DESC LIMIT 1"; + cmd.Parameters.AddWithValue("pid", pid); + var data = await ReadDataObjectAsync(cmd, cancellationToken); + if (data is not null) + { + if (data["rows"] is JsonArray rows && rows.Count > 1000) + { + var capped = new JsonArray(); + for (var i = 0; i < 1000; i++) + { + capped.Add(rows[i]?.DeepClone()); + } + + data["rows"] = capped; + } + + return data; + } + } + + var payload = await LoadPayloadAsync(conn, cancellationToken); + return payload["keywords"] as JsonObject; + } + + /// Latest GSC links snapshot for the property (full, uncapped). Mirrors load_gsc_links. + public async Task LoadGscLinksAsync(NpgsqlConnection conn, CancellationToken cancellationToken = default) + { + if (PropertyId is int pid) + { + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT data FROM gsc_links_data WHERE property_id = @pid ORDER BY id DESC LIMIT 1"; + cmd.Parameters.AddWithValue("pid", pid); + var data = await ReadDataObjectAsync(cmd, cancellationToken); + if (data is not null) + { + return data; + } + } + + var payload = await LoadPayloadAsync(conn, cancellationToken); + return payload["gsc_links"] as JsonObject; + } + + /// Report payload blob by explicit id. Mirrors Python load_report_payload_by_id. + public async Task LoadReportPayloadByIdAsync( + NpgsqlConnection conn, + int reportId, + CancellationToken cancellationToken = default) + { + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT data FROM report_payload WHERE id = @id"; + cmd.Parameters.AddWithValue("id", reportId); + return await ReadDataObjectAsync(cmd, cancellationToken) ?? []; + } + + /// Canonical domain for the property (properties table → payload → top page host). Mirrors resolve_property_domain. + public async Task ResolvePropertyDomainAsync(NpgsqlConnection conn, CancellationToken cancellationToken = default) + { + if (PropertyId is int pid) + { + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT canonical_domain FROM properties WHERE id = @id"; + cmd.Parameters.AddWithValue("id", pid); + var raw = await cmd.ExecuteScalarAsync(cancellationToken); + var domain = (raw as string ?? "").Trim().ToLowerInvariant(); + if (!string.IsNullOrEmpty(domain)) + { + return domain; + } + } + + var payload = await LoadPayloadAsync(conn, cancellationToken); + if (payload["canonical_domain"] is JsonValue cv && cv.TryGetValue(out var canonical)) + { + var value = (canonical ?? "").Trim().ToLowerInvariant(); + if (!string.IsNullOrEmpty(value)) + { + return value; + } + } + + if (payload["top_pages"] is JsonArray topPages + && topPages.Count > 0 + && topPages[0] is JsonObject first + && first["url"] is JsonValue urlValue + && urlValue.TryGetValue(out var url) + && !string.IsNullOrWhiteSpace(url) + && Uri.TryCreate(url, UriKind.Absolute, out var uri) + && !string.IsNullOrEmpty(uri.Host)) + { + return uri.Host.ToLowerInvariant(); + } + + return ""; + } + + /// + /// All crawl result rows for the run, as records-orient JsonObjects (url + fetch_method columns + /// merged with all fields from the data JSONB blob). Run id comes from payload.crawl_run_id, + /// then latest crawl run, then all results. Mirrors Python load_crawl_df / read_crawl. + /// + public async Task> LoadCrawlDfAsync( + NpgsqlConnection conn, + CancellationToken cancellationToken = default) + { + var payload = await LoadPayloadAsync(conn, cancellationToken); + var runId = ResolveCrawlRunId(payload); + + if (runId is null) + { + runId = await GetLatestCrawlRunIdAsync(conn, cancellationToken); + } + + await using var cmd = conn.CreateCommand(); + if (runId is int rid) + { + cmd.CommandText = "SELECT url, fetch_method, data FROM crawl_results WHERE crawl_run_id = @rid"; + cmd.Parameters.AddWithValue("rid", rid); + } + else + { + cmd.CommandText = "SELECT url, fetch_method, data FROM crawl_results"; + } + + return await ReadCrawlRowsAsync(cmd, cancellationToken); + } + + private static int? ResolveCrawlRunId(JsonObject payload) + { + if (payload["crawl_run_id"] is not JsonValue v) + { + return null; + } + + if (v.TryGetValue(out var i)) + { + return i; + } + + if (v.TryGetValue(out var d)) + { + return (int)d; + } + + if (v.TryGetValue(out var l)) + { + return (int)l; + } + + if (v.TryGetValue(out var s) && int.TryParse(s, out var p)) + { + return p; + } + + return null; + } + + private static async Task GetLatestCrawlRunIdAsync(NpgsqlConnection conn, CancellationToken ct) + { + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT id FROM crawl_runs ORDER BY id DESC LIMIT 1"; + var raw = await cmd.ExecuteScalarAsync(ct); + return raw is null or DBNull ? null : Convert.ToInt32(raw); + } + + private static async Task> ReadCrawlRowsAsync(NpgsqlCommand cmd, CancellationToken ct) + { + var rows = new List(); + await using var reader = await cmd.ExecuteReaderAsync(ct); + while (await reader.ReadAsync(ct)) + { + var row = new JsonObject(); + row["url"] = reader.IsDBNull(0) ? "" : reader.GetString(0); + + var fm = reader.IsDBNull(1) ? "" : reader.GetString(1).Trim(); + row["fetch_method"] = fm.Length > 0 ? fm : "static"; + + if (!reader.IsDBNull(2)) + { + try + { + if (JsonNode.Parse(reader.GetString(2)) is JsonObject blob) + { + foreach (var (k, v) in blob) + { + row[k] = v?.DeepClone(); + } + } + } + catch (JsonException) { } + } + + rows.Add(row); + } + + return rows; + } + + private async Task ReadLatestGoogleAsync(NpgsqlConnection conn, int offset, CancellationToken cancellationToken) + { + await using var cmd = conn.CreateCommand(); + if (PropertyId is int pid) + { + cmd.CommandText = "SELECT data FROM google_data WHERE property_id = @pid ORDER BY id DESC OFFSET @off LIMIT 1"; + cmd.Parameters.AddWithValue("pid", pid); + } + else + { + cmd.CommandText = "SELECT data FROM google_data ORDER BY id DESC OFFSET @off LIMIT 1"; + } + + cmd.Parameters.AddWithValue("off", Math.Max(0, offset)); + return await ReadDataObjectAsync(cmd, cancellationToken); + } + + private static async Task ReadDataObjectAsync(NpgsqlCommand cmd, CancellationToken cancellationToken) + { + var raw = await cmd.ExecuteScalarAsync(cancellationToken); + if (raw is null or DBNull) + { + return null; + } + + var text = raw switch + { + string s => s, + byte[] bytes => System.Text.Encoding.UTF8.GetString(bytes), + _ => raw.ToString() ?? string.Empty, + }; + + if (string.IsNullOrWhiteSpace(text)) + { + return null; + } + + try + { + return JsonNode.Parse(text) as JsonObject; + } + catch (JsonException) + { + return null; + } + } + + private static JsonObject StripFullBlobs(JsonObject data) + { + var output = new JsonObject(); + foreach (var (key, value) in data) + { + if (key is "gsc_full" or "ga4_full") + { + continue; + } + + output[key] = value?.DeepClone(); + } + + return output; + } + + /// Merge tool args property_id / report_id when provided. + public AuditToolContext WithArgs(JsonObject args) + { + var propertyId = PropertyId; + var reportId = ReportId; + + if (args.TryGetPropertyValue("property_id", out var pidNode) && pidNode is not null) + { + if (pidNode is JsonValue pidValue && pidValue.TryGetValue(out int pidInt)) + { + propertyId = pidInt; + } + else if (int.TryParse(pidNode.ToString(), out var parsedPid)) + { + propertyId = parsedPid; + } + } + + if (args.TryGetPropertyValue("report_id", out var ridNode) && ridNode is not null) + { + if (ridNode is JsonValue ridValue && ridValue.TryGetValue(out int ridInt)) + { + reportId = ridInt; + } + else if (int.TryParse(ridNode.ToString(), out var parsedRid)) + { + reportId = parsedRid; + } + } + + return new AuditToolContext { PropertyId = propertyId, ReportId = reportId }; + } +} diff --git a/services/AiService/src/AiService.Tools/DependencyInjection.cs b/services/AiService/src/AiService.Tools/DependencyInjection.cs new file mode 100644 index 00000000..d8c65293 --- /dev/null +++ b/services/AiService/src/AiService.Tools/DependencyInjection.cs @@ -0,0 +1,80 @@ +using AiService.Tools.Bridge; +using AiService.Tools.Modules; +using AiService.Tools.Options; +using AiService.Tools.Persistence; +using AiService.Tools.Registry; +using AiService.Tools.Selection; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Npgsql; + +namespace AiService.Tools; + +public static class DependencyInjection +{ + public const string PythonBridgeHttpClient = "python-audit-tool-bridge"; + + /// + /// Registers audit tool catalog, Postgres pool, C# handlers, and the Python HTTP bridge. + /// + public static IServiceCollection AddAiServiceTools(this IServiceCollection services) + { + services.AddOptions() + .BindConfiguration(DatabaseOptions.SectionName) + .PostConfigure(o => + { + var url = Environment.GetEnvironmentVariable("DATABASE_URL"); + if (!string.IsNullOrWhiteSpace(url)) + { + o.ConnectionString = url.Trim(); + } + }); + + services.AddOptions() + .BindConfiguration(FastApiOptions.SectionName) + .PostConfigure(o => + { + var fastApi = Environment.GetEnvironmentVariable("FASTAPI_URL"); + if (!string.IsNullOrWhiteSpace(fastApi)) + { + o.BaseUrl = fastApi.Trim(); + } + }); + + services.AddSingleton(sp => + { + var o = sp.GetRequiredService>().Value; + var builder = new NpgsqlDataSourceBuilder(NpgsqlDsn.ToNpgsql(o.ConnectionString)); + builder.ConnectionStringBuilder.MinPoolSize = o.MinPoolSize; + builder.ConnectionStringBuilder.MaxPoolSize = o.MaxPoolSize; + builder.ConnectionStringBuilder.CommandTimeout = o.CommandTimeoutSeconds; + return builder.Build(); + }); + + services.AddSingleton(); + services.AddSingleton(sp => + { + var registry = new ToolRegistry(); + registry.RegisterRange(ToolHandlerModules.AllHandlers()); + return registry; + }); + services.AddSingleton(); + services.AddMemoryCache(); + services.AddSingleton(); + + services.AddHttpClient((sp, client) => + { + var opts = sp.GetRequiredService>().Value; + client.BaseAddress = NormalizeBaseUri(opts.BaseUrl); + client.Timeout = TimeSpan.FromSeconds(120); + }); + + return services; + } + + private static Uri NormalizeBaseUri(string baseUrl) + { + var trimmed = baseUrl.Trim().TrimEnd('/'); + return new Uri(trimmed.EndsWith('/') ? trimmed : trimmed + "/", UriKind.Absolute); + } +} diff --git a/services/AiService/src/AiService.Tools/Domain/AuditToolDomains.cs b/services/AiService/src/AiService.Tools/Domain/AuditToolDomains.cs new file mode 100644 index 00000000..9efe7b4d --- /dev/null +++ b/services/AiService/src/AiService.Tools/Domain/AuditToolDomains.cs @@ -0,0 +1,532 @@ +using System.Text.Json.Nodes; +using AiService.Tools.Registry; + +namespace AiService.Tools.Domain; + +/// +/// MCP domain bundles and classification. Ports Python +/// website_profiling.tools.audit_tools.tool_domains. +/// +public static class McpToolDomains +{ + public static readonly IReadOnlyList CanonicalDomains = + [ + "core", "portfolio", "issues", "crawl", "onpage", "schema", "links", "indexation", + "content", "keywords", "google", "backlinks", "performance", "drift", "security", + "ops", "export", "images", "geo", "accessibility", "assets", "ctr", "integrations", "insight", + ]; + + /// Chat-only tools excluded from MCP domain bundles. + public static readonly HashSet ChatOnlyTools = new(StringComparer.Ordinal) + { + "prepare_audit_run", + }; + + /// Tier 0 router + insight tools always included in the core MCP bundle. + public static readonly HashSet Tier0Tools = new(StringComparer.Ordinal) + { + "search_audit_tools", + "list_tool_domains", + "get_data_coverage_report", + "run_insight_workflow", + "run_technical_workflow", + "run_keyword_workflow", + "run_domain_agent", + "get_report_summary", + "list_top_impact_issues", + "prioritize_fix_roadmap", + "get_landing_page_blended_table", + "get_opportunity_matrix", + "get_traffic_health_check", + "get_landing_page_full_diagnosis", + "get_issue_to_traffic_map", + "get_google_summary", + }; + + /// WP_MCP_DOMAIN env bundles (core, crawl, google, links, full). + public static readonly IReadOnlyDictionary> McpDomainBundles = + new Dictionary>(StringComparer.Ordinal) + { + ["core"] = new(StringComparer.Ordinal) { "core", "insight" }, + ["crawl"] = new(StringComparer.Ordinal) { "crawl", "onpage", "schema", "accessibility", "assets" }, + ["google"] = new(StringComparer.Ordinal) { "google", "insight", "ctr", "keywords", "integrations" }, + ["links"] = new(StringComparer.Ordinal) { "links", "backlinks", "indexation" }, + ["full"] = new(CanonicalDomains, StringComparer.Ordinal), + }; + + private static readonly Dictionary DomainOverrides = new(StringComparer.Ordinal) + { + ["search_audit_tools"] = "core", + ["list_tool_domains"] = "core", + ["get_data_coverage_report"] = "core", + ["run_insight_workflow"] = "core", + ["run_technical_workflow"] = "core", + ["run_keyword_workflow"] = "core", + ["run_domain_agent"] = "core", + ["get_landing_page_blended_table"] = "insight", + ["get_opportunity_matrix"] = "insight", + ["get_traffic_health_check"] = "insight", + ["get_landing_page_full_diagnosis"] = "insight", + ["get_issue_to_traffic_map"] = "insight", + ["get_gsc_daily_trend"] = "google", + ["get_ga4_daily_trend"] = "google", + ["get_ga4_by_device"] = "google", + ["get_ga4_by_channel"] = "google", + ["get_brand_keyword_split"] = "keywords", + ["list_keywords_by_intent"] = "keywords", + ["get_gsc_page_queries"] = "google", + ["prepare_audit_run"] = "ops", + ["list_broken_links"] = "links", + ["list_broken_link_sources"] = "links", + ["get_gsc_sample_links"] = "backlinks", + ["get_gsc_latest_links"] = "backlinks", + ["get_gsc_links_summary"] = "backlinks", + ["get_gsc_links_import_status"] = "backlinks", + ["list_seo_onpage_issues"] = "onpage", + ["list_content_url_issues"] = "onpage", + ["list_pages_missing_title"] = "onpage", + ["list_pages_missing_h1"] = "onpage", + ["list_pages_multiple_h1"] = "onpage", + ["list_pages_missing_meta_description"] = "onpage", + ["list_pages_meta_desc_too_short"] = "onpage", + ["list_pages_meta_desc_too_long"] = "onpage", + ["list_pages_noindex"] = "onpage", + ["list_pages_missing_canonical"] = "onpage", + ["list_canonical_mismatch"] = "onpage", + ["list_pages_with_missing_alt"] = "onpage", + ["list_pages_skipped_headings"] = "onpage", + ["list_pages_missing_viewport"] = "onpage", + ["list_pages_missing_og_image"] = "onpage", + ["get_report_summary"] = "portfolio", + ["get_critical_issues"] = "issues", + ["get_issue_priority_breakdown"] = "issues", + ["list_top_impact_issues"] = "issues", + ["prioritize_fix_roadmap"] = "issues", + ["get_google_summary"] = "google", + ["get_gsc_ctr_opportunity_pages"] = "ctr", + ["list_keywords_ctr_opportunity"] = "ctr", + ["analyze_serp_snippet_for_url"] = "ctr", + ["compare_reports"] = "drift", + ["compare_gsc_periods"] = "google", + ["list_pages_title_too_short"] = "onpage", + ["list_pages_title_too_long"] = "onpage", + ["list_pages_slow_response"] = "performance", + ["list_pages_color_contrast_failures"] = "accessibility", + ["list_pages_high_reading_level"] = "content", + ["list_pages_very_thin_content"] = "content", + ["list_hreflang_issue_pages"] = "indexation", + ["list_pages_mixed_language"] = "content", + ["list_misaligned_queries"] = "keywords", + ["list_referring_domains"] = "backlinks", + ["get_anchor_text_distribution"] = "backlinks", + ["list_backlinks_by_anchor_text"] = "backlinks", + ["list_backlinks_to_url"] = "backlinks", + ["list_backlinks_from_domain"] = "backlinks", + ["get_keyword_opportunity_score"] = "keywords", + ["list_sitemap_urls_not_in_crawl"] = "indexation", + ["list_crawl_urls_not_in_sitemap"] = "indexation", + ["list_log_googlebot_low_crawl"] = "ops", + ["list_redirect_chains_by_length"] = "crawl", + ["list_compare_new_issues"] = "drift", + ["list_compare_resolved_issues"] = "drift", + ["list_compare_lighthouse_regressions"] = "drift", + ["list_pages_ai_citation_signals"] = "geo", + ["list_pages_missing_llms_txt_reference"] = "geo", + ["list_robots_blocked_ai_crawlers"] = "geo", + ["list_pages_missing_howto_schema"] = "geo", + ["list_pages_missing_article_schema"] = "geo", + ["compare_geo_score_deltas"] = "geo", + ["check_ai_citations_live"] = "geo", + ["detect_prompt_injection"] = "geo", + ["get_negative_signals"] = "geo", + ["get_rag_chunk_readiness"] = "geo", + ["get_content_decay_signals"] = "geo", + ["get_multimodal_readiness"] = "geo", + ["get_topic_authority"] = "geo", + ["list_gsc_ctr_underperformers"] = "google", + ["get_sql_schema"] = "core", + ["run_sql_query"] = "core", + }; + + private static readonly string[] OnpagePrefixes = + [ + "list_pages_missing_", + "list_pages_meta_desc_", + "list_pages_multiple_h1", + "list_pages_noindex", + "list_seo_onpage", + "list_content_url", + ]; + + public static string ResolveMcpDomain() + { + var raw = Environment.GetEnvironmentVariable("WP_MCP_DOMAIN"); + var key = (raw ?? "core").Trim().ToLowerInvariant(); + return McpDomainBundles.ContainsKey(key) ? key : "core"; + } + + public static string ClassifyToolDomain(string name) + { + if (DomainOverrides.TryGetValue(name, out var domain)) + { + return domain; + } + + if (name.StartsWith("compare_", StringComparison.Ordinal)) + { + return "drift"; + } + + if (name.StartsWith("list_compare_", StringComparison.Ordinal)) + { + return "drift"; + } + + if (Tier0Tools.Contains(name)) + { + return DomainOverrides.GetValueOrDefault(name, "core"); + } + + if (name.StartsWith("export_", StringComparison.Ordinal) || name == "list_export_formats") + { + return "export"; + } + + if (name.StartsWith("get_image_", StringComparison.Ordinal) + || name is "list_pages_without_lazy" or "list_pages_with_images_missing" + or "list_site_image" or "list_lighthouse_image" or "list_largest_images" + or "list_unoptimized_images" or "list_images_needing") + { + return "images"; + } + + if (name.StartsWith("get_landing_page_", StringComparison.Ordinal) + || name.StartsWith("get_opportunity_", StringComparison.Ordinal) + || name.StartsWith("get_traffic_health", StringComparison.Ordinal) + || name.StartsWith("get_issue_to_traffic", StringComparison.Ordinal)) + { + return "insight"; + } + + if (name.StartsWith("get_geo_", StringComparison.Ordinal) + || name.StartsWith("get_aeo_", StringComparison.Ordinal) + || name.StartsWith("get_llms_", StringComparison.Ordinal) + || name.StartsWith("get_eeat_", StringComparison.Ordinal) + || name.StartsWith("get_faq_", StringComparison.Ordinal) + || name.StartsWith("get_ai_discovery", StringComparison.Ordinal) + || name.StartsWith("get_robots_ai_", StringComparison.Ordinal) + || name.StartsWith("get_citability_", StringComparison.Ordinal) + || name is "list_pages_missing_faq" or "draft_llms" or "check_ai_citation" + or "generate_schema" or "generate_robots_txt" or "generate_meta_tags" or "generate_geo_fix" + || name.StartsWith("get_agent_", StringComparison.Ordinal) + || name.StartsWith("get_agents_", StringComparison.Ordinal) + || name is "get_skill_md" or "get_token_budget" + or "get_copy_for_ai" or "get_markdown_availability" or "get_content_structure_aeo" + or "list_oversized_pages" or "list_pages_agent_unfriendly" + or "list_pages_missing_copy_for_ai" or "generate_agent_readiness") + { + return "geo"; + } + + if (name.Contains("axe", StringComparison.Ordinal) + || name.Contains("mixed_content", StringComparison.Ordinal) + || name == "get_heading_outline_for_url") + { + return "accessibility"; + } + + if (name is "get_asset_weight_summary" or "get_readability_summary" or "list_heavy_pages_by_bytes" + or "list_pages_poor_cache_headers" or "list_pages_low_content_ratio") + { + return "assets"; + } + + if (name.Contains("ctr", StringComparison.Ordinal) + || name is "list_keywords_ctr_opportunity" or "analyze_serp_snippet_for_url") + { + return "ctr"; + } + + if (name is "get_gsc_url_inspection" or "get_gsc_index_coverage" or "get_bing_index_status" + or "get_serp_feature_overlay") + { + return "integrations"; + } + + if (OnpagePrefixes.Any(p => name.StartsWith(p, StringComparison.Ordinal))) + { + return "onpage"; + } + + if (name.StartsWith("list_propert", StringComparison.Ordinal) + || name.StartsWith("get_propert", StringComparison.Ordinal) + || name.StartsWith("get_report", StringComparison.Ordinal) + || name.StartsWith("get_executive", StringComparison.Ordinal) + || name.StartsWith("get_site", StringComparison.Ordinal) + || name.StartsWith("list_report", StringComparison.Ordinal) + || name.StartsWith("get_portfolio", StringComparison.Ordinal) + || name is "get_ads_txt_status" or "get_security_txt_status" or "get_contact_intelligence" + or "get_rich_results_summary" or "list_rich_results_failures" or "get_competitor_keyword_gap" + or "get_pagination_audit_summary" or "get_portfolio_benchmark") + { + return "portfolio"; + } + + if (name is "list_top_impact_issues" or "prioritize_fix_roadmap" or "generate_issue_fix" + or "summarize_category_for_client" + || name.Contains("issue", StringComparison.Ordinal) + || name.Contains("category", StringComparison.Ordinal) + || name.Contains("workflow", StringComparison.Ordinal)) + { + return "issues"; + } + + if (name.StartsWith("list_pages_", StringComparison.Ordinal) + || name.StartsWith("list_canonical", StringComparison.Ordinal) + || name.StartsWith("list_long_", StringComparison.Ordinal) + || name.StartsWith("list_robots_", StringComparison.Ordinal) + || name.StartsWith("get_top_pages_by", StringComparison.Ordinal) + || name.StartsWith("search_pages", StringComparison.Ordinal) + || name.StartsWith("get_page_", StringComparison.Ordinal) + || name.StartsWith("list_redirects", StringComparison.Ordinal) + || name.StartsWith("list_broken", StringComparison.Ordinal) + || name.StartsWith("list_status_", StringComparison.Ordinal) + || name.StartsWith("get_status_code", StringComparison.Ordinal) + || name.StartsWith("get_response_time", StringComparison.Ordinal) + || name.StartsWith("get_depth", StringComparison.Ordinal) + || name.StartsWith("get_crawl_", StringComparison.Ordinal) + || name.StartsWith("get_browser", StringComparison.Ordinal) + || name.StartsWith("list_pages_with", StringComparison.Ordinal) + || name.StartsWith("list_pages_by", StringComparison.Ordinal) + || name.StartsWith("list_pages_soft", StringComparison.Ordinal) + || name.StartsWith("list_pages_poor", StringComparison.Ordinal) + || name.StartsWith("list_dead_end", StringComparison.Ordinal) + || name.StartsWith("list_duplicate_title", StringComparison.Ordinal) + || name.StartsWith("list_heavy_pages", StringComparison.Ordinal)) + { + return "crawl"; + } + + if (name.Contains("schema", StringComparison.Ordinal) || name == "get_seo_health") + { + return "schema"; + } + + if (name.Contains("orphan", StringComparison.Ordinal) + || name.Contains("link", StringComparison.Ordinal) + || name.Contains("fingerprint", StringComparison.Ordinal) + || name.Contains("pagerank", StringComparison.Ordinal)) + { + return "links"; + } + + if (name.Contains("indexation", StringComparison.Ordinal) + || name.Contains("hreflang", StringComparison.Ordinal) + || name.Contains("language", StringComparison.Ordinal) + || name == "list_subdomains") + { + return "indexation"; + } + + if (name.Contains("content", StringComparison.Ordinal) + || name.Contains("social", StringComparison.Ordinal) + || name.Contains("ner", StringComparison.Ordinal) + || name.Contains("thin", StringComparison.Ordinal) + || name.Contains("opportunit", StringComparison.Ordinal) + || name.Contains("duplicate", StringComparison.Ordinal)) + { + return "content"; + } + + if (name.Contains("keyword", StringComparison.Ordinal) + || name.Contains("cannibal", StringComparison.Ordinal) + || name.Contains("misalignment", StringComparison.Ordinal) + || name.Contains("striking", StringComparison.Ordinal) + || name.Contains("semantic", StringComparison.Ordinal) + || name is "expand_keywords" or "generate_content_brief") + { + return "keywords"; + } + + if (name.Contains("google", StringComparison.Ordinal) + || name.Contains("gsc", StringComparison.Ordinal) + || name.Contains("ga4", StringComparison.Ordinal)) + { + return "google"; + } + + if (name.Contains("backlink", StringComparison.Ordinal) + || name.Contains("competitor", StringComparison.Ordinal) + || name.Contains("bing", StringComparison.Ordinal) + || name.Contains("gsc_links", StringComparison.Ordinal)) + { + return "backlinks"; + } + + if (name.Contains("lighthouse", StringComparison.Ordinal) + || name.Contains("crux", StringComparison.Ordinal) + || name.Contains("slow", StringComparison.Ordinal) + || name.Contains("cwv", StringComparison.Ordinal)) + { + return "performance"; + } + + if (name.Contains("health", StringComparison.Ordinal) + || name.Contains("compare", StringComparison.Ordinal) + || name.Contains("alert", StringComparison.Ordinal) + || name.Contains("tech_stack", StringComparison.Ordinal) + || name == "list_pages_by_technology") + { + return "drift"; + } + + if (name.Contains("security", StringComparison.Ordinal)) + { + return "security"; + } + + if (name.Contains("log", StringComparison.Ordinal) + || name is "get_property_ops" or "list_crawl_runs" or "list_log_uploads" or "get_page_coach") + { + return "ops"; + } + + return "portfolio"; + } + + public static HashSet ToolNamesForMcpBundle(IEnumerable allToolNames, string? bundle = null) + { + var bundleKey = (bundle ?? ResolveMcpDomain()).Trim().ToLowerInvariant(); + if (!McpDomainBundles.TryGetValue(bundleKey, out var allowedDomains)) + { + allowedDomains = McpDomainBundles["core"]; + bundleKey = "core"; + } + + var allNames = allToolNames.ToHashSet(StringComparer.Ordinal); + if (bundleKey == "full") + { + allNames.ExceptWith(ChatOnlyTools); + return allNames; + } + + var byDomain = ToolsByDomain(allNames); + var names = new HashSet(StringComparer.Ordinal); + foreach (var domain in allowedDomains) + { + if (byDomain.TryGetValue(domain, out var domainTools)) + { + names.UnionWith(domainTools); + } + } + + if (bundleKey == "core") + { + names.UnionWith(Tier0Tools.Where(allNames.Contains)); + } + + names.ExceptWith(ChatOnlyTools); + return names; + } + + /// Tools in explicitly enabled canonical domains (custom bundle mode). + public static HashSet ToolNamesForEnabledDomains( + IEnumerable allToolNames, + IEnumerable enabledDomains) + { + var allowedDomains = enabledDomains + .Select(d => d.Trim().ToLowerInvariant()) + .Where(CanonicalDomains.Contains) + .ToHashSet(StringComparer.Ordinal); + + if (allowedDomains.Count == 0) + { + allowedDomains.UnionWith(["core", "insight"]); + } + + var allNames = allToolNames.ToHashSet(StringComparer.Ordinal); + var byDomain = ToolsByDomain(allNames); + var names = new HashSet(StringComparer.Ordinal); + + foreach (var domain in allowedDomains) + { + if (byDomain.TryGetValue(domain, out var domainTools)) + { + names.UnionWith(domainTools); + } + } + + if (allowedDomains.Contains("core")) + { + names.UnionWith(Tier0Tools.Where(allNames.Contains)); + } + + names.ExceptWith(ChatOnlyTools); + return names; + } + + private static Dictionary> ToolsByDomain(IEnumerable toolNames) + { + var outMap = CanonicalDomains.ToDictionary(d => d, _ => new List(), StringComparer.Ordinal); + foreach (var name in toolNames) + { + var domain = ClassifyToolDomain(name); + if (!outMap.TryGetValue(domain, out var list)) + { + list = []; + outMap[domain] = list; + } + + list.Add(name); + } + + foreach (var list in outMap.Values) + { + list.Sort(StringComparer.Ordinal); + } + + return outMap; + } + + public static int? DefaultPropertyId() + { + var raw = Environment.GetEnvironmentVariable("WP_PROPERTY_ID")?.Trim(); + if (string.IsNullOrEmpty(raw)) + { + return null; + } + + return int.TryParse(raw, out var pid) && pid > 0 ? pid : null; + } + + public static JsonObject BuildListToolsPayload( + IReadOnlyCollection exposedNames, + ToolCatalogEntryLookup catalog, + string mcpDomain) + { + var tools = new JsonArray(); + foreach (var name in exposedNames.Order(StringComparer.Ordinal)) + { + if (!catalog.TryGetEntry(name, out var entry)) + { + continue; + } + + tools.Add(new JsonObject + { + ["name"] = name, + ["description"] = entry.Description, + ["domain"] = ClassifyToolDomain(name), + ["inputSchema"] = entry.InputSchema?.DeepClone(), + }); + } + + return new JsonObject + { + ["mcp_domain"] = mcpDomain, + ["tool_count"] = tools.Count, + ["available_mcp_domains"] = new JsonArray(McpDomainBundles.Keys.Order(StringComparer.Ordinal).Select(k => JsonValue.Create(k)).ToArray()), + ["tools"] = tools, + }; + } +} diff --git a/services/AiService/src/AiService.Tools/Handlers/Insight/InsightLogic.cs b/services/AiService/src/AiService.Tools/Handlers/Insight/InsightLogic.cs new file mode 100644 index 00000000..621203c0 --- /dev/null +++ b/services/AiService/src/AiService.Tools/Handlers/Insight/InsightLogic.cs @@ -0,0 +1,293 @@ +using System.Globalization; +using System.Text.Json.Nodes; +using AiService.Tools.Models.Insight; +using AiService.Tools.Slice; +using WebsiteProfiling.Contracts.Google; +using WebsiteProfiling.Contracts.Json; + +namespace AiService.Tools.Handlers.Insight; + +/// +/// Pure cross-platform insight math — faithful port of Python +/// website_profiling.tools.audit_tools.insight.insight_helpers. Kept side-effect free so it +/// can be unit-tested against the Python behavior. +/// +public static class InsightLogic +{ + /// Coerce a JSON scalar to a double, mirroring Python float(val) with a default. + public static double Num(JsonNode? node, double @default = 0.0) => JsonCoercion.Num(node, @default); + + /// Pick (gsc_full|gsc, ga4_full|ga4) blobs from a raw google snapshot. + public static (JsonObject Gsc, JsonObject Ga4) GscGa4Blobs(JsonObject raw) + { + var gsc = raw["gsc_full"] as JsonObject ?? raw["gsc"] as JsonObject ?? new JsonObject(); + var ga4 = raw["ga4_full"] as JsonObject ?? raw["ga4"] as JsonObject ?? new JsonObject(); + return (gsc, ga4); + } + + public static ProvenanceBlock ProvenanceBlock(IReadOnlyList sources, JsonNode? fetchedAt, string confidence = "high") + => ProvenanceBlockTyped(sources, JsonCoercion.AsString(fetchedAt), confidence); + + public static ProvenanceBlock ProvenanceBlockTyped( + IReadOnlyList sources, + string? fetchedAt, + string confidence = "high") + => new() + { + Sources = sources, + FetchedAt = fetchedAt, + Confidence = confidence, + }; + + public static JsonObject ProvenanceBlockJson(IReadOnlyList sources, JsonNode? fetchedAt, string confidence = "high") + { + var block = ProvenanceBlock(sources, fetchedAt, confidence); + var arr = new JsonArray(); + foreach (var s in block.Sources) + { + arr.Add(s); + } + + return new JsonObject + { + ["sources"] = arr, + ["fetched_at"] = block.FetchedAt is { Length: > 0 } fa ? fa : null, + ["confidence"] = block.Confidence, + }; + } + + public static string ClassifyOpportunityQuadrant(GscPageRecord? gscRow, Ga4PageRecord? ga4Row, double siteMedianSessions) + { + var position = gscRow?.Position ?? 99; + var impressions = gscRow?.Impressions ?? 0; + var sessions = ga4Row?.Sessions ?? 0; + var engagement = ga4Row?.EngagementRate ?? 0; + + var rankPotential = impressions >= 100 && position >= 4 && position <= 20; + var convertPotential = sessions >= Math.Max(siteMedianSessions * 0.5, 5) || engagement >= 0.5; + + if (rankPotential && convertPotential) + { + return "high_impact"; + } + + if (rankPotential) + { + return "worth_optimizing"; + } + + return convertPotential ? "good_but_capped" : "low_priority"; + } + + public static string ClassifyOpportunityQuadrant(JsonObject? gscRow, JsonObject? ga4Row, double siteMedianSessions) + { + var position = Num(gscRow?["position"], 99); + var impressions = Num(gscRow?["impressions"]); + var sessions = Num(ga4Row?["sessions"]); + var engagement = Num(ga4Row?["engagementRate"]); + return ClassifyOpportunityQuadrant( + gscRow is null ? null : new GscPageRecord { Position = position, Impressions = (int)impressions }, + ga4Row is null ? null : new Ga4PageRecord { Sessions = (int)sessions, EngagementRate = engagement }, + siteMedianSessions); + } + + public static TrafficHealthResult TrafficHealth(GscSummary? gscSummary, Ga4Summary? ga4Summary) + { + var clicks = gscSummary?.Clicks ?? 0; + var sessions = ga4Summary?.Sessions ?? 0; + if (clicks <= 0 && sessions <= 0) + { + return new TrafficHealthResult + { + GscClicks = clicks, + Ga4Sessions = sessions, + Ratio = null, + Diagnosis = "no_data", + Note = "Connect GSC and GA4 and re-run the pipeline.", + }; + } + + double? ratio = clicks > 0 ? (double)sessions / clicks : null; + var diagnosis = "healthy"; + var note = "GSC clicks and GA4 sessions are in a plausible range."; + if (ratio is double r) + { + if (r < 0.3) + { + diagnosis = "tracking_gap"; + note = "GA4 sessions are much lower than GSC clicks — check filters, consent mode, or landing page tagging."; + } + else if (r > 3.0) + { + diagnosis = "filter_issue"; + note = "GA4 sessions exceed GSC clicks — GA4 may include non-organic traffic or GSC date range differs."; + } + } + + return new TrafficHealthResult + { + GscClicks = clicks, + Ga4Sessions = sessions, + Ratio = ratio is double rr ? Math.Round(rr, 3) : null, + Diagnosis = diagnosis, + Note = note, + }; + } + + public static JsonObject TrafficHealthRatio(JsonObject? gscSummary, JsonObject? ga4Summary) + { + GscSummary? gsc = null; + Ga4Summary? ga4 = null; + if (gscSummary is not null) + { + gsc = new GscSummary + { + Clicks = (int)Num(gscSummary["clicks"]), + Impressions = (int)Num(gscSummary["impressions"]), + Ctr = Num(gscSummary["ctr"]), + Position = Num(gscSummary["position"]), + }; + } + + if (ga4Summary is not null) + { + ga4 = new Ga4Summary + { + Sessions = (int)Num(ga4Summary["sessions"]), + ActiveUsers = (int)Num(ga4Summary["activeUsers"]), + ScreenPageViews = (int)Num(ga4Summary["screenPageViews"]), + }; + } + + var health = TrafficHealth(gsc, ga4); + return new JsonObject + { + ["gsc_clicks"] = health.GscClicks, + ["ga4_sessions"] = health.Ga4Sessions, + ["ratio"] = health.Ratio, + ["diagnosis"] = health.Diagnosis, + ["note"] = health.Note, + }; + } + + public static IReadOnlyList BlendLandingPagesTyped( + GoogleSlice google, + int limit, + int minImpressions) + { + var gscByPage = google.Gsc?.ByPage ?? new Dictionary(StringComparer.Ordinal); + var ga4ByPath = google.Ga4?.ByPath ?? new Dictionary(StringComparer.Ordinal); + + var ga4ByNorm = new Dictionary(StringComparer.Ordinal); + foreach (var (path, val) in ga4ByPath) + { + var full = !string.IsNullOrEmpty(val.FullUrl) ? val.FullUrl : path; + ga4ByNorm[GoogleUrl.NormalizeUrl(full)] = val; + ga4ByNorm[GoogleUrl.NormalizeUrl(path)] = val; + } + + var sessionVals = ga4ByNorm.Values.Select(v => (double)v.Sessions).OrderBy(x => x).ToList(); + var median = sessionVals.Count > 0 ? sessionVals[sessionVals.Count / 2] : 0.0; + + var built = new List<(LandingPageBlendedRow Row, long Clicks, long Impressions)>(); + foreach (var (pageUrl, gscRow) in gscByPage) + { + if (gscRow.Impressions < minImpressions) + { + continue; + } + + var norm = GoogleUrl.NormalizeUrl(pageUrl); + if (!ga4ByNorm.TryGetValue(norm, out var ga4Row)) + { + var path = GoogleUrl.UrlToPath(pageUrl); + ga4ByNorm.TryGetValue(GoogleUrl.NormalizeUrl(path), out ga4Row); + } + + var quadrant = ClassifyOpportunityQuadrant(gscRow, ga4Row, median); + var row = new LandingPageBlendedRow + { + Url = pageUrl, + GscClicks = gscRow.Clicks, + GscImpressions = gscRow.Impressions, + GscPosition = Math.Round(gscRow.Position, 1), + GscCtr = Math.Round(gscRow.Ctr, 4), + Ga4Sessions = ga4Row?.Sessions ?? 0, + Ga4EngagementRate = ga4Row is not null ? Math.Round(ga4Row.EngagementRate, 3) : null, + Quadrant = quadrant, + }; + built.Add((row, row.GscClicks, row.GscImpressions)); + } + + var take = Math.Max(1, Math.Min(limit, 100)); + return built + .OrderByDescending(x => x.Clicks) + .ThenByDescending(x => x.Impressions) + .Take(take) + .Select(x => x.Row) + .ToList(); + } + + /// + /// Blend GSC by_page with GA4 by_path into opportunity rows. Faithful port of + /// blend_landing_pages (median over deduped GA4 values; impression filter; clicks-desc sort). + /// + public static JsonArray BlendLandingPages(JsonObject gscByPage, JsonObject ga4ByPath, int limit, int minImpressions) + { + var gscDict = new Dictionary(StringComparer.Ordinal); + foreach (var (key, valNode) in gscByPage) + { + if (valNode is JsonObject val) + { + gscDict[key] = new GscPageRecord + { + Page = JsonCoercion.AsString(val["page"]) ?? key, + Clicks = (int)Num(val["clicks"]), + Impressions = (int)Num(val["impressions"]), + Ctr = Num(val["ctr"]), + Position = Num(val["position"]), + }; + } + } + + var ga4Dict = new Dictionary(StringComparer.Ordinal); + foreach (var (key, valNode) in ga4ByPath) + { + if (valNode is JsonObject val) + { + ga4Dict[key] = new Ga4PageRecord + { + Path = key, + FullUrl = JsonCoercion.AsString(val["full_url"]) ?? "", + Sessions = (int)Num(val["sessions"]), + EngagementRate = Num(val["engagementRate"]), + }; + } + } + + var slice = new GoogleSlice + { + Gsc = new GoogleSlice.GscBlob { ByPage = gscDict }, + Ga4 = new GoogleSlice.Ga4Blob { ByPath = ga4Dict }, + }; + + var rows = BlendLandingPagesTyped(slice, limit, minImpressions); + var result = new JsonArray(); + foreach (var row in rows) + { + result.Add(new JsonObject + { + ["url"] = row.Url, + ["gsc_clicks"] = row.GscClicks, + ["gsc_impressions"] = row.GscImpressions, + ["gsc_position"] = row.GscPosition, + ["gsc_ctr"] = row.GscCtr, + ["ga4_sessions"] = row.Ga4Sessions, + ["ga4_engagement_rate"] = row.Ga4EngagementRate, + ["quadrant"] = row.Quadrant, + }); + } + + return result; + } +} diff --git a/services/AiService/src/AiService.Tools/Handlers/Insight/InsightToolHandlers.cs b/services/AiService/src/AiService.Tools/Handlers/Insight/InsightToolHandlers.cs new file mode 100644 index 00000000..c1add022 --- /dev/null +++ b/services/AiService/src/AiService.Tools/Handlers/Insight/InsightToolHandlers.cs @@ -0,0 +1,187 @@ +using System.Text.Json.Nodes; +using AiService.Tools.Context; +using AiService.Tools.Mapping; +using AiService.Tools.Models.Insight; +using AiService.Tools.Slice; +using Npgsql; +using WebsiteProfiling.Contracts.Json; + +namespace AiService.Tools.Handlers.Insight; + +/// +/// Native cross-platform insight tools (GSC + GA4 blending). Faithful port of Python +/// website_profiling.tools.audit_tools.insight.insight_tools. +/// +public static class InsightToolHandlers +{ + public static async Task GetLandingPageBlendedTableAsync( + NpgsqlConnection conn, + AuditToolContext ctx, + JsonObject args, + CancellationToken cancellationToken) + { + var scoped = ctx.WithArgs(args); + var parsedArgs = ToolArgsMapper.Parse(args); + var result = await BuildBlendedTableAsync(conn, scoped, parsedArgs, cancellationToken); + return ToolResultMapper.ToJsonObject(result); + } + + public static async Task GetOpportunityMatrixAsync( + NpgsqlConnection conn, + AuditToolContext ctx, + JsonObject args, + CancellationToken cancellationToken) + { + var scoped = ctx.WithArgs(args); + var parsedArgs = ToolArgsMapper.Parse(args); + var blended = await BuildBlendedTableAsync(conn, scoped, parsedArgs, cancellationToken); + if (blended.Error is not null) + { + return ToolResultMapper.ToJsonObject(blended); + } + + var quadrants = new Dictionary>(StringComparer.Ordinal) + { + ["high_impact"] = [], + ["worth_optimizing"] = [], + ["good_but_capped"] = [], + ["low_priority"] = [], + }; + + foreach (var row in blended.Rows) + { + var key = row.Quadrant; + if (!quadrants.ContainsKey(key)) + { + quadrants[key] = []; + } + + quadrants[key] = quadrants[key].Append(row).ToList(); + } + + var counts = quadrants.ToDictionary(kv => kv.Key, kv => kv.Value.Count, StringComparer.Ordinal); + var highImpact = counts.GetValueOrDefault("high_impact"); + var worthOptimizing = counts.GetValueOrDefault("worth_optimizing"); + + var matrix = new OpportunityMatrixResult + { + Quadrants = quadrants, + Counts = counts, + Provenance = blended.Provenance, + Insights = + [ + $"Focus on {highImpact} high-impact pages first.", + $"{worthOptimizing} pages could rank higher with on-page work.", + ], + }; + + return ToolResultMapper.ToJsonObject(matrix); + } + + public static async Task GetTrafficHealthCheckAsync( + NpgsqlConnection conn, + AuditToolContext ctx, + JsonObject args, + CancellationToken cancellationToken) + { + var scoped = ctx.WithArgs(args); + var raw = await scoped.LoadGoogleFullAsync(conn, cancellationToken) + ?? await scoped.LoadGoogleAsync(conn, cancellationToken); + if (raw is null) + { + return ToolResultMapper.ToJsonObject(new TrafficHealthResult + { + Error = "no google data found", + Missing = true, + }); + } + + var slice = PayloadSliceMapper.ToGoogleSlice(raw); + var health = InsightLogic.TrafficHealth(slice?.Gsc?.Summary, slice?.Ga4?.Summary); + var result = health with + { + Provenance = InsightLogic.ProvenanceBlockTyped(["gsc", "ga4"], JsonCoercion.AsString(raw["fetched_at"])), + Insights = [health.Note], + }; + + return ToolResultMapper.ToJsonObject(result); + } + + private static async Task BuildBlendedTableAsync( + NpgsqlConnection conn, + AuditToolContext scoped, + BlendedTableArgs args, + CancellationToken cancellationToken) + { + var raw = await scoped.LoadGoogleFullAsync(conn, cancellationToken); + if (raw is null) + { + return new BlendedTableResult + { + Error = "no google data found", + Missing = true, + Rows = [], + }; + } + + var slice = PayloadSliceMapper.ToGoogleSlice(raw); + if (slice is null) + { + return new BlendedTableResult + { + Error = "no google data found", + Missing = true, + Rows = [], + }; + } + + if (slice.Gsc?.ByPage is not { Count: > 0 }) + { + var (gsc, _) = InsightLogic.GscGa4Blobs(raw); + if (gsc["top_pages"] is JsonArray topPages) + { + var rebuilt = new Dictionary(StringComparer.Ordinal); + foreach (var item in topPages) + { + if (item is JsonObject row + && row["page"] is JsonValue pageValue + && pageValue.TryGetValue(out var page) + && !string.IsNullOrEmpty(page)) + { + rebuilt[page] = new WebsiteProfiling.Contracts.Google.GscPageRecord + { + Page = page, + Clicks = (int)InsightLogic.Num(row["clicks"]), + Impressions = (int)InsightLogic.Num(row["impressions"]), + Ctr = InsightLogic.Num(row["ctr"]), + Position = InsightLogic.Num(row["position"]), + }; + } + } + + slice = slice with { Gsc = new WebsiteProfiling.Contracts.Google.GoogleSlice.GscBlob { ByPage = rebuilt } }; + } + } + + var limit = Math.Max(1, Math.Min(args.Limit ?? 30, 100)); + var minImpressions = Math.Max(0, Math.Min(args.MinImpressions ?? 0, 1_000_000)); + var rows = InsightLogic.BlendLandingPagesTyped(slice, limit, minImpressions); + + var highImpact = rows.Count(r => r.Quadrant == "high_impact"); + var worthOptimizing = rows.Count(r => r.Quadrant == "worth_optimizing"); + var totalPages = slice.Gsc?.ByPage.Count ?? 0; + + return new BlendedTableResult + { + Rows = rows, + Total = rows.Count, + Truncated = totalPages > limit, + Provenance = InsightLogic.ProvenanceBlockTyped(["gsc", "ga4"], JsonCoercion.AsString(raw["fetched_at"])), + Insights = + [ + $"{highImpact} high-impact landing pages", + $"{worthOptimizing} worth optimizing for rank", + ], + }; + } +} diff --git a/services/AiService/src/AiService.Tools/Handlers/Report/ReportToolHandlers.cs b/services/AiService/src/AiService.Tools/Handlers/Report/ReportToolHandlers.cs new file mode 100644 index 00000000..fe7126d0 --- /dev/null +++ b/services/AiService/src/AiService.Tools/Handlers/Report/ReportToolHandlers.cs @@ -0,0 +1,321 @@ +using System.Text.Json.Nodes; +using AiService.Tools.Context; +using Npgsql; + +namespace AiService.Tools.Handlers.Report; + +/// +/// Report summary and issue query tools. Ports Python +/// website_profiling.tools.audit_tools.report.report. +/// +public static class ReportToolHandlers +{ + private static readonly Dictionary PriorityOrder = new(StringComparer.Ordinal) + { + ["Critical"] = 0, + ["High"] = 1, + ["Medium"] = 2, + ["Low"] = 3, + }; + + private static readonly Dictionary LegacyCategoryDisplay = new(StringComparer.Ordinal) + { + ["HTML & Accessibility"] = "Accessibility & markup", + ["HTML/Accessibility"] = "Accessibility & markup", + ["Link Health"] = "Links", + ["Mobile Optimization"] = "Mobile SEO", + ["Content intelligence"] = "Content quality", + }; + + private const int IssueLimitDefault = 20; + private const int IssueLimitMax = 50; + + public static async Task GetReportSummaryAsync( + NpgsqlConnection conn, + AuditToolContext ctx, + JsonObject args, + CancellationToken cancellationToken) + { + var scoped = ctx.WithArgs(args); + var payload = await scoped.LoadPayloadAsync(conn, cancellationToken); + if (payload.Count == 0) + { + return new JsonObject { ["error"] = "no report found" }; + } + + var allIssues = IterCategoryIssues(payload); + var summary = payload["summary"] as JsonObject ?? []; + var categories = BuildCategorySummaries(payload); + + return new JsonObject + { + ["site_name"] = payload["site_name"]?.DeepClone(), + ["report_generated_at"] = payload["report_generated_at"]?.DeepClone(), + ["health_score"] = HealthScore(payload), + ["issue_counts"] = IssueCounts(allIssues), + ["total_issues"] = allIssues.Count, + ["crawl_summary"] = new JsonObject + { + ["total_urls"] = summary["total_urls"]?.DeepClone(), + ["count_2xx"] = summary["count_2xx"]?.DeepClone(), + ["count_3xx"] = summary["count_3xx"]?.DeepClone(), + ["count_4xx"] = summary["count_4xx"]?.DeepClone(), + ["count_5xx"] = summary["count_5xx"]?.DeepClone(), + ["success_rate"] = summary["success_rate"]?.DeepClone(), + }, + ["categories"] = categories, + ["property_id"] = scoped.PropertyId, + ["report_id"] = scoped.ReportId, + }; + } + + public static async Task ListIssuesAsync( + NpgsqlConnection conn, + AuditToolContext ctx, + JsonObject args, + CancellationToken cancellationToken) + { + var scoped = ctx.WithArgs(args); + var payload = await scoped.LoadPayloadAsync(conn, cancellationToken); + if (payload.Count == 0) + { + return new JsonObject + { + ["error"] = "no report found", + ["issues"] = new JsonArray(), + ["total"] = 0, + ["truncated"] = false, + }; + } + + var limit = ParseIssueLimit(args); + var priorityFilter = NormalizePriority(GetStringArg(args, "priority")); + var categoryId = GetStringArg(args, "category_id"); + var urlContains = GetStringArg(args, "url_contains").ToLowerInvariant(); + + var issues = IterCategoryIssues(payload); + if (!string.IsNullOrEmpty(priorityFilter)) + { + issues = issues.Where(i => string.Equals(i["priority"]?.GetValue(), priorityFilter, StringComparison.Ordinal)).ToList(); + } + + if (!string.IsNullOrEmpty(categoryId)) + { + issues = issues.Where(i => string.Equals(i["category_id"]?.GetValue(), categoryId, StringComparison.Ordinal)).ToList(); + } + + if (!string.IsNullOrEmpty(urlContains)) + { + issues = issues.Where(i => (i["url"]?.GetValue() ?? string.Empty).ToLowerInvariant().Contains(urlContains, StringComparison.Ordinal)).ToList(); + } + + var total = issues.Count; + var truncated = total > limit; + var page = new JsonArray(); + foreach (var issue in issues.Take(limit)) + { + page.Add(issue); + } + + return new JsonObject + { + ["issues"] = page, + ["total"] = total, + ["truncated"] = truncated, + }; + } + + public static Task GetCriticalIssuesAsync( + NpgsqlConnection conn, + AuditToolContext ctx, + JsonObject args, + CancellationToken cancellationToken) + { + var withPriority = args.DeepClone() as JsonObject ?? []; + withPriority["priority"] = "Critical"; + return ListIssuesAsync(conn, ctx, withPriority, cancellationToken); + } + + private static List IterCategoryIssues(JsonObject payload) + { + var rows = new List(); + if (payload["categories"] is not JsonArray categories) + { + return rows; + } + + foreach (var catNode in categories) + { + if (catNode is not JsonObject cat) + { + continue; + } + + var catId = cat["id"]?.GetValue() ?? string.Empty; + var catName = CategoryDisplayName(cat["name"]?.GetValue() ?? catId); + if (cat["issues"] is not JsonArray issueList) + { + continue; + } + + foreach (var issueNode in issueList) + { + if (issueNode is not JsonObject issue) + { + continue; + } + + var rec = issue["llm_recommendation"]?.GetValue() + ?? issue["recommendation"]?.GetValue() + ?? string.Empty; + var row = new JsonObject + { + ["category_id"] = catId, + ["category"] = catName, + ["priority"] = issue["priority"]?.GetValue() ?? "Medium", + ["message"] = issue["message"]?.GetValue() ?? string.Empty, + ["url"] = issue["url"]?.GetValue() ?? string.Empty, + ["recommendation"] = rec, + }; + + foreach (var key in new[] { "impact_score", "gsc_clicks", "gsc_impressions", "ga4_sessions" }) + { + if (issue.TryGetPropertyValue(key, out var value) && value is not null) + { + row[key] = value.DeepClone(); + } + } + + rows.Add(row); + } + } + + rows.Sort((a, b) => + { + var pa = PriorityOrder.GetValueOrDefault(a["priority"]?.GetValue() ?? "Low", 99); + var pb = PriorityOrder.GetValueOrDefault(b["priority"]?.GetValue() ?? "Low", 99); + return pa.CompareTo(pb); + }); + + return rows; + } + + private static JsonArray BuildCategorySummaries(JsonObject payload) + { + var categories = new JsonArray(); + if (payload["categories"] is not JsonArray source) + { + return categories; + } + + foreach (var catNode in source) + { + if (catNode is not JsonObject cat) + { + continue; + } + + var issueCount = cat["issues"] is JsonArray issues ? issues.Count : 0; + categories.Add(new JsonObject + { + ["id"] = cat["id"]?.DeepClone(), + ["name"] = CategoryDisplayName(cat["name"]?.GetValue() ?? string.Empty), + ["score"] = cat["score"]?.DeepClone(), + ["issue_count"] = issueCount, + }); + } + + return categories; + } + + private static JsonObject IssueCounts(IReadOnlyList issues) + { + var counts = new JsonObject + { + ["Critical"] = 0, + ["High"] = 0, + ["Medium"] = 0, + ["Low"] = 0, + }; + + foreach (var issue in issues) + { + var priority = issue["priority"]?.GetValue() ?? "Medium"; + if (counts.TryGetPropertyValue(priority, out var current) && current is JsonValue value && value.TryGetValue(out int count)) + { + counts[priority] = count + 1; + } + } + + return counts; + } + + private static int? HealthScore(JsonObject payload) + { + if (payload["categories"] is not JsonArray categories) + { + return null; + } + + var scores = new List(); + foreach (var catNode in categories) + { + if (catNode is not JsonObject cat) + { + continue; + } + + if (cat["score"] is JsonValue scoreValue && scoreValue.TryGetValue(out double score)) + { + scores.Add(score); + } + } + + if (scores.Count == 0) + { + return null; + } + + return (int)Math.Round(scores.Average(), MidpointRounding.AwayFromZero); + } + + private static string CategoryDisplayName(string name) + { + if (string.IsNullOrWhiteSpace(name)) + { + return string.Empty; + } + + return LegacyCategoryDisplay.GetValueOrDefault(name, name); + } + + private static string NormalizePriority(string raw) + { + var trimmed = raw.Trim(); + if (string.IsNullOrEmpty(trimmed)) + { + return string.Empty; + } + + var cap = char.ToUpperInvariant(trimmed[0]) + trimmed[1..].ToLowerInvariant(); + return PriorityOrder.ContainsKey(cap) ? cap : trimmed; + } + + private static int ParseIssueLimit(JsonObject args) + { + if (args.TryGetPropertyValue("limit", out var limitNode) && limitNode is JsonValue value && value.TryGetValue(out int limit)) + { + return Math.Max(1, Math.Min(limit, IssueLimitMax)); + } + + if (int.TryParse(limitNode?.ToString(), out var parsed)) + { + return Math.Max(1, Math.Min(parsed, IssueLimitMax)); + } + + return IssueLimitDefault; + } + + private static string GetStringArg(JsonObject args, string key) + => args.TryGetPropertyValue(key, out var node) ? node?.GetValue() ?? string.Empty : string.Empty; +} diff --git a/services/AiService/src/AiService.Tools/Mapping/CrawlRowMapper.cs b/services/AiService/src/AiService.Tools/Mapping/CrawlRowMapper.cs new file mode 100644 index 00000000..5b8b0710 --- /dev/null +++ b/services/AiService/src/AiService.Tools/Mapping/CrawlRowMapper.cs @@ -0,0 +1,80 @@ +using System.Text.Json.Nodes; +using AiService.Tools.Slice; +using WebsiteProfiling.Contracts.Crawl; + +namespace AiService.Tools.Mapping; + +public static class CrawlRowMapper +{ + public static CrawlRow FromJsonObject(JsonObject row) + { + var schemaTypes = CrawlFilter.RowSchemaTypesList(row); + return new CrawlRow + { + Url = NodeStr(row["url"]), + FetchMethod = NodeStr(row["fetch_method"]), + Status = NodeStr(row["status"]), + Title = NodeStr(row["title"]), + HasSchema = CrawlFilter.RowHasSchema(row), + SchemaTypes = schemaTypes, + PageAnalysisJson = row["page_analysis"]?.ToJsonString(), + }; + } + + public static IReadOnlyList FromJsonObjects(IReadOnlyList? rows) + { + if (rows is null || rows.Count == 0) + { + return []; + } + + return rows.Select(FromJsonObject).ToList(); + } + + public static JsonObject ToJsonObject(CrawlRow row) + { + var obj = new JsonObject + { + ["url"] = row.Url, + ["fetch_method"] = row.FetchMethod, + ["status"] = row.Status, + ["title"] = row.Title, + ["has_schema"] = row.HasSchema, + ["schema_types"] = new JsonArray(row.SchemaTypes.Select(t => JsonValue.Create(t)).ToArray()), + }; + + if (!string.IsNullOrEmpty(row.PageAnalysisJson)) + { + try + { + obj["page_analysis"] = JsonNode.Parse(row.PageAnalysisJson); + } + catch (System.Text.Json.JsonException) + { + obj["page_analysis"] = row.PageAnalysisJson; + } + } + + return obj; + } + + private static string NodeStr(JsonNode? node) + { + if (node is null) + { + return ""; + } + + if (node is JsonValue v) + { + if (v.TryGetValue(out var s)) + { + return s ?? ""; + } + + return v.ToString() ?? ""; + } + + return ""; + } +} diff --git a/services/AiService/src/AiService.Tools/Mapping/PayloadSliceMapper.cs b/services/AiService/src/AiService.Tools/Mapping/PayloadSliceMapper.cs new file mode 100644 index 00000000..371a3540 --- /dev/null +++ b/services/AiService/src/AiService.Tools/Mapping/PayloadSliceMapper.cs @@ -0,0 +1,175 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using WebsiteProfiling.Contracts.Google; +using WebsiteProfiling.Contracts.Json; +using WebsiteProfiling.Contracts.Report; + +namespace AiService.Tools.Mapping; + +public static class PayloadSliceMapper +{ + public static GoogleSlice? ToGoogleSlice(JsonObject? raw) + { + if (raw is null) + { + return null; + } + + var (gscJson, ga4Json) = GscGa4JsonBlobs(raw); + return new GoogleSlice + { + Gsc = MapGscBlob(gscJson), + Ga4 = MapGa4Blob(ga4Json), + FetchedAt = JsonCoercion.AsString(raw["fetched_at"]), + }; + } + + public static ReportMetaSlice? ToReportMetaSlice(JsonObject? payload) + { + if (payload is null) + { + return null; + } + + var meta = payload["report_meta"] as JsonObject; + var sources = new List(); + if (meta?["data_sources"] is JsonArray arr) + { + foreach (var item in arr) + { + if (JsonCoercion.AsString(item) is { Length: > 0 } s) + { + sources.Add(s); + } + } + } + + return new ReportMetaSlice + { + CrawlRunId = JsonCoercion.AsInt(payload["crawl_run_id"]), + GeneratedAt = JsonCoercion.AsString(meta?["generated_at"]), + ReportGeneratedAt = JsonCoercion.AsString(payload["report_generated_at"]), + SiteName = JsonCoercion.AsString(payload["site_name"]), + DataSources = sources, + }; + } + + public static IssuesBucketSlice? ToIssuesBucketSlice(JsonObject? payload) + { + if (payload?["issues"] is not JsonObject issues) + { + return null; + } + + return new IssuesBucketSlice + { + Critical = ParseIssueList(issues["critical"]), + High = ParseIssueList(issues["high"]), + Medium = ParseIssueList(issues["medium"]), + Low = ParseIssueList(issues["low"]), + }; + } + + private static IReadOnlyList ParseIssueList(JsonNode? node) + { + if (node is not JsonArray arr) + { + return []; + } + + var list = new List(); + foreach (var item in arr) + { + if (item is not JsonObject obj) + { + continue; + } + + try + { + var record = JsonSerializer.Deserialize(obj.ToJsonString(), ContractJsonOptions.Options); + if (record is not null) + { + list.Add(record); + } + } + catch (JsonException) + { + // skip malformed issue rows + } + } + + return list; + } + + private static (JsonObject Gsc, JsonObject Ga4) GscGa4JsonBlobs(JsonObject raw) + { + var gsc = raw["gsc_full"] as JsonObject ?? raw["gsc"] as JsonObject ?? []; + var ga4 = raw["ga4_full"] as JsonObject ?? raw["ga4"] as JsonObject ?? []; + return (gsc, ga4); + } + + private static GoogleSlice.GscBlob? MapGscBlob(JsonObject gsc) + { + if (gsc.Count == 0) + { + return null; + } + + GscSummary? summary = null; + if (gsc["summary"] is JsonObject summaryObj) + { + summary = JsonSerializer.Deserialize(summaryObj.ToJsonString(), ContractJsonOptions.Options); + } + + var byPage = new Dictionary(StringComparer.Ordinal); + if (gsc["by_page"] is JsonObject bp) + { + foreach (var (key, val) in bp) + { + if (val is JsonObject pageObj) + { + var record = JsonSerializer.Deserialize(pageObj.ToJsonString(), ContractJsonOptions.Options); + if (record is not null) + { + byPage[key] = record with { Page = string.IsNullOrEmpty(record.Page) ? key : record.Page }; + } + } + } + } + + return new GoogleSlice.GscBlob { Summary = summary, ByPage = byPage }; + } + + private static GoogleSlice.Ga4Blob? MapGa4Blob(JsonObject ga4) + { + if (ga4.Count == 0) + { + return null; + } + + Ga4Summary? summary = null; + if (ga4["summary"] is JsonObject summaryObj) + { + summary = JsonSerializer.Deserialize(summaryObj.ToJsonString(), ContractJsonOptions.Options); + } + + var byPath = new Dictionary(StringComparer.Ordinal); + if (ga4["by_path"] is JsonObject bp) + { + foreach (var (key, val) in bp) + { + if (val is JsonObject pageObj) + { + var record = JsonSerializer.Deserialize(pageObj.ToJsonString(), ContractJsonOptions.Options); + if (record is not null) + { + byPath[key] = record with { Path = string.IsNullOrEmpty(record.Path) ? key : record.Path }; + } + } + } + } + + return new GoogleSlice.Ga4Blob { Summary = summary, ByPath = byPath }; + } +} diff --git a/services/AiService/src/AiService.Tools/Mapping/ToolArgsMapper.cs b/services/AiService/src/AiService.Tools/Mapping/ToolArgsMapper.cs new file mode 100644 index 00000000..aadcc744 --- /dev/null +++ b/services/AiService/src/AiService.Tools/Mapping/ToolArgsMapper.cs @@ -0,0 +1,21 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using WebsiteProfiling.Contracts.Json; + +namespace AiService.Tools.Mapping; + +public static class ToolArgsMapper +{ + public static T Parse(JsonObject args) where T : new() + { + try + { + var json = args.ToJsonString(); + return JsonSerializer.Deserialize(json, ContractJsonOptions.Options) ?? new T(); + } + catch (JsonException) + { + return new T(); + } + } +} diff --git a/services/AiService/src/AiService.Tools/Mapping/ToolResultMapper.cs b/services/AiService/src/AiService.Tools/Mapping/ToolResultMapper.cs new file mode 100644 index 00000000..b0a35ee9 --- /dev/null +++ b/services/AiService/src/AiService.Tools/Mapping/ToolResultMapper.cs @@ -0,0 +1,14 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using WebsiteProfiling.Contracts.Json; + +namespace AiService.Tools.Mapping; + +public static class ToolResultMapper +{ + public static JsonObject ToJsonObject(T value) + { + var json = JsonSerializer.Serialize(value, ContractJsonOptions.Options); + return JsonNode.Parse(json) as JsonObject ?? []; + } +} diff --git a/services/AiService/src/AiService.Tools/Models/Core/Tier0ToolModels.cs b/services/AiService/src/AiService.Tools/Models/Core/Tier0ToolModels.cs new file mode 100644 index 00000000..ab4899eb --- /dev/null +++ b/services/AiService/src/AiService.Tools/Models/Core/Tier0ToolModels.cs @@ -0,0 +1,37 @@ +using System.Text.Json.Serialization; + +namespace AiService.Tools.Models.Core; + +/// Args for Tier-0 router/SQL tools (batch 3 migration). +public sealed record RunSqlQueryArgs +{ + [JsonPropertyName("query")] + public string Query { get; init; } = ""; + + [JsonPropertyName("limit")] + public int? Limit { get; init; } +} + +public sealed record SearchAuditToolsArgs +{ + [JsonPropertyName("query")] + public string Query { get; init; } = ""; + + [JsonPropertyName("limit")] + public int? Limit { get; init; } +} + +public sealed record RunDomainAgentArgs +{ + [JsonPropertyName("domain")] + public string Domain { get; init; } = ""; + + [JsonPropertyName("goal")] + public string Goal { get; init; } = ""; +} + +public sealed record RunWorkflowArgs +{ + [JsonPropertyName("workflow")] + public string Workflow { get; init; } = ""; +} diff --git a/services/AiService/src/AiService.Tools/Models/Insight/InsightToolModels.cs b/services/AiService/src/AiService.Tools/Models/Insight/InsightToolModels.cs new file mode 100644 index 00000000..004e99f8 --- /dev/null +++ b/services/AiService/src/AiService.Tools/Models/Insight/InsightToolModels.cs @@ -0,0 +1,109 @@ +using System.Text.Json.Serialization; +using WebsiteProfiling.Contracts.Google; + +namespace AiService.Tools.Models.Insight; + +public sealed record BlendedTableArgs +{ + [JsonPropertyName("limit")] + public int? Limit { get; init; } + + [JsonPropertyName("min_impressions")] + public int? MinImpressions { get; init; } +} + +public sealed record LandingPageBlendedRow +{ + [JsonPropertyName("url")] + public string Url { get; init; } = ""; + + [JsonPropertyName("gsc_clicks")] + public long GscClicks { get; init; } + + [JsonPropertyName("gsc_impressions")] + public long GscImpressions { get; init; } + + [JsonPropertyName("gsc_position")] + public double GscPosition { get; init; } + + [JsonPropertyName("gsc_ctr")] + public double GscCtr { get; init; } + + [JsonPropertyName("ga4_sessions")] + public long Ga4Sessions { get; init; } + + [JsonPropertyName("ga4_engagement_rate")] + public double? Ga4EngagementRate { get; init; } + + [JsonPropertyName("quadrant")] + public string Quadrant { get; init; } = "low_priority"; +} + +public sealed record BlendedTableResult +{ + [JsonPropertyName("rows")] + public IReadOnlyList Rows { get; init; } = []; + + [JsonPropertyName("total")] + public int Total { get; init; } + + [JsonPropertyName("truncated")] + public bool Truncated { get; init; } + + [JsonPropertyName("provenance")] + public ProvenanceBlock? Provenance { get; init; } + + [JsonPropertyName("insights")] + public IReadOnlyList Insights { get; init; } = []; + + [JsonPropertyName("error")] + public string? Error { get; init; } + + [JsonPropertyName("missing")] + public bool? Missing { get; init; } +} + +public sealed record OpportunityMatrixResult +{ + [JsonPropertyName("quadrants")] + public Dictionary> Quadrants { get; init; } = new(StringComparer.Ordinal); + + [JsonPropertyName("counts")] + public Dictionary Counts { get; init; } = new(StringComparer.Ordinal); + + [JsonPropertyName("provenance")] + public ProvenanceBlock? Provenance { get; init; } + + [JsonPropertyName("insights")] + public IReadOnlyList Insights { get; init; } = []; +} + +public sealed record TrafficHealthResult +{ + [JsonPropertyName("gsc_clicks")] + public double GscClicks { get; init; } + + [JsonPropertyName("ga4_sessions")] + public double Ga4Sessions { get; init; } + + [JsonPropertyName("ratio")] + public double? Ratio { get; init; } + + [JsonPropertyName("diagnosis")] + public string Diagnosis { get; init; } = ""; + + [JsonPropertyName("note")] + public string Note { get; init; } = ""; + + [JsonPropertyName("provenance")] + public ProvenanceBlock? Provenance { get; init; } + + [JsonPropertyName("insights")] + public IReadOnlyList Insights { get; init; } = []; + + [JsonPropertyName("error")] + public string? Error { get; init; } + + [JsonPropertyName("missing")] + public bool? Missing { get; init; } +} diff --git a/services/AiService/src/AiService.Tools/Modules/ToolHandlerModules.cs b/services/AiService/src/AiService.Tools/Modules/ToolHandlerModules.cs new file mode 100644 index 00000000..10035608 --- /dev/null +++ b/services/AiService/src/AiService.Tools/Modules/ToolHandlerModules.cs @@ -0,0 +1,59 @@ +using AiService.Tools.Handlers.Insight; +using AiService.Tools.Handlers.Report; +using AiService.Tools.Registry; + +namespace AiService.Tools.Modules; + +/// +/// Registers native C# audit tool handlers by domain. Extend one module at a time as tools are ported from Python. +/// +public static class ToolHandlerModules +{ + public static IEnumerable AllHandlers() + { + foreach (var handler in CoreModule()) + { + yield return handler; + } + + foreach (var handler in PortfolioModule()) + { + yield return handler; + } + + foreach (var handler in IssuesModule()) + { + yield return handler; + } + + foreach (var handler in InsightModule()) + { + yield return handler; + } + } + + /// Tier-0 router tools and SQL (when ported). + public static IEnumerable CoreModule() + => Array.Empty(); + + /// Report overview and portfolio reads. + public static IEnumerable PortfolioModule() + { + yield return new DelegatingToolHandler("get_report_summary", ReportToolHandlers.GetReportSummaryAsync); + } + + /// Issues and prioritization (partial port). + public static IEnumerable IssuesModule() + { + yield return new DelegatingToolHandler("list_issues", ReportToolHandlers.ListIssuesAsync); + yield return new DelegatingToolHandler("get_critical_issues", ReportToolHandlers.GetCriticalIssuesAsync); + } + + /// Blended insight and opportunity tools (native GSC/GA4 blending). + public static IEnumerable InsightModule() + { + yield return new DelegatingToolHandler("get_landing_page_blended_table", InsightToolHandlers.GetLandingPageBlendedTableAsync); + yield return new DelegatingToolHandler("get_opportunity_matrix", InsightToolHandlers.GetOpportunityMatrixAsync); + yield return new DelegatingToolHandler("get_traffic_health_check", InsightToolHandlers.GetTrafficHealthCheckAsync); + } +} diff --git a/services/AiService/src/AiService.Tools/Options/DatabaseOptions.cs b/services/AiService/src/AiService.Tools/Options/DatabaseOptions.cs new file mode 100644 index 00000000..662e6ef0 --- /dev/null +++ b/services/AiService/src/AiService.Tools/Options/DatabaseOptions.cs @@ -0,0 +1,23 @@ +namespace AiService.Tools.Options; + +/// +/// Postgres connection settings for audit tool handlers. is the libpq URI +/// from env DATABASE_URL (the same value the Python services use); it is converted to an Npgsql +/// keyword connection string by . +/// +public sealed class DatabaseOptions +{ + public const string SectionName = "Database"; + + /// libpq URI or Npgsql keyword string (env override: DATABASE_URL). + public string ConnectionString { get; set; } = ""; + + /// Minimum pooled connections — mirrors the Python psycopg pool (DB_POOL_MIN, default 2). + public int MinPoolSize { get; set; } = 2; + + /// Maximum pooled connections — mirrors the Python psycopg pool (DB_POOL_MAX, default 20). + public int MaxPoolSize { get; set; } = 20; + + /// Per-query command timeout (seconds). + public int CommandTimeoutSeconds { get; set; } = 30; +} diff --git a/services/AiService/src/AiService.Tools/Options/FastApiOptions.cs b/services/AiService/src/AiService.Tools/Options/FastApiOptions.cs new file mode 100644 index 00000000..265e509d --- /dev/null +++ b/services/AiService/src/AiService.Tools/Options/FastApiOptions.cs @@ -0,0 +1,13 @@ +namespace AiService.Tools.Options; + +/// +/// FastAPI upstream used by for tools not yet ported to C#. +/// Env override: FASTAPI_URL. +/// +public sealed class FastApiOptions +{ + public const string SectionName = "FastApi"; + + /// FastAPI base URL. Default matches local compose / BFF upstream. + public string BaseUrl { get; set; } = "http://127.0.0.1:8001"; +} diff --git a/services/AiService/src/AiService.Tools/Persistence/NpgsqlDsn.cs b/services/AiService/src/AiService.Tools/Persistence/NpgsqlDsn.cs new file mode 100644 index 00000000..af426629 --- /dev/null +++ b/services/AiService/src/AiService.Tools/Persistence/NpgsqlDsn.cs @@ -0,0 +1,93 @@ +using Npgsql; + +namespace AiService.Tools.Persistence; + +/// +/// Converts a libpq connection URI (postgres://user:pass@host:port/db?connect_timeout=3) — the +/// form used by DATABASE_URL across the Python services — into an Npgsql keyword connection +/// string. Npgsql's parser does NOT accept the postgres:// URI form and throws on it, so this +/// conversion is mandatory. A string that is already in keyword form is passed through unchanged. +/// +public static class NpgsqlDsn +{ + public static string ToNpgsql(string raw) + { + if (string.IsNullOrWhiteSpace(raw)) + { + throw new InvalidOperationException( + "DATABASE_URL is not set. Example: postgres://user:pass@host:5432/website_profiling"); + } + + var s = raw.Trim(); + var isUri = s.StartsWith("postgres://", StringComparison.OrdinalIgnoreCase) + || s.StartsWith("postgresql://", StringComparison.OrdinalIgnoreCase); + if (!isUri) + { + return s; + } + + var uri = new Uri(s); + var b = new NpgsqlConnectionStringBuilder + { + Host = Uri.UnescapeDataString(uri.Host), + Port = uri.IsDefaultPort || uri.Port <= 0 ? 5432 : uri.Port, + Database = Uri.UnescapeDataString(uri.AbsolutePath.TrimStart('/')), + }; + + var userInfo = uri.UserInfo.Split(':', 2); + if (userInfo.Length > 0 && userInfo[0].Length > 0) + { + b.Username = Uri.UnescapeDataString(userInfo[0]); + } + + if (userInfo.Length > 1) + { + b.Password = Uri.UnescapeDataString(userInfo[1]); + } + + foreach (var (key, value) in ParseQuery(uri.Query)) + { + switch (key.ToLowerInvariant()) + { + case "connect_timeout": + if (int.TryParse(value, out var t)) + { + b.Timeout = t; + } + + break; + case "sslmode": + if (Enum.TryParse(value, ignoreCase: true, out var mode)) + { + b.SslMode = mode; + } + + break; + case "application_name": + b.ApplicationName = value; + break; + } + } + + return b.ConnectionString; + } + + private static IEnumerable> ParseQuery(string query) + { + var q = query.TrimStart('?'); + if (q.Length == 0) + { + yield break; + } + + foreach (var part in q.Split('&', StringSplitOptions.RemoveEmptyEntries)) + { + var idx = part.IndexOf('='); + yield return idx < 0 + ? new KeyValuePair(Uri.UnescapeDataString(part), "") + : new KeyValuePair( + Uri.UnescapeDataString(part[..idx]), + Uri.UnescapeDataString(part[(idx + 1)..])); + } + } +} diff --git a/services/AiService/src/AiService.Tools/Registry/DelegatingToolHandler.cs b/services/AiService/src/AiService.Tools/Registry/DelegatingToolHandler.cs new file mode 100644 index 00000000..13df3375 --- /dev/null +++ b/services/AiService/src/AiService.Tools/Registry/DelegatingToolHandler.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Nodes; +using AiService.Tools.Context; +using Npgsql; + +namespace AiService.Tools.Registry; + +internal sealed class DelegatingToolHandler( + string toolName, + Func> handle) : IToolHandler +{ + public string ToolName { get; } = toolName; + + public Task HandleAsync( + NpgsqlConnection conn, + AuditToolContext ctx, + JsonObject args, + CancellationToken cancellationToken) + => handle(conn, ctx, args, cancellationToken); +} diff --git a/services/AiService/src/AiService.Tools/Registry/IToolHandler.cs b/services/AiService/src/AiService.Tools/Registry/IToolHandler.cs new file mode 100644 index 00000000..12017fa1 --- /dev/null +++ b/services/AiService/src/AiService.Tools/Registry/IToolHandler.cs @@ -0,0 +1,17 @@ +using System.Text.Json.Nodes; +using AiService.Tools.Context; +using Npgsql; + +namespace AiService.Tools.Registry; + +/// Single audit tool handler — mirrors Python registry call signature. +public interface IToolHandler +{ + string ToolName { get; } + + Task HandleAsync( + NpgsqlConnection conn, + AuditToolContext ctx, + JsonObject args, + CancellationToken cancellationToken); +} diff --git a/services/AiService/src/AiService.Tools/Registry/ToolCatalog.cs b/services/AiService/src/AiService.Tools/Registry/ToolCatalog.cs new file mode 100644 index 00000000..315b43da --- /dev/null +++ b/services/AiService/src/AiService.Tools/Registry/ToolCatalog.cs @@ -0,0 +1,79 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace AiService.Tools.Registry; + +/// +/// Loads the embedded tool_catalog.json and exposes OpenAI-compatible function definitions. +/// Mirrors Python TOOL_DEFINITIONS from tool_catalog.py. +/// +public sealed class ToolCatalog +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + }; + + private readonly IReadOnlyList _toolDefinitions; + private readonly IReadOnlyDictionary _byName; + + public ToolCatalog() + { + var assembly = typeof(ToolCatalog).Assembly; + const string resourceName = "AiService.Tools.tool_catalog.json"; + using var stream = assembly.GetManifestResourceStream(resourceName) + ?? throw new InvalidOperationException($"Embedded resource not found: {resourceName}"); + + using var reader = new StreamReader(stream); + var json = reader.ReadToEnd(); + var entries = JsonSerializer.Deserialize>(json, JsonOptions) + ?? throw new InvalidOperationException("tool_catalog.json did not deserialize."); + + var definitions = new List(entries.Count); + var byName = new Dictionary(entries.Count, StringComparer.Ordinal); + + foreach (var entry in entries) + { + var definition = new JsonObject + { + ["type"] = "function", + ["function"] = new JsonObject + { + ["name"] = entry.Name, + ["description"] = entry.Description, + ["parameters"] = entry.InputSchema?.DeepClone() ?? new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonObject(), + ["required"] = new JsonArray(), + }, + }, + }; + + definitions.Add(definition); + byName[entry.Name] = definition; + } + + _toolDefinitions = definitions; + _byName = byName; + } + + /// OpenAI chat-completions tool schema entries (type=function). + public IReadOnlyList ToolDefinitions => _toolDefinitions; + + public bool TryGetDefinition(string toolName, out JsonObject? definition) + => _byName.TryGetValue(toolName, out definition); + + public IEnumerable ToolNames => _byName.Keys; + + private sealed class CatalogEntry + { + public string Name { get; set; } = ""; + + public string Description { get; set; } = ""; + + [JsonPropertyName("inputSchema")] + public JsonObject? InputSchema { get; set; } + } +} diff --git a/services/AiService/src/AiService.Tools/Registry/ToolCatalogEntryLookup.cs b/services/AiService/src/AiService.Tools/Registry/ToolCatalogEntryLookup.cs new file mode 100644 index 00000000..57ae2d28 --- /dev/null +++ b/services/AiService/src/AiService.Tools/Registry/ToolCatalogEntryLookup.cs @@ -0,0 +1,29 @@ +using System.Text.Json.Nodes; +using AiService.Tools.Registry; + +namespace AiService.Tools.Registry; + +/// Lightweight catalog entry lookup for MCP list_tools responses. +public sealed class ToolCatalogEntryLookup(ToolCatalog catalog) +{ + public bool TryGetEntry(string name, out CatalogEntryView entry) + { + entry = default!; + if (!catalog.TryGetDefinition(name, out var definition) || definition is null) + { + return false; + } + + if (definition["function"] is not JsonObject fn) + { + return false; + } + + entry = new CatalogEntryView( + fn["description"]?.GetValue() ?? "", + fn["parameters"] as JsonObject); + return true; + } +} + +public readonly record struct CatalogEntryView(string Description, JsonObject? InputSchema); diff --git a/services/AiService/src/AiService.Tools/Registry/ToolDispatcher.cs b/services/AiService/src/AiService.Tools/Registry/ToolDispatcher.cs new file mode 100644 index 00000000..0c6a0390 --- /dev/null +++ b/services/AiService/src/AiService.Tools/Registry/ToolDispatcher.cs @@ -0,0 +1,47 @@ +using System.Text.Json.Nodes; +using AiService.Tools.Bridge; +using AiService.Tools.Context; +using Npgsql; + +namespace AiService.Tools.Registry; + +/// +/// Dispatches a tool call using a pooled Postgres connection. Native C# handlers take +/// precedence; unported tools fall back to the Python FastAPI audit-tool bridge. +/// +public sealed class ToolDispatcher( + NpgsqlDataSource dataSource, + ToolRegistry registry, + PythonToolBridgeClient pythonBridge) +{ + public async Task DispatchAsync( + string toolName, + AuditToolContext ctx, + JsonObject args, + CancellationToken cancellationToken = default) + { + if (registry.TryGet(toolName, out var handler) && handler is not null) + { + await using var conn = await dataSource.OpenConnectionAsync(cancellationToken); + return await handler.HandleAsync(conn, ctx, args, cancellationToken); + } + + if (ctx.PropertyId is not int propertyId) + { + return new JsonObject { ["error"] = "property_id required" }; + } + + return await pythonBridge.InvokeAsync(toolName, args, propertyId, ctx.ReportId, cancellationToken); + } + + public async Task DispatchAsync( + string toolName, + int propertyId, + int? reportId, + JsonObject args, + CancellationToken cancellationToken = default) + { + var ctx = new AuditToolContext { PropertyId = propertyId, ReportId = reportId }; + return await DispatchAsync(toolName, ctx, args, cancellationToken); + } +} diff --git a/services/AiService/src/AiService.Tools/Registry/ToolRegistry.cs b/services/AiService/src/AiService.Tools/Registry/ToolRegistry.cs new file mode 100644 index 00000000..dddfb465 --- /dev/null +++ b/services/AiService/src/AiService.Tools/Registry/ToolRegistry.cs @@ -0,0 +1,39 @@ +namespace AiService.Tools.Registry; + +/// Maps tool names to C# handlers registered at startup. +public sealed class ToolRegistry +{ + private readonly Dictionary _handlers = new(StringComparer.Ordinal); + + public void Register(IToolHandler handler) + { + ArgumentNullException.ThrowIfNull(handler); + _handlers[handler.ToolName] = handler; + } + + public void RegisterRange(IEnumerable handlers) + { + foreach (var handler in handlers) + { + Register(handler); + } + } + + public IToolHandler GetRequired(string toolName) + { + if (_handlers.TryGetValue(toolName, out var handler)) + { + return handler; + } + + throw new KeyNotFoundException($"Unknown audit tool: {toolName}"); + } + + public bool TryGet(string toolName, out IToolHandler? handler) + => _handlers.TryGetValue(toolName, out handler); + + public bool TryGetHandler(string toolName, out IToolHandler? handler) + => TryGet(toolName, out handler); + + public IReadOnlyCollection RegisteredToolNames => _handlers.Keys; +} diff --git a/services/AiService/src/AiService.Tools/Selection/AuditToolSelection.cs b/services/AiService/src/AiService.Tools/Selection/AuditToolSelection.cs new file mode 100644 index 00000000..225f4df5 --- /dev/null +++ b/services/AiService/src/AiService.Tools/Selection/AuditToolSelection.cs @@ -0,0 +1,219 @@ +using System.Text.Json; +using AiService.Domain.Repositories; +using AiService.Tools.Domain; +using AiService.Tools.Registry; +using Microsoft.Extensions.Caching.Memory; + +namespace AiService.Tools.Selection; + +/// +/// Resolves which audit tools are enabled from pipeline config, env, and per-tool opt-outs. +/// Shared by MCP, chat, and /api/mcp-tools. +/// +public sealed class AuditToolSelectionService( + IPipelineConfigRepository pipelineConfigRepository, + ToolCatalog catalog, + IMemoryCache cache) +{ + private static readonly TimeSpan ConfigCacheTtl = TimeSpan.FromSeconds(30); + + public async Task GetSnapshotAsync(CancellationToken cancellationToken = default) + { + return await cache.GetOrCreateAsync( + "audit-tool-selection", + async entry => + { + entry.AbsoluteExpirationRelativeToNow = ConfigCacheTtl; + return await BuildSnapshotAsync(cancellationToken); + }) ?? await BuildSnapshotAsync(cancellationToken); + } + + public async Task> GetEnabledToolNamesAsync(CancellationToken cancellationToken = default) + { + var snapshot = await GetSnapshotAsync(cancellationToken); + return snapshot.EnabledToolNames; + } + + public async Task IsToolEnabledAsync(string toolName, CancellationToken cancellationToken = default) + { + var snapshot = await GetSnapshotAsync(cancellationToken); + return snapshot.EnabledToolNames.Contains(toolName); + } + + private async Task BuildSnapshotAsync(CancellationToken cancellationToken) + { + IReadOnlyDictionary pipeline; + try + { + pipeline = await pipelineConfigRepository.LoadAsync(cancellationToken); + } + catch + { + pipeline = new Dictionary(StringComparer.Ordinal); + } + + var bundleKey = ResolveBundleKey(pipeline); + var enabledDomains = ResolveEnabledDomains(pipeline, bundleKey); + var disabledTools = ParseDisabledTools(pipeline.GetValueOrDefault("mcp_disabled_tools")); + var allNames = catalog.ToolNames.ToHashSet(StringComparer.Ordinal); + + HashSet baseNames; + if (string.Equals(bundleKey, "custom", StringComparison.Ordinal)) + { + baseNames = McpToolDomains.ToolNamesForEnabledDomains(allNames, enabledDomains); + } + else if (string.Equals(bundleKey, "full", StringComparison.Ordinal)) + { + baseNames = allNames; + baseNames.ExceptWith(McpToolDomains.ChatOnlyTools); + } + else + { + baseNames = McpToolDomains.ToolNamesForMcpBundle(allNames, bundleKey); + } + + baseNames.ExceptWith(disabledTools); + + return new AuditToolSelectionSnapshot( + bundleKey, + enabledDomains, + disabledTools, + baseNames, + GroupToolsByDomain(baseNames)); + } + + public static string ResolveBundleKey(IReadOnlyDictionary pipeline) + { + var env = Environment.GetEnvironmentVariable("WP_MCP_DOMAIN")?.Trim().ToLowerInvariant(); + if (!string.IsNullOrEmpty(env)) + { + return NormalizeBundleKey(env); + } + + var db = pipeline.GetValueOrDefault("mcp_domain")?.Trim().ToLowerInvariant(); + return NormalizeBundleKey(string.IsNullOrEmpty(db) ? "core" : db); + } + + public static string NormalizeBundleKey(string raw) + { + var key = raw.Trim().ToLowerInvariant(); + if (key is "core" or "crawl" or "google" or "links" or "full" or "custom") + { + return key; + } + + return "core"; + } + + public static IReadOnlyList ResolveEnabledDomains( + IReadOnlyDictionary pipeline, + string bundleKey) + { + if (!string.Equals(bundleKey, "custom", StringComparison.Ordinal)) + { + if (McpToolDomains.McpDomainBundles.TryGetValue(bundleKey, out var bundleDomains)) + { + return bundleDomains.Order(StringComparer.Ordinal).ToList(); + } + + return ["core", "insight"]; + } + + var raw = pipeline.GetValueOrDefault("mcp_enabled_domains"); + var parsed = ParseDomainList(raw); + if (parsed.Count == 0) + { + return ["core", "insight"]; + } + + return parsed; + } + + public static HashSet ParseDisabledTools(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + { + return new HashSet(StringComparer.Ordinal); + } + + try + { + var list = JsonSerializer.Deserialize>(raw); + return list?.ToHashSet(StringComparer.Ordinal) ?? new HashSet(StringComparer.Ordinal); + } + catch (JsonException) + { + return new HashSet(StringComparer.Ordinal); + } + } + + public static List ParseDomainList(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + { + return []; + } + + try + { + var jsonList = JsonSerializer.Deserialize>(raw); + if (jsonList is not null) + { + return jsonList + .Select(d => d.Trim().ToLowerInvariant()) + .Where(McpToolDomains.CanonicalDomains.Contains) + .Distinct(StringComparer.Ordinal) + .Order(StringComparer.Ordinal) + .ToList(); + } + } + catch (JsonException) + { + /* fall through to comma-separated */ + } + + return raw + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(d => d.ToLowerInvariant()) + .Where(McpToolDomains.CanonicalDomains.Contains) + .Distinct(StringComparer.Ordinal) + .Order(StringComparer.Ordinal) + .ToList(); + } + + public static Dictionary> GroupToolsByDomain(IEnumerable toolNames) + { + var map = McpToolDomains.CanonicalDomains.ToDictionary( + d => d, + _ => (IReadOnlyList)Array.Empty(), + StringComparer.Ordinal); + + var buckets = new Dictionary>(StringComparer.Ordinal); + foreach (var name in toolNames) + { + var domain = McpToolDomains.ClassifyToolDomain(name); + if (!buckets.TryGetValue(domain, out var list)) + { + list = []; + buckets[domain] = list; + } + + list.Add(name); + } + + foreach (var (domain, list) in buckets) + { + list.Sort(StringComparer.Ordinal); + map[domain] = list; + } + + return map; + } +} + +public sealed record AuditToolSelectionSnapshot( + string BundleKey, + IReadOnlyList EnabledDomains, + IReadOnlySet DisabledTools, + IReadOnlySet EnabledToolNames, + IReadOnlyDictionary> ToolsByDomain); diff --git a/services/AiService/src/AiService.Tools/Selection/ChatToolSelector.cs b/services/AiService/src/AiService.Tools/Selection/ChatToolSelector.cs new file mode 100644 index 00000000..c640eea3 --- /dev/null +++ b/services/AiService/src/AiService.Tools/Selection/ChatToolSelector.cs @@ -0,0 +1,347 @@ +using System.Text.RegularExpressions; +using AiService.Tools.Domain; +using AiService.Tools.Registry; + +namespace AiService.Tools.Selection; + +/// +/// Dynamic per-turn tool selection for chat (ports Python tool_selector.py). +/// +public static class ChatToolSelector +{ + private static readonly Dictionary DomainKeywords = new(StringComparer.Ordinal) + { + ["issues"] = ["issue", "issues", "critical issues", "fix", "priority", "roadmap", "impact"], + ["crawl"] = ["crawl", "404", "500", "redirect", "status code", "orphan", "soft 404", "robots"], + ["onpage"] = ["title tag", "meta description", "h1", "canonical", "noindex", "on-page", "onpage"], + ["google"] = ["gsc", "search console", "ga4", "analytics", "clicks", "impressions", "queries"], + ["insight"] = ["opportunity", "engagement", "landing page", "blended", "traffic health", "diagnosis"], + ["keywords"] = ["keyword", "striking", "cannibal", "brand", "intent"], + ["performance"] = ["lighthouse", "cwv", "core web vitals", "slow page", "crux", "page speed"], + ["links"] = ["broken link", "internal link", "inlink", "outlink", "anchor text", "pagerank"], + ["backlinks"] = ["backlink", "referring domain", "gsc links", "moz", "majestic"], + ["drift"] = ["compare", "baseline", "delta", "history", "trend", "drift"], + ["export"] = ["export", "pdf", "csv", "download"], + ["images"] = ["image", "alt text", "lazy load", "webp", "lcp image"], + ["geo"] = + [ + "geo", "aeo", "llms.txt", "faq schema", "eeat", "agentic", "agents.md", "token budget", + "copy for ai", "agent readiness", "skill.md", "agent permissions", "markdown availability", + ], + ["accessibility"] = ["axe", "accessibility", "a11y", "mixed content"], + ["security"] = ["security", "tls", "hsts", "ssl"], + ["indexation"] = ["indexation", "sitemap", "hreflang", "indexed"], + ["content"] = ["duplicate content", "thin content", "word count", "readability"], + ["ops"] = ["access log", "log analysis", "log upload", "crawl run", "integration status", "5xx", "googlebot"], + ["portfolio"] = ["overview", "health score", "category scores", "executive", "portfolio", "audit summary"], + ["ctr"] = ["ctr", "snippet", "title meta ctr"], + }; + + private static readonly Dictionary PlaybookAnchors = new(StringComparer.Ordinal) + { + ["images"] = ["get_image_audit_summary"], + ["export"] = ["export_audit_report", "export_list_as_csv"], + ["issues"] = ["get_critical_issues", "get_issue_priority_breakdown", "list_issues"], + ["portfolio"] = ["get_category_scores", "list_audit_categories"], + ["performance"] = ["get_lighthouse_summary", "list_pages_slow_response", "list_lighthouse_failure_lcp"], + ["drift"] = ["compare_reports", "compare_issue_deltas", "list_compare_traffic_losers"], + ["google"] = ["get_gsc_top_queries", "get_ga4_page_metrics", "list_gsc_decaying_queries", "list_gsc_decaying_pages"], + ["keywords"] = ["get_striking_distance_keywords", "get_keyword_cannibalisation", "list_keyword_rank_declines"], + ["indexation"] = ["list_hreflang_issue_pages", "list_indexation_gaps"], + ["backlinks"] = ["list_referring_domains", "list_backlinks_by_anchor_text"], + ["ops"] = ["list_log_paths_by_hits", "list_log_5xx_paths"], + }; + + private static readonly Dictionary PhraseToolPins = new(StringComparer.OrdinalIgnoreCase) + { + ["critical issues"] = ["get_report_summary", "get_issue_priority_breakdown", "get_critical_issues"], + ["top issues"] = ["get_report_summary", "get_issue_priority_breakdown", "get_critical_issues"], + ["site health"] = ["get_report_summary", "get_category_scores", "list_audit_categories"], + ["audit overview"] = ["get_report_summary", "get_category_scores", "list_audit_categories"], + ["broken links"] = ["list_broken_links", "list_internal_broken_links", "list_external_broken_links"], + ["export pdf"] = ["export_audit_report"], + ["gsc"] = ["get_gsc_top_queries", "get_google_summary", "get_gsc_daily_trend"], + ["core web vitals"] = ["get_lighthouse_summary", "list_pages_slow_response"], + }; + + public static string ResolveChatToolMode(IReadOnlyDictionary? llmConfig = null) + { + var env = Environment.GetEnvironmentVariable("CHAT_TOOL_MODE"); + if (!string.IsNullOrWhiteSpace(env)) + { + return env.Trim().ToLowerInvariant(); + } + + var cfg = llmConfig?.GetValueOrDefault("llm_chat_tool_mode")?.Trim().ToLowerInvariant(); + return string.IsNullOrEmpty(cfg) ? "dynamic" : cfg; + } + + public static int ResolveChatToolMax() + { + var floor = McpToolDomains.Tier0Tools.Count + 1; + var raw = Environment.GetEnvironmentVariable("CHAT_TOOL_MAX"); + if (int.TryParse(raw, out var parsed)) + { + return Math.Clamp(parsed, floor, 120); + } + + return Math.Max(floor, 45); + } + + public static int ResolveChatToolSearchCap() + => Math.Min(ResolveChatToolMax() + 15, 75); + + public static HashSet SelectToolsForTurn( + string userMessage, + IReadOnlyList? priorUserMessages, + IReadOnlySet allowedTools, + IReadOnlyDictionary? llmConfig = null, + int? maxTools = null, + IReadOnlySet? extraNames = null) + { + if (ResolveChatToolMode(llmConfig) == "full") + { + return allowedTools.ToHashSet(StringComparer.Ordinal); + } + + var cap = maxTools ?? ResolveChatToolMax(); + var selected = new HashSet(StringComparer.Ordinal); + var pinned = new HashSet(StringComparer.Ordinal); + foreach (var name in McpToolDomains.Tier0Tools) + { + if (allowedTools.Contains(name)) + { + selected.Add(name); + } + } + + if (extraNames is not null) + { + foreach (var name in extraNames) + { + if (allowedTools.Contains(name)) + { + selected.Add(name); + } + } + } + + var texts = new List { userMessage ?? "" }; + if (priorUserMessages is not null) + { + for (var i = priorUserMessages.Count - 1; i >= 0; i--) + { + var prior = priorUserMessages[i]; + if (!string.IsNullOrEmpty(prior) && prior != userMessage) + { + texts.Add(prior); + break; + } + } + } + + var combined = string.Join(' ', texts); + var domainScores = ScoreDomains(combined); + var toolsByDomain = AuditToolSelectionService.GroupToolsByDomain(allowedTools); + + if (domainScores.Count == 0) + { + foreach (var fallback in new[] { "portfolio", "issues", "insight" }) + { + AddDomainTools(selected, toolsByDomain, fallback); + } + } + else + { + foreach (var domain in domainScores.Take(4).Select(x => x.Domain)) + { + AddDomainTools(selected, toolsByDomain, domain); + if (PlaybookAnchors.TryGetValue(domain, out var anchors)) + { + foreach (var anchor in anchors) + { + if (allowedTools.Contains(anchor)) + { + selected.Add(anchor); + pinned.Add(anchor); + } + } + } + } + } + + ApplyPhraseToolPins(selected, combined, allowedTools, pinned); + + selected = ApplyToolCap(selected, cap, pinned); + selected.IntersectWith(allowedTools); + + if (ChatSqlToolEnabled()) + { + if (allowedTools.Contains("get_sql_schema")) + { + selected.Add("get_sql_schema"); + } + + if (allowedTools.Contains("run_sql_query")) + { + selected.Add("run_sql_query"); + } + } + + return selected; + } + + public static bool ChatSqlToolEnabled() + => IsTruthy(Environment.GetEnvironmentVariable("CHAT_SQL_TOOL_ENABLED")); + + /// + /// Expand the active tool set after search/domain-agent results (ports Python + /// _expand_active_tools_from_result). + /// + public static HashSet ExpandActiveToolsFromResult( + string toolName, + System.Text.Json.Nodes.JsonObject toolResult, + HashSet active, + IReadOnlySet allowedTools, + IReadOnlyDictionary? llmConfig = null) + { + var expanded = new HashSet(active, StringComparer.Ordinal); + var pinned = new HashSet(StringComparer.Ordinal); + + if (toolName == "search_audit_tools" && toolResult["tool_names"] is System.Text.Json.Nodes.JsonArray names) + { + var count = 0; + foreach (var nameNode in names) + { + if (count >= 12) + { + break; + } + + var name = nameNode?.GetValue(); + if (string.IsNullOrWhiteSpace(name) || !allowedTools.Contains(name)) + { + continue; + } + + expanded.Add(name); + pinned.Add(name); + count++; + } + } + else if (toolName == "run_domain_agent" && toolResult["tools_used"] is System.Text.Json.Nodes.JsonArray used) + { + foreach (var nameNode in used) + { + var name = nameNode?.GetValue(); + if (string.IsNullOrWhiteSpace(name) || !allowedTools.Contains(name)) + { + continue; + } + + expanded.Add(name); + pinned.Add(name); + } + } + + if (ResolveChatToolMode(llmConfig) != "full" && pinned.Count > 0) + { + return ApplyToolCap(expanded, ResolveChatToolSearchCap(), pinned); + } + + return expanded; + } + + public static HashSet ApplyToolCap( + HashSet selected, + int cap, + IReadOnlySet? pinned = null, + int maxPinned = 12) + { + pinned ??= new HashSet(StringComparer.Ordinal); + var tier0 = selected.Where(McpToolDomains.Tier0Tools.Contains).ToHashSet(StringComparer.Ordinal); + var pinnedKeep = pinned.Intersect(selected).Order(StringComparer.Ordinal).Take(Math.Max(0, maxPinned)).ToHashSet(StringComparer.Ordinal); + var mustKeep = tier0.Union(pinnedKeep).ToHashSet(StringComparer.Ordinal); + if (selected.Count <= cap) + { + return selected; + } + + var rest = selected.Except(mustKeep).Order(StringComparer.Ordinal).Take(Math.Max(0, cap - mustKeep.Count)); + return mustKeep.Union(rest).ToHashSet(StringComparer.Ordinal); + } + + private static void AddDomainTools( + HashSet selected, + IReadOnlyDictionary> toolsByDomain, + string domain) + { + if (toolsByDomain.TryGetValue(domain, out var names)) + { + foreach (var name in names) + { + selected.Add(name); + } + } + } + + private static void ApplyPhraseToolPins( + HashSet selected, + string combinedText, + IReadOnlySet allowedTools, + HashSet pinned) + { + var lower = combinedText.ToLowerInvariant(); + foreach (var (phrase, tools) in PhraseToolPins) + { + if (!lower.Contains(phrase, StringComparison.Ordinal)) + { + continue; + } + + foreach (var tool in tools) + { + if (allowedTools.Contains(tool)) + { + selected.Add(tool); + pinned.Add(tool); + } + } + } + } + + private static List<(int Score, string Domain)> ScoreDomains(string text) + { + var lower = text.ToLowerInvariant(); + var scores = new List<(int Score, string Domain)>(); + foreach (var (domain, keywords) in DomainKeywords) + { + var score = keywords.Sum(kw => KeywordInText(kw, lower) ? 3 : 0); + if (score > 0) + { + scores.Add((score, domain)); + } + } + + scores.Sort((a, b) => + { + var cmp = b.Score.CompareTo(a.Score); + return cmp != 0 ? cmp : string.Compare(a.Domain, b.Domain, StringComparison.Ordinal); + }); + + return scores; + } + + private static bool KeywordInText(string keyword, string text) + { + if (keyword.Contains(' ', StringComparison.Ordinal)) + { + return text.Contains(keyword, StringComparison.Ordinal); + } + + return Regex.IsMatch(text, $@"\b{Regex.Escape(keyword)}\b", RegexOptions.IgnoreCase); + } + + private static bool IsTruthy(string? raw) + => raw?.Trim().ToLowerInvariant() is "true" or "1" or "yes"; +} diff --git a/services/AiService/src/AiService.Tools/Slice/CrawlFilter.cs b/services/AiService/src/AiService.Tools/Slice/CrawlFilter.cs new file mode 100644 index 00000000..9bb6f721 --- /dev/null +++ b/services/AiService/src/AiService.Tools/Slice/CrawlFilter.cs @@ -0,0 +1,204 @@ +using System.Text.Json.Nodes; +using WebsiteProfiling.Contracts.Crawl; + +namespace AiService.Tools.Slice; + +/// +/// Crawl row filtering helpers — faithful port of Python +/// website_profiling.tools.audit_tools._slice (crawl_filter and its private helpers). +/// +public static class CrawlFilter +{ + /// + /// Filter crawl rows and return a paged result. Mirrors Python crawl_filter. + /// + public static JsonObject Filter( + IReadOnlyList? rows, + string status = "", + string urlContains = "", + bool? hasSchema = null, + string schemaType = "", + int limit = 30, + int maxCap = 30) + => FilterRows( + rows?.Select(Mapping.CrawlRowMapper.FromJsonObject).ToList(), + status, + urlContains, + hasSchema, + schemaType, + limit, + maxCap); + + /// Typed crawl filter — preferred for native handlers. + public static JsonObject FilterRows( + IReadOnlyList? rows, + string status = "", + string urlContains = "", + bool? hasSchema = null, + string schemaType = "", + int limit = 30, + int maxCap = 30) + { + if (rows is null || rows.Count == 0) + { + return new JsonObject { ["pages"] = new JsonArray(), ["total"] = 0, ["truncated"] = false }; + } + + IEnumerable filtered = rows; + + if (!string.IsNullOrEmpty(status)) + { + filtered = filtered.Where(r => r.Status == status); + } + + if (!string.IsNullOrEmpty(urlContains)) + { + var needle = urlContains.ToLowerInvariant(); + filtered = filtered.Where(r => r.Url.ToLowerInvariant().Contains(needle)); + } + + if (hasSchema is bool hs) + { + filtered = filtered.Where(r => r.HasSchema == hs); + } + + if (!string.IsNullOrEmpty(schemaType)) + { + var needle = schemaType.ToLowerInvariant(); + filtered = filtered.Where(r => string.Join(" ", r.SchemaTypes).ToLowerInvariant().Contains(needle)); + } + + var pages = filtered.Select(r => (JsonNode?)new JsonObject + { + ["url"] = r.Url, + ["status"] = r.Status, + ["title"] = r.Title, + ["has_schema"] = r.HasSchema, + ["schema_types"] = SchemaTypesToArray(r.SchemaTypes), + }).ToList(); + + var cap = Math.Max(1, Math.Min(limit, maxCap)); + var total = pages.Count; + var truncated = total > cap; + var slice = new JsonArray(); + for (var i = 0; i < Math.Min(cap, total); i++) + { + slice.Add(pages[i]?.DeepClone()); + } + + return new JsonObject { ["pages"] = slice, ["total"] = total, ["truncated"] = truncated }; + } + + /// + /// Whether a crawl row has structured schema markup. Mirrors Python _row_has_schema. + /// + public static bool RowHasSchema(JsonObject row) + { + var node = row["has_schema"]; + if (node is JsonValue v) + { + if (v.TryGetValue(out var b)) + { + return b; + } + + if (v.TryGetValue(out var s)) + { + return s.ToLowerInvariant() is "true" or "1" or "yes"; + } + + if (v.TryGetValue(out var i)) + { + return i == 1; + } + } + + return false; + } + + public static bool RowHasSchema(CrawlRow row) => row.HasSchema; + + /// + /// Schema type strings from page_analysis.json_ld_types / schema_types. Mirrors + /// Python _row_schema_types_list. + /// + public static IReadOnlyList RowSchemaTypesList(JsonObject row) + { + var pa = ParsePageAnalysis(row["page_analysis"]); + if (pa is null) + { + return Array.Empty(); + } + + var typesNode = pa["json_ld_types"] ?? pa["schema_types"]; + if (typesNode is null) + { + return Array.Empty(); + } + + if (typesNode is JsonArray arr) + { + return arr + .Select(NodeStr) + .Where(s => s.Length > 0) + .ToList(); + } + + if (typesNode is JsonValue sv && sv.TryGetValue(out var single) && single.Length > 0) + { + return new[] { single }; + } + + return Array.Empty(); + } + + private static string NodeStr(JsonNode? node) + { + if (node is null) + { + return ""; + } + + if (node is JsonValue v) + { + if (v.TryGetValue(out var s)) + { + return s ?? ""; + } + + return v.ToString() ?? ""; + } + + return ""; + } + + private static JsonObject? ParsePageAnalysis(JsonNode? node) + { + if (node is JsonObject obj) + { + return obj; + } + + if (node is JsonValue sv && sv.TryGetValue(out var raw) && !string.IsNullOrWhiteSpace(raw)) + { + try + { + return JsonNode.Parse(raw) as JsonObject; + } + catch (System.Text.Json.JsonException) { } + } + + return null; + } + + private static JsonArray SchemaTypesToArray(IReadOnlyList types) + { + var arr = new JsonArray(); + foreach (var t in types) + { + arr.Add(t); + } + + return arr; + } +} diff --git a/services/AiService/src/AiService.Tools/Slice/GoogleUrl.cs b/services/AiService/src/AiService.Tools/Slice/GoogleUrl.cs new file mode 100644 index 00000000..70004c02 --- /dev/null +++ b/services/AiService/src/AiService.Tools/Slice/GoogleUrl.cs @@ -0,0 +1,100 @@ +namespace AiService.Tools.Slice; + +/// +/// URL normalization for joining crawl URLs with GSC pages and GA4 paths. Faithful port of +/// Python website_profiling.integrations.google.normalize (urlparse semantics: netloc is +/// only populated when the URL contains //). +/// +public static class GoogleUrl +{ + /// Strip scheme, leading www., trailing slash; lowercase host — for join keys. + public static string NormalizeUrl(string? url) + { + var (netloc, path) = Split(url?.Trim() ?? string.Empty); + var host = StripWwwPrefix(netloc.ToLowerInvariant()); + var trimmed = path.TrimEnd('/'); + if (trimmed.Length == 0) + { + trimmed = "/"; + } + + return host + trimmed; + } + + /// Just the path component of a URL (or /). Mirrors Python url_to_path. + public static string UrlToPath(string? url) + { + var (_, path) = Split(url?.Trim() ?? string.Empty); + return path.Length == 0 ? "/" : path; + } + + /// Remove a single leading www. label (case-insensitive). Mirrors Python strip_www_prefix. + public static string StripWwwPrefix(string host) + { + var h = host ?? string.Empty; + return h.StartsWith("www.", StringComparison.OrdinalIgnoreCase) ? h[4..] : h; + } + + /// + /// Replicates the subset of urllib.parse.urlparse that normalize_url relies on: + /// strip a leading scheme:, treat the remainder after // as netloc, and return the + /// path component (query/fragment stripped). When there is no //, netloc is empty and the + /// whole remainder is the path. + /// + private static (string Netloc, string Path) Split(string url) + { + var rest = url; + + var colon = rest.IndexOf(':'); + if (colon > 0 && IsScheme(rest[..colon])) + { + rest = rest[(colon + 1)..]; + } + + string netloc; + string remainder; + if (rest.StartsWith("//", StringComparison.Ordinal)) + { + rest = rest[2..]; + var sep = rest.IndexOfAny(['/', '?', '#']); + if (sep < 0) + { + netloc = rest; + remainder = string.Empty; + } + else + { + netloc = rest[..sep]; + remainder = rest[sep..]; + } + } + else + { + netloc = string.Empty; + remainder = rest; + } + + // parsed.path stops at query/fragment. + var cut = remainder.IndexOfAny(['?', '#']); + var path = cut < 0 ? remainder : remainder[..cut]; + return (netloc, path); + } + + private static bool IsScheme(string s) + { + if (s.Length == 0 || !char.IsLetter(s[0])) + { + return false; + } + + foreach (var ch in s) + { + if (!char.IsLetterOrDigit(ch) && ch is not ('+' or '-' or '.')) + { + return false; + } + } + + return true; + } +} diff --git a/services/AiService/src/AiService.Tools/Slice/PayloadSliceHelpers.cs b/services/AiService/src/AiService.Tools/Slice/PayloadSliceHelpers.cs new file mode 100644 index 00000000..d56d219f --- /dev/null +++ b/services/AiService/src/AiService.Tools/Slice/PayloadSliceHelpers.cs @@ -0,0 +1,154 @@ +using System.Text.Json.Nodes; + +namespace AiService.Tools.Slice; + +/// +/// Shared list slicing and payload field helpers for audit tools. Mirrors Python +/// website_profiling.tools.audit_tools._slice. +/// +public static class PayloadSliceHelpers +{ + public static int ParseLimit(JsonNode? raw, int defaultValue, int maxCap) + { + int limit; + try + { + if (raw is JsonValue value && value.TryGetValue(out int intValue)) + { + limit = intValue; + } + else + { + limit = int.Parse(raw?.ToString() ?? defaultValue.ToString()); + } + } + catch (FormatException) + { + limit = defaultValue; + } + catch (OverflowException) + { + limit = defaultValue; + } + + return Math.Max(1, Math.Min(limit, maxCap)); + } + + public static JsonObject CapList(IReadOnlyList items, int limit, int? maxCap = null) + { + var cap = maxCap ?? limit; + limit = Math.Max(1, Math.Min(limit, cap)); + var total = items.Count; + var truncated = total > limit; + var slice = new JsonArray(); + for (var i = 0; i < Math.Min(limit, total); i++) + { + slice.Add(items[i]?.DeepClone()); + } + + return new JsonObject + { + ["items"] = slice, + ["total"] = total, + ["truncated"] = truncated, + }; + } + + public static JsonObject PayloadField( + JsonObject payload, + string key, + int limit = 50, + int maxCap = 50, + Func? filterFn = null, + string itemKey = "items") + { + if (!payload.TryGetPropertyValue(key, out var raw) || raw is null) + { + return new JsonObject + { + [itemKey] = new JsonArray(), + ["total"] = 0, + ["truncated"] = false, + ["missing"] = true, + }; + } + + if (raw is not JsonArray array) + { + var single = new JsonArray(); + var total = 0; + if (raw is JsonValue value && string.IsNullOrEmpty(value.ToString())) + { + total = 0; + } + else if (raw is not null) + { + single.Add(raw.DeepClone()); + total = 1; + } + + return new JsonObject + { + [itemKey] = single, + ["total"] = total, + ["truncated"] = false, + }; + } + + var items = new List(); + foreach (var element in array) + { + if (filterFn is null || filterFn(element)) + { + items.Add(element); + } + } + + var sliced = CapList(items, limit, maxCap); + return new JsonObject + { + [itemKey] = sliced["items"]?.DeepClone(), + ["total"] = sliced["total"]?.DeepClone(), + ["truncated"] = sliced["truncated"]?.DeepClone(), + }; + } + + public static JsonObject PayloadDictSlice( + JsonObject payload, + string key, + IReadOnlyList? fields = null) + { + if (!payload.TryGetPropertyValue(key, out var raw) || raw is not JsonObject dict) + { + return new JsonObject + { + ["data"] = null, + ["missing"] = true, + }; + } + + if (fields is null || fields.Count == 0) + { + return new JsonObject + { + ["data"] = dict.DeepClone(), + ["missing"] = false, + }; + } + + var data = new JsonObject(); + foreach (var field in fields) + { + if (dict.TryGetPropertyValue(field, out var value)) + { + data[field] = value?.DeepClone(); + } + } + + return new JsonObject + { + ["data"] = data, + ["missing"] = false, + }; + } +} diff --git a/services/AiService/src/AiService.Tools/tool_catalog.json b/services/AiService/src/AiService.Tools/tool_catalog.json new file mode 100644 index 00000000..0bbe4136 --- /dev/null +++ b/services/AiService/src/AiService.Tools/tool_catalog.json @@ -0,0 +1,9453 @@ +[ + { + "name": "list_properties", + "domain": "portfolio", + "module": "website_profiling.tools.audit_tools.portfolio.properties", + "source": "src/website_profiling/tools/audit_tools/portfolio/properties.py", + "description": "List all configured site properties (domains) in Site Audit.", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [] + } + }, + { + "name": "get_property", + "domain": "portfolio", + "module": "website_profiling.tools.audit_tools.portfolio.properties", + "source": "src/website_profiling/tools/audit_tools/portfolio/properties.py", + "description": "Get details for one property by property_id.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + } + }, + "required": [ + "property_id" + ] + } + }, + { + "name": "get_report_summary", + "domain": "portfolio", + "module": "website_profiling.tools.audit_tools.report.report", + "source": "src/website_profiling/tools/audit_tools/report/report.py", + "description": "Health score, issue counts by priority, crawl stats, and category scores for the latest or specified report.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [] + } + }, + { + "name": "get_category_scores", + "domain": "issues", + "module": "website_profiling.tools.audit_tools.report.report", + "source": "src/website_profiling/tools/audit_tools/report/report.py", + "description": "Category scores and overall health score for a report.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [] + } + }, + { + "name": "get_executive_summary", + "domain": "portfolio", + "module": "website_profiling.tools.audit_tools.report.report", + "source": "src/website_profiling/tools/audit_tools/report/report.py", + "description": "AI-generated executive summary narrative for the audit report.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [] + } + }, + { + "name": "get_report_meta", + "domain": "portfolio", + "module": "website_profiling.tools.audit_tools.report.report", + "source": "src/website_profiling/tools/audit_tools/report/report.py", + "description": "Report metadata: crawl scope, fetch methods, integration freshness.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [] + } + }, + { + "name": "get_site_level", + "domain": "portfolio", + "module": "website_profiling.tools.audit_tools.report.report", + "source": "src/website_profiling/tools/audit_tools/report/report.py", + "description": "Site-level robots.txt and sitemap.xml checks.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [] + } + }, + { + "name": "list_report_history", + "domain": "portfolio", + "module": "website_profiling.tools.audit_tools.portfolio.health", + "source": "src/website_profiling/tools/audit_tools/portfolio/health.py", + "description": "Past audit reports for a property (report IDs and dates).", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_issues", + "domain": "issues", + "module": "website_profiling.tools.audit_tools.report.report", + "source": "src/website_profiling/tools/audit_tools/report/report.py", + "description": "List audit issues with optional filters. Returns paginated results (max 50).", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "priority": { + "type": "string", + "enum": [ + "Critical", + "High", + "Medium", + "Low" + ] + }, + "category_id": { + "type": "string" + }, + "url_contains": { + "type": "string" + }, + "sort": { + "type": "string", + "enum": [ + "impact" + ] + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_issues_by_category", + "domain": "issues", + "module": "website_profiling.tools.audit_tools.issues.issues", + "source": "src/website_profiling/tools/audit_tools/issues/issues.py", + "description": "List issues for one category_id only.", + "inputSchema": { + "type": "object", + "properties": { + "category_id": { + "type": "string" + }, + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [ + "category_id" + ] + } + }, + { + "name": "get_category_issues", + "domain": "issues", + "module": "website_profiling.tools.audit_tools.issues.issues", + "source": "src/website_profiling/tools/audit_tools/issues/issues.py", + "description": "Full issue list and score for a single audit category.", + "inputSchema": { + "type": "object", + "properties": { + "category_id": { + "type": "string" + }, + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [ + "category_id" + ] + } + }, + { + "name": "list_issue_workflow", + "domain": "issues", + "module": "website_profiling.tools.audit_tools.ops.workflow", + "source": "src/website_profiling/tools/audit_tools/ops/workflow.py", + "description": "Issue triage workflow status from issue_status table.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "status": { + "type": "string" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "search_pages", + "domain": "crawl", + "module": "website_profiling.tools.audit_tools.crawl.crawl", + "source": "src/website_profiling/tools/audit_tools/crawl/crawl.py", + "description": "Search crawled pages by status code or URL substring. Max 30 results.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "status": { + "type": "string" + }, + "url_contains": { + "type": "string" + }, + "limit": { + "type": "integer", + "maximum": 30 + } + }, + "required": [] + } + }, + { + "name": "get_page_details", + "domain": "crawl", + "module": "website_profiling.tools.audit_tools.crawl.crawl", + "source": "src/website_profiling/tools/audit_tools/crawl/crawl.py", + "description": "Crawl row, Lighthouse snippet, and GSC/GA4 slice for one URL.", + "inputSchema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "Page URL" + }, + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [ + "url" + ] + } + }, + { + "name": "get_internal_links", + "domain": "links", + "module": "website_profiling.tools.audit_tools.crawl.crawl", + "source": "src/website_profiling/tools/audit_tools/crawl/crawl.py", + "description": "Inlinks and outlinks for one URL from crawl graph.", + "inputSchema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "Page URL" + }, + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [ + "url" + ] + } + }, + { + "name": "list_redirects", + "domain": "crawl", + "module": "website_profiling.tools.audit_tools.crawl.crawl", + "source": "src/website_profiling/tools/audit_tools/crawl/crawl.py", + "description": "Redirect chains detected in crawl (3xx with final URL).", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_broken_links", + "domain": "links", + "module": "website_profiling.tools.audit_tools.crawl.crawl", + "source": "src/website_profiling/tools/audit_tools/crawl/crawl.py", + "description": "Broken internal links (4xx/5xx) from crawl issues.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "get_status_code_breakdown", + "domain": "crawl", + "module": "website_profiling.tools.audit_tools.crawl.crawl", + "source": "src/website_profiling/tools/audit_tools/crawl/crawl.py", + "description": "HTTP status code counts from crawl summary.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [] + } + }, + { + "name": "get_response_time_stats", + "domain": "crawl", + "module": "website_profiling.tools.audit_tools.crawl.crawl", + "source": "src/website_profiling/tools/audit_tools/crawl/crawl.py", + "description": "Response time percentiles (p50, p95) from crawl.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [] + } + }, + { + "name": "get_depth_distribution", + "domain": "crawl", + "module": "website_profiling.tools.audit_tools.crawl.crawl", + "source": "src/website_profiling/tools/audit_tools/crawl/crawl.py", + "description": "Crawl depth histogram (clicks from start URL).", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [] + } + }, + { + "name": "get_crawl_segments", + "domain": "crawl", + "module": "website_profiling.tools.audit_tools.crawl.crawl", + "source": "src/website_profiling/tools/audit_tools/crawl/crawl.py", + "description": "Per-path-prefix crawl segment rollups (requires crawl_path_segments config).", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [] + } + }, + { + "name": "get_browser_diagnostics_summary", + "domain": "crawl", + "module": "website_profiling.tools.audit_tools.crawl.crawl", + "source": "src/website_profiling/tools/audit_tools/crawl/crawl.py", + "description": "Aggregated JS console errors and browser diagnostics from rendered crawl.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [] + } + }, + { + "name": "get_schema_coverage", + "domain": "schema", + "module": "website_profiling.tools.audit_tools.schema.schema", + "source": "src/website_profiling/tools/audit_tools/schema/schema.py", + "description": "Site-wide schema.org coverage from crawl (has_schema + JSON-LD types).", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [] + } + }, + { + "name": "list_pages_without_schema", + "domain": "crawl", + "module": "website_profiling.tools.audit_tools.schema.schema", + "source": "src/website_profiling/tools/audit_tools/schema/schema.py", + "description": "URLs missing structured data markup. Empty list means full coverage.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "maximum": 30 + } + }, + "required": [] + } + }, + { + "name": "search_pages_by_schema_type", + "domain": "crawl", + "module": "website_profiling.tools.audit_tools.schema.schema", + "source": "src/website_profiling/tools/audit_tools/schema/schema.py", + "description": "Find pages with a specific JSON-LD type (e.g. Organization, Article).", + "inputSchema": { + "type": "object", + "properties": { + "schema_type": { + "type": "string" + }, + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "maximum": 30 + } + }, + "required": [ + "schema_type" + ] + } + }, + { + "name": "get_seo_health", + "domain": "schema", + "module": "website_profiling.tools.audit_tools.crawl.crawl", + "source": "src/website_profiling/tools/audit_tools/crawl/crawl.py", + "description": "On-page SEO KPI counts: titles, meta descriptions, H1s, thin content.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [] + } + }, + { + "name": "list_orphan_pages", + "domain": "links", + "module": "website_profiling.tools.audit_tools.links.links", + "source": "src/website_profiling/tools/audit_tools/links/links.py", + "description": "Crawled URLs with zero internal inlinks.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "get_top_linked_pages", + "domain": "links", + "module": "website_profiling.tools.audit_tools.links.links", + "source": "src/website_profiling/tools/audit_tools/links/links.py", + "description": "Most-linked internal pages by inlink count.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "get_outbound_link_domains", + "domain": "links", + "module": "website_profiling.tools.audit_tools.links.links", + "source": "src/website_profiling/tools/audit_tools/links/links.py", + "description": "External domains linked from the site.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "get_link_graph_summary", + "domain": "links", + "module": "website_profiling.tools.audit_tools.links.links", + "source": "src/website_profiling/tools/audit_tools/links/links.py", + "description": "Internal link graph node/edge counts and top hub pages.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [] + } + }, + { + "name": "get_url_fingerprints", + "domain": "links", + "module": "website_profiling.tools.audit_tools.links.links", + "source": "src/website_profiling/tools/audit_tools/links/links.py", + "description": "URL pattern fingerprints for duplicate URL detection.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "get_indexation_coverage", + "domain": "indexation", + "module": "website_profiling.tools.audit_tools.indexation.indexation_tools", + "source": "src/website_profiling/tools/audit_tools/indexation/indexation_tools.py", + "description": "Sitemap vs crawl vs GSC URL set comparison and gap lists.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [] + } + }, + { + "name": "get_hreflang_summary", + "domain": "indexation", + "module": "website_profiling.tools.audit_tools.indexation.international", + "source": "src/website_profiling/tools/audit_tools/indexation/international.py", + "description": "Hreflang alternate tag coverage and issues.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [] + } + }, + { + "name": "get_language_summary", + "domain": "indexation", + "module": "website_profiling.tools.audit_tools.indexation.international", + "source": "src/website_profiling/tools/audit_tools/indexation/international.py", + "description": "Detected page language distribution.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [] + } + }, + { + "name": "get_content_analytics", + "domain": "content", + "module": "website_profiling.tools.audit_tools.content.content", + "source": "src/website_profiling/tools/audit_tools/content/content.py", + "description": "Word count stats, thin pages, top site keywords from crawl.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [] + } + }, + { + "name": "get_content_duplicates", + "domain": "content", + "module": "website_profiling.tools.audit_tools.content.content", + "source": "src/website_profiling/tools/audit_tools/content/content.py", + "description": "Near-duplicate content clusters from ML analysis.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "get_social_coverage", + "domain": "content", + "module": "website_profiling.tools.audit_tools.content.content", + "source": "src/website_profiling/tools/audit_tools/content/content.py", + "description": "Open Graph and Twitter card coverage percentages.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [] + } + }, + { + "name": "get_keyword_opportunities", + "domain": "content", + "module": "website_profiling.tools.audit_tools.content.content", + "source": "src/website_profiling/tools/audit_tools/content/content.py", + "description": "On-page keyword opportunity hints from crawl.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [] + } + }, + { + "name": "get_ner_site_summary", + "domain": "content", + "module": "website_profiling.tools.audit_tools.content.content", + "source": "src/website_profiling/tools/audit_tools/content/content.py", + "description": "Named-entity summary across site content.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [] + } + }, + { + "name": "list_thin_content_pages", + "domain": "content", + "module": "website_profiling.tools.audit_tools.content.content", + "source": "src/website_profiling/tools/audit_tools/content/content.py", + "description": "Pages flagged as thin content (low word count).", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "get_keyword_summary", + "domain": "keywords", + "module": "website_profiling.tools.audit_tools.keywords.keywords", + "source": "src/website_profiling/tools/audit_tools/keywords/keywords.py", + "description": "Top keywords, striking-distance count, and GSC metrics.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [ + "property_id" + ] + } + }, + { + "name": "search_keywords", + "domain": "keywords", + "module": "website_profiling.tools.audit_tools.keywords.keywords", + "source": "src/website_profiling/tools/audit_tools/keywords/keywords.py", + "description": "Search keyword list by substring match.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "query": { + "type": "string" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [ + "property_id", + "query" + ] + } + }, + { + "name": "get_striking_distance_keywords", + "domain": "keywords", + "module": "website_profiling.tools.audit_tools.keywords.keywords", + "source": "src/website_profiling/tools/audit_tools/keywords/keywords.py", + "description": "Keywords ranking positions 4\u201320 (striking distance opportunities).", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [ + "property_id" + ] + } + }, + { + "name": "get_keyword_cannibalisation", + "domain": "keywords", + "module": "website_profiling.tools.audit_tools.keywords.keywords", + "source": "src/website_profiling/tools/audit_tools/keywords/keywords.py", + "description": "Queries where multiple pages rank in GSC.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [ + "property_id" + ] + } + }, + { + "name": "get_query_page_misalignment", + "domain": "keywords", + "module": "website_profiling.tools.audit_tools.keywords.keywords", + "source": "src/website_profiling/tools/audit_tools/keywords/keywords.py", + "description": "GSC queries whose landing page may not match intent.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [ + "property_id" + ] + } + }, + { + "name": "get_semantic_keyword_clusters", + "domain": "keywords", + "module": "website_profiling.tools.audit_tools.keywords.keywords", + "source": "src/website_profiling/tools/audit_tools/keywords/keywords.py", + "description": "LLM-generated semantic keyword clusters.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "get_keyword_history", + "domain": "keywords", + "module": "website_profiling.tools.audit_tools.keywords.keywords", + "source": "src/website_profiling/tools/audit_tools/keywords/keywords.py", + "description": "Time-series GSC metrics for one keyword.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "keyword": { + "type": "string" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [ + "property_id", + "keyword" + ] + } + }, + { + "name": "get_google_summary", + "domain": "google", + "module": "website_profiling.tools.audit_tools.google.google", + "source": "src/website_profiling/tools/audit_tools/google/google.py", + "description": "GSC and GA4 headline metrics, top queries and pages.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + } + }, + "required": [] + } + }, + { + "name": "get_gsc_top_queries", + "domain": "google", + "module": "website_profiling.tools.audit_tools.google.google", + "source": "src/website_profiling/tools/audit_tools/google/google.py", + "description": "Top Search Console queries by clicks.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "get_gsc_top_pages", + "domain": "google", + "module": "website_profiling.tools.audit_tools.google.google", + "source": "src/website_profiling/tools/audit_tools/google/google.py", + "description": "Top Search Console pages by clicks.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "get_ga4_summary", + "domain": "google", + "module": "website_profiling.tools.audit_tools.google.google", + "source": "src/website_profiling/tools/audit_tools/google/google.py", + "description": "GA4 organic summary and top landing pages.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "get_gsc_page_query_slice", + "domain": "google", + "module": "website_profiling.tools.audit_tools.google.google", + "source": "src/website_profiling/tools/audit_tools/google/google.py", + "description": "GSC queries and metrics for a single page URL.", + "inputSchema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "Page URL" + }, + "property_id": { + "type": "integer", + "description": "Property ID" + } + }, + "required": [ + "url" + ] + } + }, + { + "name": "get_gsc_links_summary", + "domain": "backlinks", + "module": "website_profiling.tools.audit_tools.backlinks.backlinks", + "source": "src/website_profiling/tools/audit_tools/backlinks/backlinks.py", + "description": "GSC Links CSV import summary: top linking sites and linked pages.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [ + "property_id" + ] + } + }, + { + "name": "get_gsc_links_import_status", + "domain": "backlinks", + "module": "website_profiling.tools.audit_tools.backlinks.backlinks", + "source": "src/website_profiling/tools/audit_tools/backlinks/backlinks.py", + "description": "Whether GSC Links data is imported and when.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + } + }, + "required": [ + "property_id" + ] + } + }, + { + "name": "get_competitor_link_gap", + "domain": "links", + "module": "website_profiling.tools.audit_tools.backlinks.backlinks", + "source": "src/website_profiling/tools/audit_tools/backlinks/backlinks.py", + "description": "Domains linking to competitors but not you (requires competitor_domains config).", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [] + } + }, + { + "name": "get_bing_backlinks_summary", + "domain": "links", + "module": "website_profiling.tools.audit_tools.backlinks.backlinks", + "source": "src/website_profiling/tools/audit_tools/backlinks/backlinks.py", + "description": "Bing Webmaster backlinks summary (if API key configured).", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [] + } + }, + { + "name": "get_lighthouse_summary", + "domain": "performance", + "module": "website_profiling.tools.audit_tools.performance.lighthouse", + "source": "src/website_profiling/tools/audit_tools/performance/lighthouse.py", + "description": "Site-wide Lighthouse summary and poor-performance pages.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [] + } + }, + { + "name": "get_lighthouse_for_url", + "domain": "performance", + "module": "website_profiling.tools.audit_tools.performance.lighthouse", + "source": "src/website_profiling/tools/audit_tools/performance/lighthouse.py", + "description": "Lighthouse scores and audits for one URL.", + "inputSchema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "Page URL" + }, + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [ + "url" + ] + } + }, + { + "name": "get_lighthouse_diagnostics", + "domain": "performance", + "module": "website_profiling.tools.audit_tools.performance.lighthouse", + "source": "src/website_profiling/tools/audit_tools/performance/lighthouse.py", + "description": "Lighthouse audit diagnostics across sampled pages.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "get_crux_summary", + "domain": "performance", + "module": "website_profiling.tools.audit_tools.performance.lighthouse", + "source": "src/website_profiling/tools/audit_tools/performance/lighthouse.py", + "description": "Chrome UX Report field data (CrUX) for origin.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [] + } + }, + { + "name": "list_slow_pages", + "domain": "performance", + "module": "website_profiling.tools.audit_tools.performance.lighthouse", + "source": "src/website_profiling/tools/audit_tools/performance/lighthouse.py", + "description": "Pages with Lighthouse performance below threshold (default 50).", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "performance_threshold": { + "type": "integer" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "get_health_history", + "domain": "drift", + "module": "website_profiling.tools.audit_tools.portfolio.health", + "source": "src/website_profiling/tools/audit_tools/portfolio/health.py", + "description": "Historical health score snapshots for trend analysis.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [ + "property_id" + ] + } + }, + { + "name": "compare_reports", + "domain": "drift", + "module": "website_profiling.tools.audit_tools.compare.compare", + "source": "src/website_profiling/tools/audit_tools/compare/compare.py", + "description": "Full audit drift comparison between current and baseline report.", + "inputSchema": { + "type": "object", + "properties": { + "baseline_report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [ + "baseline_report_id" + ] + } + }, + { + "name": "get_integration_alerts", + "domain": "drift", + "module": "website_profiling.tools.audit_tools.ops.ops", + "source": "src/website_profiling/tools/audit_tools/ops/ops.py", + "description": "Stale GSC Links imports and health score drop alerts.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + } + }, + "required": [ + "property_id" + ] + } + }, + { + "name": "get_tech_stack_summary", + "domain": "drift", + "module": "website_profiling.tools.audit_tools.tech.tech", + "source": "src/website_profiling/tools/audit_tools/tech/tech.py", + "description": "Detected technologies (CMS, analytics, CDN) from crawl.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [] + } + }, + { + "name": "get_security_findings", + "domain": "security", + "module": "website_profiling.tools.audit_tools.security.security", + "source": "src/website_profiling/tools/audit_tools/security/security.py", + "description": "Security header and TLS findings from security scan.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "severity": { + "type": "string" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "get_audit_recommendations", + "domain": "portfolio", + "module": "website_profiling.tools.audit_tools.report.report_extras", + "source": "src/website_profiling/tools/audit_tools/report/report_extras.py", + "description": "Actionable SEO recommendation bullets from the audit.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [] + } + }, + { + "name": "get_ml_errors", + "domain": "portfolio", + "module": "website_profiling.tools.audit_tools.report.report_extras", + "source": "src/website_profiling/tools/audit_tools/report/report_extras.py", + "description": "ML analysis errors (duplicates, NER, clusters) if any failed.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [] + } + }, + { + "name": "get_ssl_expiry_info", + "domain": "portfolio", + "module": "website_profiling.tools.audit_tools.report.report_extras", + "source": "src/website_profiling/tools/audit_tools/report/report_extras.py", + "description": "Site TLS certificate expiry from the audit.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [] + } + }, + { + "name": "get_ads_txt_status", + "domain": "portfolio", + "module": "website_profiling.tools.audit_tools.portfolio.property_profile", + "source": "src/website_profiling/tools/audit_tools/portfolio/property_profile.py", + "description": "ads.txt presence and validation from site-level checks.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [] + } + }, + { + "name": "get_security_txt_status", + "domain": "portfolio", + "module": "website_profiling.tools.audit_tools.portfolio.property_profile", + "source": "src/website_profiling/tools/audit_tools/portfolio/property_profile.py", + "description": "security.txt presence and Contact/Expires fields from site-level checks.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [] + } + }, + { + "name": "list_subdomains", + "domain": "indexation", + "module": "website_profiling.tools.audit_tools.portfolio.property_profile", + "source": "src/website_profiling/tools/audit_tools/portfolio/property_profile.py", + "description": "Passive subdomain inventory from crawl, GSC, and certificate transparency.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "in_scope_only": { + "type": "boolean", + "description": "Only hosts on the property apex (default true)" + }, + "limit": { + "type": "integer", + "maximum": 200 + } + }, + "required": [] + } + }, + { + "name": "get_contact_intelligence", + "domain": "portfolio", + "module": "website_profiling.tools.audit_tools.portfolio.property_profile", + "source": "src/website_profiling/tools/audit_tools/portfolio/property_profile.py", + "description": "Business contact signals from crawl, schema, security.txt, and RDAP org (sourced).", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "maximum": 100 + } + }, + "required": [] + } + }, + { + "name": "list_audit_categories", + "domain": "portfolio", + "module": "website_profiling.tools.audit_tools.report.report_extras", + "source": "src/website_profiling/tools/audit_tools/report/report_extras.py", + "description": "All audit categories with scores and issue counts.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [] + } + }, + { + "name": "get_category_recommendations", + "domain": "issues", + "module": "website_profiling.tools.audit_tools.report.report_extras", + "source": "src/website_profiling/tools/audit_tools/report/report_extras.py", + "description": "Category-level recommendations for one category_id.", + "inputSchema": { + "type": "object", + "properties": { + "category_id": { + "type": "string" + }, + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [ + "category_id" + ] + } + }, + { + "name": "list_issues_with_ai_fixes", + "domain": "issues", + "module": "website_profiling.tools.audit_tools.report.report_extras", + "source": "src/website_profiling/tools/audit_tools/report/report_extras.py", + "description": "Issues that include LLM-generated fix suggestions.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_seo_onpage_issues", + "domain": "onpage", + "module": "website_profiling.tools.audit_tools.onpage.onpage", + "source": "src/website_profiling/tools/audit_tools/onpage/onpage.py", + "description": "Flat SEO issue list (titles, meta, H1, thin content) with optional issue_type filter.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "issue_type": { + "type": "string" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_content_url_issues", + "domain": "onpage", + "module": "website_profiling.tools.audit_tools.onpage.onpage", + "source": "src/website_profiling/tools/audit_tools/onpage/onpage.py", + "description": "Pages in a content_urls bucket (missing_title, missing_h1, thin_content, etc.).", + "inputSchema": { + "type": "object", + "properties": { + "bucket": { + "type": "string" + }, + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [ + "bucket" + ] + } + }, + { + "name": "list_pages_missing_title", + "domain": "onpage", + "module": "website_profiling.tools.audit_tools.onpage.onpage", + "source": "src/website_profiling/tools/audit_tools/onpage/onpage.py", + "description": "Pages with no title tag.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_pages_missing_h1", + "domain": "onpage", + "module": "website_profiling.tools.audit_tools.onpage.onpage", + "source": "src/website_profiling/tools/audit_tools/onpage/onpage.py", + "description": "Pages with no H1.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_pages_multiple_h1", + "domain": "onpage", + "module": "website_profiling.tools.audit_tools.onpage.onpage", + "source": "src/website_profiling/tools/audit_tools/onpage/onpage.py", + "description": "Pages with multiple H1 elements.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_pages_missing_meta_description", + "domain": "onpage", + "module": "website_profiling.tools.audit_tools.onpage.onpage", + "source": "src/website_profiling/tools/audit_tools/onpage/onpage.py", + "description": "Pages with no meta description.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_pages_meta_desc_too_short", + "domain": "onpage", + "module": "website_profiling.tools.audit_tools.onpage.onpage", + "source": "src/website_profiling/tools/audit_tools/onpage/onpage.py", + "description": "Pages with meta description under 70 characters.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_pages_meta_desc_too_long", + "domain": "onpage", + "module": "website_profiling.tools.audit_tools.onpage.onpage", + "source": "src/website_profiling/tools/audit_tools/onpage/onpage.py", + "description": "Pages with meta description over 160 characters.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_pages_noindex", + "domain": "onpage", + "module": "website_profiling.tools.audit_tools.onpage.onpage", + "source": "src/website_profiling/tools/audit_tools/onpage/onpage.py", + "description": "Crawled pages with noindex directive.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "get_crawl_summary", + "domain": "crawl", + "module": "website_profiling.tools.audit_tools.portfolio.charts", + "source": "src/website_profiling/tools/audit_tools/portfolio/charts.py", + "description": "Full crawl summary block (URL counts, success rate, timing).", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [] + } + }, + { + "name": "get_issue_priority_breakdown", + "domain": "issues", + "module": "website_profiling.tools.audit_tools.portfolio.charts", + "source": "src/website_profiling/tools/audit_tools/portfolio/charts.py", + "description": "Issue counts by priority (Critical/High/Medium/Low) as chart data for chat UI.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [] + } + }, + { + "name": "get_critical_issues", + "domain": "issues", + "module": "website_profiling.tools.audit_tools.report.report", + "source": "src/website_profiling/tools/audit_tools/report/report.py", + "description": "All Critical-priority audit issues with URL, category, and message (for chat issue table).", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "get_mime_type_breakdown", + "domain": "portfolio", + "module": "website_profiling.tools.audit_tools.portfolio.charts", + "source": "src/website_profiling/tools/audit_tools/portfolio/charts.py", + "description": "Content-Type distribution from crawl.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [] + } + }, + { + "name": "get_title_length_distribution", + "domain": "portfolio", + "module": "website_profiling.tools.audit_tools.portfolio.charts", + "source": "src/website_profiling/tools/audit_tools/portfolio/charts.py", + "description": "Title length histogram from crawl.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [] + } + }, + { + "name": "get_domain_link_distribution", + "domain": "links", + "module": "website_profiling.tools.audit_tools.portfolio.charts", + "source": "src/website_profiling/tools/audit_tools/portfolio/charts.py", + "description": "Internal link domain breakdown chart data.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [] + } + }, + { + "name": "get_outlink_distribution", + "domain": "links", + "module": "website_profiling.tools.audit_tools.portfolio.charts", + "source": "src/website_profiling/tools/audit_tools/portfolio/charts.py", + "description": "Outlink count distribution chart data.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [] + } + }, + { + "name": "get_top_crawled_pages", + "domain": "portfolio", + "module": "website_profiling.tools.audit_tools.portfolio.charts", + "source": "src/website_profiling/tools/audit_tools/portfolio/charts.py", + "description": "Top pages by inlinks from crawl.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_indexation_gaps", + "domain": "indexation", + "module": "website_profiling.tools.audit_tools.indexation.indexation_tools", + "source": "src/website_profiling/tools/audit_tools/indexation/indexation_tools.py", + "description": "URLs in an indexation gap list (sitemap_only, crawled_not_in_sitemap, gsc_not_crawled).", + "inputSchema": { + "type": "object", + "properties": { + "gap_type": { + "type": "string" + }, + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "maximum": 200 + } + }, + "required": [ + "gap_type" + ] + } + }, + { + "name": "get_indexation_url_join", + "domain": "indexation", + "module": "website_profiling.tools.audit_tools.indexation.indexation_tools", + "source": "src/website_profiling/tools/audit_tools/indexation/indexation_tools.py", + "description": "GSC vs crawl URL join table from indexation coverage.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [] + } + }, + { + "name": "get_gsc_sample_links", + "domain": "backlinks", + "module": "website_profiling.tools.audit_tools.backlinks.backlinks", + "source": "src/website_profiling/tools/audit_tools/backlinks/backlinks.py", + "description": "Sample backlinks from GSC Links CSV import.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "limit": { + "type": "integer", + "maximum": 100 + } + }, + "required": [ + "property_id" + ] + } + }, + { + "name": "get_gsc_latest_links", + "domain": "backlinks", + "module": "website_profiling.tools.audit_tools.backlinks.backlinks", + "source": "src/website_profiling/tools/audit_tools/backlinks/backlinks.py", + "description": "Latest discovered backlinks from GSC Links import.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "limit": { + "type": "integer", + "maximum": 100 + } + }, + "required": [ + "property_id" + ] + } + }, + { + "name": "get_third_party_links_overlay", + "domain": "links", + "module": "website_profiling.tools.audit_tools.backlinks.backlinks", + "source": "src/website_profiling/tools/audit_tools/backlinks/backlinks.py", + "description": "Moz/Majestic third-party backlink overlays.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "provider": { + "type": "string" + } + }, + "required": [ + "property_id" + ] + } + }, + { + "name": "get_backlinks_velocity", + "domain": "links", + "module": "website_profiling.tools.audit_tools.backlinks.backlinks", + "source": "src/website_profiling/tools/audit_tools/backlinks/backlinks.py", + "description": "Referring-domain trend from gsc_links_snapshots.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [ + "property_id" + ] + } + }, + { + "name": "get_property_ops", + "domain": "portfolio", + "module": "website_profiling.tools.audit_tools.ops.ops", + "source": "src/website_profiling/tools/audit_tools/ops/ops.py", + "description": "Schedule cron and alert webhook/email settings (read-only).", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + } + }, + "required": [ + "property_id" + ] + } + }, + { + "name": "get_google_integration_status", + "domain": "google", + "module": "website_profiling.tools.audit_tools.ops.ops", + "source": "src/website_profiling/tools/audit_tools/ops/ops.py", + "description": "Google OAuth, GSC/GA4 mapping, and data freshness.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + } + }, + "required": [ + "property_id" + ] + } + }, + { + "name": "list_crawl_runs", + "domain": "ops", + "module": "website_profiling.tools.audit_tools.ops.ops", + "source": "src/website_profiling/tools/audit_tools/ops/ops.py", + "description": "Recent crawl run history for a property.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_log_uploads", + "domain": "ops", + "module": "website_profiling.tools.audit_tools.ops.ops", + "source": "src/website_profiling/tools/audit_tools/ops/ops.py", + "description": "Access log file uploads for a property.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [ + "property_id" + ] + } + }, + { + "name": "get_latest_log_analysis", + "domain": "ops", + "module": "website_profiling.tools.audit_tools.ops.ops", + "source": "src/website_profiling/tools/audit_tools/ops/ops.py", + "description": "Most recent parsed access log analysis.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + } + }, + "required": [ + "property_id" + ] + } + }, + { + "name": "get_keyword_serp_overlay", + "domain": "keywords", + "module": "website_profiling.tools.audit_tools.keywords.keywords", + "source": "src/website_profiling/tools/audit_tools/keywords/keywords.py", + "description": "Keywords with SERP competition overlay data.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [ + "property_id" + ] + } + }, + { + "name": "list_keywords_by_action", + "domain": "keywords", + "module": "website_profiling.tools.audit_tools.keywords.keywords", + "source": "src/website_profiling/tools/audit_tools/keywords/keywords.py", + "description": "Keywords filtered by recommended_action.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "recommended_action": { + "type": "string" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [ + "property_id", + "recommended_action" + ] + } + }, + { + "name": "list_keywords_by_position", + "domain": "keywords", + "module": "website_profiling.tools.audit_tools.keywords.keywords", + "source": "src/website_profiling/tools/audit_tools/keywords/keywords.py", + "description": "Keywords filtered by GSC position range.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "min_position": { + "type": "number" + }, + "max_position": { + "type": "number" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [ + "property_id" + ] + } + }, + { + "name": "list_keywords_by_impressions", + "domain": "keywords", + "module": "website_profiling.tools.audit_tools.keywords.keywords", + "source": "src/website_profiling/tools/audit_tools/keywords/keywords.py", + "description": "Keywords with at least min_impressions.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "min_impressions": { + "type": "integer" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [ + "property_id" + ] + } + }, + { + "name": "get_lighthouse_human_summary", + "domain": "performance", + "module": "website_profiling.tools.audit_tools.performance.lighthouse", + "source": "src/website_profiling/tools/audit_tools/performance/lighthouse.py", + "description": "Natural-language Lighthouse summary narrative.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [] + } + }, + { + "name": "list_lighthouse_poor_seo_pages", + "domain": "performance", + "module": "website_profiling.tools.audit_tools.performance.lighthouse", + "source": "src/website_profiling/tools/audit_tools/performance/lighthouse.py", + "description": "Pages with Lighthouse SEO score below threshold.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "seo_threshold": { + "type": "integer" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "get_page_analysis", + "domain": "crawl", + "module": "website_profiling.tools.audit_tools.crawl.crawl", + "source": "src/website_profiling/tools/audit_tools/crawl/crawl.py", + "description": "Full page_analysis JSON (schema, console errors, accessibility) for one URL.", + "inputSchema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "Page URL" + }, + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [ + "url" + ] + } + }, + { + "name": "search_pages_advanced", + "domain": "crawl", + "module": "website_profiling.tools.audit_tools.crawl.crawl", + "source": "src/website_profiling/tools/audit_tools/crawl/crawl.py", + "description": "Search crawl with filters: status, noindex, word count, fetch method, missing title.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "status": { + "type": "string" + }, + "url_contains": { + "type": "string" + }, + "noindex_only": { + "type": "boolean" + }, + "missing_title": { + "type": "boolean" + }, + "min_word_count": { + "type": "integer" + }, + "max_word_count": { + "type": "integer" + }, + "fetch_method": { + "type": "string" + }, + "limit": { + "type": "integer", + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_pages_with_console_errors", + "domain": "crawl", + "module": "website_profiling.tools.audit_tools.crawl.crawl", + "source": "src/website_profiling/tools/audit_tools/crawl/crawl.py", + "description": "Rendered pages with JS console errors.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_pages_by_fetch_method", + "domain": "crawl", + "module": "website_profiling.tools.audit_tools.crawl.crawl", + "source": "src/website_profiling/tools/audit_tools/crawl/crawl.py", + "description": "Pages crawled via static or rendered fetch.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "fetch_method": { + "type": "string" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [ + "fetch_method" + ] + } + }, + { + "name": "get_crawl_links_table", + "domain": "crawl", + "module": "website_profiling.tools.audit_tools.crawl.crawl", + "source": "src/website_profiling/tools/audit_tools/crawl/crawl.py", + "description": "Paginated links table from report payload.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "url_contains": { + "type": "string" + }, + "limit": { + "type": "integer", + "maximum": 100 + } + }, + "required": [] + } + }, + { + "name": "get_graph_edges_sample", + "domain": "portfolio", + "module": "website_profiling.tools.audit_tools.crawl.crawl", + "source": "src/website_profiling/tools/audit_tools/crawl/crawl.py", + "description": "Sample of internal link graph edges.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "maximum": 200 + } + }, + "required": [] + } + }, + { + "name": "list_status_4xx_pages", + "domain": "crawl", + "module": "website_profiling.tools.audit_tools.crawl.crawl", + "source": "src/website_profiling/tools/audit_tools/crawl/crawl.py", + "description": "All crawled 4xx pages.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_status_5xx_pages", + "domain": "crawl", + "module": "website_profiling.tools.audit_tools.crawl.crawl", + "source": "src/website_profiling/tools/audit_tools/crawl/crawl.py", + "description": "All crawled 5xx pages.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "get_ga4_page_metrics", + "domain": "google", + "module": "website_profiling.tools.audit_tools.google.google", + "source": "src/website_profiling/tools/audit_tools/google/google.py", + "description": "GA4 metrics for a landing page path or URL.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "path": { + "type": "string" + }, + "url": { + "type": "string", + "description": "Page URL" + } + }, + "required": [] + } + }, + { + "name": "get_category_health_history", + "domain": "issues", + "module": "website_profiling.tools.audit_tools.portfolio.health", + "source": "src/website_profiling/tools/audit_tools/portfolio/health.py", + "description": "Category score trend from audit_health_snapshots.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "category_id": { + "type": "string" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [ + "property_id" + ] + } + }, + { + "name": "compare_issue_deltas", + "domain": "drift", + "module": "website_profiling.tools.audit_tools.compare.compare_slices", + "source": "src/website_profiling/tools/audit_tools/compare/compare_slices.py", + "description": "New and resolved issues vs baseline report.", + "inputSchema": { + "type": "object", + "properties": { + "baseline_report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "maximum": 100 + } + }, + "required": [ + "baseline_report_id" + ] + } + }, + { + "name": "compare_category_deltas", + "domain": "drift", + "module": "website_profiling.tools.audit_tools.compare.compare_slices", + "source": "src/website_profiling/tools/audit_tools/compare/compare_slices.py", + "description": "Category score changes vs baseline report.", + "inputSchema": { + "type": "object", + "properties": { + "baseline_report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [ + "baseline_report_id" + ] + } + }, + { + "name": "compare_seo_health_deltas", + "domain": "drift", + "module": "website_profiling.tools.audit_tools.compare.compare_slices", + "source": "src/website_profiling/tools/audit_tools/compare/compare_slices.py", + "description": "On-page SEO KPI changes vs baseline report.", + "inputSchema": { + "type": "object", + "properties": { + "baseline_report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [ + "baseline_report_id" + ] + } + }, + { + "name": "compare_lighthouse_deltas", + "domain": "drift", + "module": "website_profiling.tools.audit_tools.compare.compare_slices", + "source": "src/website_profiling/tools/audit_tools/compare/compare_slices.py", + "description": "Lighthouse score changes per URL vs baseline.", + "inputSchema": { + "type": "object", + "properties": { + "baseline_report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [ + "baseline_report_id" + ] + } + }, + { + "name": "compare_url_set_diff", + "domain": "drift", + "module": "website_profiling.tools.audit_tools.compare.compare_slices", + "source": "src/website_profiling/tools/audit_tools/compare/compare_slices.py", + "description": "URLs added or removed from crawl vs baseline.", + "inputSchema": { + "type": "object", + "properties": { + "baseline_report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "maximum": 200 + } + }, + "required": [ + "baseline_report_id" + ] + } + }, + { + "name": "compare_redirect_deltas", + "domain": "drift", + "module": "website_profiling.tools.audit_tools.compare.compare_slices", + "source": "src/website_profiling/tools/audit_tools/compare/compare_slices.py", + "description": "Redirect chain changes vs baseline.", + "inputSchema": { + "type": "object", + "properties": { + "baseline_report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "maximum": 100 + } + }, + "required": [ + "baseline_report_id" + ] + } + }, + { + "name": "compare_link_metric_deltas", + "domain": "drift", + "module": "website_profiling.tools.audit_tools.compare.compare_slices", + "source": "src/website_profiling/tools/audit_tools/compare/compare_slices.py", + "description": "Per-URL inlink/outlink/word-count changes vs baseline.", + "inputSchema": { + "type": "object", + "properties": { + "baseline_report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "maximum": 200 + } + }, + "required": [ + "baseline_report_id" + ] + } + }, + { + "name": "compare_security_deltas", + "domain": "drift", + "module": "website_profiling.tools.audit_tools.compare.compare_slices", + "source": "src/website_profiling/tools/audit_tools/compare/compare_slices.py", + "description": "Security finding additions and resolutions vs baseline.", + "inputSchema": { + "type": "object", + "properties": { + "baseline_report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [ + "baseline_report_id" + ] + } + }, + { + "name": "compare_duplicate_deltas", + "domain": "drift", + "module": "website_profiling.tools.audit_tools.compare.compare_slices", + "source": "src/website_profiling/tools/audit_tools/compare/compare_slices.py", + "description": "Near-duplicate cluster changes vs baseline.", + "inputSchema": { + "type": "object", + "properties": { + "baseline_report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [ + "baseline_report_id" + ] + } + }, + { + "name": "compare_tech_deltas", + "domain": "drift", + "module": "website_profiling.tools.audit_tools.compare.compare_slices", + "source": "src/website_profiling/tools/audit_tools/compare/compare_slices.py", + "description": "Technology stack changes vs baseline.", + "inputSchema": { + "type": "object", + "properties": { + "baseline_report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [ + "baseline_report_id" + ] + } + }, + { + "name": "compare_content_metrics", + "domain": "drift", + "module": "website_profiling.tools.audit_tools.compare.compare_slices", + "source": "src/website_profiling/tools/audit_tools/compare/compare_slices.py", + "description": "Content and crawl metric changes vs baseline.", + "inputSchema": { + "type": "object", + "properties": { + "baseline_report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [ + "baseline_report_id" + ] + } + }, + { + "name": "compare_google_metrics", + "domain": "drift", + "module": "website_profiling.tools.audit_tools.compare.compare_slices", + "source": "src/website_profiling/tools/audit_tools/compare/compare_slices.py", + "description": "GSC and GA4 metric changes vs baseline.", + "inputSchema": { + "type": "object", + "properties": { + "baseline_report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [ + "baseline_report_id" + ] + } + }, + { + "name": "compare_priority_counts", + "domain": "drift", + "module": "website_profiling.tools.audit_tools.compare.compare_slices", + "source": "src/website_profiling/tools/audit_tools/compare/compare_slices.py", + "description": "Issue priority count changes vs baseline.", + "inputSchema": { + "type": "object", + "properties": { + "baseline_report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [ + "baseline_report_id" + ] + } + }, + { + "name": "compare_health_score_delta", + "domain": "drift", + "module": "website_profiling.tools.audit_tools.compare.compare_slices", + "source": "src/website_profiling/tools/audit_tools/compare/compare_slices.py", + "description": "Overall health score change vs baseline.", + "inputSchema": { + "type": "object", + "properties": { + "baseline_report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [ + "baseline_report_id" + ] + } + }, + { + "name": "list_pages_missing_canonical", + "domain": "onpage", + "module": "website_profiling.tools.audit_tools.crawl.crawl_lists", + "source": "src/website_profiling/tools/audit_tools/crawl/crawl_lists.py", + "description": "2xx pages with no canonical URL.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_canonical_mismatch", + "domain": "onpage", + "module": "website_profiling.tools.audit_tools.crawl.crawl_lists", + "source": "src/website_profiling/tools/audit_tools/crawl/crawl_lists.py", + "description": "Pages where canonical URL differs from crawled URL.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_pages_with_missing_alt", + "domain": "onpage", + "module": "website_profiling.tools.audit_tools.crawl.crawl_lists", + "source": "src/website_profiling/tools/audit_tools/crawl/crawl_lists.py", + "description": "Pages with images missing alt text.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_pages_skipped_headings", + "domain": "onpage", + "module": "website_profiling.tools.audit_tools.crawl.crawl_lists", + "source": "src/website_profiling/tools/audit_tools/crawl/crawl_lists.py", + "description": "Pages with skipped heading levels (e.g. H1 to H3).", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_pages_missing_viewport", + "domain": "onpage", + "module": "website_profiling.tools.audit_tools.crawl.crawl_lists", + "source": "src/website_profiling/tools/audit_tools/crawl/crawl_lists.py", + "description": "Pages missing viewport meta tag.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_long_redirect_chains", + "domain": "crawl", + "module": "website_profiling.tools.audit_tools.crawl.crawl_lists", + "source": "src/website_profiling/tools/audit_tools/crawl/crawl_lists.py", + "description": "URLs with redirect chains of 2+ hops.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_robots_blocked_urls", + "domain": "crawl", + "module": "website_profiling.tools.audit_tools.crawl.crawl_lists", + "source": "src/website_profiling/tools/audit_tools/crawl/crawl_lists.py", + "description": "URLs blocked by robots.txt during crawl.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_pages_missing_og_image", + "domain": "onpage", + "module": "website_profiling.tools.audit_tools.crawl.crawl_lists", + "source": "src/website_profiling/tools/audit_tools/crawl/crawl_lists.py", + "description": "Pages missing Open Graph image.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "get_top_pages_by_pagerank", + "domain": "crawl", + "module": "website_profiling.tools.audit_tools.crawl.crawl_lists", + "source": "src/website_profiling/tools/audit_tools/crawl/crawl_lists.py", + "description": "Top internal pages by crawl PageRank score.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "get_log_top_paths", + "domain": "ops", + "module": "website_profiling.tools.audit_tools.ops.ops", + "source": "src/website_profiling/tools/audit_tools/ops/ops.py", + "description": "Top hit paths from latest access log analysis.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "limit": { + "type": "integer", + "maximum": 100 + } + }, + "required": [ + "property_id" + ] + } + }, + { + "name": "list_log_only_paths", + "domain": "ops", + "module": "website_profiling.tools.audit_tools.ops.ops", + "source": "src/website_profiling/tools/audit_tools/ops/ops.py", + "description": "Paths in access logs but not in crawl.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "limit": { + "type": "integer", + "maximum": 200 + } + }, + "required": [ + "property_id" + ] + } + }, + { + "name": "list_crawl_only_paths", + "domain": "portfolio", + "module": "website_profiling.tools.audit_tools.ops.ops", + "source": "src/website_profiling/tools/audit_tools/ops/ops.py", + "description": "Crawled paths not seen in access logs.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "limit": { + "type": "integer", + "maximum": 200 + } + }, + "required": [ + "property_id" + ] + } + }, + { + "name": "get_log_googlebot_stats", + "domain": "google", + "module": "website_profiling.tools.audit_tools.ops.ops", + "source": "src/website_profiling/tools/audit_tools/ops/ops.py", + "description": "Googlebot hit counts and ratio from access log.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + } + }, + "required": [ + "property_id" + ] + } + }, + { + "name": "get_log_analysis_by_id", + "domain": "ops", + "module": "website_profiling.tools.audit_tools.ops.ops", + "source": "src/website_profiling/tools/audit_tools/ops/ops.py", + "description": "Access log analysis for a specific upload_id.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "upload_id": { + "type": "integer" + } + }, + "required": [ + "property_id", + "upload_id" + ] + } + }, + { + "name": "list_lighthouse_poor_accessibility_pages", + "domain": "performance", + "module": "website_profiling.tools.audit_tools.performance.lighthouse", + "source": "src/website_profiling/tools/audit_tools/performance/lighthouse.py", + "description": "Pages with Lighthouse accessibility below threshold.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "accessibility_threshold": { + "type": "integer" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_lighthouse_poor_best_practices_pages", + "domain": "performance", + "module": "website_profiling.tools.audit_tools.performance.lighthouse", + "source": "src/website_profiling/tools/audit_tools/performance/lighthouse.py", + "description": "Pages with Lighthouse best-practices below threshold.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "best_practices_threshold": { + "type": "integer" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_lighthouse_cwv_failures", + "domain": "performance", + "module": "website_profiling.tools.audit_tools.performance.lighthouse", + "source": "src/website_profiling/tools/audit_tools/performance/lighthouse.py", + "description": "Pages failing LCP, CLS, or TBT thresholds.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_pages_by_technology", + "domain": "crawl", + "module": "website_profiling.tools.audit_tools.tech.tech", + "source": "src/website_profiling/tools/audit_tools/tech/tech.py", + "description": "Pages using a detected technology name.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "technology_name": { + "type": "string" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [ + "technology_name" + ] + } + }, + { + "name": "get_duplicate_cluster", + "domain": "content", + "module": "website_profiling.tools.audit_tools.content.content", + "source": "src/website_profiling/tools/audit_tools/content/content.py", + "description": "Duplicate content cluster by index or member URL.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "cluster_index": { + "type": "integer" + }, + "url": { + "type": "string", + "description": "Page URL" + } + }, + "required": [] + } + }, + { + "name": "get_security_findings_summary", + "domain": "security", + "module": "website_profiling.tools.audit_tools.security.security", + "source": "src/website_profiling/tools/audit_tools/security/security.py", + "description": "Security findings grouped by finding_type.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [] + } + }, + { + "name": "list_security_findings_by_type", + "domain": "security", + "module": "website_profiling.tools.audit_tools.security.security", + "source": "src/website_profiling/tools/audit_tools/security/security.py", + "description": "Security findings filtered by finding_type.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "finding_type": { + "type": "string" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [ + "finding_type" + ] + } + }, + { + "name": "list_broken_link_sources", + "domain": "links", + "module": "website_profiling.tools.audit_tools.links.links", + "source": "src/website_profiling/tools/audit_tools/links/links.py", + "description": "Pages linking to broken URLs.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "get_link_rel_summary", + "domain": "links", + "module": "website_profiling.tools.audit_tools.links.links", + "source": "src/website_profiling/tools/audit_tools/links/links.py", + "description": "nofollow/sponsored/UGC counts from crawl link_edges.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [] + } + }, + { + "name": "get_inlink_anchors", + "domain": "links", + "module": "website_profiling.tools.audit_tools.links.links", + "source": "src/website_profiling/tools/audit_tools/links/links.py", + "description": "Inlink anchor text matrix (target \u00d7 anchor counts).", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "url": { + "type": "string", + "description": "Page URL" + }, + "limit": { + "type": "integer", + "maximum": 200 + } + }, + "required": [] + } + }, + { + "name": "list_nofollow_internal_links", + "domain": "links", + "module": "website_profiling.tools.audit_tools.links.links", + "source": "src/website_profiling/tools/audit_tools/links/links.py", + "description": "Internal links with rel=nofollow from crawl.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "search_issues", + "domain": "issues", + "module": "website_profiling.tools.audit_tools.report.report", + "source": "src/website_profiling/tools/audit_tools/report/report.py", + "description": "Search audit issues by message, URL, category, or priority.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "message_contains": { + "type": "string" + }, + "url_contains": { + "type": "string" + }, + "category_id": { + "type": "string" + }, + "priority": { + "type": "string" + }, + "sort": { + "type": "string", + "enum": [ + "impact" + ] + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "generate_content_brief", + "domain": "content", + "module": "website_profiling.tools.audit_tools.integrations.llm_tools", + "source": "src/website_profiling/tools/audit_tools/integrations/llm_tools.py", + "description": "Generate a content brief for a target keyword.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "keyword": { + "type": "string" + }, + "gaps": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "keyword" + ] + } + }, + { + "name": "get_page_coach", + "domain": "crawl", + "module": "website_profiling.tools.audit_tools.integrations.llm_tools", + "source": "src/website_profiling/tools/audit_tools/integrations/llm_tools.py", + "description": "LLM internal linking coach for one URL.", + "inputSchema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "Page URL" + }, + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "refresh": { + "type": "boolean" + } + }, + "required": [ + "url" + ] + } + }, + { + "name": "get_portfolio_summary", + "domain": "portfolio", + "module": "website_profiling.tools.audit_tools.integrations.llm_tools", + "source": "src/website_profiling/tools/audit_tools/integrations/llm_tools.py", + "description": "Multi-property health score rollup.", + "inputSchema": { + "type": "object", + "properties": { + "limit": { + "type": "integer", + "maximum": 100 + } + }, + "required": [] + } + }, + { + "name": "expand_keywords", + "domain": "keywords", + "module": "website_profiling.tools.audit_tools.integrations.llm_tools", + "source": "src/website_profiling/tools/audit_tools/integrations/llm_tools.py", + "description": "Expand seed keywords via Google Suggest.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "seeds": { + "type": "array", + "items": { + "type": "string" + } + }, + "sources": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "seeds" + ] + } + }, + { + "name": "export_audit_report", + "domain": "export", + "module": "website_profiling.tools.audit_tools.export.export_tools", + "source": "src/website_profiling/tools/audit_tools/export/export_tools.py", + "description": "Export full audit report as PDF, HTML, CSV, or JSON. Returns download artifact metadata.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "format": { + "type": "string", + "enum": [ + "pdf", + "csv", + "json" + ] + } + }, + "required": [] + } + }, + { + "name": "export_compare_csv", + "domain": "export", + "module": "website_profiling.tools.audit_tools.export.export_tools", + "source": "src/website_profiling/tools/audit_tools/export/export_tools.py", + "description": "Export issue added/removed CSV diff between current and baseline report.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "baseline_report_id": { + "type": "integer" + } + }, + "required": [ + "baseline_report_id" + ] + } + }, + { + "name": "export_list_as_csv", + "domain": "export", + "module": "website_profiling.tools.audit_tools.export.export_tools", + "source": "src/website_profiling/tools/audit_tools/export/export_tools.py", + "description": "Export rows from an allowlisted list tool as CSV.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "tool_name": { + "type": "string" + }, + "tool_args": { + "type": "object" + }, + "columns": { + "type": "array", + "items": { + "type": "string" + } + }, + "limit": { + "type": "integer", + "maximum": 500 + } + }, + "required": [ + "tool_name" + ] + } + }, + { + "name": "export_sitemap_xml", + "domain": "export", + "module": "website_profiling.tools.audit_tools.export.export_extras", + "source": "src/website_profiling/tools/audit_tools/export/export_extras.py", + "description": "Generate XML sitemap from indexable crawled URLs.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [] + } + }, + { + "name": "validate_rich_results", + "domain": "portfolio", + "module": "website_profiling.tools.audit_tools.export.export_extras", + "source": "src/website_profiling/tools/audit_tools/export/export_extras.py", + "description": "Validate structured data / Rich Results for sample URLs (Estimated without API key).", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "maximum": 50 + }, + "api_key": { + "type": "string" + } + }, + "required": [] + } + }, + { + "name": "list_export_formats", + "domain": "export", + "module": "website_profiling.tools.audit_tools.export.export_tools", + "source": "src/website_profiling/tools/audit_tools/export/export_tools.py", + "description": "List supported export tools, formats, and example prompts.", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [] + } + }, + { + "name": "get_image_audit_summary", + "domain": "images", + "module": "website_profiling.tools.audit_tools.images.image_tools", + "source": "src/website_profiling/tools/audit_tools/images/image_tools.py", + "description": "Site-wide image audit totals: alt, lazy-load, dimensions, OG, Lighthouse image diagnostics.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [] + } + }, + { + "name": "list_pages_without_lazy_images", + "domain": "images", + "module": "website_profiling.tools.audit_tools.images.image_tools", + "source": "src/website_profiling/tools/audit_tools/images/image_tools.py", + "description": "Pages with images not using loading=lazy.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_pages_with_images_missing_dimensions", + "domain": "images", + "module": "website_profiling.tools.audit_tools.images.image_tools", + "source": "src/website_profiling/tools/audit_tools/images/image_tools.py", + "description": "Pages with images missing width/height (CLS risk).", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_site_image_urls", + "domain": "images", + "module": "website_profiling.tools.audit_tools.images.image_tools", + "source": "src/website_profiling/tools/audit_tools/images/image_tools.py", + "description": "Unique image URLs from crawl (on-page, OG, Twitter) with source page.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "kind": { + "type": "string", + "enum": [ + "content", + "og", + "twitter" + ] + }, + "limit": { + "type": "integer", + "maximum": 100 + } + }, + "required": [] + } + }, + { + "name": "list_lighthouse_image_opportunities", + "domain": "images", + "module": "website_profiling.tools.audit_tools.images.image_tools", + "source": "src/website_profiling/tools/audit_tools/images/image_tools.py", + "description": "Lighthouse diagnostics related to images and LCP.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_largest_images", + "domain": "images", + "module": "website_profiling.tools.audit_tools.images.image_tools", + "source": "src/website_profiling/tools/audit_tools/images/image_tools.py", + "description": "Largest probed images by file size (requires probe_image_inventory on report build).", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "min_size_kb": { + "type": "integer" + }, + "limit": { + "type": "integer", + "maximum": 100 + } + }, + "required": [] + } + }, + { + "name": "list_unoptimized_images", + "domain": "images", + "module": "website_profiling.tools.audit_tools.images.image_tools", + "source": "src/website_profiling/tools/audit_tools/images/image_tools.py", + "description": "Large images not in WebP/AVIF (requires image inventory probe).", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "min_size_kb": { + "type": "integer" + }, + "limit": { + "type": "integer", + "maximum": 100 + } + }, + "required": [] + } + }, + { + "name": "list_images_needing_attention", + "domain": "images", + "module": "website_profiling.tools.audit_tools.images.image_tools", + "source": "src/website_profiling/tools/audit_tools/images/image_tools.py", + "description": "Ranked images/pages with composite attention reasons (size, format, alt/lazy/dimension issues).", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "min_size_kb": { + "type": "integer" + }, + "limit": { + "type": "integer", + "maximum": 100 + } + }, + "required": [] + } + }, + { + "name": "list_top_impact_issues", + "domain": "issues", + "module": "website_profiling.tools.audit_tools.report.report", + "source": "src/website_profiling/tools/audit_tools/report/report.py", + "description": "Audit issues ranked by traffic-weighted impact_score (GSC clicks + GA4 sessions + priority).", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "priority": { + "type": "string" + }, + "category_id": { + "type": "string" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "get_rich_results_summary", + "domain": "portfolio", + "module": "website_profiling.tools.audit_tools.core.payload_extras", + "source": "src/website_profiling/tools/audit_tools/core/payload_extras.py", + "description": "Rich Results validation meta counts from report build.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [] + } + }, + { + "name": "list_rich_results_failures", + "domain": "portfolio", + "module": "website_profiling.tools.audit_tools.core.payload_extras", + "source": "src/website_profiling/tools/audit_tools/core/payload_extras.py", + "description": "URLs failing rich-results validation (status != pass).", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "get_competitor_keyword_gap", + "domain": "portfolio", + "module": "website_profiling.tools.audit_tools.core.payload_extras", + "source": "src/website_profiling/tools/audit_tools/core/payload_extras.py", + "description": "Competitor keyword gap rows from competitor_keyword_gap_json config.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "get_portfolio_benchmark", + "domain": "portfolio", + "module": "website_profiling.tools.audit_tools.core.payload_extras", + "source": "src/website_profiling/tools/audit_tools/core/payload_extras.py", + "description": "Property health vs portfolio median benchmark.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [] + } + }, + { + "name": "get_site_anchor_text_summary", + "domain": "portfolio", + "module": "website_profiling.tools.audit_tools.core.payload_extras", + "source": "src/website_profiling/tools/audit_tools/core/payload_extras.py", + "description": "Top sitewide inlink anchor texts from inlink_anchor_matrix.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "get_pagination_audit_summary", + "domain": "portfolio", + "module": "website_profiling.tools.audit_tools.core.payload_extras", + "source": "src/website_profiling/tools/audit_tools/core/payload_extras.py", + "description": "rel=prev/next and AMP pairing issue counts from crawl.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [] + } + }, + { + "name": "list_pages_soft_404", + "domain": "crawl", + "module": "website_profiling.tools.audit_tools.crawl.crawl_lists", + "source": "src/website_profiling/tools/audit_tools/crawl/crawl_lists.py", + "description": "2xx pages whose title suggests not-found (soft 404).", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_pages_with_axe_violations", + "domain": "accessibility", + "module": "website_profiling.tools.audit_tools.crawl.crawl_lists", + "source": "src/website_profiling/tools/audit_tools/crawl/crawl_lists.py", + "description": "Pages with axe-core accessibility violations (enable_axe crawl).", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "get_axe_audit_summary", + "domain": "accessibility", + "module": "website_profiling.tools.audit_tools.crawl.crawl_lists", + "source": "src/website_profiling/tools/audit_tools/crawl/crawl_lists.py", + "description": "Site-wide axe violation counts by rule id.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [] + } + }, + { + "name": "list_pages_with_mixed_content", + "domain": "accessibility", + "module": "website_profiling.tools.audit_tools.crawl.crawl_lists", + "source": "src/website_profiling/tools/audit_tools/crawl/crawl_lists.py", + "description": "HTTPS pages with mixed HTTP content references.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_dead_end_pages", + "domain": "crawl", + "module": "website_profiling.tools.audit_tools.crawl.crawl_lists", + "source": "src/website_profiling/tools/audit_tools/crawl/crawl_lists.py", + "description": "Crawlable pages with inlinks but zero outlinks.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_duplicate_title_groups", + "domain": "crawl", + "module": "website_profiling.tools.audit_tools.crawl.crawl_lists", + "source": "src/website_profiling/tools/audit_tools/crawl/crawl_lists.py", + "description": "Groups of pages sharing the same title and meta description.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_heavy_pages_by_bytes", + "domain": "assets", + "module": "website_profiling.tools.audit_tools.crawl.crawl_lists", + "source": "src/website_profiling/tools/audit_tools/crawl/crawl_lists.py", + "description": "Pages ranked by total JS + CSS bytes.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_pages_poor_cache_headers", + "domain": "assets", + "module": "website_profiling.tools.audit_tools.crawl.crawl_lists", + "source": "src/website_profiling/tools/audit_tools/crawl/crawl_lists.py", + "description": "Pages missing or weak cache-control / etag headers.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_pages_low_content_ratio", + "domain": "assets", + "module": "website_profiling.tools.audit_tools.crawl.crawl_lists", + "source": "src/website_profiling/tools/audit_tools/crawl/crawl_lists.py", + "description": "Pages with low content_html_ratio (bloated HTML).", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "max_content_html_ratio": { + "type": "number" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "get_heading_outline_for_url", + "domain": "accessibility", + "module": "website_profiling.tools.audit_tools.crawl.crawl_lists", + "source": "src/website_profiling/tools/audit_tools/crawl/crawl_lists.py", + "description": "Heading sequence and outline for one URL.", + "inputSchema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "Page URL" + }, + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [ + "url" + ] + } + }, + { + "name": "get_asset_weight_summary", + "domain": "assets", + "module": "website_profiling.tools.audit_tools.crawl.crawl_metrics", + "source": "src/website_profiling/tools/audit_tools/crawl/crawl_metrics.py", + "description": "JS/CSS bytes and script_count percentiles from crawl.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [] + } + }, + { + "name": "get_readability_summary", + "domain": "assets", + "module": "website_profiling.tools.audit_tools.crawl.crawl_metrics", + "source": "src/website_profiling/tools/audit_tools/crawl/crawl_metrics.py", + "description": "Reading level histogram and mean/median from crawl.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [] + } + }, + { + "name": "list_keywords_ctr_opportunity", + "domain": "ctr", + "module": "website_profiling.tools.audit_tools.keywords.keywords", + "source": "src/website_profiling/tools/audit_tools/keywords/keywords.py", + "description": "Keywords flagged for CTR improvement (title/meta).", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [ + "property_id" + ] + } + }, + { + "name": "get_gsc_ctr_opportunity_pages", + "domain": "ctr", + "module": "website_profiling.tools.audit_tools.google.google", + "source": "src/website_profiling/tools/audit_tools/google/google.py", + "description": "GSC pages with high impressions and below-curve CTR.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "min_impressions": { + "type": "integer" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "compare_indexation_deltas", + "domain": "drift", + "module": "website_profiling.tools.audit_tools.compare.compare_slices", + "source": "src/website_profiling/tools/audit_tools/compare/compare_slices.py", + "description": "Indexation coverage count and gap list changes vs baseline.", + "inputSchema": { + "type": "object", + "properties": { + "baseline_report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [ + "baseline_report_id" + ] + } + }, + { + "name": "compare_orphan_deltas", + "domain": "drift", + "module": "website_profiling.tools.audit_tools.compare.compare_slices", + "source": "src/website_profiling/tools/audit_tools/compare/compare_slices.py", + "description": "Orphan URL set changes vs baseline report.", + "inputSchema": { + "type": "object", + "properties": { + "baseline_report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [ + "baseline_report_id" + ] + } + }, + { + "name": "get_llms_txt_status", + "domain": "geo", + "module": "website_profiling.tools.audit_tools.geo.geo_tools", + "source": "src/website_profiling/tools/audit_tools/geo/geo_tools.py", + "description": "Check for /llms.txt and /.well-known/llms.txt with depth scoring (H1, blockquote, sections, links).", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [] + } + }, + { + "name": "get_ai_discovery_status", + "domain": "geo", + "module": "website_profiling.tools.audit_tools.geo.geo_tools", + "source": "src/website_profiling/tools/audit_tools/geo/geo_tools.py", + "description": "Check AI discovery endpoints: /.well-known/ai.txt, /ai/summary.json, /ai/faq.json, /ai/service.json.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [] + } + }, + { + "name": "get_robots_ai_access_score", + "domain": "geo", + "module": "website_profiling.tools.audit_tools.geo.geo_list_tools", + "source": "src/website_profiling/tools/audit_tools/geo/geo_list_tools.py", + "description": "Score robots.txt AI-bot access /18 with 27 bots across training/search/citation tiers.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [] + } + }, + { + "name": "get_faq_schema_coverage", + "domain": "geo", + "module": "website_profiling.tools.audit_tools.geo.geo_tools", + "source": "src/website_profiling/tools/audit_tools/geo/geo_tools.py", + "description": "FAQPage/QAPage schema coverage across crawled pages.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [] + } + }, + { + "name": "list_pages_missing_faq_schema", + "domain": "geo", + "module": "website_profiling.tools.audit_tools.geo.geo_tools", + "source": "src/website_profiling/tools/audit_tools/geo/geo_tools.py", + "description": "Q&A-style URLs missing FAQ schema markup.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "get_geo_readiness_score", + "domain": "geo", + "module": "website_profiling.tools.audit_tools.geo.geo_tools", + "source": "src/website_profiling/tools/audit_tools/geo/geo_tools.py", + "description": "8-category GEO readiness score (0-100) with score bands: Robots/18, llms.txt/18, Schema/16, Meta/14, Content/12, Brand/1", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [] + } + }, + { + "name": "get_aeo_content_signals_for_url", + "domain": "geo", + "module": "website_profiling.tools.audit_tools.geo.geo_tools", + "source": "src/website_profiling/tools/audit_tools/geo/geo_tools.py", + "description": "Per-URL answer-engine quotability signals.", + "inputSchema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "Page URL" + }, + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [ + "url" + ] + } + }, + { + "name": "get_eeat_signals_summary", + "domain": "geo", + "module": "website_profiling.tools.audit_tools.geo.geo_tools", + "source": "src/website_profiling/tools/audit_tools/geo/geo_tools.py", + "description": "Author/Organization schema and about/contact page counts.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [] + } + }, + { + "name": "get_js_rendering_delta", + "domain": "portfolio", + "module": "website_profiling.tools.audit_tools.geo.geo_tools", + "source": "src/website_profiling/tools/audit_tools/geo/geo_tools.py", + "description": "Static vs rendered title/word-count differences.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "get_internal_link_suggestions", + "domain": "links", + "module": "website_profiling.tools.audit_tools.geo.geo_tools", + "source": "src/website_profiling/tools/audit_tools/geo/geo_tools.py", + "description": "TF-IDF related pages and anchor hints for a source URL.", + "inputSchema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "Page URL" + }, + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "maximum": 10 + } + }, + "required": [ + "url" + ] + } + }, + { + "name": "get_citability_score", + "domain": "geo", + "module": "website_profiling.tools.audit_tools.geo.geo_citability", + "source": "src/website_profiling/tools/audit_tools/geo/geo_citability.py", + "description": "Site-wide citability score (0-100) from 9 research-backed KDD/AutoGEO signals (quotations, stats, fluency, front-loading", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [] + } + }, + { + "name": "get_citability_for_url", + "domain": "geo", + "module": "website_profiling.tools.audit_tools.geo.geo_citability", + "source": "src/website_profiling/tools/audit_tools/geo/geo_citability.py", + "description": "Per-URL citability score and detailed signal breakdown.", + "inputSchema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "Page URL" + }, + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [ + "url" + ] + } + }, + { + "name": "get_negative_signals", + "domain": "geo", + "module": "website_profiling.tools.audit_tools.geo.geo_detectors", + "source": "src/website_profiling/tools/audit_tools/geo/geo_detectors.py", + "description": "Detect 7 anti-citation signals: CTA overload, thin content, keyword stuffing, popups, missing author, no structure, affi", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "detect_prompt_injection", + "domain": "geo", + "module": "website_profiling.tools.audit_tools.geo.geo_detectors", + "source": "src/website_profiling/tools/audit_tools/geo/geo_detectors.py", + "description": "Detect 8 prompt-injection / content-manipulation patterns: hidden text, invisible Unicode, micro-font, LLM instructions,", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "get_rag_chunk_readiness", + "domain": "geo", + "module": "website_profiling.tools.audit_tools.geo.geo_detectors", + "source": "src/website_profiling/tools/audit_tools/geo/geo_detectors.py", + "description": "Score RAG retrieval readiness: section sizes, heading boundaries, anchor sentences.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "get_content_decay_signals", + "domain": "geo", + "module": "website_profiling.tools.audit_tools.geo.geo_detectors", + "source": "src/website_profiling/tools/audit_tools/geo/geo_detectors.py", + "description": "Detect temporal, statistical, version, event, and price decay patterns. Returns evergreen score 0-100.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "get_multimodal_readiness", + "domain": "geo", + "module": "website_profiling.tools.audit_tools.geo.geo_detectors", + "source": "src/website_profiling/tools/audit_tools/geo/geo_detectors.py", + "description": "Check image alt coverage, VideoObject/AudioObject schema, transcript/subtitle signals for multimodal AI engines.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [] + } + }, + { + "name": "get_topic_authority", + "domain": "geo", + "module": "website_profiling.tools.audit_tools.geo.geo_detectors", + "source": "src/website_profiling/tools/audit_tools/geo/geo_detectors.py", + "description": "Multi-page entity clusters and pillar page detection via TF-IDF cosine similarity.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "compare_geo_score_deltas", + "domain": "geo", + "module": "website_profiling.tools.audit_tools.compare.compare_slices", + "source": "src/website_profiling/tools/audit_tools/compare/compare_slices.py", + "description": "GEO readiness score drift between current and baseline report.", + "inputSchema": { + "type": "object", + "properties": { + "baseline_report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [ + "baseline_report_id" + ] + } + }, + { + "name": "generate_issue_fix", + "domain": "issues", + "module": "website_profiling.tools.audit_tools.integrations.llm_tools", + "source": "src/website_profiling/tools/audit_tools/integrations/llm_tools.py", + "description": "LLM fix suggestion for one audit issue message.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "message": { + "type": "string" + }, + "url": { + "type": "string", + "description": "Page URL" + }, + "priority": { + "type": "string" + }, + "category_id": { + "type": "string" + }, + "refresh": { + "type": "boolean" + } + }, + "required": [ + "message" + ] + } + }, + { + "name": "summarize_category_for_client", + "domain": "issues", + "module": "website_profiling.tools.audit_tools.integrations.llm_tools", + "source": "src/website_profiling/tools/audit_tools/integrations/llm_tools.py", + "description": "Client-friendly category summary with optional LLM narrative.", + "inputSchema": { + "type": "object", + "properties": { + "category_id": { + "type": "string" + }, + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [ + "category_id" + ] + } + }, + { + "name": "prioritize_fix_roadmap", + "domain": "issues", + "module": "website_profiling.tools.audit_tools.integrations.llm_tools", + "source": "src/website_profiling/tools/audit_tools/integrations/llm_tools.py", + "description": "Top N issues ranked by impact_score for a fix roadmap.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "maximum": 30 + } + }, + "required": [] + } + }, + { + "name": "analyze_serp_snippet_for_url", + "domain": "ctr", + "module": "website_profiling.tools.audit_tools.integrations.llm_tools", + "source": "src/website_profiling/tools/audit_tools/integrations/llm_tools.py", + "description": "GSC query context plus LLM title/meta CTR suggestions.", + "inputSchema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "Page URL" + }, + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [ + "url" + ] + } + }, + { + "name": "draft_llms_txt", + "domain": "geo", + "module": "website_profiling.tools.audit_tools.integrations.llm_tools", + "source": "src/website_profiling/tools/audit_tools/integrations/llm_tools.py", + "description": "Draft llms.txt content from top pages and schema coverage.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [] + } + }, + { + "name": "generate_schema", + "domain": "geo", + "module": "website_profiling.tools.audit_tools.integrations.llm_tools", + "source": "src/website_profiling/tools/audit_tools/integrations/llm_tools.py", + "description": "Generate JSON-LD schema markup (WebSite/Organization/FAQPage/Article) from crawl data.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "schema_type": { + "type": "string" + }, + "url": { + "type": "string", + "description": "Page URL" + } + }, + "required": [] + } + }, + { + "name": "generate_robots_txt", + "domain": "geo", + "module": "website_profiling.tools.audit_tools.integrations.llm_tools", + "source": "src/website_profiling/tools/audit_tools/integrations/llm_tools.py", + "description": "Generate a robots.txt that explicitly allows all 27 AI citation/search/training bots.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [] + } + }, + { + "name": "generate_meta_tags", + "domain": "geo", + "module": "website_profiling.tools.audit_tools.integrations.llm_tools", + "source": "src/website_profiling/tools/audit_tools/integrations/llm_tools.py", + "description": "Generate meta/OG tag HTML recommendations for a URL.", + "inputSchema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "Page URL" + }, + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [ + "url" + ] + } + }, + { + "name": "generate_geo_fix_bundle", + "domain": "geo", + "module": "website_profiling.tools.audit_tools.integrations.llm_tools", + "source": "src/website_profiling/tools/audit_tools/integrations/llm_tools.py", + "description": "Generate all missing GEO fix files: llms.txt, robots.txt, WebSite schema, Organization schema.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [] + } + }, + { + "name": "get_agent_readiness_score", + "domain": "geo", + "module": "website_profiling.tools.audit_tools.geo.agent_readiness", + "source": "src/website_profiling/tools/audit_tools/geo/agent_readiness.py", + "description": "Agent documentation readiness score (0-100, A-F grade) across 5 categories: discovery/25, content_structure/25, token_ec", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "max_tokens_per_page": { + "type": "integer" + }, + "warn_tokens": { + "type": "integer" + } + }, + "required": [] + } + }, + { + "name": "get_agents_md_status", + "domain": "geo", + "module": "website_profiling.tools.audit_tools.geo.agent_readiness", + "source": "src/website_profiling/tools/audit_tools/geo/agent_readiness.py", + "description": "Check for AGENTS.md, CLAUDE.md, GEMINI.md, AGENT.md at the site root with content quality scoring (purpose, stack, edit ", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [] + } + }, + { + "name": "get_skill_md_status", + "domain": "geo", + "module": "website_profiling.tools.audit_tools.geo.agent_readiness", + "source": "src/website_profiling/tools/audit_tools/geo/agent_readiness.py", + "description": "Check for /skill.md or /.well-known/skill.md with capability description, inputs, constraints, and examples scoring.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [] + } + }, + { + "name": "get_agent_permissions_status", + "domain": "geo", + "module": "website_profiling.tools.audit_tools.geo.agent_readiness", + "source": "src/website_profiling/tools/audit_tools/geo/agent_readiness.py", + "description": "Check for /agent-permissions.json or /.well-known/agent-permissions.json with loose JSON schema validation.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [] + } + }, + { + "name": "get_token_budget_summary", + "domain": "geo", + "module": "website_profiling.tools.audit_tools.geo.agent_readiness", + "source": "src/website_profiling/tools/audit_tools/geo/agent_readiness.py", + "description": "Per-page approximate token counts (cl100k_base): p50/p95, pages over warn/max thresholds, budget score.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "max_tokens_per_page": { + "type": "integer" + }, + "warn_tokens": { + "type": "integer" + } + }, + "required": [] + } + }, + { + "name": "list_oversized_pages_for_agents", + "domain": "geo", + "module": "website_profiling.tools.audit_tools.geo.agent_readiness", + "source": "src/website_profiling/tools/audit_tools/geo/agent_readiness.py", + "description": "Pages exceeding the warn token threshold (default 8000 tokens).", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "warn_tokens": { + "type": "integer" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "get_content_structure_aeo_summary", + "domain": "geo", + "module": "website_profiling.tools.audit_tools.geo.agent_readiness", + "source": "src/website_profiling/tools/audit_tools/geo/agent_readiness.py", + "description": "Site-wide content structure score for agent readiness: headings hierarchy, semantic HTML landmarks, code blocks, tables.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [] + } + }, + { + "name": "get_markdown_availability_summary", + "domain": "geo", + "module": "website_profiling.tools.audit_tools.geo.agent_readiness", + "source": "src/website_profiling/tools/audit_tools/geo/agent_readiness.py", + "description": "Check markdown source availability, HTML noise ratio, and JS-empty page detection for doc-like URLs.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "probe_limit": { + "type": "integer" + } + }, + "required": [] + } + }, + { + "name": "list_pages_agent_unfriendly", + "domain": "geo", + "module": "website_profiling.tools.audit_tools.geo.agent_readiness", + "source": "src/website_profiling/tools/audit_tools/geo/agent_readiness.py", + "description": "Combined: pages with high token count, poor content structure, or JS-only empty shells.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "warn_tokens": { + "type": "integer" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "get_copy_for_ai_signals", + "domain": "geo", + "module": "website_profiling.tools.audit_tools.geo.agent_readiness", + "source": "src/website_profiling/tools/audit_tools/geo/agent_readiness.py", + "description": "Site-wide coverage of copy-for-AI and raw-view affordances on doc-like pages.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [] + } + }, + { + "name": "list_pages_missing_copy_for_ai", + "domain": "geo", + "module": "website_profiling.tools.audit_tools.geo.agent_readiness", + "source": "src/website_profiling/tools/audit_tools/geo/agent_readiness.py", + "description": "Doc-like pages without copy-for-AI or raw markdown view affordances.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "generate_agent_readiness_bundle", + "domain": "geo", + "module": "website_profiling.tools.audit_tools.geo.agent_readiness", + "source": "src/website_profiling/tools/audit_tools/geo/agent_readiness.py", + "description": "Generate draft AGENTS.md, skill.md, and agent-permissions.json for the site. Detects which files are missing.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [] + } + }, + { + "name": "get_gsc_url_inspection", + "domain": "integrations", + "module": "website_profiling.tools.audit_tools.integrations.integration_tools", + "source": "src/website_profiling/tools/audit_tools/integrations/integration_tools.py", + "description": "Live GSC URL Inspection (indexing + rich results). Requires Google OAuth.", + "inputSchema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "Page URL" + }, + "property_id": { + "type": "integer", + "description": "Property ID" + } + }, + "required": [ + "url", + "property_id" + ] + } + }, + { + "name": "get_gsc_index_coverage", + "domain": "integrations", + "module": "website_profiling.tools.audit_tools.integrations.integration_tools", + "source": "src/website_profiling/tools/audit_tools/integrations/integration_tools.py", + "description": "Estimated indexation coverage from crawl + sitemap + GSC join.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [] + } + }, + { + "name": "get_bing_index_status", + "domain": "integrations", + "module": "website_profiling.tools.audit_tools.integrations.integration_tools", + "source": "src/website_profiling/tools/audit_tools/integrations/integration_tools.py", + "description": "Bing Webmaster URL info (requires bing_webmaster_api_key).", + "inputSchema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "Page URL" + }, + "property_id": { + "type": "integer", + "description": "Property ID" + } + }, + "required": [ + "url", + "property_id" + ] + } + }, + { + "name": "get_serp_feature_overlay", + "domain": "integrations", + "module": "website_profiling.tools.audit_tools.integrations.integration_tools", + "source": "src/website_profiling/tools/audit_tools/integrations/integration_tools.py", + "description": "Keywords with SERP feature / competition overlay data.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [ + "property_id" + ] + } + }, + { + "name": "check_ai_citation_presence", + "domain": "geo", + "module": "website_profiling.tools.audit_tools.integrations.integration_tools", + "source": "src/website_profiling/tools/audit_tools/integrations/integration_tools.py", + "description": "On-site citation readiness estimate for brand/query (no live LLM API).", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "query": { + "type": "string" + }, + "brand": { + "type": "string" + } + }, + "required": [] + } + }, + { + "name": "check_ai_citations_live", + "domain": "geo", + "module": "website_profiling.tools.audit_tools.integrations.integration_tools", + "source": "src/website_profiling/tools/audit_tools/integrations/integration_tools.py", + "description": "Live AI citation check via Perplexity/OpenAI/Anthropic/Groq (opt-in, BYO key). Reports brand-mentioned, domain-cited, an", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "brand": { + "type": "string" + }, + "query": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "api_key": { + "type": "string" + }, + "opt_in": { + "type": "boolean" + }, + "multi_query": { + "type": "string" + } + }, + "required": [] + } + }, + { + "name": "search_audit_tools", + "domain": "core", + "module": "website_profiling.tools.audit_tools.core.router_tools", + "source": "src/website_profiling/tools/audit_tools/core/router_tools.py", + "description": "Search the audit tool catalog by keyword. Returns matching tool names to call next.", + "inputSchema": { + "type": "object", + "properties": { + "query": { + "type": "string" + }, + "limit": { + "type": "integer", + "maximum": 50 + } + }, + "required": [ + "query" + ] + } + }, + { + "name": "list_tool_domains", + "domain": "core", + "module": "website_profiling.tools.audit_tools.core.router_tools", + "source": "src/website_profiling/tools/audit_tools/core/router_tools.py", + "description": "List SEO tool domains with counts and example prompts.", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [] + } + }, + { + "name": "get_data_coverage_report", + "domain": "core", + "module": "website_profiling.tools.audit_tools.core.data_coverage", + "source": "src/website_profiling/tools/audit_tools/core/data_coverage.py", + "description": "Report which integrations and optional audit data are populated for a property.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + } + }, + "required": [ + "property_id" + ] + } + }, + { + "name": "run_insight_workflow", + "domain": "core", + "module": "website_profiling.tools.audit_tools.core.router_tools", + "source": "src/website_profiling/tools/audit_tools/core/router_tools.py", + "description": "Run insight workflow: type=traffic|landing_pages|priorities (default).", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "type": { + "type": "string" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "run_technical_workflow", + "domain": "core", + "module": "website_profiling.tools.audit_tools.core.router_tools", + "source": "src/website_profiling/tools/audit_tools/core/router_tools.py", + "description": "Run technical workflow: report summary, critical issues, priority chart; optional baseline compare.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "baseline_report_id": { + "type": "integer" + } + }, + "required": [] + } + }, + { + "name": "run_keyword_workflow", + "domain": "core", + "module": "website_profiling.tools.audit_tools.core.router_tools", + "source": "src/website_profiling/tools/audit_tools/core/router_tools.py", + "description": "Run keyword workflow: brand split, striking distance, CTR opportunities.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [ + "property_id" + ] + } + }, + { + "name": "run_domain_agent", + "domain": "core", + "module": "website_profiling.tools.audit_tools.core.router_tools", + "source": "src/website_profiling/tools/audit_tools/core/router_tools.py", + "description": "Subagent-style: run up to max_steps tools in one domain for a task description.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "task": { + "type": "string" + }, + "domain": { + "type": "string" + }, + "max_steps": { + "type": "integer", + "maximum": 8 + } + }, + "required": [ + "task" + ] + } + }, + { + "name": "get_landing_page_blended_table", + "domain": "insight", + "module": "website_profiling.tools.audit_tools.insight.insight_tools", + "source": "src/website_profiling/tools/audit_tools/insight/insight_tools.py", + "description": "GSC clicks + GA4 sessions per landing page with opportunity quadrant.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "limit": { + "type": "integer", + "maximum": 100 + }, + "min_impressions": { + "type": "integer" + } + }, + "required": [ + "property_id" + ] + } + }, + { + "name": "get_opportunity_matrix", + "domain": "insight", + "module": "website_profiling.tools.audit_tools.insight.insight_tools", + "source": "src/website_profiling/tools/audit_tools/insight/insight_tools.py", + "description": "Landing pages grouped by rank vs conversion opportunity quadrants.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [ + "property_id" + ] + } + }, + { + "name": "get_traffic_health_check", + "domain": "insight", + "module": "website_profiling.tools.audit_tools.insight.insight_tools", + "source": "src/website_profiling/tools/audit_tools/insight/insight_tools.py", + "description": "GSC clicks vs GA4 sessions ratio and tracking health diagnosis.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + } + }, + "required": [ + "property_id" + ] + } + }, + { + "name": "get_landing_page_full_diagnosis", + "domain": "insight", + "module": "website_profiling.tools.audit_tools.insight.insight_tools", + "source": "src/website_profiling/tools/audit_tools/insight/insight_tools.py", + "description": "One URL: GSC+GA4 slice, crawl, issues, Lighthouse, composite score.", + "inputSchema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "Page URL" + }, + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + } + }, + "required": [ + "url" + ] + } + }, + { + "name": "get_issue_to_traffic_map", + "domain": "insight", + "module": "website_profiling.tools.audit_tools.insight.insight_tools", + "source": "src/website_profiling/tools/audit_tools/insight/insight_tools.py", + "description": "Audit issues ranked by traffic-weighted impact with GSC/GA4 columns.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "get_gsc_daily_trend", + "domain": "google", + "module": "website_profiling.tools.audit_tools.google.google", + "source": "src/website_profiling/tools/audit_tools/google/google.py", + "description": "GSC daily clicks/impressions trend from latest fetch.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + } + }, + "required": [ + "property_id" + ] + } + }, + { + "name": "get_ga4_daily_trend", + "domain": "google", + "module": "website_profiling.tools.audit_tools.google.google", + "source": "src/website_profiling/tools/audit_tools/google/google.py", + "description": "GA4 daily sessions trend from latest fetch.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + } + }, + "required": [ + "property_id" + ] + } + }, + { + "name": "get_ga4_by_device", + "domain": "google", + "module": "website_profiling.tools.audit_tools.google.google", + "source": "src/website_profiling/tools/audit_tools/google/google.py", + "description": "GA4 sessions breakdown by device category.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + } + }, + "required": [ + "property_id" + ] + } + }, + { + "name": "get_ga4_by_channel", + "domain": "google", + "module": "website_profiling.tools.audit_tools.google.google", + "source": "src/website_profiling/tools/audit_tools/google/google.py", + "description": "GA4 sessions breakdown by channel.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + } + }, + "required": [ + "property_id" + ] + } + }, + { + "name": "get_gsc_page_queries", + "domain": "google", + "module": "website_profiling.tools.audit_tools.google.google", + "source": "src/website_profiling/tools/audit_tools/google/google.py", + "description": "GSC queries for a single page URL (full by_page blob).", + "inputSchema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "Page URL" + }, + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [ + "url" + ] + } + }, + { + "name": "get_brand_keyword_split", + "domain": "keywords", + "module": "website_profiling.tools.audit_tools.keywords.keywords", + "source": "src/website_profiling/tools/audit_tools/keywords/keywords.py", + "description": "Branded vs non-branded keyword counts and samples.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + } + }, + "required": [ + "property_id" + ] + } + }, + { + "name": "list_keywords_by_intent", + "domain": "keywords", + "module": "website_profiling.tools.audit_tools.keywords.keywords", + "source": "src/website_profiling/tools/audit_tools/keywords/keywords.py", + "description": "Keywords filtered by intent (informational, commercial, etc.).", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "intent": { + "type": "string" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [ + "property_id", + "intent" + ] + } + }, + { + "name": "list_pages_title_too_short", + "domain": "onpage", + "module": "website_profiling.tools.audit_tools.issues.issue_lists", + "source": "src/website_profiling/tools/audit_tools/issues/issue_lists.py", + "description": "Pages with title shorter than SEO minimum.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_pages_title_too_long", + "domain": "onpage", + "module": "website_profiling.tools.audit_tools.issues.issue_lists", + "source": "src/website_profiling/tools/audit_tools/issues/issue_lists.py", + "description": "Pages with title longer than SEO maximum.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_pages_slow_response", + "domain": "performance", + "module": "website_profiling.tools.audit_tools.issues.issue_lists", + "source": "src/website_profiling/tools/audit_tools/issues/issue_lists.py", + "description": "Pages with server response time above threshold.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_pages_missing_html_lang", + "domain": "onpage", + "module": "website_profiling.tools.audit_tools.issues.issue_lists", + "source": "src/website_profiling/tools/audit_tools/issues/issue_lists.py", + "description": "Pages missing html lang attribute.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_pages_invalid_viewport", + "domain": "crawl", + "module": "website_profiling.tools.audit_tools.issues.issue_lists", + "source": "src/website_profiling/tools/audit_tools/issues/issue_lists.py", + "description": "Pages missing or invalid viewport meta.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_pages_color_contrast_failures", + "domain": "accessibility", + "module": "website_profiling.tools.audit_tools.issues.issue_lists", + "source": "src/website_profiling/tools/audit_tools/issues/issue_lists.py", + "description": "Pages failing color contrast checks.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_pages_high_reading_level", + "domain": "content", + "module": "website_profiling.tools.audit_tools.issues.issue_lists", + "source": "src/website_profiling/tools/audit_tools/issues/issue_lists.py", + "description": "Pages with high reading grade level.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_pages_very_thin_content", + "domain": "content", + "module": "website_profiling.tools.audit_tools.issues.issue_lists", + "source": "src/website_profiling/tools/audit_tools/issues/issue_lists.py", + "description": "Pages with very low word count.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_hreflang_issue_pages", + "domain": "indexation", + "module": "website_profiling.tools.audit_tools.issues.issue_lists", + "source": "src/website_profiling/tools/audit_tools/issues/issue_lists.py", + "description": "Pages with hreflang cluster issues.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_pages_missing_og_tags", + "domain": "onpage", + "module": "website_profiling.tools.audit_tools.issues.issue_lists", + "source": "src/website_profiling/tools/audit_tools/issues/issue_lists.py", + "description": "Pages missing Open Graph tags.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_pages_missing_twitter_cards", + "domain": "onpage", + "module": "website_profiling.tools.audit_tools.issues.issue_lists", + "source": "src/website_profiling/tools/audit_tools/issues/issue_lists.py", + "description": "Pages missing Twitter card tags.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_pages_invalid_json_ld", + "domain": "crawl", + "module": "website_profiling.tools.audit_tools.issues.issue_lists", + "source": "src/website_profiling/tools/audit_tools/issues/issue_lists.py", + "description": "Pages with invalid JSON-LD schema.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_pages_mixed_language", + "domain": "content", + "module": "website_profiling.tools.audit_tools.issues.issue_lists", + "source": "src/website_profiling/tools/audit_tools/issues/issue_lists.py", + "description": "Pages with mixed-language content signals.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_orphan_hub_suggestions", + "domain": "links", + "module": "website_profiling.tools.audit_tools.issues.issue_lists", + "source": "src/website_profiling/tools/audit_tools/issues/issue_lists.py", + "description": "Suggested hub pages to link orphan URLs.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_lighthouse_failure_lcp", + "domain": "performance", + "module": "website_profiling.tools.audit_tools.issues.issue_lists", + "source": "src/website_profiling/tools/audit_tools/issues/issue_lists.py", + "description": "URLs failing Lighthouse LCP audit.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_lighthouse_failure_inp", + "domain": "performance", + "module": "website_profiling.tools.audit_tools.issues.issue_lists", + "source": "src/website_profiling/tools/audit_tools/issues/issue_lists.py", + "description": "URLs failing Lighthouse INP audit.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_lighthouse_failure_cls", + "domain": "performance", + "module": "website_profiling.tools.audit_tools.issues.issue_lists", + "source": "src/website_profiling/tools/audit_tools/issues/issue_lists.py", + "description": "URLs failing Lighthouse CLS audit.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_lighthouse_failure_seo", + "domain": "performance", + "module": "website_profiling.tools.audit_tools.issues.issue_lists", + "source": "src/website_profiling/tools/audit_tools/issues/issue_lists.py", + "description": "URLs failing Lighthouse SEO audit.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_gsc_pages_by_impressions", + "domain": "google", + "module": "website_profiling.tools.audit_tools.google.google_lists", + "source": "src/website_profiling/tools/audit_tools/google/google_lists.py", + "description": "GSC pages sorted by impressions.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_gsc_pages_by_clicks", + "domain": "google", + "module": "website_profiling.tools.audit_tools.google.google_lists", + "source": "src/website_profiling/tools/audit_tools/google/google_lists.py", + "description": "GSC pages sorted by clicks.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_gsc_queries_by_impressions", + "domain": "google", + "module": "website_profiling.tools.audit_tools.google.google_lists", + "source": "src/website_profiling/tools/audit_tools/google/google_lists.py", + "description": "GSC queries sorted by impressions.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_gsc_queries_by_clicks", + "domain": "google", + "module": "website_profiling.tools.audit_tools.google.google_lists", + "source": "src/website_profiling/tools/audit_tools/google/google_lists.py", + "description": "GSC queries sorted by clicks.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_gsc_ctr_underperformers", + "domain": "google", + "module": "website_profiling.tools.audit_tools.google.google_lists", + "source": "src/website_profiling/tools/audit_tools/google/google_lists.py", + "description": "Queries/pages with low CTR for position band.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_gsc_decaying_pages", + "domain": "google", + "module": "website_profiling.tools.audit_tools.google.google_lists", + "source": "src/website_profiling/tools/audit_tools/google/google_lists.py", + "description": "GSC pages with click decline vs prior period.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_gsc_decaying_queries", + "domain": "google", + "module": "website_profiling.tools.audit_tools.google.google_lists", + "source": "src/website_profiling/tools/audit_tools/google/google_lists.py", + "description": "GSC queries with click decline vs prior period.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_gsc_new_queries", + "domain": "google", + "module": "website_profiling.tools.audit_tools.google.google_lists", + "source": "src/website_profiling/tools/audit_tools/google/google_lists.py", + "description": "New GSC queries vs prior period.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_ga4_landing_pages", + "domain": "google", + "module": "website_profiling.tools.audit_tools.google.google_lists", + "source": "src/website_profiling/tools/audit_tools/google/google_lists.py", + "description": "GA4 landing pages by sessions.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_ga4_pages_by_bounce_rate", + "domain": "google", + "module": "website_profiling.tools.audit_tools.google.google_lists", + "source": "src/website_profiling/tools/audit_tools/google/google_lists.py", + "description": "GA4 pages with high bounce rate.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_ga4_pages_by_engagement_rate", + "domain": "google", + "module": "website_profiling.tools.audit_tools.google.google_lists", + "source": "src/website_profiling/tools/audit_tools/google/google_lists.py", + "description": "GA4 pages sorted by engagement rate.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "get_gsc_query_trend", + "domain": "google", + "module": "website_profiling.tools.audit_tools.google.google_lists", + "source": "src/website_profiling/tools/audit_tools/google/google_lists.py", + "description": "Daily GSC clicks/impressions for one query.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + }, + "query": { + "type": "string" + } + }, + "required": [ + "query" + ] + } + }, + { + "name": "get_gsc_page_trend", + "domain": "google", + "module": "website_profiling.tools.audit_tools.google.google_lists", + "source": "src/website_profiling/tools/audit_tools/google/google_lists.py", + "description": "Daily GSC clicks/impressions for one URL.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + }, + "url": { + "type": "string", + "description": "Page URL" + } + }, + "required": [ + "url" + ] + } + }, + { + "name": "get_ga4_path_trend", + "domain": "google", + "module": "website_profiling.tools.audit_tools.google.google_lists", + "source": "src/website_profiling/tools/audit_tools/google/google_lists.py", + "description": "GA4 sessions trend for one path.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + }, + "path": { + "type": "string" + } + }, + "required": [ + "path" + ] + } + }, + { + "name": "list_gsc_ga4_mismatch_pages", + "domain": "google", + "module": "website_profiling.tools.audit_tools.google.google_lists", + "source": "src/website_profiling/tools/audit_tools/google/google_lists.py", + "description": "High GSC clicks but low GA4 sessions.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_gsc_pages_by_position_band", + "domain": "google", + "module": "website_profiling.tools.audit_tools.google.google_lists", + "source": "src/website_profiling/tools/audit_tools/google/google_lists.py", + "description": "GSC pages in position range.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + }, + "min_position": { + "type": "number" + }, + "max_position": { + "type": "number" + } + }, + "required": [] + } + }, + { + "name": "get_gsc_site_benchmarks", + "domain": "google", + "module": "website_profiling.tools.audit_tools.google.google_lists", + "source": "src/website_profiling/tools/audit_tools/google/google_lists.py", + "description": "Sitewide median CTR and position benchmarks.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_gsc_branded_queries", + "domain": "google", + "module": "website_profiling.tools.audit_tools.google.google_lists", + "source": "src/website_profiling/tools/audit_tools/google/google_lists.py", + "description": "Branded GSC queries.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_gsc_non_branded_queries", + "domain": "google", + "module": "website_profiling.tools.audit_tools.google.google_lists", + "source": "src/website_profiling/tools/audit_tools/google/google_lists.py", + "description": "Non-branded GSC queries.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "compare_gsc_periods", + "domain": "google", + "module": "website_profiling.tools.audit_tools.google.google_lists", + "source": "src/website_profiling/tools/audit_tools/google/google_lists.py", + "description": "GSC summary delta vs prior Google snapshot.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_keyword_rank_improvements", + "domain": "keywords", + "module": "website_profiling.tools.audit_tools.keywords.keyword_lists", + "source": "src/website_profiling/tools/audit_tools/keywords/keyword_lists.py", + "description": "Keywords with improved position vs prior snapshot.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [ + "property_id" + ] + } + }, + { + "name": "list_keyword_rank_declines", + "domain": "keywords", + "module": "website_profiling.tools.audit_tools.keywords.keyword_lists", + "source": "src/website_profiling/tools/audit_tools/keywords/keyword_lists.py", + "description": "Keywords with declined position vs prior snapshot.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [ + "property_id" + ] + } + }, + { + "name": "list_keywords_new_to_top_10", + "domain": "keywords", + "module": "website_profiling.tools.audit_tools.keywords.keyword_lists", + "source": "src/website_profiling/tools/audit_tools/keywords/keyword_lists.py", + "description": "Keywords newly in top 10.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [ + "property_id" + ] + } + }, + { + "name": "list_keywords_fell_out_of_top_10", + "domain": "keywords", + "module": "website_profiling.tools.audit_tools.keywords.keyword_lists", + "source": "src/website_profiling/tools/audit_tools/keywords/keyword_lists.py", + "description": "Keywords that fell out of top 10.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [ + "property_id" + ] + } + }, + { + "name": "list_cannibalisation_queries", + "domain": "keywords", + "module": "website_profiling.tools.audit_tools.keywords.keyword_lists", + "source": "src/website_profiling/tools/audit_tools/keywords/keyword_lists.py", + "description": "Queries with multiple ranking URLs.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [ + "property_id" + ] + } + }, + { + "name": "list_cannibalisation_urls", + "domain": "keywords", + "module": "website_profiling.tools.audit_tools.keywords.keyword_lists", + "source": "src/website_profiling/tools/audit_tools/keywords/keyword_lists.py", + "description": "URLs involved in keyword cannibalisation.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [ + "property_id" + ] + } + }, + { + "name": "list_misaligned_queries", + "domain": "keywords", + "module": "website_profiling.tools.audit_tools.keywords.keyword_lists", + "source": "src/website_profiling/tools/audit_tools/keywords/keyword_lists.py", + "description": "Queries landing on misaligned URLs.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [ + "property_id" + ] + } + }, + { + "name": "list_keywords_by_recommended_action", + "domain": "keywords", + "module": "website_profiling.tools.audit_tools.keywords.keyword_lists", + "source": "src/website_profiling/tools/audit_tools/keywords/keyword_lists.py", + "description": "Keywords filtered by recommended_action.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + }, + "recommended_action": { + "type": "string" + } + }, + "required": [ + "property_id" + ] + } + }, + { + "name": "list_keywords_by_serp_feature", + "domain": "keywords", + "module": "website_profiling.tools.audit_tools.keywords.keyword_lists", + "source": "src/website_profiling/tools/audit_tools/keywords/keyword_lists.py", + "description": "Keywords with given SERP feature.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + }, + "serp_feature": { + "type": "string" + } + }, + "required": [ + "property_id" + ] + } + }, + { + "name": "list_semantic_cluster_queries", + "domain": "keywords", + "module": "website_profiling.tools.audit_tools.keywords.keyword_lists", + "source": "src/website_profiling/tools/audit_tools/keywords/keyword_lists.py", + "description": "Queries in a semantic keyword cluster.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + }, + "cluster_id": { + "type": "string" + } + }, + "required": [] + } + }, + { + "name": "list_semantic_cluster_pages", + "domain": "keywords", + "module": "website_profiling.tools.audit_tools.keywords.keyword_lists", + "source": "src/website_profiling/tools/audit_tools/keywords/keyword_lists.py", + "description": "Pages in a semantic keyword cluster.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + }, + "cluster_id": { + "type": "string" + } + }, + "required": [] + } + }, + { + "name": "get_keyword_opportunity_score", + "domain": "keywords", + "module": "website_profiling.tools.audit_tools.keywords.keyword_lists", + "source": "src/website_profiling/tools/audit_tools/keywords/keyword_lists.py", + "description": "Composite opportunity score for one keyword.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + }, + "keyword": { + "type": "string" + } + }, + "required": [ + "keyword", + "property_id" + ] + } + }, + { + "name": "list_keywords_near_page_one", + "domain": "keywords", + "module": "website_profiling.tools.audit_tools.keywords.keyword_lists", + "source": "src/website_profiling/tools/audit_tools/keywords/keyword_lists.py", + "description": "Keywords in striking-distance position band.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [ + "property_id" + ] + } + }, + { + "name": "list_keywords_high_impression_zero_click", + "domain": "keywords", + "module": "website_profiling.tools.audit_tools.keywords.keyword_lists", + "source": "src/website_profiling/tools/audit_tools/keywords/keyword_lists.py", + "description": "High-impression keywords with zero clicks.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [ + "property_id" + ] + } + }, + { + "name": "list_keywords_by_competition_band", + "domain": "keywords", + "module": "website_profiling.tools.audit_tools.keywords.keyword_lists", + "source": "src/website_profiling/tools/audit_tools/keywords/keyword_lists.py", + "description": "Keywords by SERP competition band.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + }, + "min_competition": { + "type": "number" + }, + "max_competition": { + "type": "number" + } + }, + "required": [ + "property_id" + ] + } + }, + { + "name": "get_keyword_serp_snapshot", + "domain": "keywords", + "module": "website_profiling.tools.audit_tools.keywords.keyword_lists", + "source": "src/website_profiling/tools/audit_tools/keywords/keyword_lists.py", + "description": "SERP overlay fields for one keyword.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + }, + "keyword": { + "type": "string" + } + }, + "required": [ + "keyword", + "property_id" + ] + } + }, + { + "name": "list_keywords_with_ai_overview", + "domain": "keywords", + "module": "website_profiling.tools.audit_tools.keywords.keyword_lists", + "source": "src/website_profiling/tools/audit_tools/keywords/keyword_lists.py", + "description": "Keywords triggering AI overview SERP features.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [ + "property_id" + ] + } + }, + { + "name": "list_keywords_local_pack", + "domain": "keywords", + "module": "website_profiling.tools.audit_tools.keywords.keyword_lists", + "source": "src/website_profiling/tools/audit_tools/keywords/keyword_lists.py", + "description": "Keywords with local pack SERP feature.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [ + "property_id" + ] + } + }, + { + "name": "list_keywords_question_intent", + "domain": "keywords", + "module": "website_profiling.tools.audit_tools.keywords.keyword_lists", + "source": "src/website_profiling/tools/audit_tools/keywords/keyword_lists.py", + "description": "Question-intent keywords.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [ + "property_id" + ] + } + }, + { + "name": "list_keywords_commercial_intent", + "domain": "keywords", + "module": "website_profiling.tools.audit_tools.keywords.keyword_lists", + "source": "src/website_profiling/tools/audit_tools/keywords/keyword_lists.py", + "description": "Commercial-intent keywords.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [ + "property_id" + ] + } + }, + { + "name": "list_referring_domains", + "domain": "backlinks", + "module": "website_profiling.tools.audit_tools.backlinks.backlink_lists", + "source": "src/website_profiling/tools/audit_tools/backlinks/backlink_lists.py", + "description": "Top referring domains from GSC Links.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [ + "property_id" + ] + } + }, + { + "name": "list_backlinks_by_anchor_text", + "domain": "backlinks", + "module": "website_profiling.tools.audit_tools.backlinks.backlink_lists", + "source": "src/website_profiling/tools/audit_tools/backlinks/backlink_lists.py", + "description": "Backlinks filtered by anchor text.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + }, + "anchor_text": { + "type": "string" + } + }, + "required": [ + "property_id" + ] + } + }, + { + "name": "list_backlinks_to_url", + "domain": "backlinks", + "module": "website_profiling.tools.audit_tools.backlinks.backlink_lists", + "source": "src/website_profiling/tools/audit_tools/backlinks/backlink_lists.py", + "description": "Inbound links to target URL.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + }, + "url": { + "type": "string", + "description": "Target URL" + } + }, + "required": [ + "url", + "property_id" + ] + } + }, + { + "name": "list_backlinks_from_domain", + "domain": "backlinks", + "module": "website_profiling.tools.audit_tools.backlinks.backlink_lists", + "source": "src/website_profiling/tools/audit_tools/backlinks/backlink_lists.py", + "description": "Backlinks from referring domain.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + }, + "domain": { + "type": "string" + } + }, + "required": [ + "domain", + "property_id" + ] + } + }, + { + "name": "get_anchor_text_distribution", + "domain": "backlinks", + "module": "website_profiling.tools.audit_tools.backlinks.backlink_lists", + "source": "src/website_profiling/tools/audit_tools/backlinks/backlink_lists.py", + "description": "Sitewide anchor text frequency from GSC Links.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [ + "property_id" + ] + } + }, + { + "name": "get_text_content_analysis", + "domain": "content", + "module": "website_profiling.tools.audit_tools.content.content_lists", + "source": "src/website_profiling/tools/audit_tools/content/content_lists.py", + "description": "Sitewide text content analysis summary.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_pages_containing_keyword", + "domain": "crawl", + "module": "website_profiling.tools.audit_tools.content.content_lists", + "source": "src/website_profiling/tools/audit_tools/content/content_lists.py", + "description": "Pages containing keyword in body.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + }, + "keyword": { + "type": "string" + } + }, + "required": [ + "keyword" + ] + } + }, + { + "name": "list_pages_by_word_count_band", + "domain": "crawl", + "module": "website_profiling.tools.audit_tools.content.content_lists", + "source": "src/website_profiling/tools/audit_tools/content/content_lists.py", + "description": "Pages filtered by word count range.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + }, + "min_words": { + "type": "integer" + }, + "max_words": { + "type": "integer" + } + }, + "required": [] + } + }, + { + "name": "list_duplicate_content_pairs", + "domain": "content", + "module": "website_profiling.tools.audit_tools.content.content_lists", + "source": "src/website_profiling/tools/audit_tools/content/content_lists.py", + "description": "Near-duplicate content pairs.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_spell_check_issues", + "domain": "issues", + "module": "website_profiling.tools.audit_tools.content.content_lists", + "source": "src/website_profiling/tools/audit_tools/content/content_lists.py", + "description": "Spell-check optional audit issues.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_html_validation_issues", + "domain": "issues", + "module": "website_profiling.tools.audit_tools.content.content_lists", + "source": "src/website_profiling/tools/audit_tools/content/content_lists.py", + "description": "HTML validation optional audit issues.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_amp_validation_issues", + "domain": "issues", + "module": "website_profiling.tools.audit_tools.content.content_lists", + "source": "src/website_profiling/tools/audit_tools/content/content_lists.py", + "description": "AMP validation optional audit issues.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_pagination_issues", + "domain": "issues", + "module": "website_profiling.tools.audit_tools.content.content_lists", + "source": "src/website_profiling/tools/audit_tools/content/content_lists.py", + "description": "Pagination optional audit issues.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_schema_errors_by_type", + "domain": "schema", + "module": "website_profiling.tools.audit_tools.content.content_lists", + "source": "src/website_profiling/tools/audit_tools/content/content_lists.py", + "description": "Schema validation errors by type.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + }, + "schema_type": { + "type": "string" + } + }, + "required": [] + } + }, + { + "name": "list_pages_missing_article_schema", + "domain": "geo", + "module": "website_profiling.tools.audit_tools.content.content_lists", + "source": "src/website_profiling/tools/audit_tools/content/content_lists.py", + "description": "Article/blog URLs missing Article schema.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_outbound_links", + "domain": "links", + "module": "website_profiling.tools.audit_tools.links.link_lists", + "source": "src/website_profiling/tools/audit_tools/links/link_lists.py", + "description": "Outbound links from crawl graph.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_internal_links_from_url", + "domain": "links", + "module": "website_profiling.tools.audit_tools.links.link_lists", + "source": "src/website_profiling/tools/audit_tools/links/link_lists.py", + "description": "Internal outlinks from source URL.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + }, + "url": { + "type": "string", + "description": "Source URL" + } + }, + "required": [ + "url" + ] + } + }, + { + "name": "list_internal_links_to_url", + "domain": "links", + "module": "website_profiling.tools.audit_tools.links.link_lists", + "source": "src/website_profiling/tools/audit_tools/links/link_lists.py", + "description": "Internal inlinks to target URL.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + }, + "url": { + "type": "string", + "description": "Target URL" + } + }, + "required": [ + "url" + ] + } + }, + { + "name": "list_links_by_rel_nofollow", + "domain": "links", + "module": "website_profiling.tools.audit_tools.links.link_lists", + "source": "src/website_profiling/tools/audit_tools/links/link_lists.py", + "description": "Links filtered by rel attribute.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + }, + "rel": { + "type": "string" + } + }, + "required": [] + } + }, + { + "name": "list_pagerank_low_pages", + "domain": "links", + "module": "website_profiling.tools.audit_tools.links.link_lists", + "source": "src/website_profiling/tools/audit_tools/links/link_lists.py", + "description": "Pages with lowest internal PageRank.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + }, + "max_pagerank": { + "type": "number" + } + }, + "required": [] + } + }, + { + "name": "list_indexation_submitted_not_indexed", + "domain": "indexation", + "module": "website_profiling.tools.audit_tools.indexation.indexation_lists", + "source": "src/website_profiling/tools/audit_tools/indexation/indexation_lists.py", + "description": "URLs submitted but not indexed.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_indexation_indexed_not_submitted", + "domain": "indexation", + "module": "website_profiling.tools.audit_tools.indexation.indexation_lists", + "source": "src/website_profiling/tools/audit_tools/indexation/indexation_lists.py", + "description": "Indexed URLs not in sitemap.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_sitemap_urls_not_in_crawl", + "domain": "indexation", + "module": "website_profiling.tools.audit_tools.indexation.indexation_lists", + "source": "src/website_profiling/tools/audit_tools/indexation/indexation_lists.py", + "description": "Sitemap URLs missing from crawl.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_crawl_urls_not_in_sitemap", + "domain": "indexation", + "module": "website_profiling.tools.audit_tools.indexation.indexation_lists", + "source": "src/website_profiling/tools/audit_tools/indexation/indexation_lists.py", + "description": "Crawled URLs missing from sitemap.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_log_paths_by_hits", + "domain": "ops", + "module": "website_profiling.tools.audit_tools.indexation.indexation_lists", + "source": "src/website_profiling/tools/audit_tools/indexation/indexation_lists.py", + "description": "Access log paths ranked by hits.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_log_5xx_paths", + "domain": "ops", + "module": "website_profiling.tools.audit_tools.indexation.indexation_lists", + "source": "src/website_profiling/tools/audit_tools/indexation/indexation_lists.py", + "description": "Access log paths with 5xx responses.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_log_googlebot_low_crawl", + "domain": "ops", + "module": "website_profiling.tools.audit_tools.indexation.indexation_lists", + "source": "src/website_profiling/tools/audit_tools/indexation/indexation_lists.py", + "description": "High-value paths under-crawled by Googlebot.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_log_orphan_high_traffic", + "domain": "links", + "module": "website_profiling.tools.audit_tools.indexation.indexation_lists", + "source": "src/website_profiling/tools/audit_tools/indexation/indexation_lists.py", + "description": "Orphan URLs with high log traffic.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_redirect_chains_by_length", + "domain": "crawl", + "module": "website_profiling.tools.audit_tools.indexation.indexation_lists", + "source": "src/website_profiling/tools/audit_tools/indexation/indexation_lists.py", + "description": "Redirect chains above min_length.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + }, + "min_length": { + "type": "integer" + } + }, + "required": [] + } + }, + { + "name": "list_hreflang_reciprocal_gaps", + "domain": "indexation", + "module": "website_profiling.tools.audit_tools.indexation.indexation_lists", + "source": "src/website_profiling/tools/audit_tools/indexation/indexation_lists.py", + "description": "Hreflang alternates missing reciprocal return tags.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_compare_new_issues", + "domain": "drift", + "module": "website_profiling.tools.audit_tools.compare.compare_list_tools", + "source": "src/website_profiling/tools/audit_tools/compare/compare_list_tools.py", + "description": "Issues new since baseline report.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + }, + "baseline_report_id": { + "type": "integer", + "description": "Baseline report ID" + } + }, + "required": [ + "baseline_report_id" + ] + } + }, + { + "name": "list_compare_resolved_issues", + "domain": "drift", + "module": "website_profiling.tools.audit_tools.compare.compare_list_tools", + "source": "src/website_profiling/tools/audit_tools/compare/compare_list_tools.py", + "description": "Issues resolved since baseline report.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + }, + "baseline_report_id": { + "type": "integer", + "description": "Baseline report ID" + } + }, + "required": [ + "baseline_report_id" + ] + } + }, + { + "name": "list_compare_new_urls", + "domain": "drift", + "module": "website_profiling.tools.audit_tools.compare.compare_list_tools", + "source": "src/website_profiling/tools/audit_tools/compare/compare_list_tools.py", + "description": "URLs new since baseline crawl.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + }, + "baseline_report_id": { + "type": "integer", + "description": "Baseline report ID" + } + }, + "required": [ + "baseline_report_id" + ] + } + }, + { + "name": "list_compare_removed_urls", + "domain": "drift", + "module": "website_profiling.tools.audit_tools.compare.compare_list_tools", + "source": "src/website_profiling/tools/audit_tools/compare/compare_list_tools.py", + "description": "URLs removed since baseline crawl.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + }, + "baseline_report_id": { + "type": "integer", + "description": "Baseline report ID" + } + }, + "required": [ + "baseline_report_id" + ] + } + }, + { + "name": "list_compare_lighthouse_regressions", + "domain": "drift", + "module": "website_profiling.tools.audit_tools.compare.compare_list_tools", + "source": "src/website_profiling/tools/audit_tools/compare/compare_list_tools.py", + "description": "Lighthouse score regressions vs baseline.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + }, + "baseline_report_id": { + "type": "integer", + "description": "Baseline report ID" + } + }, + "required": [ + "baseline_report_id" + ] + } + }, + { + "name": "list_compare_traffic_losers", + "domain": "drift", + "module": "website_profiling.tools.audit_tools.compare.compare_list_tools", + "source": "src/website_profiling/tools/audit_tools/compare/compare_list_tools.py", + "description": "GSC click losers vs baseline report.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + }, + "baseline_report_id": { + "type": "integer", + "description": "Baseline report ID" + } + }, + "required": [ + "baseline_report_id" + ] + } + }, + { + "name": "list_pages_missing_howto_schema", + "domain": "geo", + "module": "website_profiling.tools.audit_tools.geo.geo_list_tools", + "source": "src/website_profiling/tools/audit_tools/geo/geo_list_tools.py", + "description": "How-to URLs missing HowTo schema.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_pages_ai_citation_signals", + "domain": "geo", + "module": "website_profiling.tools.audit_tools.geo.geo_list_tools", + "source": "src/website_profiling/tools/audit_tools/geo/geo_list_tools.py", + "description": "Pages with AEO/AI citation readiness signals.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_pages_missing_llms_txt_reference", + "domain": "geo", + "module": "website_profiling.tools.audit_tools.geo.geo_list_tools", + "source": "src/website_profiling/tools/audit_tools/geo/geo_list_tools.py", + "description": "Pages not referenced in llms.txt.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_robots_blocked_ai_crawlers", + "domain": "geo", + "module": "website_profiling.tools.audit_tools.geo.geo_list_tools", + "source": "src/website_profiling/tools/audit_tools/geo/geo_list_tools.py", + "description": "Pages blocking AI crawler user-agents.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "list_pages_console_errors_by_type", + "domain": "crawl", + "module": "website_profiling.tools.audit_tools.crawl.crawl", + "source": "src/website_profiling/tools/audit_tools/crawl/crawl.py", + "description": "Console errors filtered by error_type.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + }, + "error_type": { + "type": "string" + } + }, + "required": [] + } + }, + { + "name": "list_pages_js_rendering_delta", + "domain": "crawl", + "module": "website_profiling.tools.audit_tools.crawl.crawl", + "source": "src/website_profiling/tools/audit_tools/crawl/crawl.py", + "description": "URLs with static vs rendered content delta.", + "inputSchema": { + "type": "object", + "properties": { + "property_id": { + "type": "integer", + "description": "Property ID" + }, + "report_id": { + "type": "integer", + "description": "Report ID (defaults to latest)" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + } + }, + "required": [] + } + }, + { + "name": "get_sql_schema", + "domain": "core", + "module": "website_profiling.tools.audit_tools.core.sql_query", + "source": "src/website_profiling/tools/audit_tools/core/sql_query.py", + "description": "Return all public-schema table names and their columns so you can write accurate SQL. Call this before run_sql_query to ", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [] + } + }, + { + "name": "run_sql_query", + "domain": "core", + "module": "website_profiling.tools.audit_tools.core.sql_query", + "source": "src/website_profiling/tools/audit_tools/core/sql_query.py", + "description": "Execute a read-only SELECT against the audit database and return rows as JSON. Only a single SELECT statement is allowed", + "inputSchema": { + "type": "object", + "properties": { + "sql": { + "type": "string", + "description": "A single read-only SELECT statement. No writes or DDL permitted." + }, + "row_cap": { + "type": "integer", + "minimum": 1, + "maximum": 500, + "description": "Maximum rows to return (default 200, max 500)." + } + }, + "required": [ + "sql" + ] + } + }, + { + "name": "prepare_audit_run", + "domain": "ops", + "module": "website_profiling.tools.audit_tools.crawl.crawl_actions", + "source": "src/website_profiling/tools/audit_tools/crawl/crawl_actions.py", + "description": "Preview an audit/crawl run for in-chat confirmation. Does not start the job \u2014 the user must authorize and click Run in t", + "inputSchema": { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": [ + "default", + "custom" + ], + "description": "default or custom configuration" + }, + "start_url": { + "type": "string", + "description": "Site URL to crawl (required for new properties)" + }, + "crawl_preset_id": { + "type": "string", + "enum": [ + "starter", + "spa", + "ecommerce", + "performance" + ], + "description": "Crawl preset (default: starter)" + }, + "pipeline_mode": { + "type": "string", + "enum": [ + "full-audit", + "crawl-only" + ], + "description": "Full audit (crawl+report) or crawl-only" + }, + "create_property": { + "type": "object", + "description": "When adding a new property, provide name and site_url", + "properties": { + "name": { + "type": "string" + }, + "site_url": { + "type": "string" + } + } + }, + "config_overrides": { + "type": "object", + "description": "Custom mode only: max_pages, crawl_render_mode, run_lighthouse_on_pages, concurrency", + "properties": { + "max_pages": { + "type": "string" + }, + "crawl_render_mode": { + "type": "string", + "enum": [ + "static", + "auto", + "javascript" + ] + }, + "run_lighthouse_on_pages": { + "type": "boolean" + }, + "concurrency": { + "type": "string" + } + } + } + }, + "required": [] + } + } +] \ No newline at end of file diff --git a/services/AiService/tests/AiService.Tests/AiService.Tests.csproj b/services/AiService/tests/AiService.Tests/AiService.Tests.csproj new file mode 100644 index 00000000..52a12aa4 --- /dev/null +++ b/services/AiService/tests/AiService.Tests/AiService.Tests.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + diff --git a/services/AiService/tests/AiService.Tests/AuditToolSelectionTests.cs b/services/AiService/tests/AiService.Tests/AuditToolSelectionTests.cs new file mode 100644 index 00000000..79489761 --- /dev/null +++ b/services/AiService/tests/AiService.Tests/AuditToolSelectionTests.cs @@ -0,0 +1,68 @@ +using AiService.Tools.Domain; +using AiService.Tools.Registry; +using AiService.Tools.Selection; + +namespace AiService.Tests; + +public sealed class AuditToolSelectionTests +{ + private static readonly HashSet SampleNames = + [ + "get_report_summary", + "list_issues", + "get_opportunity_matrix", + "get_gsc_daily_trend", + "list_broken_links", + "prepare_audit_run", + ]; + + [Fact] + public void Core_bundle_includes_tier0_and_insight() + { + var names = McpToolDomains.ToolNamesForMcpBundle(SampleNames, "core"); + Assert.Contains("get_report_summary", names); + Assert.Contains("get_opportunity_matrix", names); + Assert.DoesNotContain("get_gsc_daily_trend", names); + Assert.DoesNotContain("prepare_audit_run", names); + } + + [Fact] + public void Custom_domains_only_include_selected_groups() + { + var names = McpToolDomains.ToolNamesForEnabledDomains(SampleNames, ["google", "links"]); + Assert.Contains("get_gsc_daily_trend", names); + Assert.Contains("list_broken_links", names); + Assert.DoesNotContain("get_opportunity_matrix", names); + } + + [Fact] + public void Disabled_tools_are_removed_from_snapshot() + { + var pipeline = new Dictionary(StringComparer.Ordinal) + { + ["mcp_domain"] = "full", + ["mcp_disabled_tools"] = """["list_issues"]""", + }; + + var bundle = AuditToolSelectionService.ResolveBundleKey(pipeline); + Assert.Equal("full", bundle); + + var disabled = AuditToolSelectionService.ParseDisabledTools(pipeline["mcp_disabled_tools"]); + Assert.Contains("list_issues", disabled); + } + + [Fact] + public void Chat_selector_caps_tools_and_keeps_tier0() + { + var catalog = new ToolCatalog(); + var allowed = catalog.ToolNames.ToHashSet(StringComparer.Ordinal); + var selected = ChatToolSelector.SelectToolsForTurn( + "show me broken links on the site", + null, + allowed); + + Assert.True(selected.Count <= ChatToolSelector.ResolveChatToolMax()); + Assert.Contains("search_audit_tools", selected); + Assert.Contains("get_report_summary", selected); + } +} diff --git a/services/AiService/tests/AiService.Tests/ChatAgentParityTests.cs b/services/AiService/tests/AiService.Tests/ChatAgentParityTests.cs new file mode 100644 index 00000000..d9e58311 --- /dev/null +++ b/services/AiService/tests/AiService.Tests/ChatAgentParityTests.cs @@ -0,0 +1,154 @@ +using System.Text.Json.Nodes; +using AiService.Application.Chat; +using AiService.Application.Prompts; +using AiService.Tools.Domain; +using AiService.Tools.Selection; + +namespace AiService.Tests; + +public sealed class ChatAgentParityTests +{ + [Fact] + public void ResolveSystemPrompt_adds_crawl_suffix_when_enabled() + { + var cfg = new Dictionary(StringComparer.Ordinal) + { + ["llm_chat_allow_crawl"] = "true", + }; + + var prompt = ChatAgentConfig.ResolveSystemPrompt(cfg); + Assert.Contains(LlmPrompts.ChatAgentCrawlSuffix.Trim(), prompt); + Assert.DoesNotContain(LlmPrompts.ChatAgentReadOnlySuffix.Trim(), prompt); + } + + [Fact] + public void ResolveSystemPrompt_adds_readonly_suffix_when_crawl_disabled() + { + var cfg = new Dictionary(StringComparer.Ordinal) + { + ["llm_chat_allow_crawl"] = "false", + }; + + var prompt = ChatAgentConfig.ResolveSystemPrompt(cfg); + Assert.Contains(LlmPrompts.ChatAgentReadOnlySuffix.Trim(), prompt); + Assert.DoesNotContain("prepare_audit_run", prompt); + } + + [Fact] + public void ResolveMaxToolRounds_honors_env_overrides() + { + var cfg = new Dictionary(StringComparer.Ordinal) + { + ["llm_chat_unlimited_tool_rounds"] = "false", + }; + + Environment.SetEnvironmentVariable("CHAT_MAX_TOOL_ROUNDS", "17"); + try + { + Assert.Equal(17, ChatAgentConfig.ResolveMaxToolRounds(cfg)); + } + finally + { + Environment.SetEnvironmentVariable("CHAT_MAX_TOOL_ROUNDS", null); + } + } + + [Fact] + public void ExpandActiveToolsFromResult_pins_search_results() + { + var allowed = new HashSet(StringComparer.Ordinal) + { + "search_audit_tools", + "list_broken_links", + "list_issues", + }; + var active = new HashSet(StringComparer.Ordinal) { "search_audit_tools" }; + var result = new JsonObject + { + ["tool_names"] = new JsonArray("list_broken_links", "list_issues"), + }; + + var expanded = ChatToolSelector.ExpandActiveToolsFromResult( + "search_audit_tools", + result, + active, + allowed); + + Assert.Contains("list_broken_links", expanded); + Assert.Contains("list_issues", expanded); + } + + [Fact] + public void PhraseToolPins_critical_issues_includes_report_trio() + { + var allowed = new HashSet(StringComparer.Ordinal) + { + "get_report_summary", + "get_issue_priority_breakdown", + "get_critical_issues", + "list_issues", + "search_audit_tools", + }; + + var selected = ChatToolSelector.SelectToolsForTurn( + "show me critical issues on this site", + priorUserMessages: null, + allowed); + + Assert.Contains("get_report_summary", selected); + Assert.Contains("get_issue_priority_breakdown", selected); + Assert.Contains("get_critical_issues", selected); + } + + [Fact] + public void ApplyToolCap_preserves_pinned_playbook_tools() + { + var selected = new HashSet(StringComparer.Ordinal); + foreach (var name in McpToolDomains.Tier0Tools) + { + selected.Add(name); + } + + for (var i = 0; i < 40; i++) + { + selected.Add($"synthetic_tool_{i:D2}"); + } + + selected.Add("get_issue_priority_breakdown"); + var pinned = new HashSet(StringComparer.Ordinal) { "get_issue_priority_breakdown" }; + + var capped = ChatToolSelector.ApplyToolCap(selected, cap: 25, pinned); + + Assert.Contains("get_issue_priority_breakdown", capped); + } + + [Fact] + public void PartialDone_event_serializes_for_sse() + { + var json = ChatSseSerializer.ToJson(new ChatPartialDoneStreamEvent("Stopped early")); + Assert.Equal("partial_done", json["type"]?.GetValue()); + Assert.Equal("Stopped early", json["message"]?.GetValue()); + } + + [Fact] + public void StripSurrogates_removes_invalid_unicode() + { + var cleaned = ChatTextSanitize.StripSurrogates("hi\ud800there"); + Assert.Equal("hithere", cleaned); + } + + [Fact] + public void StripSurrogates_preserves_valid_surrogate_pairs() + { + // 🚀 (U+1F680) is a valid high+low surrogate pair and must survive. + const string withEmoji = "rocket 🚀 ok"; + Assert.Equal(withEmoji, ChatTextSanitize.StripSurrogates(withEmoji)); + } + + [Fact] + public void StripSurrogates_drops_lone_low_surrogate() + { + var cleaned = ChatTextSanitize.StripSurrogates("\udc00x"); + Assert.Equal("x", cleaned); + } +} diff --git a/services/AiService/tests/AiService.Tests/ChatNarrativeFallbackTests.cs b/services/AiService/tests/AiService.Tests/ChatNarrativeFallbackTests.cs new file mode 100644 index 00000000..d66983a3 --- /dev/null +++ b/services/AiService/tests/AiService.Tests/ChatNarrativeFallbackTests.cs @@ -0,0 +1,59 @@ +using AiService.Application.Chat; + +namespace AiService.Tests; + +public sealed class ChatNarrativeFallbackTests +{ + [Fact] + public void TryFromToolEvents_builds_narrative_from_workflow_steps() + { + var resultJson = """ + { + "workflow": "insight", + "type": "priorities", + "steps": [ + { + "tool": "get_opportunity_matrix", + "result": { "total": 12, "summary": "Twelve landing pages have striking-distance keywords." } + }, + { + "tool": "get_issue_to_traffic_map", + "result": { "total": 4, "issues": [{ "message": "Missing titles" }] } + } + ] + } + """; + + var narrative = ChatNarrativeFallback.TryFromToolEvents( + [new ChatToolEvent("run_insight_workflow", "{}", resultJson)], + "show priorities"); + + Assert.NotNull(narrative); + Assert.NotEmpty(narrative!.PowerInsights); + Assert.Contains("Twelve landing pages", narrative.PowerInsights[0]); + } + + [Fact] + public void TryFromToolEvents_does_not_throw_on_unexpected_field_types() + { + // Tool results are arbitrary JSON: a float "total", a numeric "summary", and an + // object "error" previously threw InvalidOperationException via GetValue(). + var resultJson = """ + { + "total": 5.0, + "summary": 42, + "error": { "code": 500 }, + "issues": [{ "message": "Missing titles" }] + } + """; + + var narrative = ChatNarrativeFallback.TryFromToolEvents( + [new ChatToolEvent("list_issues", "{}", resultJson)], + "what is broken"); + + Assert.NotNull(narrative); + // Float total is coerced to an int count; mistyped summary/error are skipped, not thrown. + Assert.Contains(narrative!.PowerInsights, i => i.Contains("returned 5 item(s)")); + Assert.Contains(narrative.PowerInsights, i => i.Contains("issue(s)")); + } +} diff --git a/services/AiService/tests/AiService.Tests/ChatNarrativeParserTests.cs b/services/AiService/tests/AiService.Tests/ChatNarrativeParserTests.cs new file mode 100644 index 00000000..d7877a6b --- /dev/null +++ b/services/AiService/tests/AiService.Tests/ChatNarrativeParserTests.cs @@ -0,0 +1,29 @@ +using AiService.Application.Chat; + +namespace AiService.Tests; + +public sealed class ChatNarrativeParserTests +{ + [Fact] + public void TryParsePartial_returns_insights_from_incomplete_json() + { + var partial = ChatNarrativeParser.TryParsePartial( + """{"power_insights": ["Fix broken links", "Improve titles"],"""); + + Assert.NotNull(partial); + Assert.Equal(2, partial.PowerInsights.Count); + } + + [Fact] + public void ValidateNarrative_normalizes_full_object() + { + var raw = System.Text.Json.Nodes.JsonNode.Parse( + """{"power_insights":["A"],"recommended_actions":["B"]}""") as System.Text.Json.Nodes.JsonObject; + Assert.NotNull(raw); + + var (narrative, errors) = ChatNarrativeParser.ValidateNarrative(raw); + Assert.Empty(errors); + Assert.Equal(["A"], narrative.PowerInsights); + Assert.Equal(["B"], narrative.RecommendedActions); + } +} diff --git a/services/AiService/tests/AiService.Tests/ChatPersistenceMapperTests.cs b/services/AiService/tests/AiService.Tests/ChatPersistenceMapperTests.cs new file mode 100644 index 00000000..cf01f6bd --- /dev/null +++ b/services/AiService/tests/AiService.Tests/ChatPersistenceMapperTests.cs @@ -0,0 +1,48 @@ +using AiService.Application.Chat; + +namespace AiService.Tests; + +public sealed class ChatPersistenceMapperTests +{ + [Fact] + public void ToToolResultJson_ProducesFrontendCompatibleShape() + { + var result = new ChatTurnResult( + Ok: true, + Narrative: new ChatNarrative(["insight one"], ["action one"]), + ToolEvents: [new ChatToolEvent("search", """{"q":"gi"}""", """{"items":[]}""")], + Error: null); + + var json = ChatPersistenceMapper.ToToolResultJson(result); + Assert.NotNull(json); + Assert.Contains("power_insights", json); + Assert.Contains("tool_events", json); + Assert.Contains("insight one", json); + } + + [Fact] + public void ToToolResultJson_IncludesAgentErrorOnPartialFailure() + { + var result = new ChatTurnResult( + Ok: false, + Narrative: null, + ToolEvents: [new ChatToolEvent("search", "{}", """{"error":"x"}""")], + Error: "narrative failed"); + + var json = ChatPersistenceMapper.ToToolResultJson(result); + Assert.NotNull(json); + Assert.Contains("agent_error", json); + } + + [Fact] + public void FirstNarrativeInsight_PrefersPowerInsights() + { + var result = new ChatTurnResult( + Ok: true, + Narrative: new ChatNarrative(["first insight"], ["first action"]), + ToolEvents: [], + Error: null); + + Assert.Equal("first insight", ChatPersistenceMapper.FirstNarrativeInsight(result)); + } +} diff --git a/services/AiService/tests/AiService.Tests/ChatSseSerializerTests.cs b/services/AiService/tests/AiService.Tests/ChatSseSerializerTests.cs new file mode 100644 index 00000000..a7be462e --- /dev/null +++ b/services/AiService/tests/AiService.Tests/ChatSseSerializerTests.cs @@ -0,0 +1,51 @@ +using System.Text.Json.Nodes; +using AiService.Application.Chat; + +namespace AiService.Tests; + +public sealed class ChatSseSerializerTests +{ + [Fact] + public void Tool_events_include_call_id_and_truncation_metadata() + { + var json = ChatSseSerializer.ToJson(new ChatToolEndEvent( + "call-1", + "list_issues", + """{"total":10}""", + Truncated: true, + ResultBytes: 4096)); + + Assert.Equal("tool_end", json["type"]?.GetValue()); + Assert.Equal("call-1", json["call_id"]?.GetValue()); + Assert.True(json["truncated"]?.GetValue()); + Assert.Equal(4096, json["result_bytes"]?.GetValue()); + } + + [Fact] + public void Tool_progress_event_serializes() + { + var json = ChatSseSerializer.ToJson(new ChatToolProgressEvent("call-2", "run_technical_workflow", "Running workflow steps…")); + Assert.Equal("tool_progress", json["type"]?.GetValue()); + Assert.Equal("call-2", json["call_id"]?.GetValue()); + } + + [Fact] + public void Token_event_serializes() + { + var json = ChatSseSerializer.ToJson(new ChatTokenStreamEvent("hello")); + Assert.Equal("token", json["type"]?.GetValue()); + Assert.Equal("hello", json["text"]?.GetValue()); + } + + [Fact] + public void Narrative_partial_event_serializes() + { + var narrative = new ChatNarrative(["Insight one"], ["Action one"]); + var json = ChatSseSerializer.ToJson(new ChatNarrativePartialStreamEvent(narrative)); + Assert.Equal("narrative_partial", json["type"]?.GetValue()); + var n = json["narrative"] as JsonObject; + Assert.NotNull(n); + Assert.Equal("Insight one", n["power_insights"]![0]!.GetValue()); + Assert.Equal("Action one", n["recommended_actions"]![0]!.GetValue()); + } +} diff --git a/services/AiService/tests/AiService.Tests/CrawlFilterTests.cs b/services/AiService/tests/AiService.Tests/CrawlFilterTests.cs new file mode 100644 index 00000000..fdce3cb6 --- /dev/null +++ b/services/AiService/tests/AiService.Tests/CrawlFilterTests.cs @@ -0,0 +1,177 @@ +using System.Text.Json.Nodes; +using AiService.Tools.Slice; + +namespace AiService.Tests; + +/// Parity tests for the native crawl filter port (mirrors Python _slice.crawl_filter). +public sealed class CrawlFilterTests +{ + // ─── RowHasSchema ─────────────────────────────────────────────────────────── + + [Fact] + public void RowHasSchema_true_for_json_bool_true() + => Assert.True(CrawlFilter.RowHasSchema(Row("""{"has_schema": true}"""))); + + [Fact] + public void RowHasSchema_false_for_json_bool_false() + => Assert.False(CrawlFilter.RowHasSchema(Row("""{"has_schema": false}"""))); + + [Fact] + public void RowHasSchema_true_for_string_true() + => Assert.True(CrawlFilter.RowHasSchema(Row("""{"has_schema": "true"}"""))); + + [Fact] + public void RowHasSchema_true_for_string_1() + => Assert.True(CrawlFilter.RowHasSchema(Row("""{"has_schema": "1"}"""))); + + [Fact] + public void RowHasSchema_false_for_missing_key() + => Assert.False(CrawlFilter.RowHasSchema(Row("{}"))); + + // ─── RowSchemaTypesList ───────────────────────────────────────────────────── + + [Fact] + public void RowSchemaTypesList_from_json_ld_types_array() + { + var row = Row("""{"page_analysis": {"json_ld_types": ["Product", "BreadcrumbList"]}}"""); + var types = CrawlFilter.RowSchemaTypesList(row); + Assert.Equal(new[] { "Product", "BreadcrumbList" }, types); + } + + [Fact] + public void RowSchemaTypesList_falls_back_to_schema_types() + { + var row = Row("""{"page_analysis": {"schema_types": ["Article"]}}"""); + var types = CrawlFilter.RowSchemaTypesList(row); + Assert.Equal(new[] { "Article" }, types); + } + + [Fact] + public void RowSchemaTypesList_empty_when_no_page_analysis() + => Assert.Empty(CrawlFilter.RowSchemaTypesList(Row("{}"))); + + [Fact] + public void RowSchemaTypesList_handles_double_encoded_page_analysis() + { + // page_analysis stored as a JSON string inside the data blob. + var encoded = System.Text.Json.JsonSerializer.Serialize( + new { json_ld_types = new[] { "FAQPage" } }); + var row = Row($$"""{"page_analysis": {{System.Text.Json.JsonSerializer.Serialize(encoded)}}}"""); + var types = CrawlFilter.RowSchemaTypesList(row); + Assert.Equal(new[] { "FAQPage" }, types); + } + + // ─── Filter — basic ───────────────────────────────────────────────────────── + + [Fact] + public void Filter_empty_returns_empty_result() + { + var result = CrawlFilter.Filter(null); + Assert.Equal(0, result["total"]!.GetValue()); + } + + [Fact] + public void Filter_no_predicates_returns_all() + { + var rows = MakeRows(5); + var result = CrawlFilter.Filter(rows, limit: 10); + Assert.Equal(5, result["total"]!.GetValue()); + Assert.False(result["truncated"]!.GetValue()); + Assert.Equal(5, ((JsonArray)result["pages"]!).Count); + } + + [Fact] + public void Filter_status_filters_by_exact_match() + { + var rows = new[] + { + Row("""{"url": "https://x.com/a", "status": "200"}"""), + Row("""{"url": "https://x.com/b", "status": "404"}"""), + Row("""{"url": "https://x.com/c", "status": "200"}"""), + }; + + var result = CrawlFilter.Filter(rows, status: "404"); + Assert.Equal(1, result["total"]!.GetValue()); + var page = (JsonObject)((JsonArray)result["pages"]!)[0]!; + Assert.Equal("https://x.com/b", page["url"]!.GetValue()); + } + + [Fact] + public void Filter_url_contains_case_insensitive() + { + var rows = new[] + { + Row("""{"url": "https://x.com/Blog/Post", "status": "200"}"""), + Row("""{"url": "https://x.com/about", "status": "200"}"""), + }; + + var result = CrawlFilter.Filter(rows, urlContains: "blog"); + Assert.Equal(1, result["total"]!.GetValue()); + } + + [Fact] + public void Filter_has_schema_true_keeps_only_schema_rows() + { + var rows = new[] + { + Row("""{"url": "https://x.com/a", "has_schema": true, "status": "200"}"""), + Row("""{"url": "https://x.com/b", "has_schema": false, "status": "200"}"""), + }; + + var result = CrawlFilter.Filter(rows, hasSchema: true); + Assert.Equal(1, result["total"]!.GetValue()); + var page = (JsonObject)((JsonArray)result["pages"]!)[0]!; + Assert.Equal("https://x.com/a", page["url"]!.GetValue()); + } + + [Fact] + public void Filter_schema_type_matches_substring() + { + var rows = new[] + { + Row("""{"url": "https://x.com/a", "status": "200", "page_analysis": {"json_ld_types": ["Product"]}}"""), + Row("""{"url": "https://x.com/b", "status": "200", "page_analysis": {"json_ld_types": ["Article"]}}"""), + }; + + var result = CrawlFilter.Filter(rows, schemaType: "product"); + Assert.Equal(1, result["total"]!.GetValue()); + } + + [Fact] + public void Filter_truncates_at_limit() + { + var rows = MakeRows(10); + var result = CrawlFilter.Filter(rows, limit: 3, maxCap: 3); + Assert.Equal(10, result["total"]!.GetValue()); + Assert.True(result["truncated"]!.GetValue()); + Assert.Equal(3, ((JsonArray)result["pages"]!).Count); + } + + [Fact] + public void Filter_pages_contain_expected_fields() + { + var rows = new[] + { + Row("""{"url":"https://x.com/p","status":"200","title":"Page","has_schema":true,"page_analysis":{"json_ld_types":["Article"]}}"""), + }; + + var result = CrawlFilter.Filter(rows); + var page = (JsonObject)((JsonArray)result["pages"]!)[0]!; + Assert.Equal("https://x.com/p", page["url"]!.GetValue()); + Assert.Equal("200", page["status"]!.GetValue()); + Assert.Equal("Page", page["title"]!.GetValue()); + Assert.True(page["has_schema"]!.GetValue()); + var schemaArr = (JsonArray)page["schema_types"]!; + Assert.Equal("Article", schemaArr[0]!.GetValue()); + } + + // ─── helpers ──────────────────────────────────────────────────────────────── + + private static JsonObject Row(string json) + => (JsonObject)JsonNode.Parse(json)!; + + private static IReadOnlyList MakeRows(int n) + => Enumerable.Range(0, n) + .Select(i => Row($$"""{"url":"https://x.com/page{{i}}","status":"200","title":"Page {{i}}"}""")) + .ToList(); +} diff --git a/services/AiService/tests/AiService.Tests/InsightLogicTests.cs b/services/AiService/tests/AiService.Tests/InsightLogicTests.cs new file mode 100644 index 00000000..7834bfe7 --- /dev/null +++ b/services/AiService/tests/AiService.Tests/InsightLogicTests.cs @@ -0,0 +1,118 @@ +using System.Text.Json.Nodes; +using AiService.Tools.Handlers.Insight; +using AiService.Tools.Slice; + +namespace AiService.Tests; + +/// Parity tests for the native GSC/GA4 insight port (mirrors Python insight_helpers). +public sealed class InsightLogicTests +{ + [Theory] + [InlineData("https://www.example.com/blog/", "example.com/blog")] + [InlineData("https://example.com/", "example.com/")] + [InlineData("https://www.example.com", "example.com/")] + [InlineData("/blog/post/", "/blog/post")] + [InlineData("HTTP://Example.com/A", "example.com/A")] + [InlineData("https://x.com/a?q=1#frag", "x.com/a")] + public void NormalizeUrl_matches_python(string input, string expected) + { + Assert.Equal(expected, GoogleUrl.NormalizeUrl(input)); + } + + [Theory] + [InlineData("https://x.com/a/b", "/a/b")] + [InlineData("https://x.com", "/")] + [InlineData("/just/path", "/just/path")] + public void UrlToPath_matches_python(string input, string expected) + { + Assert.Equal(expected, GoogleUrl.UrlToPath(input)); + } + + [Fact] + public void StripWwwPrefix_only_removes_single_leading_label() + { + Assert.Equal("washington.edu", GoogleUrl.StripWwwPrefix("www.washington.edu")); + Assert.Equal("example.com", GoogleUrl.StripWwwPrefix("example.com")); + } + + [Fact] + public void TrafficHealthRatio_no_data() + { + var health = InsightLogic.TrafficHealthRatio(Obj("{}"), Obj("{}")); + Assert.Equal("no_data", health["diagnosis"]!.GetValue()); + Assert.Null(health["ratio"]); + } + + [Theory] + [InlineData(100, 20, "tracking_gap")] + [InlineData(100, 150, "healthy")] + [InlineData(10, 40, "filter_issue")] + public void TrafficHealthRatio_diagnoses_by_ratio(int clicks, int sessions, string expected) + { + var health = InsightLogic.TrafficHealthRatio( + Obj($$"""{"clicks": {{clicks}}}"""), + Obj($$"""{"sessions": {{sessions}}}""")); + Assert.Equal(expected, health["diagnosis"]!.GetValue()); + } + + [Theory] + [InlineData(200, 10, 50, "high_impact")] + [InlineData(200, 10, 0, "worth_optimizing")] + [InlineData(10, 2, 100, "good_but_capped")] + [InlineData(10, 50, 0, "low_priority")] + public void ClassifyOpportunityQuadrant_matches_python(int impressions, int position, int sessions, string expected) + { + var gsc = Obj($$"""{"impressions": {{impressions}}, "position": {{position}}}"""); + var ga4 = sessions > 0 ? Obj($$"""{"sessions": {{sessions}}}""") : null; + Assert.Equal(expected, InsightLogic.ClassifyOpportunityQuadrant(gsc, ga4, siteMedianSessions: 0)); + } + + [Fact] + public void BlendLandingPages_joins_sorts_and_classifies() + { + var byPage = Obj(""" + { + "https://x.com/a": {"clicks": 10, "impressions": 200, "position": 10, "ctr": 0.05}, + "https://x.com/b": {"clicks": 50, "impressions": 50, "position": 2, "ctr": 0.1} + } + """); + var byPath = Obj(""" + { + "/a": {"full_url": "https://x.com/a", "sessions": 80, "engagementRate": 0.6} + } + """); + + var rows = InsightLogic.BlendLandingPages(byPage, byPath, limit: 30, minImpressions: 1); + + Assert.Equal(2, rows.Count); + // Sorted by clicks desc: /b (50 clicks) first. + var first = (JsonObject)rows[0]!; + Assert.Equal("https://x.com/b", first["url"]!.GetValue()); + Assert.Equal("low_priority", first["quadrant"]!.GetValue()); + Assert.Equal(0L, first["ga4_sessions"]!.GetValue()); + + var second = (JsonObject)rows[1]!; + Assert.Equal("https://x.com/a", second["url"]!.GetValue()); + Assert.Equal("high_impact", second["quadrant"]!.GetValue()); + Assert.Equal(80L, second["ga4_sessions"]!.GetValue()); + } + + [Fact] + public void BlendLandingPages_filters_below_min_impressions() + { + var byPage = Obj(""" + { + "https://x.com/low": {"clicks": 1, "impressions": 0, "position": 5}, + "https://x.com/ok": {"clicks": 1, "impressions": 5, "position": 5} + } + """); + + // parse_limit floors min_impressions to 1, so the 0-impression page is dropped. + var rows = InsightLogic.BlendLandingPages(byPage, Obj("{}"), limit: 30, minImpressions: 1); + + Assert.Single(rows); + Assert.Equal("https://x.com/ok", ((JsonObject)rows[0]!)["url"]!.GetValue()); + } + + private static JsonObject Obj(string json) => (JsonObject)JsonNode.Parse(json)!; +} diff --git a/services/AiService/tests/AiService.Tests/JsonNodeCopyTests.cs b/services/AiService/tests/AiService.Tests/JsonNodeCopyTests.cs new file mode 100644 index 00000000..e9de9162 --- /dev/null +++ b/services/AiService/tests/AiService.Tests/JsonNodeCopyTests.cs @@ -0,0 +1,46 @@ +using System.Text.Json.Nodes; +using AiService.Application.Json; + +namespace AiService.Tests; + +public sealed class JsonNodeCopyTests +{ + [Fact] + public void JsonArray_is_not_reused_across_two_json_objects() + { + var issues = new JsonArray { "missing title tags" }; + var cachePayload = new JsonObject + { + ["domain"] = "example.com", + ["issues"] = JsonNodeCopy.CloneArray(issues), + }; + var userPayload = new JsonObject + { + ["domain"] = "example.com", + ["issues"] = issues, + }; + + _ = cachePayload.ToJsonString(); + var userJson = userPayload.ToJsonString(); + + Assert.Contains("missing title tags", userJson); + } + + [Fact] + public void Scalar_fields_are_copied_without_moving_nodes_from_source_object() + { + var score = new JsonObject + { + ["grade_score"] = 75, + ["grade_label"] = "B", + }; + var result = new JsonObject { ["score"] = score }; + var cachePayload = new JsonObject + { + ["grade_score"] = score["grade_score"]?.GetValue() ?? 0, + }; + + Assert.Equal(75, result["score"]?["grade_score"]?.GetValue()); + Assert.Equal(75, cachePayload["grade_score"]?.GetValue()); + } +} diff --git a/services/AiService/tests/AiService.Tests/LlmConfigPutHelpersTests.cs b/services/AiService/tests/AiService.Tests/LlmConfigPutHelpersTests.cs new file mode 100644 index 00000000..65fde35b --- /dev/null +++ b/services/AiService/tests/AiService.Tests/LlmConfigPutHelpersTests.cs @@ -0,0 +1,37 @@ +using System.Text.Json.Nodes; +using AiService.Application.Repositories; + +namespace AiService.Tests; + +public sealed class LlmConfigPutHelpersTests +{ + [Fact] + public void ParsePutEntries_SkipsMaskedMetadataKeys() + { + var state = new JsonObject + { + ["llm_provider"] = "groq", + ["llm_api_key_groq_masked"] = true, + ["llm_enabled"] = true, + }; + + var entries = LlmConfigPutHelpers.ParsePutEntries(state); + + Assert.Equal("groq", entries["llm_provider"]); + Assert.Equal("true", entries["llm_enabled"]); + Assert.False(entries.ContainsKey("llm_api_key_groq_masked")); + } + + [Fact] + public void ParsePutEntries_CoercesBooleanValues() + { + var state = new JsonObject + { + ["llm_chat_allow_crawl"] = false, + }; + + var entries = LlmConfigPutHelpers.ParsePutEntries(state); + + Assert.Equal("false", entries["llm_chat_allow_crawl"]); + } +} diff --git a/services/AiService/tests/AiService.Tests/LlmConfigRepositoryTests.cs b/services/AiService/tests/AiService.Tests/LlmConfigRepositoryTests.cs new file mode 100644 index 00000000..d471b3ec --- /dev/null +++ b/services/AiService/tests/AiService.Tests/LlmConfigRepositoryTests.cs @@ -0,0 +1,63 @@ +using AiService.Application.Persistence; +using AiService.Application.Repositories; +using AiService.Domain.Entities; +using Microsoft.EntityFrameworkCore; + +namespace AiService.Tests; + +public sealed class LlmConfigRepositoryTests +{ + private static LlmConfigRepository CreateRepo(out AiDbContext db) + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .ConfigureWarnings(w => w.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.InMemoryEventId.TransactionIgnoredWarning)) + .Options; + db = new AiDbContext(options); + return new LlmConfigRepository(db); + } + + [Fact] + public async Task SaveAsync_PartialUpdate_PreservesOtherKeys() + { + var repo = CreateRepo(out var db); + await repo.SaveAsync(new Dictionary(StringComparer.Ordinal) + { + ["llm_enabled"] = "true", + ["llm_api_key_openai"] = "sk-secret", + ["llm_provider"] = "openai", + }); + + await repo.SaveAsync(new Dictionary(StringComparer.Ordinal) + { + ["llm_enabled"] = "false", + }); + + var loaded = await repo.LoadAsync(); + Assert.Equal("false", loaded["llm_enabled"]); + Assert.Equal("sk-secret", loaded["llm_api_key_openai"]); + Assert.Equal("openai", loaded["llm_provider"]); + await db.DisposeAsync(); + } + + [Fact] + public async Task SaveAsync_MaskedSentinel_PreservesExistingSecret() + { + var repo = CreateRepo(out var db); + await repo.SaveAsync(new Dictionary(StringComparer.Ordinal) + { + ["llm_api_key_openai"] = "sk-original", + }); + + await repo.SaveAsync(new Dictionary(StringComparer.Ordinal) + { + ["llm_api_key_openai"] = "*", + ["llm_enabled"] = "true", + }); + + var loaded = await repo.LoadAsync(); + Assert.Equal("sk-original", loaded["llm_api_key_openai"]); + Assert.Equal("true", loaded["llm_enabled"]); + await db.DisposeAsync(); + } +} diff --git a/services/AiService/tests/AiService.Tests/OllamaCatalogServiceTests.cs b/services/AiService/tests/AiService.Tests/OllamaCatalogServiceTests.cs new file mode 100644 index 00000000..536bf8a5 --- /dev/null +++ b/services/AiService/tests/AiService.Tests/OllamaCatalogServiceTests.cs @@ -0,0 +1,20 @@ +using System.Text.Json.Nodes; +using AiService.Application.Services; + +namespace AiService.Tests; + +public sealed class OllamaCatalogServiceTests +{ + [Fact] + public void Billing_fields_can_be_applied_to_many_models_without_json_parent_error() + { + for (var i = 0; i < 10; i++) + { + var tier = OllamaCatalogService.ResolveBillingTier("gemma3:4b-cloud", "cloud"); + var entry = new JsonObject { ["name"] = "gemma3:4b-cloud" }; + entry["billing"] = tier["billing"]?.GetValue(); + entry["requires_subscription"] = tier["requires_subscription"]?.GetValue() ?? false; + Assert.Equal("cloud_free", entry["billing"]?.GetValue()); + } + } +} diff --git a/services/AiService/tests/AiService.Tests/SecretsKeyCatalogTests.cs b/services/AiService/tests/AiService.Tests/SecretsKeyCatalogTests.cs new file mode 100644 index 00000000..080822e0 --- /dev/null +++ b/services/AiService/tests/AiService.Tests/SecretsKeyCatalogTests.cs @@ -0,0 +1,28 @@ +using AiService.Application.Services; + +namespace AiService.Tests; + +public sealed class SecretsKeyCatalogTests +{ + [Theory] + [InlineData("bing_webmaster_api_key", "pipeline")] + [InlineData("mcp_token", "pipeline")] + [InlineData("feature_chat_enabled", "pipeline")] + [InlineData("llm_api_key_openai", "llm")] + [InlineData("google_client_id", "google")] + public void ResolveStorage_RoutesByCatalog(string key, string expected) + { + Assert.Equal(expected, SecretsKeyCatalog.ResolveStorage(key)?.ToString().ToLowerInvariant()); + } + + [Theory] + [InlineData("*", true)] + [InlineData("••••", true)] + [InlineData("{configured}", true)] + [InlineData("sk-live-key", false)] + [InlineData("", false)] + public void IsMaskedSentinel_DetectsPlaceholders(string value, bool expected) + { + Assert.Equal(expected, ConfigSecretHelpers.IsMaskedSentinel(value)); + } +} diff --git a/services/AiService/tests/AiService.Tests/SecretsServiceTests.cs b/services/AiService/tests/AiService.Tests/SecretsServiceTests.cs new file mode 100644 index 00000000..c2aed102 --- /dev/null +++ b/services/AiService/tests/AiService.Tests/SecretsServiceTests.cs @@ -0,0 +1,136 @@ +using System.Text.Json.Nodes; +using AiService.Application.Services; +using AiService.Domain.Entities; +using AiService.Domain.Repositories; + +namespace AiService.Tests; + +public sealed class SecretsServiceTests +{ + [Fact] + public async Task PutStateAsync_RoutesPipelineSecretToPipelineRepository() + { + var llm = new FakeLlmConfigRepository(); + var pipeline = new FakePipelineConfigRepository(); + var google = new FakeGoogleAppSettingsRepository(); + var service = new SecretsService(llm, pipeline, google); + + var incoming = new JsonObject { ["bing_webmaster_api_key"] = "bing-key" }; + await service.PutStateAsync(incoming); + + Assert.Equal("bing-key", pipeline.Known["bing_webmaster_api_key"]); + Assert.Null(llm.SavedEntries); + Assert.False(google.Merged); + } + + [Fact] + public async Task PutStateAsync_RoutesLlmApiKeyToLlmRepository() + { + var llm = new FakeLlmConfigRepository(); + var pipeline = new FakePipelineConfigRepository(); + var google = new FakeGoogleAppSettingsRepository(); + var service = new SecretsService(llm, pipeline, google); + + var incoming = new JsonObject { ["llm_api_key_openai"] = "sk-test" }; + await service.PutStateAsync(incoming); + + Assert.NotNull(llm.SavedEntries); + Assert.Equal("sk-test", llm.SavedEntries!["llm_api_key_openai"]); + Assert.Empty(pipeline.Known); + } + + [Fact] + public async Task PutStateAsync_RoutesGoogleClientIdToGoogleRepository() + { + var llm = new FakeLlmConfigRepository(); + var pipeline = new FakePipelineConfigRepository(); + var google = new FakeGoogleAppSettingsRepository(); + var service = new SecretsService(llm, pipeline, google); + + var incoming = new JsonObject { ["google_client_id"] = "client.apps.googleusercontent.com" }; + await service.PutStateAsync(incoming); + + Assert.True(google.Merged); + Assert.Equal("client.apps.googleusercontent.com", google.LastPatch!.ClientId); + } + + [Fact] + public async Task PutStateAsync_SkipsMaskedSentinel() + { + var llm = new FakeLlmConfigRepository(); + var pipeline = new FakePipelineConfigRepository(); + var google = new FakeGoogleAppSettingsRepository(); + var service = new SecretsService(llm, pipeline, google); + + var incoming = new JsonObject + { + ["llm_api_key_openai"] = "*", + ["bing_webmaster_api_key"] = "••••", + }; + await service.PutStateAsync(incoming); + + Assert.Null(llm.SavedEntries); + Assert.Empty(pipeline.Known); + } + + private sealed class FakeLlmConfigRepository : ILlmConfigRepository + { + public IReadOnlyDictionary? SavedEntries { get; private set; } + + public Task> LoadAsync(CancellationToken cancellationToken = default) + => Task.FromResult>(new Dictionary()); + + public Task> LoadFullAsync(CancellationToken cancellationToken = default) + => Task.FromResult>(Array.Empty()); + + public Task SaveAsync(IReadOnlyDictionary entries, CancellationToken cancellationToken = default) + { + SavedEntries = new Dictionary(entries, StringComparer.Ordinal); + return Task.CompletedTask; + } + } + + private sealed class FakePipelineConfigRepository : IPipelineConfigRepository + { + public Dictionary Known { get; } = new(StringComparer.Ordinal); + + public Task> LoadAsync(CancellationToken cancellationToken = default) + => Task.FromResult>(Known); + + public Task<(IReadOnlyDictionary Known, IReadOnlyList Unknown)> LoadFullAsync( + CancellationToken cancellationToken = default) + => Task.FromResult<(IReadOnlyDictionary, IReadOnlyList)>( + (new Dictionary(Known, StringComparer.Ordinal), Array.Empty())); + + public Task SaveAsync( + IReadOnlyDictionary known, + IReadOnlyList unknown, + CancellationToken cancellationToken = default) + { + Known.Clear(); + foreach (var (key, value) in known) + { + Known[key] = value; + } + + return Task.CompletedTask; + } + } + + private sealed class FakeGoogleAppSettingsRepository : IGoogleAppSettingsRepository + { + public bool Merged { get; private set; } + + public GoogleAppSettingsPatch? LastPatch { get; private set; } + + public Task LoadAsync(CancellationToken cancellationToken = default) + => Task.FromResult(new GoogleAppSettings()); + + public Task MergeAsync(GoogleAppSettingsPatch patch, CancellationToken cancellationToken = default) + { + Merged = true; + LastPatch = patch; + return Task.CompletedTask; + } + } +} diff --git a/services/AiService/tests/AiService.Tests/StreamingNarrativeExtractorTests.cs b/services/AiService/tests/AiService.Tests/StreamingNarrativeExtractorTests.cs new file mode 100644 index 00000000..492f4d21 --- /dev/null +++ b/services/AiService/tests/AiService.Tests/StreamingNarrativeExtractorTests.cs @@ -0,0 +1,50 @@ +using AiService.Application.Chat; + +namespace AiService.Tests; + +public sealed class StreamingNarrativeExtractorTests +{ + [Fact] + public void Extracts_incremental_insights_as_json_streams() + { + var extractor = new StreamingNarrativeExtractor(); + ChatNarrative? last = null; + + foreach (var chunk in new[] + { + "{\"power_insights\": [\"First insight", + "\", \"Second insight\"], \"recommended_actions\": []}", + }) + { + extractor.Append(chunk); + last = extractor.TryExtractPartial() ?? last; + } + + Assert.NotNull(last); + Assert.Equal(2, last.PowerInsights.Count); + Assert.Equal("First insight", last.PowerInsights[0]); + Assert.Equal("Second insight", last.PowerInsights[1]); + } + + [Fact] + public void Does_not_re_emit_unchanged_partial() + { + var extractor = new StreamingNarrativeExtractor(); + extractor.Append("{\"power_insights\": [\"Only one\"]}"); + Assert.NotNull(extractor.TryExtractPartial()); + Assert.Null(extractor.TryExtractPartial()); + } + + [Fact] + public void Extracts_actions_after_insights_complete() + { + var extractor = new StreamingNarrativeExtractor(); + extractor.Append("{\"power_insights\": [\"Insight\"], \"recommended_actions\": [\"Action"); + Assert.NotNull(extractor.TryExtractPartial()); + extractor.Append(" one\"]}"); + var partial = extractor.TryExtractPartial(); + Assert.NotNull(partial); + Assert.Single(partial.RecommendedActions); + Assert.Equal("Action one", partial.RecommendedActions[0]); + } +} diff --git a/services/AiService/tests/AiService.Tests/StructuredCompletionStreamingTests.cs b/services/AiService/tests/AiService.Tests/StructuredCompletionStreamingTests.cs new file mode 100644 index 00000000..276d7667 --- /dev/null +++ b/services/AiService/tests/AiService.Tests/StructuredCompletionStreamingTests.cs @@ -0,0 +1,82 @@ +using System.Runtime.CompilerServices; +using System.Text.Json.Nodes; +using AiService.Providers.Chat; +using Microsoft.Extensions.AI; + +namespace AiService.Tests; + +public sealed class StructuredCompletionStreamingTests +{ + [Fact] + public async Task CompleteJsonStreamingAsync_emits_tokens_and_parses_json() + { + var factory = new FakeChatClientFactory(["{\"power_", "insights\": [\"Hello\"]}"]); + var service = new StructuredCompletionService(factory); + var tokens = new List(); + + var result = await service.CompleteJsonStreamingAsync( + "system", + "user", + new Dictionary { ["llm_provider"] = "openai", ["openai_api_key"] = "test" }, + tokens.Add); + + Assert.Equal(2, tokens.Count); + Assert.Equal("Hello", result["power_insights"]![0]!.GetValue()); + } + + [Fact] + public async Task CompleteJsonAsync_delegates_to_streaming_without_tokens() + { + var factory = new FakeChatClientFactory(["{\"ok\": true}"]); + var service = new StructuredCompletionService(factory); + + var result = await service.CompleteJsonAsync( + "system", + "user", + new Dictionary { ["llm_provider"] = "openai", ["openai_api_key"] = "test" }); + + Assert.True(result["ok"]!.GetValue()); + } + + private sealed class FakeChatClientFactory(string[] chunks) : IChatClientFactory + { + public Task CreateFromConfigAsync(CancellationToken cancellationToken = default) + => Task.FromResult(CreateClient(new Dictionary())); + + public IChatClient CreateClient(IReadOnlyDictionary cfg) + => new FakeChatClient(chunks); + } + + private sealed class FakeChatClient(string[] chunks) : IChatClient + { + public ChatClientMetadata Metadata { get; } = new("fake"); + + public void Dispose() + { + } + + public object? GetService(Type serviceType, object? serviceKey = null) + => serviceType.IsInstanceOfType(this) ? this : null; + + public Task GetResponseAsync( + IEnumerable chatMessages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + var text = string.Concat(chunks); + return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, text))); + } + + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable chatMessages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + foreach (var chunk in chunks) + { + yield return new ChatResponseUpdate(ChatRole.Assistant, chunk); + await Task.Yield(); + } + } + } +} diff --git a/services/AiService/tests/AiService.Tests/ToolCatalogTests.cs b/services/AiService/tests/AiService.Tests/ToolCatalogTests.cs new file mode 100644 index 00000000..6f206834 --- /dev/null +++ b/services/AiService/tests/AiService.Tests/ToolCatalogTests.cs @@ -0,0 +1,13 @@ +using AiService.Tools.Registry; + +namespace AiService.Tests; + +public sealed class ToolCatalogTests +{ + [Fact] + public void ToolCatalog_loads_369_tools() + { + var catalog = new ToolCatalog(); + Assert.Equal(369, catalog.ToolDefinitions.Count); + } +} diff --git a/services/AiService/tests/AiService.Tests/ToolConcurrencyTests.cs b/services/AiService/tests/AiService.Tests/ToolConcurrencyTests.cs new file mode 100644 index 00000000..b176e21e --- /dev/null +++ b/services/AiService/tests/AiService.Tests/ToolConcurrencyTests.cs @@ -0,0 +1,34 @@ +using System.Text.Json.Nodes; +using AiService.Application.Chat; + +namespace AiService.Tests; + +public sealed class ToolConcurrencyTests +{ + [Fact] + public async Task MapParallelAsync_preserves_order_with_bounded_workers() + { + var started = 0; + var maxInFlight = 0; + var gate = new object(); + + var factories = Enumerable.Range(0, 8) + .Select>>(i => async () => + { + var current = Interlocked.Increment(ref started); + lock (gate) + { + maxInFlight = Math.Max(maxInFlight, current); + } + + await Task.Delay(20); + Interlocked.Decrement(ref started); + return i; + }) + .ToList(); + + var results = await ToolConcurrency.MapParallelAsync(factories, maxWorkers: 2); + Assert.Equal(Enumerable.Range(0, 8), results); + Assert.True(maxInFlight <= 2); + } +} diff --git a/services/AiService/tests/AiService.Tests/ToolResultCompactorTests.cs b/services/AiService/tests/AiService.Tests/ToolResultCompactorTests.cs new file mode 100644 index 00000000..f68c5aee --- /dev/null +++ b/services/AiService/tests/AiService.Tests/ToolResultCompactorTests.cs @@ -0,0 +1,68 @@ +using System.Text.Json.Nodes; +using AiService.Application.Chat; + +namespace AiService.Tests; + +public sealed class ToolResultCompactorTests +{ + [Fact] + public void CompactForLlm_truncates_large_issue_lists() + { + var issues = new JsonArray(); + for (var i = 0; i < 500; i++) + { + issues.Add(new JsonObject { ["url"] = $"https://example.com/{i}" }); + } + + var full = new JsonObject + { + ["issues"] = issues, + ["total"] = 500, + }; + + var compact = ToolResultCompactor.CompactForLlm("list_issues", full); + var compactIssues = compact["issues"] as JsonArray; + Assert.NotNull(compactIssues); + Assert.True(compactIssues!.Count <= ToolResultCompactor.DefaultLlmListLimit); + Assert.True(compact["truncated"]?.GetValue()); + } + + [Fact] + public void CompactForUi_keeps_export_artifact_fields() + { + var full = new JsonObject + { + ["artifact_id"] = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + ["format"] = "pdf", + ["filename"] = "audit.pdf", + ["ready"] = true, + ["bytes"] = new JsonArray(Enumerable.Range(0, 1000).Select(i => (JsonNode?)JsonValue.Create(i)).ToArray()), + }; + + var compact = ToolResultCompactor.CompactForUi("export_audit_report", full); + Assert.Equal("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", compact["artifact_id"]?.GetValue()); + Assert.Null(compact["bytes"]); + } + + [Fact] + public void CompactForLlm_summarizes_workflow_steps() + { + var full = new JsonObject + { + ["workflow"] = "technical", + ["steps"] = new JsonArray + { + new JsonObject + { + ["tool"] = "get_report_summary", + ["result"] = new JsonObject { ["health_score"] = 82 }, + }, + }, + }; + + var compact = ToolResultCompactor.CompactForLlm("run_technical_workflow", full); + var steps = compact["steps"] as JsonArray; + Assert.NotNull(steps); + Assert.Single(steps!); + } +} diff --git a/services/AiService/tests/AiService.Tests/UnitTest1.cs b/services/AiService/tests/AiService.Tests/UnitTest1.cs new file mode 100644 index 00000000..86b8afbf --- /dev/null +++ b/services/AiService/tests/AiService.Tests/UnitTest1.cs @@ -0,0 +1,10 @@ +namespace AiService.Tests; + +public class UnitTest1 +{ + [Fact] + public void Test1() + { + + } +} diff --git a/services/Bff/src/Bff.Api/Endpoints/ProxyEndpoints.cs b/services/Bff/src/Bff.Api/Endpoints/ProxyEndpoints.cs index 9ada56e8..50b1131c 100644 --- a/services/Bff/src/Bff.Api/Endpoints/ProxyEndpoints.cs +++ b/services/Bff/src/Bff.Api/Endpoints/ProxyEndpoints.cs @@ -14,11 +14,17 @@ public static class ProxyEndpoints { public static void MapProxyEndpoints(this IEndpointRouteBuilder app) { - // Chat: Server-Sent Events stream to FastAPI (upstream route has a trailing slash). - app.MapPost("/api/chat", (HttpContext ctx) => (IResult)new ForwardingResult( - DependencyInjection.FastApiStreamClient, - $"/api/chat/{ctx.Request.QueryString}", - disableResponseBuffering: true)); + // Chat: Server-Sent Events stream to AiService (or FastAPI when AI_ROUTES is empty). + app.MapPost("/api/chat", (HttpContext ctx) => + { + var upstream = ctx.RequestServices.GetRequiredService>().Value; + var useAi = upstream.AiRoutes.Any(prefix => + "/api/chat".StartsWith(prefix.TrimEnd('/'), StringComparison.OrdinalIgnoreCase) + || prefix.Equals("/api/chat", StringComparison.OrdinalIgnoreCase)); + var client = useAi ? DependencyInjection.AiStreamClient : DependencyInjection.FastApiStreamClient; + var path = useAi ? $"/api/chat/{ctx.Request.QueryString}" : $"/api/chat/{ctx.Request.QueryString}"; + return (IResult)new ForwardingResult(client, path, disableResponseBuffering: true); + }); // Report export: PDF/CSV/JSON are all rendered by the FileService (which reads Postgres // directly). A missing format defaults to csv (matches the old Python default); any other @@ -70,6 +76,9 @@ public static void MapProxyEndpoints(this IEndpointRouteBuilder app) var upstream = ctx.RequestServices.GetRequiredService>().Value; var matchesDataRoute = upstream.DataRoutes.Any(prefix => path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)); + var matchesIntegrationsRoute = MatchesIntegrationsRoute(path, upstream.IntegrationsRoutes); + var matchesAiRoute = upstream.AiRoutes.Any(prefix => + path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)); var toData = !streaming && matchesDataRoute && (HttpMethods.IsGet(ctx.Request.Method) @@ -77,10 +86,32 @@ public static void MapProxyEndpoints(this IEndpointRouteBuilder app) || HttpMethods.IsPost(ctx.Request.Method) || HttpMethods.IsPut(ctx.Request.Method) || HttpMethods.IsDelete(ctx.Request.Method)); + var toIntegrations = !streaming + && !toData + && matchesIntegrationsRoute + && (HttpMethods.IsGet(ctx.Request.Method) + || HttpMethods.IsHead(ctx.Request.Method) + || HttpMethods.IsPost(ctx.Request.Method) + || HttpMethods.IsPut(ctx.Request.Method) + || HttpMethods.IsPatch(ctx.Request.Method) + || HttpMethods.IsDelete(ctx.Request.Method)); + var toAi = !streaming + && !toData + && !toIntegrations + && matchesAiRoute + && (HttpMethods.IsGet(ctx.Request.Method) + || HttpMethods.IsHead(ctx.Request.Method) + || HttpMethods.IsPost(ctx.Request.Method) + || HttpMethods.IsPut(ctx.Request.Method) + || HttpMethods.IsDelete(ctx.Request.Method)); var client = toData ? DependencyInjection.DataClient - : streaming ? DependencyInjection.FastApiStreamClient : DependencyInjection.FastApiClient; + : toIntegrations + ? DependencyInjection.IntegrationsClient + : toAi + ? DependencyInjection.AiClient + : streaming ? DependencyInjection.FastApiStreamClient : DependencyInjection.FastApiClient; return (IResult)new ForwardingResult( client, @@ -140,4 +171,38 @@ public static void MapProxyEndpoints(this IEndpointRouteBuilder app) private static string Defaulted(string value, string fallback) => string.IsNullOrEmpty(value) ? fallback : value; + + private static readonly string[] IntegrationsFastApiFallbackPaths = + [ + "/api/integrations/google/credentials", + "/api/integrations/google/keywords/expand", + "/api/integrations/google/keywords/planner", + ]; + + private static bool MatchesIntegrationsRoute(string path, string[] routes) + { + if (routes.Length == 0) + { + return false; + } + + foreach (var fallback in IntegrationsFastApiFallbackPaths) + { + if (path.StartsWith(fallback, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + } + + foreach (var prefix in routes) + { + if (path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return path.StartsWith("/api/properties/", StringComparison.OrdinalIgnoreCase) + && path.Contains("/google", StringComparison.OrdinalIgnoreCase); + } } diff --git a/services/Bff/src/Bff.Api/appsettings.Development.json b/services/Bff/src/Bff.Api/appsettings.Development.json index 308b3b5a..67bacf00 100644 --- a/services/Bff/src/Bff.Api/appsettings.Development.json +++ b/services/Bff/src/Bff.Api/appsettings.Development.json @@ -1,6 +1,8 @@ { "Upstream": { "DataBaseUrl": "http://127.0.0.1:8091", + "IntegrationsBaseUrl": "http://127.0.0.1:8093", + "AiBaseUrl": "http://127.0.0.1:8092", "DataRoutes": [ "/api/report/meta", "/api/report/payload", @@ -11,6 +13,25 @@ "/api/portfolio", "/api/issues/status", "/api/filters" + ], + "IntegrationsRoutes": [ + "/api/integrations/google", + "/api/integrations/bing" + ], + "AiRoutes": [ + "/api/chat", + "/api/links/page-coach", + "/api/issues/fix-suggestion", + "/api/issues/action-plan", + "/api/ai/fix-suggestion", + "/api/dashboards/ai-generate", + "/api/content/analyze", + "/api/content/wizard", + "/api/llm-config", + "/api/secrets", + "/api/ollama/status", + "/api/report/audit-tool", + "/api/mcp-tools" ] }, "Logging": { diff --git a/services/Bff/src/Bff.Application/DependencyInjection.cs b/services/Bff/src/Bff.Application/DependencyInjection.cs index 668173d2..9160d069 100644 --- a/services/Bff/src/Bff.Application/DependencyInjection.cs +++ b/services/Bff/src/Bff.Application/DependencyInjection.cs @@ -18,6 +18,15 @@ public static class DependencyInjection /// Named HttpClient for the internal Data service (direct-Postgres reads) — idempotent retry. public const string DataClient = "data"; + /// Named HttpClient for the internal Ai service — idempotent retry. + public const string AiClient = "ai"; + + /// Named HttpClient for the internal Integrations service — idempotent retry. + public const string IntegrationsClient = "integrations"; + + /// Named HttpClient for Ai service streaming (chat SSE) — no retry/buffering. + public const string AiStreamClient = "ai-stream"; + public static IServiceCollection AddBffApplication(this IServiceCollection services) { services.AddOptions() @@ -45,6 +54,28 @@ public static IServiceCollection AddBffApplication(this IServiceCollection servi o.DataRoutes = dataRoutes .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); } + var ai = Environment.GetEnvironmentVariable("AI_SERVICE_URL"); + if (!string.IsNullOrWhiteSpace(ai)) + { + o.AiBaseUrl = ai.Trim(); + } + var aiRoutes = Environment.GetEnvironmentVariable("AI_ROUTES"); + if (!string.IsNullOrWhiteSpace(aiRoutes)) + { + o.AiRoutes = aiRoutes + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + } + var integrations = Environment.GetEnvironmentVariable("INTEGRATIONS_SERVICE_URL"); + if (!string.IsNullOrWhiteSpace(integrations)) + { + o.IntegrationsBaseUrl = integrations.Trim(); + } + var integrationsRoutes = Environment.GetEnvironmentVariable("INTEGRATIONS_ROUTES"); + if (!string.IsNullOrWhiteSpace(integrationsRoutes)) + { + o.IntegrationsRoutes = integrationsRoutes + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + } }); services.AddOptions() @@ -123,6 +154,31 @@ public static IServiceCollection AddBffApplication(this IServiceCollection servi }) .AddHttpMessageHandler(); + services.AddHttpClient(AiClient) + .ConfigureHttpClient((sp, client) => + { + var opts = GetUpstream(sp); + client.BaseAddress = NormalizeBase(opts.AiBaseUrl); + client.Timeout = TimeSpan.FromSeconds(Math.Max(5, opts.TimeoutSeconds)); + }) + .AddHttpMessageHandler(); + + services.AddHttpClient(IntegrationsClient) + .ConfigureHttpClient((sp, client) => + { + var opts = GetUpstream(sp); + client.BaseAddress = NormalizeBase(opts.IntegrationsBaseUrl); + client.Timeout = TimeSpan.FromSeconds(Math.Max(5, opts.TimeoutSeconds)); + }) + .AddHttpMessageHandler(); + + services.AddHttpClient(AiStreamClient) + .ConfigureHttpClient((sp, client) => + { + client.BaseAddress = NormalizeBase(GetUpstream(sp).AiBaseUrl); + client.Timeout = Timeout.InfiniteTimeSpan; + }); + services.AddHttpClient(FastApiStreamClient) .ConfigureHttpClient((sp, client) => { diff --git a/services/Bff/src/Bff.Application/Generated/FastApiClient.g.cs b/services/Bff/src/Bff.Application/Generated/FastApiClient.g.cs index afe2dcdb..570bb2e2 100644 --- a/services/Bff/src/Bff.Application/Generated/FastApiClient.g.cs +++ b/services/Bff/src/Bff.Application/Generated/FastApiClient.g.cs @@ -38,46 +38,6 @@ public partial interface IFastApiClient /// A server side error occurred. System.Threading.Tasks.Task Health_check_api_health_getAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Report Meta - /// - /// Successful Response - /// A server side error occurred. - System.Threading.Tasks.Task Report_meta_api_report_meta_getAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Report Payload - /// - /// Successful Response - /// A server side error occurred. - System.Threading.Tasks.Task Report_payload_api_report_payload_getAsync(Reportid? reportId = null, Domain? domain = null, Section? section = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Report History - /// - /// Successful Response - /// A server side error occurred. - System.Threading.Tasks.Task Report_history_api_report_history_getAsync(Propertyid? propertyId = null, Anonymous? domain = null, int? limit = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Crawl Payload - /// - /// Successful Response - /// A server side error occurred. - System.Threading.Tasks.Task Crawl_payload_api_report_crawl_payload_getAsync(Crawlrunid? crawlRunId = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Mobile Delta - /// - /// Successful Response - /// A server side error occurred. - System.Threading.Tasks.Task Mobile_delta_api_report_mobile_delta_getAsync(Id? id = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// /// Run Pipeline @@ -126,62 +86,6 @@ public partial interface IFastApiClient /// A server side error occurred. System.Threading.Tasks.Task Resume_pipeline_job_api_jobs__job_id__resume_postAsync(string job_id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Chat Turn - /// - /// Successful Response - /// A server side error occurred. - System.Threading.Tasks.Task Chat_turn_api_chat__postAsync(ChatRequest body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// List Sessions - /// - /// Successful Response - /// A server side error occurred. - System.Threading.Tasks.Task List_sessions_api_chat_sessions_getAsync(int propertyId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Create Session - /// - /// Successful Response - /// A server side error occurred. - System.Threading.Tasks.Task Create_session_api_chat_sessions_postAsync(ChatSessionCreate body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Get Session Route - /// - /// Successful Response - /// A server side error occurred. - System.Threading.Tasks.Task Get_session_route_api_chat_sessions__session_id__getAsync(int session_id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Delete Session Route - /// - /// Successful Response - /// A server side error occurred. - System.Threading.Tasks.Task Delete_session_route_api_chat_sessions__session_id__deleteAsync(int session_id, int propertyId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Get Session Messages - /// - /// Successful Response - /// A server side error occurred. - System.Threading.Tasks.Task Get_session_messages_api_chat_sessions__session_id__messages_getAsync(int session_id, int propertyId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Get Artifact - /// - /// Successful Response - /// A server side error occurred. - System.Threading.Tasks.Task Get_artifact_api_chat_artifacts__artifact_id__getAsync(string artifact_id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// /// Browser Status Check @@ -204,7 +108,7 @@ public partial interface IFastApiClient /// Crawl run ID /// Successful Response /// A server side error occurred. - System.Threading.Tasks.Task Get_page_html_api_crawl_page_html_getAsync(string url, Anonymous2? crawlRunId = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + System.Threading.Tasks.Task Get_page_html_api_crawl_page_html_getAsync(string url, Crawlrunid? crawlRunId = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// @@ -222,38 +126,6 @@ public partial interface IFastApiClient /// A server side error occurred. System.Threading.Tasks.Task Put_pipeline_config_api_pipeline_config_putAsync(PipelineConfigBody body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Get Llm Config - /// - /// Successful Response - /// A server side error occurred. - System.Threading.Tasks.Task Get_llm_config_api_llm_config_getAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Put Llm Config - /// - /// Successful Response - /// A server side error occurred. - System.Threading.Tasks.Task Put_llm_config_api_llm_config_putAsync(LlmConfigBody body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Get Secrets - /// - /// Successful Response - /// A server side error occurred. - System.Threading.Tasks.Task Get_secrets_api_secrets_getAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Put Secrets - /// - /// Successful Response - /// A server side error occurred. - System.Threading.Tasks.Task Put_secrets_api_secrets_putAsync(SecretsBody body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// /// Get App Setting @@ -287,6 +159,17 @@ public partial interface IFastApiClient /// A server side error occurred. System.Threading.Tasks.Task Create_property_api_properties_postAsync(PropertyUpsertBody body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. + /// + /// Ensure Property + /// + /// + /// Create a property row when the URL is complete (OAuth / explicit actions only). + /// + /// Successful Response + /// A server side error occurred. + System.Threading.Tasks.Task Ensure_property_api_properties_ensure_postAsync(PropertyEnsureBody body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// /// Resolve Property @@ -470,31 +353,6 @@ public partial interface IFastApiClient /// A server side error occurred. System.Threading.Tasks.Task Dashboards_ai_generate_api_dashboards_ai_generate_postAsync(DashboardAiGenerateBody body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// List Filters - /// - /// Property ID - /// Successful Response - /// A server side error occurred. - System.Threading.Tasks.Task List_filters_api_filters_getAsync(int propertyId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Upsert Filter - /// - /// Successful Response - /// A server side error occurred. - System.Threading.Tasks.Task Upsert_filter_api_filters_postAsync(FilterUpsertBody body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Delete Filter - /// - /// Successful Response - /// A server side error occurred. - System.Threading.Tasks.Task Delete_filter_api_filters_deleteAsync(FilterDeleteBody body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// /// Get Google Credentials @@ -506,14 +364,6 @@ public partial interface IFastApiClient /// A server side error occurred. System.Threading.Tasks.Task Get_google_credentials_api_integrations_google_credentials_getAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Save Google Credentials - /// - /// Successful Response - /// A server side error occurred. - System.Threading.Tasks.Task Save_google_credentials_api_integrations_google_credentials_postAsync(object? body = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// /// Google Status @@ -522,21 +372,10 @@ public partial interface IFastApiClient /// A server side error occurred. System.Threading.Tasks.Task Google_status_api_integrations_google_status_getAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Upload Google Credentials - /// - /// Successful Response - /// A server side error occurred. - System.Threading.Tasks.Task Upload_google_credentials_api_integrations_google_credentials_upload_postAsync(object? body = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// /// Google Disconnect /// - /// - /// Global disconnect is deprecated — use per-property disconnect. - /// /// Successful Response /// A server side error occurred. System.Threading.Tasks.Task Google_disconnect_api_integrations_google_disconnect_postAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); @@ -547,7 +386,7 @@ public partial interface IFastApiClient /// /// Successful Response /// A server side error occurred. - System.Threading.Tasks.Task Google_oauth_start_api_integrations_google_auth_getAsync(Anonymous3? propertyId = null, Starturl? startUrl = null, Returnto? returnTo = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + System.Threading.Tasks.Task Google_oauth_start_api_integrations_google_auth_getAsync(Propertyid? propertyId = null, Starturl? startUrl = null, Returnto? returnTo = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// @@ -561,20 +400,14 @@ public partial interface IFastApiClient /// /// Google Properties Deprecated /// - /// - /// Deprecated — use /api/properties/{id}/google/properties. - /// /// Successful Response /// A server side error occurred. - System.Threading.Tasks.Task Google_properties_deprecated_api_integrations_google_properties_getAsync(Anonymous4? propertyId = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + System.Threading.Tasks.Task Google_properties_deprecated_api_integrations_google_properties_getAsync(Anonymous? propertyId = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// /// Google Test /// - /// - /// Run `python -m src google --test` and return stdout log. - /// /// Successful Response /// A server side error occurred. System.Threading.Tasks.Task Google_test_api_integrations_google_test_postAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); @@ -585,7 +418,7 @@ public partial interface IFastApiClient /// /// Successful Response /// A server side error occurred. - System.Threading.Tasks.Task Google_page_data_api_integrations_google_page_data_getAsync(string url, Googlesnapshotid? googleSnapshotId = null, Anonymous5? propertyId = null, Anonymous6? domain = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + System.Threading.Tasks.Task Google_page_data_api_integrations_google_page_data_getAsync(string url, Googlesnapshotid? googleSnapshotId = null, Anonymous2? propertyId = null, Domain? domain = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// @@ -593,7 +426,7 @@ public partial interface IFastApiClient /// /// Successful Response /// A server side error occurred. - System.Threading.Tasks.Task Google_page_data_history_api_integrations_google_page_data_history_getAsync(string url, Anonymous7? propertyId = null, Anonymous8? domain = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + System.Threading.Tasks.Task Google_page_data_history_api_integrations_google_page_data_history_getAsync(string url, Anonymous3? propertyId = null, Anonymous4? domain = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// @@ -609,7 +442,7 @@ public partial interface IFastApiClient /// /// Successful Response /// A server side error occurred. - System.Threading.Tasks.Task Google_keywords_by_page_api_integrations_google_keywords_by_page_getAsync(string url, Anonymous9? propertyId = null, Anonymous10? domain = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + System.Threading.Tasks.Task Google_keywords_by_page_api_integrations_google_keywords_by_page_getAsync(string url, Anonymous5? propertyId = null, Anonymous6? domain = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// @@ -617,15 +450,12 @@ public partial interface IFastApiClient /// /// Successful Response /// A server side error occurred. - System.Threading.Tasks.Task Google_keywords_history_api_integrations_google_keywords_history_getAsync(string keyword, Anonymous11? propertyId = null, Anonymous12? domain = null, int? limit = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + System.Threading.Tasks.Task Google_keywords_history_api_integrations_google_keywords_history_getAsync(string keyword, Anonymous7? propertyId = null, Anonymous8? domain = null, int? limit = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// /// Bing Sync /// - /// - /// Fetch Bing Webmaster backlinks summary using config from DB. - /// /// Successful Response /// A server side error occurred. System.Threading.Tasks.Task Bing_sync_api_integrations_bing_sync_postAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); @@ -634,9 +464,6 @@ public partial interface IFastApiClient /// /// Google Page Compare /// - /// - /// Compare two page Google data snapshots. - /// /// Successful Response /// A server side error occurred. System.Threading.Tasks.Task Google_page_compare_api_integrations_google_page_compare_getAsync(string url, int currentId, int baselineId, string? currentType = null, string? baselineType = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); @@ -645,9 +472,6 @@ public partial interface IFastApiClient /// /// Google Page Live History /// - /// - /// Return history of page Google snapshots for a URL. - /// /// Successful Response /// A server side error occurred. System.Threading.Tasks.Task Google_page_live_history_api_integrations_google_page_live_history_getAsync(string url, int? limit = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); @@ -656,9 +480,6 @@ public partial interface IFastApiClient /// /// Google Keywords History Batch /// - /// - /// Batch keyword history: { keywords: str[], limit?: int, propertyId?: int, domain?: str } - /// /// Successful Response /// A server side error occurred. System.Threading.Tasks.Task Google_keywords_history_batch_api_integrations_google_keywords_history_batch_postAsync(object body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); @@ -687,43 +508,19 @@ public partial interface IFastApiClient /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// List Issue Status Route - /// - /// Successful Response - /// A server side error occurred. - System.Threading.Tasks.Task List_issue_status_route_api_issues_status_getAsync(int propertyId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Upsert Issue Status Route - /// - /// Successful Response - /// A server side error occurred. - System.Threading.Tasks.Task Upsert_issue_status_route_api_issues_status_putAsync(object? body = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Issues Fix Suggestion - /// - /// Successful Response - /// A server side error occurred. - System.Threading.Tasks.Task Issues_fix_suggestion_api_issues_fix_suggestion_postAsync(object? body = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Issues Action Plan + /// Internal Keyword Enrich /// /// Successful Response /// A server side error occurred. - System.Threading.Tasks.Task Issues_action_plan_api_issues_action_plan_postAsync(object? body = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + System.Threading.Tasks.Task Internal_keyword_enrich_internal_integrations_keywords_enrich_postAsync(object body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Ai Fix Suggestion + /// Internal Gsc Links Import /// /// Successful Response /// A server side error occurred. - System.Threading.Tasks.Task Ai_fix_suggestion_api_ai_fix_suggestion_postAsync(object? body = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + System.Threading.Tasks.Task Internal_gsc_links_import_internal_integrations_gsc_links_import_postAsync(object body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// @@ -867,31 +664,7 @@ public partial interface IFastApiClient /// /// Successful Response /// A server side error occurred. - System.Threading.Tasks.Task Page_markdown_runs_route_api_page_markdown_runs_getAsync(Anonymous13? propertyId = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Ollama Status - /// - /// Successful Response - /// A server side error occurred. - System.Threading.Tasks.Task Ollama_status_api_ollama_status_getAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Mcp Tools - /// - /// Successful Response - /// A server side error occurred. - System.Threading.Tasks.Task Mcp_tools_api_mcp_tools_getAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Delete Portfolio Item - /// - /// Successful Response - /// A server side error occurred. - System.Threading.Tasks.Task Delete_portfolio_item_api_portfolio_delete_deleteAsync(DeletePortfolioBody body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); + System.Threading.Tasks.Task Page_markdown_runs_route_api_page_markdown_runs_getAsync(Anonymous9? propertyId = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// @@ -925,14 +698,6 @@ public partial interface IFastApiClient /// A server side error occurred. System.Threading.Tasks.Task Compare_export_api_compare_export_postAsync(CompareExportBody body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Page Coach - /// - /// Successful Response - /// A server side error occurred. - System.Threading.Tasks.Task Page_coach_api_links_page_coach_postAsync(PageCoachBody body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// /// Run Audit Tool @@ -941,33 +706,6 @@ public partial interface IFastApiClient /// A server side error occurred. System.Threading.Tasks.Task Run_audit_tool_api_report_audit_tool_postAsync(AuditToolBody body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Export Report - /// - /// Successful Response - /// A server side error occurred. - System.Threading.Tasks.Task Export_report_api_report_export_getAsync(string? format = null, Anonymous14? reportId = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Export Sitemap - /// - /// Successful Response - /// A server side error occurred. - System.Threading.Tasks.Task Export_sitemap_api_report_export_sitemap_getAsync(Anonymous15? reportId = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Report Portfolio - /// - /// - /// Return portfolio data — groups, crawl history, summary, or single card. - /// - /// Successful Response - /// A server side error occurred. - System.Threading.Tasks.Task Report_portfolio_api_report_portfolio_getAsync(string? widget = null, Ids? ids = null, Anonymous16? reportId = null, Anonymous17? crawlRunId = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)); - } [System.CodeDom.Compiler.GeneratedCode("NSwag", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] @@ -1078,25 +816,32 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Report Meta + /// Run Pipeline /// /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Report_meta_api_report_meta_getAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Run_pipeline_api_run_postAsync(RunPostBody body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { + if (body == null) + throw new System.ArgumentNullException("body"); + var client_ = _httpClient; var disposeClient_ = false; try { using (var request_ = new System.Net.Http.HttpRequestMessage()) { - request_.Method = new System.Net.Http.HttpMethod("GET"); + var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); + var content_ = new System.Net.Http.ByteArrayContent(json_); + content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); + request_.Content = content_; + request_.Method = new System.Net.Http.HttpMethod("POST"); request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/report/meta" - urlBuilder_.Append("api/report/meta"); + // Operation Path: "api/run" + urlBuilder_.Append("api/run"); PrepareRequest(client_, request_, urlBuilder_); @@ -1123,7 +868,7 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() var status_ = (int)response_.StatusCode; if (status_ == 200) { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); if (objectResponse_.Object == null) { throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); @@ -1131,6 +876,16 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() return objectResponse_.Object; } else + if (status_ == 422) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new FastApiClientException("Validation Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else { var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); throw new FastApiClientException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); @@ -1152,11 +907,11 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Report Payload + /// List Pipeline Jobs /// /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Report_payload_api_report_payload_getAsync(Reportid? reportId = null, Domain? domain = null, Section? section = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task List_pipeline_jobs_api_jobs_getAsync(int? limit = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { var client_ = _httpClient; var disposeClient_ = false; @@ -1169,20 +924,12 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/report/payload" - urlBuilder_.Append("api/report/payload"); + // Operation Path: "api/jobs" + urlBuilder_.Append("api/jobs"); urlBuilder_.Append('?'); - if (reportId != null) - { - urlBuilder_.Append(System.Uri.EscapeDataString("reportId")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(reportId, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - } - if (domain != null) - { - urlBuilder_.Append(System.Uri.EscapeDataString("domain")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(domain, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - } - if (section != null) + if (limit != null) { - urlBuilder_.Append(System.Uri.EscapeDataString("section")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(section, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + urlBuilder_.Append(System.Uri.EscapeDataString("limit")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(limit, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); } urlBuilder_.Length--; @@ -1211,7 +958,7 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() var status_ = (int)response_.StatusCode; if (status_ == 200) { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); if (objectResponse_.Object == null) { throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); @@ -1250,12 +997,15 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Report History + /// Get Pipeline Job /// /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Report_history_api_report_history_getAsync(Propertyid? propertyId = null, Anonymous? domain = null, int? limit = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Get_pipeline_job_api_jobs__job_id__getAsync(string job_id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { + if (job_id == null) + throw new System.ArgumentNullException("job_id"); + var client_ = _httpClient; var disposeClient_ = false; try @@ -1267,22 +1017,9 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/report/history" - urlBuilder_.Append("api/report/history"); - urlBuilder_.Append('?'); - if (propertyId != null) - { - urlBuilder_.Append(System.Uri.EscapeDataString("propertyId")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(propertyId, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - } - if (domain != null) - { - urlBuilder_.Append(System.Uri.EscapeDataString("domain")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(domain, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - } - if (limit != null) - { - urlBuilder_.Append(System.Uri.EscapeDataString("limit")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(limit, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - } - urlBuilder_.Length--; + // Operation Path: "api/jobs/{job_id}" + urlBuilder_.Append("api/jobs/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(job_id, System.Globalization.CultureInfo.InvariantCulture))); PrepareRequest(client_, request_, urlBuilder_); @@ -1348,31 +1085,31 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Crawl Payload + /// Cancel Pipeline Job /// /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Crawl_payload_api_report_crawl_payload_getAsync(Crawlrunid? crawlRunId = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Cancel_pipeline_job_api_jobs__job_id__cancel_postAsync(string job_id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { + if (job_id == null) + throw new System.ArgumentNullException("job_id"); + var client_ = _httpClient; var disposeClient_ = false; try { using (var request_ = new System.Net.Http.HttpRequestMessage()) { - request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Content = new System.Net.Http.StringContent(string.Empty, System.Text.Encoding.UTF8, "application/json"); + request_.Method = new System.Net.Http.HttpMethod("POST"); request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/report/crawl-payload" - urlBuilder_.Append("api/report/crawl-payload"); - urlBuilder_.Append('?'); - if (crawlRunId != null) - { - urlBuilder_.Append(System.Uri.EscapeDataString("crawlRunId")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(crawlRunId, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - } - urlBuilder_.Length--; + // Operation Path: "api/jobs/{job_id}/cancel" + urlBuilder_.Append("api/jobs/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(job_id, System.Globalization.CultureInfo.InvariantCulture))); + urlBuilder_.Append("/cancel"); PrepareRequest(client_, request_, urlBuilder_); @@ -1399,7 +1136,7 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() var status_ = (int)response_.StatusCode; if (status_ == 200) { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); if (objectResponse_.Object == null) { throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); @@ -1438,31 +1175,31 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Mobile Delta + /// Pause Pipeline Job /// /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Mobile_delta_api_report_mobile_delta_getAsync(Id? id = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Pause_pipeline_job_api_jobs__job_id__pause_postAsync(string job_id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { + if (job_id == null) + throw new System.ArgumentNullException("job_id"); + var client_ = _httpClient; var disposeClient_ = false; try { using (var request_ = new System.Net.Http.HttpRequestMessage()) { - request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Content = new System.Net.Http.StringContent(string.Empty, System.Text.Encoding.UTF8, "application/json"); + request_.Method = new System.Net.Http.HttpMethod("POST"); request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/report/mobile-delta" - urlBuilder_.Append("api/report/mobile-delta"); - urlBuilder_.Append('?'); - if (id != null) - { - urlBuilder_.Append(System.Uri.EscapeDataString("id")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(id, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - } - urlBuilder_.Length--; + // Operation Path: "api/jobs/{job_id}/pause" + urlBuilder_.Append("api/jobs/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(job_id, System.Globalization.CultureInfo.InvariantCulture))); + urlBuilder_.Append("/pause"); PrepareRequest(client_, request_, urlBuilder_); @@ -1489,7 +1226,7 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() var status_ = (int)response_.StatusCode; if (status_ == 200) { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); if (objectResponse_.Object == null) { throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); @@ -1528,14 +1265,14 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Run Pipeline + /// Resume Pipeline Job /// /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Run_pipeline_api_run_postAsync(RunPostBody body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Resume_pipeline_job_api_jobs__job_id__resume_postAsync(string job_id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { - if (body == null) - throw new System.ArgumentNullException("body"); + if (job_id == null) + throw new System.ArgumentNullException("job_id"); var client_ = _httpClient; var disposeClient_ = false; @@ -1543,17 +1280,16 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() { using (var request_ = new System.Net.Http.HttpRequestMessage()) { - var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); - var content_ = new System.Net.Http.ByteArrayContent(json_); - content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); - request_.Content = content_; + request_.Content = new System.Net.Http.StringContent(string.Empty, System.Text.Encoding.UTF8, "application/json"); request_.Method = new System.Net.Http.HttpMethod("POST"); request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/run" - urlBuilder_.Append("api/run"); + // Operation Path: "api/jobs/{job_id}/resume" + urlBuilder_.Append("api/jobs/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(job_id, System.Globalization.CultureInfo.InvariantCulture))); + urlBuilder_.Append("/resume"); PrepareRequest(client_, request_, urlBuilder_); @@ -1580,7 +1316,7 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() var status_ = (int)response_.StatusCode; if (status_ == 200) { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); if (objectResponse_.Object == null) { throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); @@ -1619,11 +1355,14 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// List Pipeline Jobs + /// Browser Status Check /// + /// + /// Return whether Playwright + Chromium are available. + /// /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task List_pipeline_jobs_api_jobs_getAsync(int? limit = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Browser_status_check_api_crawl_browser_status_getAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { var client_ = _httpClient; var disposeClient_ = false; @@ -1636,14 +1375,8 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/jobs" - urlBuilder_.Append("api/jobs"); - urlBuilder_.Append('?'); - if (limit != null) - { - urlBuilder_.Append(System.Uri.EscapeDataString("limit")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(limit, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - } - urlBuilder_.Length--; + // Operation Path: "api/crawl/browser-status" + urlBuilder_.Append("api/crawl/browser-status"); PrepareRequest(client_, request_, urlBuilder_); @@ -1670,7 +1403,7 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() var status_ = (int)response_.StatusCode; if (status_ == 200) { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); if (objectResponse_.Object == null) { throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); @@ -1678,16 +1411,6 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() return objectResponse_.Object; } else - if (status_ == 422) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - throw new FastApiClientException("Validation Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); - } - else { var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); throw new FastApiClientException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); @@ -1709,14 +1432,19 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Get Pipeline Job + /// Get Page Html /// + /// + /// Return stored HTML and metadata for a URL within a crawl run. + /// + /// Page URL to retrieve stored HTML for + /// Crawl run ID /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Get_pipeline_job_api_jobs__job_id__getAsync(string job_id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Get_page_html_api_crawl_page_html_getAsync(string url, Crawlrunid? crawlRunId = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { - if (job_id == null) - throw new System.ArgumentNullException("job_id"); + if (url == null) + throw new System.ArgumentNullException("url"); var client_ = _httpClient; var disposeClient_ = false; @@ -1729,9 +1457,15 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/jobs/{job_id}" - urlBuilder_.Append("api/jobs/"); - urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(job_id, System.Globalization.CultureInfo.InvariantCulture))); + // Operation Path: "api/crawl/page-html" + urlBuilder_.Append("api/crawl/page-html"); + urlBuilder_.Append('?'); + urlBuilder_.Append(System.Uri.EscapeDataString("url")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(url, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + if (crawlRunId != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("crawlRunId")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(crawlRunId, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + urlBuilder_.Length--; PrepareRequest(client_, request_, urlBuilder_); @@ -1797,31 +1531,25 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Cancel Pipeline Job + /// Get Pipeline Config /// /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Cancel_pipeline_job_api_jobs__job_id__cancel_postAsync(string job_id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Get_pipeline_config_api_pipeline_config_getAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { - if (job_id == null) - throw new System.ArgumentNullException("job_id"); - var client_ = _httpClient; var disposeClient_ = false; try { using (var request_ = new System.Net.Http.HttpRequestMessage()) { - request_.Content = new System.Net.Http.StringContent(string.Empty, System.Text.Encoding.UTF8, "application/json"); - request_.Method = new System.Net.Http.HttpMethod("POST"); + request_.Method = new System.Net.Http.HttpMethod("GET"); request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/jobs/{job_id}/cancel" - urlBuilder_.Append("api/jobs/"); - urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(job_id, System.Globalization.CultureInfo.InvariantCulture))); - urlBuilder_.Append("/cancel"); + // Operation Path: "api/pipeline-config" + urlBuilder_.Append("api/pipeline-config"); PrepareRequest(client_, request_, urlBuilder_); @@ -1848,7 +1576,7 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() var status_ = (int)response_.StatusCode; if (status_ == 200) { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); if (objectResponse_.Object == null) { throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); @@ -1856,16 +1584,6 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() return objectResponse_.Object; } else - if (status_ == 422) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - throw new FastApiClientException("Validation Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); - } - else { var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); throw new FastApiClientException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); @@ -1887,14 +1605,14 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Pause Pipeline Job + /// Put Pipeline Config /// /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Pause_pipeline_job_api_jobs__job_id__pause_postAsync(string job_id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Put_pipeline_config_api_pipeline_config_putAsync(PipelineConfigBody body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { - if (job_id == null) - throw new System.ArgumentNullException("job_id"); + if (body == null) + throw new System.ArgumentNullException("body"); var client_ = _httpClient; var disposeClient_ = false; @@ -1902,16 +1620,17 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() { using (var request_ = new System.Net.Http.HttpRequestMessage()) { - request_.Content = new System.Net.Http.StringContent(string.Empty, System.Text.Encoding.UTF8, "application/json"); - request_.Method = new System.Net.Http.HttpMethod("POST"); + var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); + var content_ = new System.Net.Http.ByteArrayContent(json_); + content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); + request_.Content = content_; + request_.Method = new System.Net.Http.HttpMethod("PUT"); request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/jobs/{job_id}/pause" - urlBuilder_.Append("api/jobs/"); - urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(job_id, System.Globalization.CultureInfo.InvariantCulture))); - urlBuilder_.Append("/pause"); + // Operation Path: "api/pipeline-config" + urlBuilder_.Append("api/pipeline-config"); PrepareRequest(client_, request_, urlBuilder_); @@ -1938,7 +1657,7 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() var status_ = (int)response_.StatusCode; if (status_ == 200) { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); if (objectResponse_.Object == null) { throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); @@ -1977,14 +1696,15 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Resume Pipeline Job + /// Get App Setting /// + /// Settings key to retrieve /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Resume_pipeline_job_api_jobs__job_id__resume_postAsync(string job_id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Get_app_setting_api_app_settings_getAsync(string key, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { - if (job_id == null) - throw new System.ArgumentNullException("job_id"); + if (key == null) + throw new System.ArgumentNullException("key"); var client_ = _httpClient; var disposeClient_ = false; @@ -1992,16 +1712,16 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() { using (var request_ = new System.Net.Http.HttpRequestMessage()) { - request_.Content = new System.Net.Http.StringContent(string.Empty, System.Text.Encoding.UTF8, "application/json"); - request_.Method = new System.Net.Http.HttpMethod("POST"); + request_.Method = new System.Net.Http.HttpMethod("GET"); request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/jobs/{job_id}/resume" - urlBuilder_.Append("api/jobs/"); - urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(job_id, System.Globalization.CultureInfo.InvariantCulture))); - urlBuilder_.Append("/resume"); + // Operation Path: "api/app-settings" + urlBuilder_.Append("api/app-settings"); + urlBuilder_.Append('?'); + urlBuilder_.Append(System.Uri.EscapeDataString("key")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(key, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + urlBuilder_.Length--; PrepareRequest(client_, request_, urlBuilder_); @@ -2028,7 +1748,7 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() var status_ = (int)response_.StatusCode; if (status_ == 200) { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); if (objectResponse_.Object == null) { throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); @@ -2067,11 +1787,11 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Chat Turn + /// Put App Setting /// /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Chat_turn_api_chat__postAsync(ChatRequest body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Put_app_setting_api_app_settings_putAsync(AppSettingBody body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { if (body == null) throw new System.ArgumentNullException("body"); @@ -2086,13 +1806,13 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() var content_ = new System.Net.Http.ByteArrayContent(json_); content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); request_.Content = content_; - request_.Method = new System.Net.Http.HttpMethod("POST"); + request_.Method = new System.Net.Http.HttpMethod("PUT"); request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/chat/" - urlBuilder_.Append("api/chat/"); + // Operation Path: "api/app-settings" + urlBuilder_.Append("api/app-settings"); PrepareRequest(client_, request_, urlBuilder_); @@ -2158,15 +1878,12 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// List Sessions + /// List Properties /// /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task List_sessions_api_chat_sessions_getAsync(int propertyId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task List_properties_api_properties_getAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { - if (propertyId == null) - throw new System.ArgumentNullException("propertyId"); - var client_ = _httpClient; var disposeClient_ = false; try @@ -2178,11 +1895,8 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/chat/sessions" - urlBuilder_.Append("api/chat/sessions"); - urlBuilder_.Append('?'); - urlBuilder_.Append(System.Uri.EscapeDataString("propertyId")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(propertyId, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - urlBuilder_.Length--; + // Operation Path: "api/properties" + urlBuilder_.Append("api/properties"); PrepareRequest(client_, request_, urlBuilder_); @@ -2217,16 +1931,6 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() return objectResponse_.Object; } else - if (status_ == 422) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - throw new FastApiClientException("Validation Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); - } - else { var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); throw new FastApiClientException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); @@ -2248,11 +1952,11 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Create Session + /// Create Property /// /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Create_session_api_chat_sessions_postAsync(ChatSessionCreate body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Create_property_api_properties_postAsync(PropertyUpsertBody body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { if (body == null) throw new System.ArgumentNullException("body"); @@ -2272,8 +1976,8 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/chat/sessions" - urlBuilder_.Append("api/chat/sessions"); + // Operation Path: "api/properties" + urlBuilder_.Append("api/properties"); PrepareRequest(client_, request_, urlBuilder_); @@ -2298,7 +2002,7 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() ProcessResponse(client_, response_); var status_ = (int)response_.StatusCode; - if (status_ == 200) + if (status_ == 201) { var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); if (objectResponse_.Object == null) @@ -2339,14 +2043,17 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Get Session Route + /// Ensure Property /// + /// + /// Create a property row when the URL is complete (OAuth / explicit actions only). + /// /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Get_session_route_api_chat_sessions__session_id__getAsync(int session_id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Ensure_property_api_properties_ensure_postAsync(PropertyEnsureBody body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { - if (session_id == null) - throw new System.ArgumentNullException("session_id"); + if (body == null) + throw new System.ArgumentNullException("body"); var client_ = _httpClient; var disposeClient_ = false; @@ -2354,14 +2061,17 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() { using (var request_ = new System.Net.Http.HttpRequestMessage()) { - request_.Method = new System.Net.Http.HttpMethod("GET"); + var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); + var content_ = new System.Net.Http.ByteArrayContent(json_); + content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); + request_.Content = content_; + request_.Method = new System.Net.Http.HttpMethod("POST"); request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/chat/sessions/{session_id}" - urlBuilder_.Append("api/chat/sessions/"); - urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(session_id, System.Globalization.CultureInfo.InvariantCulture))); + // Operation Path: "api/properties/ensure" + urlBuilder_.Append("api/properties/ensure"); PrepareRequest(client_, request_, urlBuilder_); @@ -2427,17 +2137,15 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Delete Session Route + /// Resolve Property /// + /// Start URL to resolve a property from /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Delete_session_route_api_chat_sessions__session_id__deleteAsync(int session_id, int propertyId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Resolve_property_api_properties_resolve_getAsync(string startUrl, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { - if (session_id == null) - throw new System.ArgumentNullException("session_id"); - - if (propertyId == null) - throw new System.ArgumentNullException("propertyId"); + if (startUrl == null) + throw new System.ArgumentNullException("startUrl"); var client_ = _httpClient; var disposeClient_ = false; @@ -2445,16 +2153,15 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() { using (var request_ = new System.Net.Http.HttpRequestMessage()) { - request_.Method = new System.Net.Http.HttpMethod("DELETE"); + request_.Method = new System.Net.Http.HttpMethod("GET"); request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/chat/sessions/{session_id}" - urlBuilder_.Append("api/chat/sessions/"); - urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(session_id, System.Globalization.CultureInfo.InvariantCulture))); + // Operation Path: "api/properties/resolve" + urlBuilder_.Append("api/properties/resolve"); urlBuilder_.Append('?'); - urlBuilder_.Append(System.Uri.EscapeDataString("propertyId")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(propertyId, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + urlBuilder_.Append(System.Uri.EscapeDataString("startUrl")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(startUrl, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); urlBuilder_.Length--; PrepareRequest(client_, request_, urlBuilder_); @@ -2521,17 +2228,14 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Get Session Messages + /// Get Property /// /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Get_session_messages_api_chat_sessions__session_id__messages_getAsync(int session_id, int propertyId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Get_property_api_properties__property_id__getAsync(int property_id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { - if (session_id == null) - throw new System.ArgumentNullException("session_id"); - - if (propertyId == null) - throw new System.ArgumentNullException("propertyId"); + if (property_id == null) + throw new System.ArgumentNullException("property_id"); var client_ = _httpClient; var disposeClient_ = false; @@ -2544,13 +2248,9 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/chat/sessions/{session_id}/messages" - urlBuilder_.Append("api/chat/sessions/"); - urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(session_id, System.Globalization.CultureInfo.InvariantCulture))); - urlBuilder_.Append("/messages"); - urlBuilder_.Append('?'); - urlBuilder_.Append(System.Uri.EscapeDataString("propertyId")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(propertyId, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - urlBuilder_.Length--; + // Operation Path: "api/properties/{property_id}" + urlBuilder_.Append("api/properties/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(property_id, System.Globalization.CultureInfo.InvariantCulture))); PrepareRequest(client_, request_, urlBuilder_); @@ -2616,14 +2316,14 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Get Artifact + /// Delete Property Route /// /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Get_artifact_api_chat_artifacts__artifact_id__getAsync(string artifact_id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Delete_property_route_api_properties__property_id__deleteAsync(int property_id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { - if (artifact_id == null) - throw new System.ArgumentNullException("artifact_id"); + if (property_id == null) + throw new System.ArgumentNullException("property_id"); var client_ = _httpClient; var disposeClient_ = false; @@ -2631,14 +2331,14 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() { using (var request_ = new System.Net.Http.HttpRequestMessage()) { - request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Method = new System.Net.Http.HttpMethod("DELETE"); request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/chat/artifacts/{artifact_id}" - urlBuilder_.Append("api/chat/artifacts/"); - urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(artifact_id, System.Globalization.CultureInfo.InvariantCulture))); + // Operation Path: "api/properties/{property_id}" + urlBuilder_.Append("api/properties/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(property_id, System.Globalization.CultureInfo.InvariantCulture))); PrepareRequest(client_, request_, urlBuilder_); @@ -2704,15 +2404,15 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Browser Status Check + /// Get Property Ops Route /// - /// - /// Return whether Playwright + Chromium are available. - /// /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Browser_status_check_api_crawl_browser_status_getAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Get_property_ops_route_api_properties__property_id__ops_getAsync(int property_id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { + if (property_id == null) + throw new System.ArgumentNullException("property_id"); + var client_ = _httpClient; var disposeClient_ = false; try @@ -2724,8 +2424,10 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/crawl/browser-status" - urlBuilder_.Append("api/crawl/browser-status"); + // Operation Path: "api/properties/{property_id}/ops" + urlBuilder_.Append("api/properties/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(property_id, System.Globalization.CultureInfo.InvariantCulture))); + urlBuilder_.Append("/ops"); PrepareRequest(client_, request_, urlBuilder_); @@ -2760,6 +2462,16 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() return objectResponse_.Object; } else + if (status_ == 422) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new FastApiClientException("Validation Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else { var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); throw new FastApiClientException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); @@ -2781,19 +2493,17 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Get Page Html + /// Update Property Ops Route /// - /// - /// Return stored HTML and metadata for a URL within a crawl run. - /// - /// Page URL to retrieve stored HTML for - /// Crawl run ID /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Get_page_html_api_crawl_page_html_getAsync(string url, Anonymous2? crawlRunId = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Update_property_ops_route_api_properties__property_id__ops_putAsync(int property_id, OpsSettingsBody body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { - if (url == null) - throw new System.ArgumentNullException("url"); + if (property_id == null) + throw new System.ArgumentNullException("property_id"); + + if (body == null) + throw new System.ArgumentNullException("body"); var client_ = _httpClient; var disposeClient_ = false; @@ -2801,20 +2511,19 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() { using (var request_ = new System.Net.Http.HttpRequestMessage()) { - request_.Method = new System.Net.Http.HttpMethod("GET"); + var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); + var content_ = new System.Net.Http.ByteArrayContent(json_); + content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); + request_.Content = content_; + request_.Method = new System.Net.Http.HttpMethod("PUT"); request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/crawl/page-html" - urlBuilder_.Append("api/crawl/page-html"); - urlBuilder_.Append('?'); - urlBuilder_.Append(System.Uri.EscapeDataString("url")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(url, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - if (crawlRunId != null) - { - urlBuilder_.Append(System.Uri.EscapeDataString("crawlRunId")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(crawlRunId, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - } - urlBuilder_.Length--; + // Operation Path: "api/properties/{property_id}/ops" + urlBuilder_.Append("api/properties/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(property_id, System.Globalization.CultureInfo.InvariantCulture))); + urlBuilder_.Append("/ops"); PrepareRequest(client_, request_, urlBuilder_); @@ -2880,12 +2589,15 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Get Pipeline Config + /// Get Property Preset /// /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Get_pipeline_config_api_pipeline_config_getAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Get_property_preset_api_properties__property_id__preset_getAsync(int property_id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { + if (property_id == null) + throw new System.ArgumentNullException("property_id"); + var client_ = _httpClient; var disposeClient_ = false; try @@ -2897,8 +2609,10 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/pipeline-config" - urlBuilder_.Append("api/pipeline-config"); + // Operation Path: "api/properties/{property_id}/preset" + urlBuilder_.Append("api/properties/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(property_id, System.Globalization.CultureInfo.InvariantCulture))); + urlBuilder_.Append("/preset"); PrepareRequest(client_, request_, urlBuilder_); @@ -2933,6 +2647,16 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() return objectResponse_.Object; } else + if (status_ == 422) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new FastApiClientException("Validation Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else { var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); throw new FastApiClientException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); @@ -2954,12 +2678,15 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Put Pipeline Config + /// Update Property Preset /// /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Put_pipeline_config_api_pipeline_config_putAsync(PipelineConfigBody body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Update_property_preset_api_properties__property_id__preset_putAsync(int property_id, PresetBody body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { + if (property_id == null) + throw new System.ArgumentNullException("property_id"); + if (body == null) throw new System.ArgumentNullException("body"); @@ -2978,8 +2705,10 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/pipeline-config" - urlBuilder_.Append("api/pipeline-config"); + // Operation Path: "api/properties/{property_id}/preset" + urlBuilder_.Append("api/properties/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(property_id, System.Globalization.CultureInfo.InvariantCulture))); + urlBuilder_.Append("/preset"); PrepareRequest(client_, request_, urlBuilder_); @@ -3045,25 +2774,31 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Get Llm Config + /// Authorize Property Crawl Route /// /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Get_llm_config_api_llm_config_getAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Authorize_property_crawl_route_api_properties__property_id__authorize_postAsync(int property_id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { + if (property_id == null) + throw new System.ArgumentNullException("property_id"); + var client_ = _httpClient; var disposeClient_ = false; try { using (var request_ = new System.Net.Http.HttpRequestMessage()) { - request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Content = new System.Net.Http.StringContent(string.Empty, System.Text.Encoding.UTF8, "application/json"); + request_.Method = new System.Net.Http.HttpMethod("POST"); request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/llm-config" - urlBuilder_.Append("api/llm-config"); + // Operation Path: "api/properties/{property_id}/authorize" + urlBuilder_.Append("api/properties/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(property_id, System.Globalization.CultureInfo.InvariantCulture))); + urlBuilder_.Append("/authorize"); PrepareRequest(client_, request_, urlBuilder_); @@ -3098,6 +2833,16 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() return objectResponse_.Object; } else + if (status_ == 422) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new FastApiClientException("Validation Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else { var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); throw new FastApiClientException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); @@ -3119,14 +2864,14 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Put Llm Config + /// Property Google Status /// /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Put_llm_config_api_llm_config_putAsync(LlmConfigBody body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Property_google_status_api_properties__property_id__google_status_getAsync(int property_id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { - if (body == null) - throw new System.ArgumentNullException("body"); + if (property_id == null) + throw new System.ArgumentNullException("property_id"); var client_ = _httpClient; var disposeClient_ = false; @@ -3134,17 +2879,15 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() { using (var request_ = new System.Net.Http.HttpRequestMessage()) { - var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); - var content_ = new System.Net.Http.ByteArrayContent(json_); - content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); - request_.Content = content_; - request_.Method = new System.Net.Http.HttpMethod("PUT"); + request_.Method = new System.Net.Http.HttpMethod("GET"); request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/llm-config" - urlBuilder_.Append("api/llm-config"); + // Operation Path: "api/properties/{property_id}/google/status" + urlBuilder_.Append("api/properties/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(property_id, System.Globalization.CultureInfo.InvariantCulture))); + urlBuilder_.Append("/google/status"); PrepareRequest(client_, request_, urlBuilder_); @@ -3210,25 +2953,31 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Get Secrets + /// Property Google Test /// /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Get_secrets_api_secrets_getAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Property_google_test_api_properties__property_id__google_test_postAsync(int property_id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { + if (property_id == null) + throw new System.ArgumentNullException("property_id"); + var client_ = _httpClient; var disposeClient_ = false; try { using (var request_ = new System.Net.Http.HttpRequestMessage()) { - request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Content = new System.Net.Http.StringContent(string.Empty, System.Text.Encoding.UTF8, "application/json"); + request_.Method = new System.Net.Http.HttpMethod("POST"); request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/secrets" - urlBuilder_.Append("api/secrets"); + // Operation Path: "api/properties/{property_id}/google/test" + urlBuilder_.Append("api/properties/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(property_id, System.Globalization.CultureInfo.InvariantCulture))); + urlBuilder_.Append("/google/test"); PrepareRequest(client_, request_, urlBuilder_); @@ -3263,6 +3012,16 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() return objectResponse_.Object; } else + if (status_ == 422) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new FastApiClientException("Validation Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else { var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); throw new FastApiClientException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); @@ -3284,14 +3043,14 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Put Secrets + /// Property Google Properties /// /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Put_secrets_api_secrets_putAsync(SecretsBody body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Property_google_properties_api_properties__property_id__google_properties_getAsync(int property_id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { - if (body == null) - throw new System.ArgumentNullException("body"); + if (property_id == null) + throw new System.ArgumentNullException("property_id"); var client_ = _httpClient; var disposeClient_ = false; @@ -3299,17 +3058,15 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() { using (var request_ = new System.Net.Http.HttpRequestMessage()) { - var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); - var content_ = new System.Net.Http.ByteArrayContent(json_); - content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); - request_.Content = content_; - request_.Method = new System.Net.Http.HttpMethod("PUT"); + request_.Method = new System.Net.Http.HttpMethod("GET"); request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/secrets" - urlBuilder_.Append("api/secrets"); + // Operation Path: "api/properties/{property_id}/google/properties" + urlBuilder_.Append("api/properties/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(property_id, System.Globalization.CultureInfo.InvariantCulture))); + urlBuilder_.Append("/google/properties"); PrepareRequest(client_, request_, urlBuilder_); @@ -3375,15 +3132,14 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Get App Setting + /// Property Google Links Status /// - /// Settings key to retrieve /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Get_app_setting_api_app_settings_getAsync(string key, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Property_google_links_status_api_properties__property_id__google_links_status_getAsync(int property_id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { - if (key == null) - throw new System.ArgumentNullException("key"); + if (property_id == null) + throw new System.ArgumentNullException("property_id"); var client_ = _httpClient; var disposeClient_ = false; @@ -3396,11 +3152,10 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/app-settings" - urlBuilder_.Append("api/app-settings"); - urlBuilder_.Append('?'); - urlBuilder_.Append(System.Uri.EscapeDataString("key")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(key, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - urlBuilder_.Length--; + // Operation Path: "api/properties/{property_id}/google/links/status" + urlBuilder_.Append("api/properties/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(property_id, System.Globalization.CultureInfo.InvariantCulture))); + urlBuilder_.Append("/google/links/status"); PrepareRequest(client_, request_, urlBuilder_); @@ -3466,14 +3221,14 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Put App Setting + /// Property Google Links Import /// /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Put_app_setting_api_app_settings_putAsync(AppSettingBody body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Property_google_links_import_api_properties__property_id__google_links_import_postAsync(int property_id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { - if (body == null) - throw new System.ArgumentNullException("body"); + if (property_id == null) + throw new System.ArgumentNullException("property_id"); var client_ = _httpClient; var disposeClient_ = false; @@ -3481,17 +3236,16 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() { using (var request_ = new System.Net.Http.HttpRequestMessage()) { - var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); - var content_ = new System.Net.Http.ByteArrayContent(json_); - content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); - request_.Content = content_; - request_.Method = new System.Net.Http.HttpMethod("PUT"); + request_.Content = new System.Net.Http.StringContent(string.Empty, System.Text.Encoding.UTF8, "application/json"); + request_.Method = new System.Net.Http.HttpMethod("POST"); request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/app-settings" - urlBuilder_.Append("api/app-settings"); + // Operation Path: "api/properties/{property_id}/google/links/import" + urlBuilder_.Append("api/properties/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(property_id, System.Globalization.CultureInfo.InvariantCulture))); + urlBuilder_.Append("/google/links/import"); PrepareRequest(client_, request_, urlBuilder_); @@ -3557,28 +3311,40 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// List Properties + /// Patch Property Google Credentials /// /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task List_properties_api_properties_getAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Patch_property_google_credentials_api_properties__property_id__google_credentials_patchAsync(int property_id, GoogleCredentialsPatch body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { + if (property_id == null) + throw new System.ArgumentNullException("property_id"); + + if (body == null) + throw new System.ArgumentNullException("body"); + var client_ = _httpClient; var disposeClient_ = false; try { using (var request_ = new System.Net.Http.HttpRequestMessage()) { - request_.Method = new System.Net.Http.HttpMethod("GET"); + var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); + var content_ = new System.Net.Http.ByteArrayContent(json_); + content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); + request_.Content = content_; + request_.Method = new System.Net.Http.HttpMethod("PATCH"); request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/properties" - urlBuilder_.Append("api/properties"); - - PrepareRequest(client_, request_, urlBuilder_); - + // Operation Path: "api/properties/{property_id}/google/credentials" + urlBuilder_.Append("api/properties/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(property_id, System.Globalization.CultureInfo.InvariantCulture))); + urlBuilder_.Append("/google/credentials"); + + PrepareRequest(client_, request_, urlBuilder_); + var url_ = urlBuilder_.ToString(); request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); @@ -3610,6 +3376,16 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() return objectResponse_.Object; } else + if (status_ == 422) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new FastApiClientException("Validation Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else { var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); throw new FastApiClientException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); @@ -3631,12 +3407,15 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Create Property + /// Post Property Google Credentials /// /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Create_property_api_properties_postAsync(PropertyUpsertBody body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Post_property_google_credentials_api_properties__property_id__google_credentials_postAsync(int property_id, GoogleCredentialsPostBody body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { + if (property_id == null) + throw new System.ArgumentNullException("property_id"); + if (body == null) throw new System.ArgumentNullException("body"); @@ -3655,8 +3434,10 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/properties" - urlBuilder_.Append("api/properties"); + // Operation Path: "api/properties/{property_id}/google/credentials" + urlBuilder_.Append("api/properties/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(property_id, System.Globalization.CultureInfo.InvariantCulture))); + urlBuilder_.Append("/google/credentials"); PrepareRequest(client_, request_, urlBuilder_); @@ -3681,7 +3462,7 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() ProcessResponse(client_, response_); var status_ = (int)response_.StatusCode; - if (status_ == 201) + if (status_ == 200) { var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); if (objectResponse_.Object == null) @@ -3722,15 +3503,14 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Resolve Property + /// Post Property Google Disconnect /// - /// Start URL to resolve a property from /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Resolve_property_api_properties_resolve_getAsync(string startUrl, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Post_property_google_disconnect_api_properties__property_id__google_disconnect_postAsync(int property_id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { - if (startUrl == null) - throw new System.ArgumentNullException("startUrl"); + if (property_id == null) + throw new System.ArgumentNullException("property_id"); var client_ = _httpClient; var disposeClient_ = false; @@ -3738,16 +3518,16 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() { using (var request_ = new System.Net.Http.HttpRequestMessage()) { - request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Content = new System.Net.Http.StringContent(string.Empty, System.Text.Encoding.UTF8, "application/json"); + request_.Method = new System.Net.Http.HttpMethod("POST"); request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/properties/resolve" - urlBuilder_.Append("api/properties/resolve"); - urlBuilder_.Append('?'); - urlBuilder_.Append(System.Uri.EscapeDataString("startUrl")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(startUrl, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - urlBuilder_.Length--; + // Operation Path: "api/properties/{property_id}/google/disconnect" + urlBuilder_.Append("api/properties/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(property_id, System.Globalization.CultureInfo.InvariantCulture))); + urlBuilder_.Append("/google/disconnect"); PrepareRequest(client_, request_, urlBuilder_); @@ -3813,14 +3593,15 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Get Property + /// List Dashboards /// + /// Property ID /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Get_property_api_properties__property_id__getAsync(int property_id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task List_dashboards_api_dashboards_getAsync(int propertyId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { - if (property_id == null) - throw new System.ArgumentNullException("property_id"); + if (propertyId == null) + throw new System.ArgumentNullException("propertyId"); var client_ = _httpClient; var disposeClient_ = false; @@ -3833,9 +3614,11 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/properties/{property_id}" - urlBuilder_.Append("api/properties/"); - urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(property_id, System.Globalization.CultureInfo.InvariantCulture))); + // Operation Path: "api/dashboards" + urlBuilder_.Append("api/dashboards"); + urlBuilder_.Append('?'); + urlBuilder_.Append(System.Uri.EscapeDataString("propertyId")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(propertyId, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + urlBuilder_.Length--; PrepareRequest(client_, request_, urlBuilder_); @@ -3901,14 +3684,14 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Delete Property Route + /// Create Dashboard /// /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Delete_property_route_api_properties__property_id__deleteAsync(int property_id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Create_dashboard_api_dashboards_postAsync(DashboardCreateBody body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { - if (property_id == null) - throw new System.ArgumentNullException("property_id"); + if (body == null) + throw new System.ArgumentNullException("body"); var client_ = _httpClient; var disposeClient_ = false; @@ -3916,14 +3699,17 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() { using (var request_ = new System.Net.Http.HttpRequestMessage()) { - request_.Method = new System.Net.Http.HttpMethod("DELETE"); + var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); + var content_ = new System.Net.Http.ByteArrayContent(json_); + content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); + request_.Content = content_; + request_.Method = new System.Net.Http.HttpMethod("POST"); request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/properties/{property_id}" - urlBuilder_.Append("api/properties/"); - urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(property_id, System.Globalization.CultureInfo.InvariantCulture))); + // Operation Path: "api/dashboards" + urlBuilder_.Append("api/dashboards"); PrepareRequest(client_, request_, urlBuilder_); @@ -3948,7 +3734,7 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() ProcessResponse(client_, response_); var status_ = (int)response_.StatusCode; - if (status_ == 200) + if (status_ == 201) { var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); if (objectResponse_.Object == null) @@ -3989,14 +3775,18 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Get Property Ops Route + /// Get Dashboard /// + /// Property ID /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Get_property_ops_route_api_properties__property_id__ops_getAsync(int property_id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Get_dashboard_api_dashboards__dashboard_id__getAsync(int dashboard_id, int propertyId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { - if (property_id == null) - throw new System.ArgumentNullException("property_id"); + if (dashboard_id == null) + throw new System.ArgumentNullException("dashboard_id"); + + if (propertyId == null) + throw new System.ArgumentNullException("propertyId"); var client_ = _httpClient; var disposeClient_ = false; @@ -4009,10 +3799,12 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/properties/{property_id}/ops" - urlBuilder_.Append("api/properties/"); - urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(property_id, System.Globalization.CultureInfo.InvariantCulture))); - urlBuilder_.Append("/ops"); + // Operation Path: "api/dashboards/{dashboard_id}" + urlBuilder_.Append("api/dashboards/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(dashboard_id, System.Globalization.CultureInfo.InvariantCulture))); + urlBuilder_.Append('?'); + urlBuilder_.Append(System.Uri.EscapeDataString("propertyId")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(propertyId, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + urlBuilder_.Length--; PrepareRequest(client_, request_, urlBuilder_); @@ -4078,14 +3870,14 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Update Property Ops Route + /// Update Dashboard /// /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Update_property_ops_route_api_properties__property_id__ops_putAsync(int property_id, OpsSettingsBody body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Update_dashboard_api_dashboards__dashboard_id__putAsync(int dashboard_id, DashboardUpdateBody body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { - if (property_id == null) - throw new System.ArgumentNullException("property_id"); + if (dashboard_id == null) + throw new System.ArgumentNullException("dashboard_id"); if (body == null) throw new System.ArgumentNullException("body"); @@ -4105,10 +3897,9 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/properties/{property_id}/ops" - urlBuilder_.Append("api/properties/"); - urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(property_id, System.Globalization.CultureInfo.InvariantCulture))); - urlBuilder_.Append("/ops"); + // Operation Path: "api/dashboards/{dashboard_id}" + urlBuilder_.Append("api/dashboards/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(dashboard_id, System.Globalization.CultureInfo.InvariantCulture))); PrepareRequest(client_, request_, urlBuilder_); @@ -4174,14 +3965,18 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Get Property Preset + /// Delete Dashboard /// + /// Property ID /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Get_property_preset_api_properties__property_id__preset_getAsync(int property_id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Delete_dashboard_api_dashboards__dashboard_id__deleteAsync(int dashboard_id, int propertyId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { - if (property_id == null) - throw new System.ArgumentNullException("property_id"); + if (dashboard_id == null) + throw new System.ArgumentNullException("dashboard_id"); + + if (propertyId == null) + throw new System.ArgumentNullException("propertyId"); var client_ = _httpClient; var disposeClient_ = false; @@ -4189,15 +3984,17 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() { using (var request_ = new System.Net.Http.HttpRequestMessage()) { - request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Method = new System.Net.Http.HttpMethod("DELETE"); request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/properties/{property_id}/preset" - urlBuilder_.Append("api/properties/"); - urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(property_id, System.Globalization.CultureInfo.InvariantCulture))); - urlBuilder_.Append("/preset"); + // Operation Path: "api/dashboards/{dashboard_id}" + urlBuilder_.Append("api/dashboards/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(dashboard_id, System.Globalization.CultureInfo.InvariantCulture))); + urlBuilder_.Append('?'); + urlBuilder_.Append(System.Uri.EscapeDataString("propertyId")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(propertyId, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + urlBuilder_.Length--; PrepareRequest(client_, request_, urlBuilder_); @@ -4263,15 +4060,15 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Update Property Preset + /// Dashboards Ai Generate /// + /// + /// Generate DashScript, a widget, or a full dashboard via LLM. + /// /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Update_property_preset_api_properties__property_id__preset_putAsync(int property_id, PresetBody body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Dashboards_ai_generate_api_dashboards_ai_generate_postAsync(DashboardAiGenerateBody body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { - if (property_id == null) - throw new System.ArgumentNullException("property_id"); - if (body == null) throw new System.ArgumentNullException("body"); @@ -4285,15 +4082,13 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() var content_ = new System.Net.Http.ByteArrayContent(json_); content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); request_.Content = content_; - request_.Method = new System.Net.Http.HttpMethod("PUT"); + request_.Method = new System.Net.Http.HttpMethod("POST"); request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/properties/{property_id}/preset" - urlBuilder_.Append("api/properties/"); - urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(property_id, System.Globalization.CultureInfo.InvariantCulture))); - urlBuilder_.Append("/preset"); + // Operation Path: "api/dashboards/ai-generate" + urlBuilder_.Append("api/dashboards/ai-generate"); PrepareRequest(client_, request_, urlBuilder_); @@ -4359,31 +4154,28 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Authorize Property Crawl Route + /// Get Google Credentials /// + /// + /// Full app-level Google OAuth settings (server-side / local admin only). + /// /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Authorize_property_crawl_route_api_properties__property_id__authorize_postAsync(int property_id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Get_google_credentials_api_integrations_google_credentials_getAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { - if (property_id == null) - throw new System.ArgumentNullException("property_id"); - var client_ = _httpClient; var disposeClient_ = false; try { using (var request_ = new System.Net.Http.HttpRequestMessage()) { - request_.Content = new System.Net.Http.StringContent(string.Empty, System.Text.Encoding.UTF8, "application/json"); - request_.Method = new System.Net.Http.HttpMethod("POST"); + request_.Method = new System.Net.Http.HttpMethod("GET"); request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/properties/{property_id}/authorize" - urlBuilder_.Append("api/properties/"); - urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(property_id, System.Globalization.CultureInfo.InvariantCulture))); - urlBuilder_.Append("/authorize"); + // Operation Path: "api/integrations/google/credentials" + urlBuilder_.Append("api/integrations/google/credentials"); PrepareRequest(client_, request_, urlBuilder_); @@ -4418,16 +4210,6 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() return objectResponse_.Object; } else - if (status_ == 422) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - throw new FastApiClientException("Validation Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); - } - else { var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); throw new FastApiClientException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); @@ -4449,15 +4231,12 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Property Google Status + /// Google Status /// /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Property_google_status_api_properties__property_id__google_status_getAsync(int property_id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Google_status_api_integrations_google_status_getAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { - if (property_id == null) - throw new System.ArgumentNullException("property_id"); - var client_ = _httpClient; var disposeClient_ = false; try @@ -4469,10 +4248,8 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/properties/{property_id}/google/status" - urlBuilder_.Append("api/properties/"); - urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(property_id, System.Globalization.CultureInfo.InvariantCulture))); - urlBuilder_.Append("/google/status"); + // Operation Path: "api/integrations/google/status" + urlBuilder_.Append("api/integrations/google/status"); PrepareRequest(client_, request_, urlBuilder_); @@ -4507,16 +4284,6 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() return objectResponse_.Object; } else - if (status_ == 422) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - throw new FastApiClientException("Validation Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); - } - else { var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); throw new FastApiClientException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); @@ -4538,15 +4305,12 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Property Google Test + /// Google Disconnect /// /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Property_google_test_api_properties__property_id__google_test_postAsync(int property_id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Google_disconnect_api_integrations_google_disconnect_postAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { - if (property_id == null) - throw new System.ArgumentNullException("property_id"); - var client_ = _httpClient; var disposeClient_ = false; try @@ -4559,10 +4323,8 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/properties/{property_id}/google/test" - urlBuilder_.Append("api/properties/"); - urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(property_id, System.Globalization.CultureInfo.InvariantCulture))); - urlBuilder_.Append("/google/test"); + // Operation Path: "api/integrations/google/disconnect" + urlBuilder_.Append("api/integrations/google/disconnect"); PrepareRequest(client_, request_, urlBuilder_); @@ -4597,16 +4359,6 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() return objectResponse_.Object; } else - if (status_ == 422) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - throw new FastApiClientException("Validation Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); - } - else { var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); throw new FastApiClientException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); @@ -4628,15 +4380,12 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Property Google Properties + /// Google Oauth Start /// /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Property_google_properties_api_properties__property_id__google_properties_getAsync(int property_id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Google_oauth_start_api_integrations_google_auth_getAsync(Propertyid? propertyId = null, Starturl? startUrl = null, Returnto? returnTo = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { - if (property_id == null) - throw new System.ArgumentNullException("property_id"); - var client_ = _httpClient; var disposeClient_ = false; try @@ -4648,11 +4397,23 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/properties/{property_id}/google/properties" - urlBuilder_.Append("api/properties/"); - urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(property_id, System.Globalization.CultureInfo.InvariantCulture))); - urlBuilder_.Append("/google/properties"); - + // Operation Path: "api/integrations/google/auth" + urlBuilder_.Append("api/integrations/google/auth"); + urlBuilder_.Append('?'); + if (propertyId != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("propertyId")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(propertyId, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + if (startUrl != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("startUrl")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(startUrl, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + if (returnTo != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("returnTo")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(returnTo, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + urlBuilder_.Length--; + PrepareRequest(client_, request_, urlBuilder_); var url_ = urlBuilder_.ToString(); @@ -4717,15 +4478,12 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Property Google Links Status + /// Google Oauth Callback /// /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Property_google_links_status_api_properties__property_id__google_links_status_getAsync(int property_id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Google_oauth_callback_api_integrations_google_callback_getAsync(Code? code = null, State? state = null, Error? error = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { - if (property_id == null) - throw new System.ArgumentNullException("property_id"); - var client_ = _httpClient; var disposeClient_ = false; try @@ -4737,10 +4495,22 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/properties/{property_id}/google/links/status" - urlBuilder_.Append("api/properties/"); - urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(property_id, System.Globalization.CultureInfo.InvariantCulture))); - urlBuilder_.Append("/google/links/status"); + // Operation Path: "api/integrations/google/callback" + urlBuilder_.Append("api/integrations/google/callback"); + urlBuilder_.Append('?'); + if (code != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("code")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(code, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + if (state != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("state")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(state, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + if (error != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("error")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(error, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + urlBuilder_.Length--; PrepareRequest(client_, request_, urlBuilder_); @@ -4806,31 +4576,31 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Property Google Links Import + /// Google Properties Deprecated /// /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Property_google_links_import_api_properties__property_id__google_links_import_postAsync(int property_id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Google_properties_deprecated_api_integrations_google_properties_getAsync(Anonymous? propertyId = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { - if (property_id == null) - throw new System.ArgumentNullException("property_id"); - var client_ = _httpClient; var disposeClient_ = false; try { using (var request_ = new System.Net.Http.HttpRequestMessage()) { - request_.Content = new System.Net.Http.StringContent(string.Empty, System.Text.Encoding.UTF8, "application/json"); - request_.Method = new System.Net.Http.HttpMethod("POST"); + request_.Method = new System.Net.Http.HttpMethod("GET"); request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/properties/{property_id}/google/links/import" - urlBuilder_.Append("api/properties/"); - urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(property_id, System.Globalization.CultureInfo.InvariantCulture))); - urlBuilder_.Append("/google/links/import"); + // Operation Path: "api/integrations/google/properties" + urlBuilder_.Append("api/integrations/google/properties"); + urlBuilder_.Append('?'); + if (propertyId != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("propertyId")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(propertyId, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + urlBuilder_.Length--; PrepareRequest(client_, request_, urlBuilder_); @@ -4896,37 +4666,26 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Patch Property Google Credentials + /// Google Test /// /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Patch_property_google_credentials_api_properties__property_id__google_credentials_patchAsync(int property_id, GoogleCredentialsPatch body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Google_test_api_integrations_google_test_postAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { - if (property_id == null) - throw new System.ArgumentNullException("property_id"); - - if (body == null) - throw new System.ArgumentNullException("body"); - var client_ = _httpClient; var disposeClient_ = false; try { using (var request_ = new System.Net.Http.HttpRequestMessage()) { - var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); - var content_ = new System.Net.Http.ByteArrayContent(json_); - content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); - request_.Content = content_; - request_.Method = new System.Net.Http.HttpMethod("PATCH"); + request_.Content = new System.Net.Http.StringContent(string.Empty, System.Text.Encoding.UTF8, "application/json"); + request_.Method = new System.Net.Http.HttpMethod("POST"); request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/properties/{property_id}/google/credentials" - urlBuilder_.Append("api/properties/"); - urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(property_id, System.Globalization.CultureInfo.InvariantCulture))); - urlBuilder_.Append("/google/credentials"); + // Operation Path: "api/integrations/google/test" + urlBuilder_.Append("api/integrations/google/test"); PrepareRequest(client_, request_, urlBuilder_); @@ -4961,16 +4720,6 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() return objectResponse_.Object; } else - if (status_ == 422) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - throw new FastApiClientException("Validation Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); - } - else { var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); throw new FastApiClientException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); @@ -4992,17 +4741,14 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Post Property Google Credentials + /// Google Page Data /// /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Post_property_google_credentials_api_properties__property_id__google_credentials_postAsync(int property_id, GoogleCredentialsPostBody body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Google_page_data_api_integrations_google_page_data_getAsync(string url, Googlesnapshotid? googleSnapshotId = null, Anonymous2? propertyId = null, Domain? domain = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { - if (property_id == null) - throw new System.ArgumentNullException("property_id"); - - if (body == null) - throw new System.ArgumentNullException("body"); + if (url == null) + throw new System.ArgumentNullException("url"); var client_ = _httpClient; var disposeClient_ = false; @@ -5010,19 +4756,28 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() { using (var request_ = new System.Net.Http.HttpRequestMessage()) { - var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); - var content_ = new System.Net.Http.ByteArrayContent(json_); - content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); - request_.Content = content_; - request_.Method = new System.Net.Http.HttpMethod("POST"); + request_.Method = new System.Net.Http.HttpMethod("GET"); request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/properties/{property_id}/google/credentials" - urlBuilder_.Append("api/properties/"); - urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(property_id, System.Globalization.CultureInfo.InvariantCulture))); - urlBuilder_.Append("/google/credentials"); + // Operation Path: "api/integrations/google/page-data" + urlBuilder_.Append("api/integrations/google/page-data"); + urlBuilder_.Append('?'); + urlBuilder_.Append(System.Uri.EscapeDataString("url")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(url, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + if (googleSnapshotId != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("googleSnapshotId")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(googleSnapshotId, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + if (propertyId != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("propertyId")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(propertyId, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + if (domain != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("domain")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(domain, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + urlBuilder_.Length--; PrepareRequest(client_, request_, urlBuilder_); @@ -5088,14 +4843,14 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Post Property Google Disconnect + /// Google Page Data History /// /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Post_property_google_disconnect_api_properties__property_id__google_disconnect_postAsync(int property_id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Google_page_data_history_api_integrations_google_page_data_history_getAsync(string url, Anonymous3? propertyId = null, Anonymous4? domain = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { - if (property_id == null) - throw new System.ArgumentNullException("property_id"); + if (url == null) + throw new System.ArgumentNullException("url"); var client_ = _httpClient; var disposeClient_ = false; @@ -5103,16 +4858,24 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() { using (var request_ = new System.Net.Http.HttpRequestMessage()) { - request_.Content = new System.Net.Http.StringContent(string.Empty, System.Text.Encoding.UTF8, "application/json"); - request_.Method = new System.Net.Http.HttpMethod("POST"); + request_.Method = new System.Net.Http.HttpMethod("GET"); request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/properties/{property_id}/google/disconnect" - urlBuilder_.Append("api/properties/"); - urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(property_id, System.Globalization.CultureInfo.InvariantCulture))); - urlBuilder_.Append("/google/disconnect"); + // Operation Path: "api/integrations/google/page-data/history" + urlBuilder_.Append("api/integrations/google/page-data/history"); + urlBuilder_.Append('?'); + urlBuilder_.Append(System.Uri.EscapeDataString("url")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(url, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + if (propertyId != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("propertyId")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(propertyId, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + if (domain != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("domain")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(domain, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + urlBuilder_.Length--; PrepareRequest(client_, request_, urlBuilder_); @@ -5178,32 +4941,29 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// List Dashboards + /// Google Page Live /// - /// Property ID /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task List_dashboards_api_dashboards_getAsync(int propertyId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Google_page_live_api_integrations_google_page_live_postAsync(object? body = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { - if (propertyId == null) - throw new System.ArgumentNullException("propertyId"); - var client_ = _httpClient; var disposeClient_ = false; try { using (var request_ = new System.Net.Http.HttpRequestMessage()) { - request_.Method = new System.Net.Http.HttpMethod("GET"); + var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); + var content_ = new System.Net.Http.ByteArrayContent(json_); + content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); + request_.Content = content_; + request_.Method = new System.Net.Http.HttpMethod("POST"); request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/dashboards" - urlBuilder_.Append("api/dashboards"); - urlBuilder_.Append('?'); - urlBuilder_.Append(System.Uri.EscapeDataString("propertyId")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(propertyId, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - urlBuilder_.Length--; + // Operation Path: "api/integrations/google/page-live" + urlBuilder_.Append("api/integrations/google/page-live"); PrepareRequest(client_, request_, urlBuilder_); @@ -5269,14 +5029,14 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Create Dashboard + /// Google Keywords By Page /// /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Create_dashboard_api_dashboards_postAsync(DashboardCreateBody body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Google_keywords_by_page_api_integrations_google_keywords_by_page_getAsync(string url, Anonymous5? propertyId = null, Anonymous6? domain = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { - if (body == null) - throw new System.ArgumentNullException("body"); + if (url == null) + throw new System.ArgumentNullException("url"); var client_ = _httpClient; var disposeClient_ = false; @@ -5284,17 +5044,24 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() { using (var request_ = new System.Net.Http.HttpRequestMessage()) { - var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); - var content_ = new System.Net.Http.ByteArrayContent(json_); - content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); - request_.Content = content_; - request_.Method = new System.Net.Http.HttpMethod("POST"); + request_.Method = new System.Net.Http.HttpMethod("GET"); request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/dashboards" - urlBuilder_.Append("api/dashboards"); + // Operation Path: "api/integrations/google/keywords/by-page" + urlBuilder_.Append("api/integrations/google/keywords/by-page"); + urlBuilder_.Append('?'); + urlBuilder_.Append(System.Uri.EscapeDataString("url")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(url, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + if (propertyId != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("propertyId")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(propertyId, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + if (domain != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("domain")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(domain, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + urlBuilder_.Length--; PrepareRequest(client_, request_, urlBuilder_); @@ -5319,7 +5086,7 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() ProcessResponse(client_, response_); var status_ = (int)response_.StatusCode; - if (status_ == 201) + if (status_ == 200) { var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); if (objectResponse_.Object == null) @@ -5360,18 +5127,14 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Get Dashboard + /// Google Keywords History /// - /// Property ID /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Get_dashboard_api_dashboards__dashboard_id__getAsync(int dashboard_id, int propertyId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Google_keywords_history_api_integrations_google_keywords_history_getAsync(string keyword, Anonymous7? propertyId = null, Anonymous8? domain = null, int? limit = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { - if (dashboard_id == null) - throw new System.ArgumentNullException("dashboard_id"); - - if (propertyId == null) - throw new System.ArgumentNullException("propertyId"); + if (keyword == null) + throw new System.ArgumentNullException("keyword"); var client_ = _httpClient; var disposeClient_ = false; @@ -5384,11 +5147,22 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/dashboards/{dashboard_id}" - urlBuilder_.Append("api/dashboards/"); - urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(dashboard_id, System.Globalization.CultureInfo.InvariantCulture))); + // Operation Path: "api/integrations/google/keywords/history" + urlBuilder_.Append("api/integrations/google/keywords/history"); urlBuilder_.Append('?'); - urlBuilder_.Append(System.Uri.EscapeDataString("propertyId")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(propertyId, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + urlBuilder_.Append(System.Uri.EscapeDataString("keyword")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(keyword, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + if (propertyId != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("propertyId")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(propertyId, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + if (domain != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("domain")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(domain, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + if (limit != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("limit")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(limit, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } urlBuilder_.Length--; PrepareRequest(client_, request_, urlBuilder_); @@ -5455,36 +5229,26 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Update Dashboard + /// Bing Sync /// /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Update_dashboard_api_dashboards__dashboard_id__putAsync(int dashboard_id, DashboardUpdateBody body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Bing_sync_api_integrations_bing_sync_postAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { - if (dashboard_id == null) - throw new System.ArgumentNullException("dashboard_id"); - - if (body == null) - throw new System.ArgumentNullException("body"); - var client_ = _httpClient; var disposeClient_ = false; try { using (var request_ = new System.Net.Http.HttpRequestMessage()) { - var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); - var content_ = new System.Net.Http.ByteArrayContent(json_); - content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); - request_.Content = content_; - request_.Method = new System.Net.Http.HttpMethod("PUT"); + request_.Content = new System.Net.Http.StringContent(string.Empty, System.Text.Encoding.UTF8, "application/json"); + request_.Method = new System.Net.Http.HttpMethod("POST"); request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/dashboards/{dashboard_id}" - urlBuilder_.Append("api/dashboards/"); - urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(dashboard_id, System.Globalization.CultureInfo.InvariantCulture))); + // Operation Path: "api/integrations/bing/sync" + urlBuilder_.Append("api/integrations/bing/sync"); PrepareRequest(client_, request_, urlBuilder_); @@ -5519,16 +5283,6 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() return objectResponse_.Object; } else - if (status_ == 422) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - throw new FastApiClientException("Validation Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); - } - else { var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); throw new FastApiClientException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); @@ -5550,18 +5304,20 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Delete Dashboard + /// Google Page Compare /// - /// Property ID /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Delete_dashboard_api_dashboards__dashboard_id__deleteAsync(int dashboard_id, int propertyId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Google_page_compare_api_integrations_google_page_compare_getAsync(string url, int currentId, int baselineId, string? currentType = null, string? baselineType = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { - if (dashboard_id == null) - throw new System.ArgumentNullException("dashboard_id"); + if (url == null) + throw new System.ArgumentNullException("url"); - if (propertyId == null) - throw new System.ArgumentNullException("propertyId"); + if (currentId == null) + throw new System.ArgumentNullException("currentId"); + + if (baselineId == null) + throw new System.ArgumentNullException("baselineId"); var client_ = _httpClient; var disposeClient_ = false; @@ -5569,16 +5325,25 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() { using (var request_ = new System.Net.Http.HttpRequestMessage()) { - request_.Method = new System.Net.Http.HttpMethod("DELETE"); + request_.Method = new System.Net.Http.HttpMethod("GET"); request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/dashboards/{dashboard_id}" - urlBuilder_.Append("api/dashboards/"); - urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(dashboard_id, System.Globalization.CultureInfo.InvariantCulture))); + // Operation Path: "api/integrations/google/page-compare" + urlBuilder_.Append("api/integrations/google/page-compare"); urlBuilder_.Append('?'); - urlBuilder_.Append(System.Uri.EscapeDataString("propertyId")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(propertyId, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + urlBuilder_.Append(System.Uri.EscapeDataString("url")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(url, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + urlBuilder_.Append(System.Uri.EscapeDataString("currentId")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(currentId, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + urlBuilder_.Append(System.Uri.EscapeDataString("baselineId")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(baselineId, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + if (currentType != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("currentType")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(currentType, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + if (baselineType != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("baselineType")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(baselineType, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } urlBuilder_.Length--; PrepareRequest(client_, request_, urlBuilder_); @@ -5645,17 +5410,14 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Dashboards Ai Generate + /// Google Page Live History /// - /// - /// Generate DashScript, a widget, or a full dashboard via LLM. - /// /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Dashboards_ai_generate_api_dashboards_ai_generate_postAsync(DashboardAiGenerateBody body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Google_page_live_history_api_integrations_google_page_live_history_getAsync(string url, int? limit = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { - if (body == null) - throw new System.ArgumentNullException("body"); + if (url == null) + throw new System.ArgumentNullException("url"); var client_ = _httpClient; var disposeClient_ = false; @@ -5663,17 +5425,20 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() { using (var request_ = new System.Net.Http.HttpRequestMessage()) { - var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); - var content_ = new System.Net.Http.ByteArrayContent(json_); - content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); - request_.Content = content_; - request_.Method = new System.Net.Http.HttpMethod("POST"); + request_.Method = new System.Net.Http.HttpMethod("GET"); request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/dashboards/ai-generate" - urlBuilder_.Append("api/dashboards/ai-generate"); + // Operation Path: "api/integrations/google/page-live/history" + urlBuilder_.Append("api/integrations/google/page-live/history"); + urlBuilder_.Append('?'); + urlBuilder_.Append(System.Uri.EscapeDataString("url")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(url, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + if (limit != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("limit")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(limit, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + urlBuilder_.Length--; PrepareRequest(client_, request_, urlBuilder_); @@ -5739,15 +5504,14 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// List Filters + /// Google Keywords History Batch /// - /// Property ID /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task List_filters_api_filters_getAsync(int propertyId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Google_keywords_history_batch_api_integrations_google_keywords_history_batch_postAsync(object body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { - if (propertyId == null) - throw new System.ArgumentNullException("propertyId"); + if (body == null) + throw new System.ArgumentNullException("body"); var client_ = _httpClient; var disposeClient_ = false; @@ -5755,16 +5519,17 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() { using (var request_ = new System.Net.Http.HttpRequestMessage()) { - request_.Method = new System.Net.Http.HttpMethod("GET"); + var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); + var content_ = new System.Net.Http.ByteArrayContent(json_); + content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); + request_.Content = content_; + request_.Method = new System.Net.Http.HttpMethod("POST"); request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/filters" - urlBuilder_.Append("api/filters"); - urlBuilder_.Append('?'); - urlBuilder_.Append(System.Uri.EscapeDataString("propertyId")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(propertyId, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - urlBuilder_.Length--; + // Operation Path: "api/integrations/google/keywords/history/batch" + urlBuilder_.Append("api/integrations/google/keywords/history/batch"); PrepareRequest(client_, request_, urlBuilder_); @@ -5830,11 +5595,14 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Upsert Filter + /// Google Keywords Expand /// + /// + /// Expand keyword ideas from Google Keyword Planner or suggest API. + /// /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Upsert_filter_api_filters_postAsync(FilterUpsertBody body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Google_keywords_expand_api_integrations_google_keywords_expand_postAsync(object body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { if (body == null) throw new System.ArgumentNullException("body"); @@ -5854,8 +5622,8 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/filters" - urlBuilder_.Append("api/filters"); + // Operation Path: "api/integrations/google/keywords/expand" + urlBuilder_.Append("api/integrations/google/keywords/expand"); PrepareRequest(client_, request_, urlBuilder_); @@ -5921,11 +5689,14 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Delete Filter + /// Google Keywords Planner /// + /// + /// Fetch keyword planner data from Google Ads API. + /// /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Delete_filter_api_filters_deleteAsync(FilterDeleteBody body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Google_keywords_planner_api_integrations_google_keywords_planner_postAsync(object body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { if (body == null) throw new System.ArgumentNullException("body"); @@ -5940,13 +5711,13 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() var content_ = new System.Net.Http.ByteArrayContent(json_); content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); request_.Content = content_; - request_.Method = new System.Net.Http.HttpMethod("DELETE"); + request_.Method = new System.Net.Http.HttpMethod("POST"); request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/filters" - urlBuilder_.Append("api/filters"); + // Operation Path: "api/integrations/google/keywords/planner" + urlBuilder_.Append("api/integrations/google/keywords/planner"); PrepareRequest(client_, request_, urlBuilder_); @@ -6012,28 +5783,32 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Get Google Credentials + /// Internal Keyword Enrich /// - /// - /// Full app-level Google OAuth settings (server-side / local admin only). - /// /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Get_google_credentials_api_integrations_google_credentials_getAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Internal_keyword_enrich_internal_integrations_keywords_enrich_postAsync(object body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { + if (body == null) + throw new System.ArgumentNullException("body"); + var client_ = _httpClient; var disposeClient_ = false; try { using (var request_ = new System.Net.Http.HttpRequestMessage()) { - request_.Method = new System.Net.Http.HttpMethod("GET"); + var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); + var content_ = new System.Net.Http.ByteArrayContent(json_); + content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); + request_.Content = content_; + request_.Method = new System.Net.Http.HttpMethod("POST"); request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/integrations/google/credentials" - urlBuilder_.Append("api/integrations/google/credentials"); + // Operation Path: "internal/integrations/keywords/enrich" + urlBuilder_.Append("internal/integrations/keywords/enrich"); PrepareRequest(client_, request_, urlBuilder_); @@ -6068,6 +5843,16 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() return objectResponse_.Object; } else + if (status_ == 422) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new FastApiClientException("Validation Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else { var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); throw new FastApiClientException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); @@ -6089,12 +5874,15 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Save Google Credentials + /// Internal Gsc Links Import /// /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Save_google_credentials_api_integrations_google_credentials_postAsync(object? body = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Internal_gsc_links_import_internal_integrations_gsc_links_import_postAsync(object body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { + if (body == null) + throw new System.ArgumentNullException("body"); + var client_ = _httpClient; var disposeClient_ = false; try @@ -6110,8 +5898,8 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/integrations/google/credentials" - urlBuilder_.Append("api/integrations/google/credentials"); + // Operation Path: "internal/integrations/gsc-links/import" + urlBuilder_.Append("internal/integrations/gsc-links/import"); PrepareRequest(client_, request_, urlBuilder_); @@ -6177,11 +5965,11 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Google Status + /// Keywords Competitor Import /// /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Google_status_api_integrations_google_status_getAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Keywords_competitor_import_api_keywords_competitor_import_postAsync(object? body = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { var client_ = _httpClient; var disposeClient_ = false; @@ -6189,13 +5977,17 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() { using (var request_ = new System.Net.Http.HttpRequestMessage()) { - request_.Method = new System.Net.Http.HttpMethod("GET"); + var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); + var content_ = new System.Net.Http.ByteArrayContent(json_); + content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); + request_.Content = content_; + request_.Method = new System.Net.Http.HttpMethod("POST"); request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/integrations/google/status" - urlBuilder_.Append("api/integrations/google/status"); + // Operation Path: "api/keywords/competitor-import" + urlBuilder_.Append("api/keywords/competitor-import"); PrepareRequest(client_, request_, urlBuilder_); @@ -6230,6 +6022,16 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() return objectResponse_.Object; } else + if (status_ == 422) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new FastApiClientException("Validation Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else { var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); throw new FastApiClientException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); @@ -6251,11 +6053,11 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Upload Google Credentials + /// Keywords Content Brief /// /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Upload_google_credentials_api_integrations_google_credentials_upload_postAsync(object? body = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Keywords_content_brief_api_keywords_content_brief_postAsync(object? body = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { var client_ = _httpClient; var disposeClient_ = false; @@ -6272,8 +6074,8 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/integrations/google/credentials/upload" - urlBuilder_.Append("api/integrations/google/credentials/upload"); + // Operation Path: "api/keywords/content-brief" + urlBuilder_.Append("api/keywords/content-brief"); PrepareRequest(client_, request_, urlBuilder_); @@ -6339,29 +6141,31 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Google Disconnect + /// Backlinks Velocity /// - /// - /// Global disconnect is deprecated — use per-property disconnect. - /// /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Google_disconnect_api_integrations_google_disconnect_postAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Backlinks_velocity_api_backlinks_velocity_getAsync(int propertyId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { + if (propertyId == null) + throw new System.ArgumentNullException("propertyId"); + var client_ = _httpClient; var disposeClient_ = false; try { using (var request_ = new System.Net.Http.HttpRequestMessage()) { - request_.Content = new System.Net.Http.StringContent(string.Empty, System.Text.Encoding.UTF8, "application/json"); - request_.Method = new System.Net.Http.HttpMethod("POST"); + request_.Method = new System.Net.Http.HttpMethod("GET"); request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/integrations/google/disconnect" - urlBuilder_.Append("api/integrations/google/disconnect"); + // Operation Path: "api/backlinks/velocity" + urlBuilder_.Append("api/backlinks/velocity"); + urlBuilder_.Append('?'); + urlBuilder_.Append(System.Uri.EscapeDataString("propertyId")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(propertyId, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + urlBuilder_.Length--; PrepareRequest(client_, request_, urlBuilder_); @@ -6396,6 +6200,16 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() return objectResponse_.Object; } else + if (status_ == 422) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new FastApiClientException("Validation Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else { var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); throw new FastApiClientException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); @@ -6417,11 +6231,11 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Google Oauth Start + /// Backlinks Competitor Import /// /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Google_oauth_start_api_integrations_google_auth_getAsync(Anonymous3? propertyId = null, Starturl? startUrl = null, Returnto? returnTo = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Backlinks_competitor_import_api_backlinks_competitor_import_postAsync(object? body = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { var client_ = _httpClient; var disposeClient_ = false; @@ -6429,27 +6243,17 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() { using (var request_ = new System.Net.Http.HttpRequestMessage()) { - request_.Method = new System.Net.Http.HttpMethod("GET"); + var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); + var content_ = new System.Net.Http.ByteArrayContent(json_); + content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); + request_.Content = content_; + request_.Method = new System.Net.Http.HttpMethod("POST"); request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/integrations/google/auth" - urlBuilder_.Append("api/integrations/google/auth"); - urlBuilder_.Append('?'); - if (propertyId != null) - { - urlBuilder_.Append(System.Uri.EscapeDataString("propertyId")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(propertyId, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - } - if (startUrl != null) - { - urlBuilder_.Append(System.Uri.EscapeDataString("startUrl")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(startUrl, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - } - if (returnTo != null) - { - urlBuilder_.Append(System.Uri.EscapeDataString("returnTo")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(returnTo, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - } - urlBuilder_.Length--; + // Operation Path: "api/backlinks/competitor-import" + urlBuilder_.Append("api/backlinks/competitor-import"); PrepareRequest(client_, request_, urlBuilder_); @@ -6515,11 +6319,11 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Google Oauth Callback + /// Backlinks Third Party Import /// /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Google_oauth_callback_api_integrations_google_callback_getAsync(Code? code = null, State? state = null, Error? error = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Backlinks_third_party_import_api_backlinks_third_party_import_postAsync(object? body = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { var client_ = _httpClient; var disposeClient_ = false; @@ -6527,27 +6331,17 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() { using (var request_ = new System.Net.Http.HttpRequestMessage()) { - request_.Method = new System.Net.Http.HttpMethod("GET"); + var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); + var content_ = new System.Net.Http.ByteArrayContent(json_); + content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); + request_.Content = content_; + request_.Method = new System.Net.Http.HttpMethod("POST"); request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/integrations/google/callback" - urlBuilder_.Append("api/integrations/google/callback"); - urlBuilder_.Append('?'); - if (code != null) - { - urlBuilder_.Append(System.Uri.EscapeDataString("code")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(code, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - } - if (state != null) - { - urlBuilder_.Append(System.Uri.EscapeDataString("state")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(state, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - } - if (error != null) - { - urlBuilder_.Append(System.Uri.EscapeDataString("error")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(error, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - } - urlBuilder_.Length--; + // Operation Path: "api/backlinks/third-party-import" + urlBuilder_.Append("api/backlinks/third-party-import"); PrepareRequest(client_, request_, urlBuilder_); @@ -6613,14 +6407,11 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Google Properties Deprecated + /// Content Analyze /// - /// - /// Deprecated — use /api/properties/{id}/google/properties. - /// /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Google_properties_deprecated_api_integrations_google_properties_getAsync(Anonymous4? propertyId = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Content_analyze_api_content_analyze_postAsync(object? body = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { var client_ = _httpClient; var disposeClient_ = false; @@ -6628,19 +6419,17 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() { using (var request_ = new System.Net.Http.HttpRequestMessage()) { - request_.Method = new System.Net.Http.HttpMethod("GET"); + var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); + var content_ = new System.Net.Http.ByteArrayContent(json_); + content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); + request_.Content = content_; + request_.Method = new System.Net.Http.HttpMethod("POST"); request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/integrations/google/properties" - urlBuilder_.Append("api/integrations/google/properties"); - urlBuilder_.Append('?'); - if (propertyId != null) - { - urlBuilder_.Append(System.Uri.EscapeDataString("propertyId")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(propertyId, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - } - urlBuilder_.Length--; + // Operation Path: "api/content/analyze" + urlBuilder_.Append("api/content/analyze"); PrepareRequest(client_, request_, urlBuilder_); @@ -6706,14 +6495,11 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Google Test + /// Content Score /// - /// - /// Run `python -m src google --test` and return stdout log. - /// /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Google_test_api_integrations_google_test_postAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Content_score_api_content_score_postAsync(object? body = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { var client_ = _httpClient; var disposeClient_ = false; @@ -6721,14 +6507,17 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() { using (var request_ = new System.Net.Http.HttpRequestMessage()) { - request_.Content = new System.Net.Http.StringContent(string.Empty, System.Text.Encoding.UTF8, "application/json"); + var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); + var content_ = new System.Net.Http.ByteArrayContent(json_); + content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); + request_.Content = content_; request_.Method = new System.Net.Http.HttpMethod("POST"); request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/integrations/google/test" - urlBuilder_.Append("api/integrations/google/test"); + // Operation Path: "api/content/score" + urlBuilder_.Append("api/content/score"); PrepareRequest(client_, request_, urlBuilder_); @@ -6763,6 +6552,16 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() return objectResponse_.Object; } else + if (status_ == 422) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new FastApiClientException("Validation Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else { var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); throw new FastApiClientException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); @@ -6784,43 +6583,29 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Google Page Data + /// Content Wizard /// /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Google_page_data_api_integrations_google_page_data_getAsync(string url, Googlesnapshotid? googleSnapshotId = null, Anonymous5? propertyId = null, Anonymous6? domain = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Content_wizard_api_content_wizard_postAsync(object? body = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { - if (url == null) - throw new System.ArgumentNullException("url"); - var client_ = _httpClient; var disposeClient_ = false; try { using (var request_ = new System.Net.Http.HttpRequestMessage()) { - request_.Method = new System.Net.Http.HttpMethod("GET"); + var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); + var content_ = new System.Net.Http.ByteArrayContent(json_); + content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); + request_.Content = content_; + request_.Method = new System.Net.Http.HttpMethod("POST"); request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/integrations/google/page-data" - urlBuilder_.Append("api/integrations/google/page-data"); - urlBuilder_.Append('?'); - urlBuilder_.Append(System.Uri.EscapeDataString("url")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(url, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - if (googleSnapshotId != null) - { - urlBuilder_.Append(System.Uri.EscapeDataString("googleSnapshotId")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(googleSnapshotId, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - } - if (propertyId != null) - { - urlBuilder_.Append(System.Uri.EscapeDataString("propertyId")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(propertyId, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - } - if (domain != null) - { - urlBuilder_.Append(System.Uri.EscapeDataString("domain")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(domain, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - } - urlBuilder_.Length--; + // Operation Path: "api/content/wizard" + urlBuilder_.Append("api/content/wizard"); PrepareRequest(client_, request_, urlBuilder_); @@ -6886,14 +6671,14 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Google Page Data History + /// List Content Drafts Route /// /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Google_page_data_history_api_integrations_google_page_data_history_getAsync(string url, Anonymous7? propertyId = null, Anonymous8? domain = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task List_content_drafts_route_api_content_drafts_getAsync(int propertyId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { - if (url == null) - throw new System.ArgumentNullException("url"); + if (propertyId == null) + throw new System.ArgumentNullException("propertyId"); var client_ = _httpClient; var disposeClient_ = false; @@ -6906,18 +6691,10 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/integrations/google/page-data/history" - urlBuilder_.Append("api/integrations/google/page-data/history"); + // Operation Path: "api/content-drafts" + urlBuilder_.Append("api/content-drafts"); urlBuilder_.Append('?'); - urlBuilder_.Append(System.Uri.EscapeDataString("url")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(url, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - if (propertyId != null) - { - urlBuilder_.Append(System.Uri.EscapeDataString("propertyId")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(propertyId, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - } - if (domain != null) - { - urlBuilder_.Append(System.Uri.EscapeDataString("domain")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(domain, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - } + urlBuilder_.Append(System.Uri.EscapeDataString("propertyId")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(propertyId, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); urlBuilder_.Length--; PrepareRequest(client_, request_, urlBuilder_); @@ -6984,11 +6761,11 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Google Page Live + /// Create Content Draft Route /// /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Google_page_live_api_integrations_google_page_live_postAsync(object? body = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Create_content_draft_route_api_content_drafts_postAsync(object? body = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { var client_ = _httpClient; var disposeClient_ = false; @@ -7005,8 +6782,8 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/integrations/google/page-live" - urlBuilder_.Append("api/integrations/google/page-live"); + // Operation Path: "api/content-drafts" + urlBuilder_.Append("api/content-drafts"); PrepareRequest(client_, request_, urlBuilder_); @@ -7072,14 +6849,14 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Google Keywords By Page + /// Get Content Draft Route /// /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Google_keywords_by_page_api_integrations_google_keywords_by_page_getAsync(string url, Anonymous9? propertyId = null, Anonymous10? domain = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Get_content_draft_route_api_content_drafts__draft_id__getAsync(int draft_id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { - if (url == null) - throw new System.ArgumentNullException("url"); + if (draft_id == null) + throw new System.ArgumentNullException("draft_id"); var client_ = _httpClient; var disposeClient_ = false; @@ -7092,19 +6869,9 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/integrations/google/keywords/by-page" - urlBuilder_.Append("api/integrations/google/keywords/by-page"); - urlBuilder_.Append('?'); - urlBuilder_.Append(System.Uri.EscapeDataString("url")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(url, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - if (propertyId != null) - { - urlBuilder_.Append(System.Uri.EscapeDataString("propertyId")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(propertyId, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - } - if (domain != null) - { - urlBuilder_.Append(System.Uri.EscapeDataString("domain")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(domain, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - } - urlBuilder_.Length--; + // Operation Path: "api/content-drafts/{draft_id}" + urlBuilder_.Append("api/content-drafts/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(draft_id, System.Globalization.CultureInfo.InvariantCulture))); PrepareRequest(client_, request_, urlBuilder_); @@ -7170,14 +6937,14 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Google Keywords History + /// Update Content Draft Route /// /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Google_keywords_history_api_integrations_google_keywords_history_getAsync(string keyword, Anonymous11? propertyId = null, Anonymous12? domain = null, int? limit = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Update_content_draft_route_api_content_drafts__draft_id__patchAsync(int draft_id, object? body = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { - if (keyword == null) - throw new System.ArgumentNullException("keyword"); + if (draft_id == null) + throw new System.ArgumentNullException("draft_id"); var client_ = _httpClient; var disposeClient_ = false; @@ -7185,28 +6952,18 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() { using (var request_ = new System.Net.Http.HttpRequestMessage()) { - request_.Method = new System.Net.Http.HttpMethod("GET"); + var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); + var content_ = new System.Net.Http.ByteArrayContent(json_); + content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); + request_.Content = content_; + request_.Method = new System.Net.Http.HttpMethod("PATCH"); request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/integrations/google/keywords/history" - urlBuilder_.Append("api/integrations/google/keywords/history"); - urlBuilder_.Append('?'); - urlBuilder_.Append(System.Uri.EscapeDataString("keyword")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(keyword, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - if (propertyId != null) - { - urlBuilder_.Append(System.Uri.EscapeDataString("propertyId")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(propertyId, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - } - if (domain != null) - { - urlBuilder_.Append(System.Uri.EscapeDataString("domain")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(domain, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - } - if (limit != null) - { - urlBuilder_.Append(System.Uri.EscapeDataString("limit")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(limit, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - } - urlBuilder_.Length--; + // Operation Path: "api/content-drafts/{draft_id}" + urlBuilder_.Append("api/content-drafts/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(draft_id, System.Globalization.CultureInfo.InvariantCulture))); PrepareRequest(client_, request_, urlBuilder_); @@ -7272,29 +7029,29 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Bing Sync + /// Delete Content Draft Route /// - /// - /// Fetch Bing Webmaster backlinks summary using config from DB. - /// /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Bing_sync_api_integrations_bing_sync_postAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Delete_content_draft_route_api_content_drafts__draft_id__deleteAsync(int draft_id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { + if (draft_id == null) + throw new System.ArgumentNullException("draft_id"); + var client_ = _httpClient; var disposeClient_ = false; try { using (var request_ = new System.Net.Http.HttpRequestMessage()) { - request_.Content = new System.Net.Http.StringContent(string.Empty, System.Text.Encoding.UTF8, "application/json"); - request_.Method = new System.Net.Http.HttpMethod("POST"); + request_.Method = new System.Net.Http.HttpMethod("DELETE"); request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/integrations/bing/sync" - urlBuilder_.Append("api/integrations/bing/sync"); + // Operation Path: "api/content-drafts/{draft_id}" + urlBuilder_.Append("api/content-drafts/"); + urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(draft_id, System.Globalization.CultureInfo.InvariantCulture))); PrepareRequest(client_, request_, urlBuilder_); @@ -7329,6 +7086,16 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() return objectResponse_.Object; } else + if (status_ == 422) + { + var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); + if (objectResponse_.Object == null) + { + throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); + } + throw new FastApiClientException("Validation Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); + } + else { var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); throw new FastApiClientException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); @@ -7350,23 +7117,14 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Google Page Compare + /// List Page Markdown Route /// - /// - /// Compare two page Google data snapshots. - /// /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Google_page_compare_api_integrations_google_page_compare_getAsync(string url, int currentId, int baselineId, string? currentType = null, string? baselineType = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task List_page_markdown_route_api_page_markdown_getAsync(int crawlRunId, int? page = null, int? limit = null, Q? q = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { - if (url == null) - throw new System.ArgumentNullException("url"); - - if (currentId == null) - throw new System.ArgumentNullException("currentId"); - - if (baselineId == null) - throw new System.ArgumentNullException("baselineId"); + if (crawlRunId == null) + throw new System.ArgumentNullException("crawlRunId"); var client_ = _httpClient; var disposeClient_ = false; @@ -7379,19 +7137,21 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/integrations/google/page-compare" - urlBuilder_.Append("api/integrations/google/page-compare"); + // Operation Path: "api/page-markdown" + urlBuilder_.Append("api/page-markdown"); urlBuilder_.Append('?'); - urlBuilder_.Append(System.Uri.EscapeDataString("url")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(url, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - urlBuilder_.Append(System.Uri.EscapeDataString("currentId")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(currentId, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - urlBuilder_.Append(System.Uri.EscapeDataString("baselineId")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(baselineId, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - if (currentType != null) + urlBuilder_.Append(System.Uri.EscapeDataString("crawlRunId")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(crawlRunId, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + if (page != null) { - urlBuilder_.Append(System.Uri.EscapeDataString("currentType")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(currentType, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + urlBuilder_.Append(System.Uri.EscapeDataString("page")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(page, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); } - if (baselineType != null) + if (limit != null) { - urlBuilder_.Append(System.Uri.EscapeDataString("baselineType")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(baselineType, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + urlBuilder_.Append(System.Uri.EscapeDataString("limit")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(limit, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + if (q != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("q")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(q, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); } urlBuilder_.Length--; @@ -7459,38 +7219,29 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Google Page Live History + /// Delete Page Markdown Route /// - /// - /// Return history of page Google snapshots for a URL. - /// /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Google_page_live_history_api_integrations_google_page_live_history_getAsync(string url, int? limit = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Delete_page_markdown_route_api_page_markdown_deleteAsync(object? body = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { - if (url == null) - throw new System.ArgumentNullException("url"); - var client_ = _httpClient; var disposeClient_ = false; try { using (var request_ = new System.Net.Http.HttpRequestMessage()) { - request_.Method = new System.Net.Http.HttpMethod("GET"); + var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); + var content_ = new System.Net.Http.ByteArrayContent(json_); + content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); + request_.Content = content_; + request_.Method = new System.Net.Http.HttpMethod("DELETE"); request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/integrations/google/page-live/history" - urlBuilder_.Append("api/integrations/google/page-live/history"); - urlBuilder_.Append('?'); - urlBuilder_.Append(System.Uri.EscapeDataString("url")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(url, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - if (limit != null) - { - urlBuilder_.Append(System.Uri.EscapeDataString("limit")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(limit, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - } - urlBuilder_.Length--; + // Operation Path: "api/page-markdown" + urlBuilder_.Append("api/page-markdown"); PrepareRequest(client_, request_, urlBuilder_); @@ -7556,17 +7307,17 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Google Keywords History Batch + /// Page Markdown Content Route /// - /// - /// Batch keyword history: { keywords: str[], limit?: int, propertyId?: int, domain?: str } - /// /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Google_keywords_history_batch_api_integrations_google_keywords_history_batch_postAsync(object body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Page_markdown_content_route_api_page_markdown_content_getAsync(int crawlRunId, string url, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { - if (body == null) - throw new System.ArgumentNullException("body"); + if (crawlRunId == null) + throw new System.ArgumentNullException("crawlRunId"); + + if (url == null) + throw new System.ArgumentNullException("url"); var client_ = _httpClient; var disposeClient_ = false; @@ -7574,17 +7325,17 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() { using (var request_ = new System.Net.Http.HttpRequestMessage()) { - var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); - var content_ = new System.Net.Http.ByteArrayContent(json_); - content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); - request_.Content = content_; - request_.Method = new System.Net.Http.HttpMethod("POST"); + request_.Method = new System.Net.Http.HttpMethod("GET"); request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/integrations/google/keywords/history/batch" - urlBuilder_.Append("api/integrations/google/keywords/history/batch"); + // Operation Path: "api/page-markdown/content" + urlBuilder_.Append("api/page-markdown/content"); + urlBuilder_.Append('?'); + urlBuilder_.Append(System.Uri.EscapeDataString("crawlRunId")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(crawlRunId, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + urlBuilder_.Append(System.Uri.EscapeDataString("url")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(url, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + urlBuilder_.Length--; PrepareRequest(client_, request_, urlBuilder_); @@ -7650,18 +7401,12 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Google Keywords Expand + /// Page Markdown Extract /// - /// - /// Expand keyword ideas from Google Keyword Planner or suggest API. - /// /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Google_keywords_expand_api_integrations_google_keywords_expand_postAsync(object body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Page_markdown_extract_api_page_markdown_extract_postAsync(object? body = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { - if (body == null) - throw new System.ArgumentNullException("body"); - var client_ = _httpClient; var disposeClient_ = false; try @@ -7677,8 +7422,8 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/integrations/google/keywords/expand" - urlBuilder_.Append("api/integrations/google/keywords/expand"); + // Operation Path: "api/page-markdown/extract" + urlBuilder_.Append("api/page-markdown/extract"); PrepareRequest(client_, request_, urlBuilder_); @@ -7744,35 +7489,31 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Google Keywords Planner + /// Page Markdown Runs Route /// - /// - /// Fetch keyword planner data from Google Ads API. - /// /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Google_keywords_planner_api_integrations_google_keywords_planner_postAsync(object body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Page_markdown_runs_route_api_page_markdown_runs_getAsync(Anonymous9? propertyId = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { - if (body == null) - throw new System.ArgumentNullException("body"); - var client_ = _httpClient; var disposeClient_ = false; try { using (var request_ = new System.Net.Http.HttpRequestMessage()) { - var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); - var content_ = new System.Net.Http.ByteArrayContent(json_); - content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); - request_.Content = content_; - request_.Method = new System.Net.Http.HttpMethod("POST"); + request_.Method = new System.Net.Http.HttpMethod("GET"); request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/integrations/google/keywords/planner" - urlBuilder_.Append("api/integrations/google/keywords/planner"); + // Operation Path: "api/page-markdown/runs" + urlBuilder_.Append("api/page-markdown/runs"); + urlBuilder_.Append('?'); + if (propertyId != null) + { + urlBuilder_.Append(System.Uri.EscapeDataString("propertyId")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(propertyId, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); + } + urlBuilder_.Length--; PrepareRequest(client_, request_, urlBuilder_); @@ -7838,11 +7579,11 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// List Issue Status Route + /// Alerts Check /// /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task List_issue_status_route_api_issues_status_getAsync(int propertyId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Alerts_check_api_alerts_check_postAsync(int propertyId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { if (propertyId == null) throw new System.ArgumentNullException("propertyId"); @@ -7853,13 +7594,14 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() { using (var request_ = new System.Net.Http.HttpRequestMessage()) { - request_.Method = new System.Net.Http.HttpMethod("GET"); + request_.Content = new System.Net.Http.StringContent(string.Empty, System.Text.Encoding.UTF8, "application/json"); + request_.Method = new System.Net.Http.HttpMethod("POST"); request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/issues/status" - urlBuilder_.Append("api/issues/status"); + // Operation Path: "api/alerts/check" + urlBuilder_.Append("api/alerts/check"); urlBuilder_.Append('?'); urlBuilder_.Append(System.Uri.EscapeDataString("propertyId")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(propertyId, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); urlBuilder_.Length--; @@ -7928,11 +7670,11 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Upsert Issue Status Route + /// Schedule Check /// /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Upsert_issue_status_route_api_issues_status_putAsync(object? body = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Schedule_check_api_schedule_check_postAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { var client_ = _httpClient; var disposeClient_ = false; @@ -7940,17 +7682,14 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() { using (var request_ = new System.Net.Http.HttpRequestMessage()) { - var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); - var content_ = new System.Net.Http.ByteArrayContent(json_); - content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); - request_.Content = content_; - request_.Method = new System.Net.Http.HttpMethod("PUT"); + request_.Content = new System.Net.Http.StringContent(string.Empty, System.Text.Encoding.UTF8, "application/json"); + request_.Method = new System.Net.Http.HttpMethod("POST"); request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/issues/status" - urlBuilder_.Append("api/issues/status"); + // Operation Path: "api/schedule/check" + urlBuilder_.Append("api/schedule/check"); PrepareRequest(client_, request_, urlBuilder_); @@ -7985,16 +7724,6 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() return objectResponse_.Object; } else - if (status_ == 422) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - throw new FastApiClientException("Validation Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); - } - else { var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); throw new FastApiClientException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); @@ -8016,11 +7745,11 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Issues Fix Suggestion + /// Logs Upload /// /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Issues_fix_suggestion_api_issues_fix_suggestion_postAsync(object? body = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Logs_upload_api_logs_upload_postAsync(int? propertyId = null, string? file = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { var client_ = _httpClient; var disposeClient_ = false; @@ -8028,17 +7757,32 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() { using (var request_ = new System.Net.Http.HttpRequestMessage()) { - var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); - var content_ = new System.Net.Http.ByteArrayContent(json_); - content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); + var boundary_ = System.Guid.NewGuid().ToString(); + var content_ = new System.Net.Http.MultipartFormDataContent(boundary_); + content_.Headers.Remove("Content-Type"); + content_.Headers.TryAddWithoutValidation("Content-Type", "multipart/form-data; boundary=" + boundary_); + + if (propertyId == null) + throw new System.ArgumentNullException("propertyId"); + else + { + content_.Add(new System.Net.Http.StringContent(ConvertToString(propertyId, System.Globalization.CultureInfo.InvariantCulture)), "propertyId"); + } + + if (file == null) + throw new System.ArgumentNullException("file"); + else + { + content_.Add(new System.Net.Http.StringContent(ConvertToString(file, System.Globalization.CultureInfo.InvariantCulture)), "file"); + } request_.Content = content_; request_.Method = new System.Net.Http.HttpMethod("POST"); request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/issues/fix-suggestion" - urlBuilder_.Append("api/issues/fix-suggestion"); + // Operation Path: "api/logs/upload" + urlBuilder_.Append("api/logs/upload"); PrepareRequest(client_, request_, urlBuilder_); @@ -8104,12 +7848,15 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Issues Action Plan + /// Compare Export /// /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Issues_action_plan_api_issues_action_plan_postAsync(object? body = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Compare_export_api_compare_export_postAsync(CompareExportBody body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { + if (body == null) + throw new System.ArgumentNullException("body"); + var client_ = _httpClient; var disposeClient_ = false; try @@ -8125,8 +7872,8 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/issues/action-plan" - urlBuilder_.Append("api/issues/action-plan"); + // Operation Path: "api/compare/export" + urlBuilder_.Append("api/compare/export"); PrepareRequest(client_, request_, urlBuilder_); @@ -8192,12 +7939,15 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// - /// Ai Fix Suggestion + /// Run Audit Tool /// /// Successful Response /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Ai_fix_suggestion_api_ai_fix_suggestion_postAsync(object? body = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + public virtual async System.Threading.Tasks.Task Run_audit_tool_api_report_audit_tool_postAsync(AuditToolBody body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { + if (body == null) + throw new System.ArgumentNullException("body"); + var client_ = _httpClient; var disposeClient_ = false; try @@ -8213,8 +7963,8 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() var urlBuilder_ = new System.Text.StringBuilder(); - // Operation Path: "api/ai/fix-suggestion" - urlBuilder_.Append("api/ai/fix-suggestion"); + // Operation Path: "api/report/audit-tool" + urlBuilder_.Append("api/report/audit-tool"); PrepareRequest(client_, request_, urlBuilder_); @@ -8278,3395 +8028,144 @@ private static System.Text.Json.JsonSerializerOptions CreateSerializerSettings() } } - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Keywords Competitor Import - /// - /// Successful Response - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Keywords_competitor_import_api_keywords_competitor_import_postAsync(object? body = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) + protected struct ObjectResponseResult { - var client_ = _httpClient; - var disposeClient_ = false; - try + public ObjectResponseResult(T responseObject, string responseText) { - using (var request_ = new System.Net.Http.HttpRequestMessage()) - { - var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); - var content_ = new System.Net.Http.ByteArrayContent(json_); - content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); - request_.Content = content_; - request_.Method = new System.Net.Http.HttpMethod("POST"); - request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/keywords/competitor-import" - urlBuilder_.Append("api/keywords/competitor-import"); + this.Object = responseObject; + this.Text = responseText; + } - PrepareRequest(client_, request_, urlBuilder_); + public T Object { get; } - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); + public string Text { get; } + } - PrepareRequest(client_, request_, url_); + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] + private static System.Threading.Tasks.Task ReadAsStringAsync(System.Net.Http.HttpContent content, System.Threading.CancellationToken cancellationToken) + { + #if NET5_0_OR_GREATER + return content.ReadAsStringAsync(cancellationToken); + #else + return content.ReadAsStringAsync(); + #endif + } - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] + private static System.Threading.Tasks.Task ReadAsStreamAsync(System.Net.Http.HttpContent content, System.Threading.CancellationToken cancellationToken) + { + #if NET5_0_OR_GREATER + return content.ReadAsStreamAsync(cancellationToken); + #else + return content.ReadAsStreamAsync(); + #endif + } - ProcessResponse(client_, response_); + public bool ReadResponseAsString { get; set; } - var status_ = (int)response_.StatusCode; - if (status_ == 200) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - return objectResponse_.Object; - } - else - if (status_ == 422) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - throw new FastApiClientException("Validation Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); - } - else - { - var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); - throw new FastApiClientException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally - { - if (disposeResponse_) - response_.Dispose(); - } - } - } - finally + protected virtual async System.Threading.Tasks.Task> ReadObjectResponseAsync(System.Net.Http.HttpResponseMessage response, System.Collections.Generic.IReadOnlyDictionary> headers, System.Threading.CancellationToken cancellationToken) + { + if (response == null || response.Content == null) { - if (disposeClient_) - client_.Dispose(); + return new ObjectResponseResult(default(T)!, string.Empty); } - } - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Keywords Content Brief - /// - /// Successful Response - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Keywords_content_brief_api_keywords_content_brief_postAsync(object? body = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) - { - var client_ = _httpClient; - var disposeClient_ = false; - try + if (ReadResponseAsString) { - using (var request_ = new System.Net.Http.HttpRequestMessage()) + var responseText = await ReadAsStringAsync(response.Content, cancellationToken).ConfigureAwait(false); + try { - var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); - var content_ = new System.Net.Http.ByteArrayContent(json_); - content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); - request_.Content = content_; - request_.Method = new System.Net.Http.HttpMethod("POST"); - request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/keywords/content-brief" - urlBuilder_.Append("api/keywords/content-brief"); - - PrepareRequest(client_, request_, urlBuilder_); - - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); - - PrepareRequest(client_, request_, url_); - - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } - - ProcessResponse(client_, response_); - - var status_ = (int)response_.StatusCode; - if (status_ == 200) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - return objectResponse_.Object; - } - else - if (status_ == 422) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - throw new FastApiClientException("Validation Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); - } - else - { - var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); - throw new FastApiClientException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally - { - if (disposeResponse_) - response_.Dispose(); - } + var typedBody = System.Text.Json.JsonSerializer.Deserialize(responseText, JsonSerializerSettings); + return new ObjectResponseResult(typedBody!, responseText); } - } - finally - { - if (disposeClient_) - client_.Dispose(); - } - } - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Backlinks Velocity - /// - /// Successful Response - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Backlinks_velocity_api_backlinks_velocity_getAsync(int propertyId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) - { - if (propertyId == null) - throw new System.ArgumentNullException("propertyId"); - - var client_ = _httpClient; - var disposeClient_ = false; - try - { - using (var request_ = new System.Net.Http.HttpRequestMessage()) + catch (System.Text.Json.JsonException exception) { - request_.Method = new System.Net.Http.HttpMethod("GET"); - request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/backlinks/velocity" - urlBuilder_.Append("api/backlinks/velocity"); - urlBuilder_.Append('?'); - urlBuilder_.Append(System.Uri.EscapeDataString("propertyId")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(propertyId, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - urlBuilder_.Length--; - - PrepareRequest(client_, request_, urlBuilder_); - - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); - - PrepareRequest(client_, request_, url_); - - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } - - ProcessResponse(client_, response_); - - var status_ = (int)response_.StatusCode; - if (status_ == 200) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - return objectResponse_.Object; - } - else - if (status_ == 422) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - throw new FastApiClientException("Validation Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); - } - else - { - var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); - throw new FastApiClientException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally - { - if (disposeResponse_) - response_.Dispose(); - } + var message = "Could not deserialize the response body string as " + typeof(T).FullName + "."; + throw new FastApiClientException(message, (int)response.StatusCode, responseText, headers, exception); } } - finally - { - if (disposeClient_) - client_.Dispose(); - } - } - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Backlinks Competitor Import - /// - /// Successful Response - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Backlinks_competitor_import_api_backlinks_competitor_import_postAsync(object? body = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) - { - var client_ = _httpClient; - var disposeClient_ = false; - try + else { - using (var request_ = new System.Net.Http.HttpRequestMessage()) + try { - var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); - var content_ = new System.Net.Http.ByteArrayContent(json_); - content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); - request_.Content = content_; - request_.Method = new System.Net.Http.HttpMethod("POST"); - request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/backlinks/competitor-import" - urlBuilder_.Append("api/backlinks/competitor-import"); - - PrepareRequest(client_, request_, urlBuilder_); - - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); - - PrepareRequest(client_, request_, url_); - - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } - - ProcessResponse(client_, response_); - - var status_ = (int)response_.StatusCode; - if (status_ == 200) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - return objectResponse_.Object; - } - else - if (status_ == 422) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - throw new FastApiClientException("Validation Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); - } - else - { - var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); - throw new FastApiClientException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally + using (var responseStream = await ReadAsStreamAsync(response.Content, cancellationToken).ConfigureAwait(false)) { - if (disposeResponse_) - response_.Dispose(); + var typedBody = await System.Text.Json.JsonSerializer.DeserializeAsync(responseStream, JsonSerializerSettings, cancellationToken).ConfigureAwait(false); + return new ObjectResponseResult(typedBody!, string.Empty); } } + catch (System.Text.Json.JsonException exception) + { + var message = "Could not deserialize the response body stream as " + typeof(T).FullName + "."; + throw new FastApiClientException(message, (int)response.StatusCode, string.Empty, headers, exception); + } } - finally - { - if (disposeClient_) - client_.Dispose(); - } - } - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Backlinks Third Party Import - /// - /// Successful Response - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Backlinks_third_party_import_api_backlinks_third_party_import_postAsync(object? body = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) - { - var client_ = _httpClient; - var disposeClient_ = false; - try - { - using (var request_ = new System.Net.Http.HttpRequestMessage()) - { - var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); - var content_ = new System.Net.Http.ByteArrayContent(json_); - content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); - request_.Content = content_; - request_.Method = new System.Net.Http.HttpMethod("POST"); - request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/backlinks/third-party-import" - urlBuilder_.Append("api/backlinks/third-party-import"); - - PrepareRequest(client_, request_, urlBuilder_); - - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); - - PrepareRequest(client_, request_, url_); - - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } - - ProcessResponse(client_, response_); - - var status_ = (int)response_.StatusCode; - if (status_ == 200) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - return objectResponse_.Object; - } - else - if (status_ == 422) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - throw new FastApiClientException("Validation Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); - } - else - { - var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); - throw new FastApiClientException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally - { - if (disposeResponse_) - response_.Dispose(); - } - } - } - finally - { - if (disposeClient_) - client_.Dispose(); - } - } - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Content Analyze - /// - /// Successful Response - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Content_analyze_api_content_analyze_postAsync(object? body = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) - { - var client_ = _httpClient; - var disposeClient_ = false; - try - { - using (var request_ = new System.Net.Http.HttpRequestMessage()) - { - var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); - var content_ = new System.Net.Http.ByteArrayContent(json_); - content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); - request_.Content = content_; - request_.Method = new System.Net.Http.HttpMethod("POST"); - request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/content/analyze" - urlBuilder_.Append("api/content/analyze"); - - PrepareRequest(client_, request_, urlBuilder_); - - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); - - PrepareRequest(client_, request_, url_); - - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } - - ProcessResponse(client_, response_); - - var status_ = (int)response_.StatusCode; - if (status_ == 200) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - return objectResponse_.Object; - } - else - if (status_ == 422) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - throw new FastApiClientException("Validation Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); - } - else - { - var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); - throw new FastApiClientException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally - { - if (disposeResponse_) - response_.Dispose(); - } - } - } - finally - { - if (disposeClient_) - client_.Dispose(); - } - } - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Content Score - /// - /// Successful Response - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Content_score_api_content_score_postAsync(object? body = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) - { - var client_ = _httpClient; - var disposeClient_ = false; - try - { - using (var request_ = new System.Net.Http.HttpRequestMessage()) - { - var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); - var content_ = new System.Net.Http.ByteArrayContent(json_); - content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); - request_.Content = content_; - request_.Method = new System.Net.Http.HttpMethod("POST"); - request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/content/score" - urlBuilder_.Append("api/content/score"); - - PrepareRequest(client_, request_, urlBuilder_); - - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); - - PrepareRequest(client_, request_, url_); - - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } - - ProcessResponse(client_, response_); - - var status_ = (int)response_.StatusCode; - if (status_ == 200) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - return objectResponse_.Object; - } - else - if (status_ == 422) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - throw new FastApiClientException("Validation Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); - } - else - { - var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); - throw new FastApiClientException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally - { - if (disposeResponse_) - response_.Dispose(); - } - } - } - finally - { - if (disposeClient_) - client_.Dispose(); - } - } - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Content Wizard - /// - /// Successful Response - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Content_wizard_api_content_wizard_postAsync(object? body = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) - { - var client_ = _httpClient; - var disposeClient_ = false; - try - { - using (var request_ = new System.Net.Http.HttpRequestMessage()) - { - var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); - var content_ = new System.Net.Http.ByteArrayContent(json_); - content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); - request_.Content = content_; - request_.Method = new System.Net.Http.HttpMethod("POST"); - request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/content/wizard" - urlBuilder_.Append("api/content/wizard"); - - PrepareRequest(client_, request_, urlBuilder_); - - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); - - PrepareRequest(client_, request_, url_); - - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } - - ProcessResponse(client_, response_); - - var status_ = (int)response_.StatusCode; - if (status_ == 200) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - return objectResponse_.Object; - } - else - if (status_ == 422) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - throw new FastApiClientException("Validation Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); - } - else - { - var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); - throw new FastApiClientException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally - { - if (disposeResponse_) - response_.Dispose(); - } - } - } - finally - { - if (disposeClient_) - client_.Dispose(); - } - } - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// List Content Drafts Route - /// - /// Successful Response - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task List_content_drafts_route_api_content_drafts_getAsync(int propertyId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) - { - if (propertyId == null) - throw new System.ArgumentNullException("propertyId"); - - var client_ = _httpClient; - var disposeClient_ = false; - try - { - using (var request_ = new System.Net.Http.HttpRequestMessage()) - { - request_.Method = new System.Net.Http.HttpMethod("GET"); - request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/content-drafts" - urlBuilder_.Append("api/content-drafts"); - urlBuilder_.Append('?'); - urlBuilder_.Append(System.Uri.EscapeDataString("propertyId")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(propertyId, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - urlBuilder_.Length--; - - PrepareRequest(client_, request_, urlBuilder_); - - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); - - PrepareRequest(client_, request_, url_); - - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } - - ProcessResponse(client_, response_); - - var status_ = (int)response_.StatusCode; - if (status_ == 200) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - return objectResponse_.Object; - } - else - if (status_ == 422) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - throw new FastApiClientException("Validation Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); - } - else - { - var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); - throw new FastApiClientException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally - { - if (disposeResponse_) - response_.Dispose(); - } - } - } - finally - { - if (disposeClient_) - client_.Dispose(); - } - } - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Create Content Draft Route - /// - /// Successful Response - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Create_content_draft_route_api_content_drafts_postAsync(object? body = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) - { - var client_ = _httpClient; - var disposeClient_ = false; - try - { - using (var request_ = new System.Net.Http.HttpRequestMessage()) - { - var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); - var content_ = new System.Net.Http.ByteArrayContent(json_); - content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); - request_.Content = content_; - request_.Method = new System.Net.Http.HttpMethod("POST"); - request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/content-drafts" - urlBuilder_.Append("api/content-drafts"); - - PrepareRequest(client_, request_, urlBuilder_); - - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); - - PrepareRequest(client_, request_, url_); - - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } - - ProcessResponse(client_, response_); - - var status_ = (int)response_.StatusCode; - if (status_ == 200) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - return objectResponse_.Object; - } - else - if (status_ == 422) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - throw new FastApiClientException("Validation Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); - } - else - { - var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); - throw new FastApiClientException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally - { - if (disposeResponse_) - response_.Dispose(); - } - } - } - finally - { - if (disposeClient_) - client_.Dispose(); - } - } - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Get Content Draft Route - /// - /// Successful Response - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Get_content_draft_route_api_content_drafts__draft_id__getAsync(int draft_id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) - { - if (draft_id == null) - throw new System.ArgumentNullException("draft_id"); - - var client_ = _httpClient; - var disposeClient_ = false; - try - { - using (var request_ = new System.Net.Http.HttpRequestMessage()) - { - request_.Method = new System.Net.Http.HttpMethod("GET"); - request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/content-drafts/{draft_id}" - urlBuilder_.Append("api/content-drafts/"); - urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(draft_id, System.Globalization.CultureInfo.InvariantCulture))); - - PrepareRequest(client_, request_, urlBuilder_); - - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); - - PrepareRequest(client_, request_, url_); - - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } - - ProcessResponse(client_, response_); - - var status_ = (int)response_.StatusCode; - if (status_ == 200) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - return objectResponse_.Object; - } - else - if (status_ == 422) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - throw new FastApiClientException("Validation Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); - } - else - { - var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); - throw new FastApiClientException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally - { - if (disposeResponse_) - response_.Dispose(); - } - } - } - finally - { - if (disposeClient_) - client_.Dispose(); - } - } - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Update Content Draft Route - /// - /// Successful Response - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Update_content_draft_route_api_content_drafts__draft_id__patchAsync(int draft_id, object? body = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) - { - if (draft_id == null) - throw new System.ArgumentNullException("draft_id"); - - var client_ = _httpClient; - var disposeClient_ = false; - try - { - using (var request_ = new System.Net.Http.HttpRequestMessage()) - { - var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); - var content_ = new System.Net.Http.ByteArrayContent(json_); - content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); - request_.Content = content_; - request_.Method = new System.Net.Http.HttpMethod("PATCH"); - request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/content-drafts/{draft_id}" - urlBuilder_.Append("api/content-drafts/"); - urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(draft_id, System.Globalization.CultureInfo.InvariantCulture))); - - PrepareRequest(client_, request_, urlBuilder_); - - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); - - PrepareRequest(client_, request_, url_); - - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } - - ProcessResponse(client_, response_); - - var status_ = (int)response_.StatusCode; - if (status_ == 200) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - return objectResponse_.Object; - } - else - if (status_ == 422) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - throw new FastApiClientException("Validation Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); - } - else - { - var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); - throw new FastApiClientException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally - { - if (disposeResponse_) - response_.Dispose(); - } - } - } - finally - { - if (disposeClient_) - client_.Dispose(); - } - } - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Delete Content Draft Route - /// - /// Successful Response - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Delete_content_draft_route_api_content_drafts__draft_id__deleteAsync(int draft_id, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) - { - if (draft_id == null) - throw new System.ArgumentNullException("draft_id"); - - var client_ = _httpClient; - var disposeClient_ = false; - try - { - using (var request_ = new System.Net.Http.HttpRequestMessage()) - { - request_.Method = new System.Net.Http.HttpMethod("DELETE"); - request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/content-drafts/{draft_id}" - urlBuilder_.Append("api/content-drafts/"); - urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(draft_id, System.Globalization.CultureInfo.InvariantCulture))); - - PrepareRequest(client_, request_, urlBuilder_); - - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); - - PrepareRequest(client_, request_, url_); - - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } - - ProcessResponse(client_, response_); - - var status_ = (int)response_.StatusCode; - if (status_ == 200) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - return objectResponse_.Object; - } - else - if (status_ == 422) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - throw new FastApiClientException("Validation Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); - } - else - { - var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); - throw new FastApiClientException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally - { - if (disposeResponse_) - response_.Dispose(); - } - } - } - finally - { - if (disposeClient_) - client_.Dispose(); - } - } - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// List Page Markdown Route - /// - /// Successful Response - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task List_page_markdown_route_api_page_markdown_getAsync(int crawlRunId, int? page = null, int? limit = null, Q? q = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) - { - if (crawlRunId == null) - throw new System.ArgumentNullException("crawlRunId"); - - var client_ = _httpClient; - var disposeClient_ = false; - try - { - using (var request_ = new System.Net.Http.HttpRequestMessage()) - { - request_.Method = new System.Net.Http.HttpMethod("GET"); - request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/page-markdown" - urlBuilder_.Append("api/page-markdown"); - urlBuilder_.Append('?'); - urlBuilder_.Append(System.Uri.EscapeDataString("crawlRunId")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(crawlRunId, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - if (page != null) - { - urlBuilder_.Append(System.Uri.EscapeDataString("page")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(page, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - } - if (limit != null) - { - urlBuilder_.Append(System.Uri.EscapeDataString("limit")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(limit, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - } - if (q != null) - { - urlBuilder_.Append(System.Uri.EscapeDataString("q")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(q, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - } - urlBuilder_.Length--; - - PrepareRequest(client_, request_, urlBuilder_); - - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); - - PrepareRequest(client_, request_, url_); - - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } - - ProcessResponse(client_, response_); - - var status_ = (int)response_.StatusCode; - if (status_ == 200) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - return objectResponse_.Object; - } - else - if (status_ == 422) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - throw new FastApiClientException("Validation Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); - } - else - { - var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); - throw new FastApiClientException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally - { - if (disposeResponse_) - response_.Dispose(); - } - } - } - finally - { - if (disposeClient_) - client_.Dispose(); - } - } - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Delete Page Markdown Route - /// - /// Successful Response - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Delete_page_markdown_route_api_page_markdown_deleteAsync(object? body = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) - { - var client_ = _httpClient; - var disposeClient_ = false; - try - { - using (var request_ = new System.Net.Http.HttpRequestMessage()) - { - var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); - var content_ = new System.Net.Http.ByteArrayContent(json_); - content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); - request_.Content = content_; - request_.Method = new System.Net.Http.HttpMethod("DELETE"); - request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/page-markdown" - urlBuilder_.Append("api/page-markdown"); - - PrepareRequest(client_, request_, urlBuilder_); - - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); - - PrepareRequest(client_, request_, url_); - - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } - - ProcessResponse(client_, response_); - - var status_ = (int)response_.StatusCode; - if (status_ == 200) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - return objectResponse_.Object; - } - else - if (status_ == 422) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - throw new FastApiClientException("Validation Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); - } - else - { - var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); - throw new FastApiClientException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally - { - if (disposeResponse_) - response_.Dispose(); - } - } - } - finally - { - if (disposeClient_) - client_.Dispose(); - } - } - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Page Markdown Content Route - /// - /// Successful Response - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Page_markdown_content_route_api_page_markdown_content_getAsync(int crawlRunId, string url, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) - { - if (crawlRunId == null) - throw new System.ArgumentNullException("crawlRunId"); - - if (url == null) - throw new System.ArgumentNullException("url"); - - var client_ = _httpClient; - var disposeClient_ = false; - try - { - using (var request_ = new System.Net.Http.HttpRequestMessage()) - { - request_.Method = new System.Net.Http.HttpMethod("GET"); - request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/page-markdown/content" - urlBuilder_.Append("api/page-markdown/content"); - urlBuilder_.Append('?'); - urlBuilder_.Append(System.Uri.EscapeDataString("crawlRunId")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(crawlRunId, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - urlBuilder_.Append(System.Uri.EscapeDataString("url")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(url, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - urlBuilder_.Length--; - - PrepareRequest(client_, request_, urlBuilder_); - - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); - - PrepareRequest(client_, request_, url_); - - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } - - ProcessResponse(client_, response_); - - var status_ = (int)response_.StatusCode; - if (status_ == 200) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - return objectResponse_.Object; - } - else - if (status_ == 422) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - throw new FastApiClientException("Validation Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); - } - else - { - var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); - throw new FastApiClientException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally - { - if (disposeResponse_) - response_.Dispose(); - } - } - } - finally - { - if (disposeClient_) - client_.Dispose(); - } - } - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Page Markdown Extract - /// - /// Successful Response - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Page_markdown_extract_api_page_markdown_extract_postAsync(object? body = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) - { - var client_ = _httpClient; - var disposeClient_ = false; - try - { - using (var request_ = new System.Net.Http.HttpRequestMessage()) - { - var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); - var content_ = new System.Net.Http.ByteArrayContent(json_); - content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); - request_.Content = content_; - request_.Method = new System.Net.Http.HttpMethod("POST"); - request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/page-markdown/extract" - urlBuilder_.Append("api/page-markdown/extract"); - - PrepareRequest(client_, request_, urlBuilder_); - - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); - - PrepareRequest(client_, request_, url_); - - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } - - ProcessResponse(client_, response_); - - var status_ = (int)response_.StatusCode; - if (status_ == 200) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - return objectResponse_.Object; - } - else - if (status_ == 422) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - throw new FastApiClientException("Validation Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); - } - else - { - var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); - throw new FastApiClientException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally - { - if (disposeResponse_) - response_.Dispose(); - } - } - } - finally - { - if (disposeClient_) - client_.Dispose(); - } - } - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Page Markdown Runs Route - /// - /// Successful Response - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Page_markdown_runs_route_api_page_markdown_runs_getAsync(Anonymous13? propertyId = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) - { - var client_ = _httpClient; - var disposeClient_ = false; - try - { - using (var request_ = new System.Net.Http.HttpRequestMessage()) - { - request_.Method = new System.Net.Http.HttpMethod("GET"); - request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/page-markdown/runs" - urlBuilder_.Append("api/page-markdown/runs"); - urlBuilder_.Append('?'); - if (propertyId != null) - { - urlBuilder_.Append(System.Uri.EscapeDataString("propertyId")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(propertyId, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - } - urlBuilder_.Length--; - - PrepareRequest(client_, request_, urlBuilder_); - - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); - - PrepareRequest(client_, request_, url_); - - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } - - ProcessResponse(client_, response_); - - var status_ = (int)response_.StatusCode; - if (status_ == 200) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - return objectResponse_.Object; - } - else - if (status_ == 422) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - throw new FastApiClientException("Validation Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); - } - else - { - var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); - throw new FastApiClientException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally - { - if (disposeResponse_) - response_.Dispose(); - } - } - } - finally - { - if (disposeClient_) - client_.Dispose(); - } - } - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Ollama Status - /// - /// Successful Response - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Ollama_status_api_ollama_status_getAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) - { - var client_ = _httpClient; - var disposeClient_ = false; - try - { - using (var request_ = new System.Net.Http.HttpRequestMessage()) - { - request_.Method = new System.Net.Http.HttpMethod("GET"); - request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/ollama/status" - urlBuilder_.Append("api/ollama/status"); - - PrepareRequest(client_, request_, urlBuilder_); - - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); - - PrepareRequest(client_, request_, url_); - - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } - - ProcessResponse(client_, response_); - - var status_ = (int)response_.StatusCode; - if (status_ == 200) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - return objectResponse_.Object; - } - else - { - var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); - throw new FastApiClientException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally - { - if (disposeResponse_) - response_.Dispose(); - } - } - } - finally - { - if (disposeClient_) - client_.Dispose(); - } - } - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Mcp Tools - /// - /// Successful Response - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Mcp_tools_api_mcp_tools_getAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) - { - var client_ = _httpClient; - var disposeClient_ = false; - try - { - using (var request_ = new System.Net.Http.HttpRequestMessage()) - { - request_.Method = new System.Net.Http.HttpMethod("GET"); - request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/mcp-tools" - urlBuilder_.Append("api/mcp-tools"); - - PrepareRequest(client_, request_, urlBuilder_); - - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); - - PrepareRequest(client_, request_, url_); - - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } - - ProcessResponse(client_, response_); - - var status_ = (int)response_.StatusCode; - if (status_ == 200) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - return objectResponse_.Object; - } - else - { - var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); - throw new FastApiClientException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally - { - if (disposeResponse_) - response_.Dispose(); - } - } - } - finally - { - if (disposeClient_) - client_.Dispose(); - } - } - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Delete Portfolio Item - /// - /// Successful Response - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Delete_portfolio_item_api_portfolio_delete_deleteAsync(DeletePortfolioBody body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) - { - if (body == null) - throw new System.ArgumentNullException("body"); - - var client_ = _httpClient; - var disposeClient_ = false; - try - { - using (var request_ = new System.Net.Http.HttpRequestMessage()) - { - var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); - var content_ = new System.Net.Http.ByteArrayContent(json_); - content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); - request_.Content = content_; - request_.Method = new System.Net.Http.HttpMethod("DELETE"); - request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/portfolio/delete" - urlBuilder_.Append("api/portfolio/delete"); - - PrepareRequest(client_, request_, urlBuilder_); - - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); - - PrepareRequest(client_, request_, url_); - - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } - - ProcessResponse(client_, response_); - - var status_ = (int)response_.StatusCode; - if (status_ == 200) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - return objectResponse_.Object; - } - else - if (status_ == 422) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - throw new FastApiClientException("Validation Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); - } - else - { - var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); - throw new FastApiClientException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally - { - if (disposeResponse_) - response_.Dispose(); - } - } - } - finally - { - if (disposeClient_) - client_.Dispose(); - } - } - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Alerts Check - /// - /// Successful Response - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Alerts_check_api_alerts_check_postAsync(int propertyId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) - { - if (propertyId == null) - throw new System.ArgumentNullException("propertyId"); - - var client_ = _httpClient; - var disposeClient_ = false; - try - { - using (var request_ = new System.Net.Http.HttpRequestMessage()) - { - request_.Content = new System.Net.Http.StringContent(string.Empty, System.Text.Encoding.UTF8, "application/json"); - request_.Method = new System.Net.Http.HttpMethod("POST"); - request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/alerts/check" - urlBuilder_.Append("api/alerts/check"); - urlBuilder_.Append('?'); - urlBuilder_.Append(System.Uri.EscapeDataString("propertyId")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(propertyId, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - urlBuilder_.Length--; - - PrepareRequest(client_, request_, urlBuilder_); - - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); - - PrepareRequest(client_, request_, url_); - - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } - - ProcessResponse(client_, response_); - - var status_ = (int)response_.StatusCode; - if (status_ == 200) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - return objectResponse_.Object; - } - else - if (status_ == 422) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - throw new FastApiClientException("Validation Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); - } - else - { - var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); - throw new FastApiClientException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally - { - if (disposeResponse_) - response_.Dispose(); - } - } - } - finally - { - if (disposeClient_) - client_.Dispose(); - } - } - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Schedule Check - /// - /// Successful Response - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Schedule_check_api_schedule_check_postAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) - { - var client_ = _httpClient; - var disposeClient_ = false; - try - { - using (var request_ = new System.Net.Http.HttpRequestMessage()) - { - request_.Content = new System.Net.Http.StringContent(string.Empty, System.Text.Encoding.UTF8, "application/json"); - request_.Method = new System.Net.Http.HttpMethod("POST"); - request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/schedule/check" - urlBuilder_.Append("api/schedule/check"); - - PrepareRequest(client_, request_, urlBuilder_); - - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); - - PrepareRequest(client_, request_, url_); - - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } - - ProcessResponse(client_, response_); - - var status_ = (int)response_.StatusCode; - if (status_ == 200) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - return objectResponse_.Object; - } - else - { - var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); - throw new FastApiClientException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally - { - if (disposeResponse_) - response_.Dispose(); - } - } - } - finally - { - if (disposeClient_) - client_.Dispose(); - } - } - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Logs Upload - /// - /// Successful Response - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Logs_upload_api_logs_upload_postAsync(int? propertyId = null, string? file = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) - { - var client_ = _httpClient; - var disposeClient_ = false; - try - { - using (var request_ = new System.Net.Http.HttpRequestMessage()) - { - var boundary_ = System.Guid.NewGuid().ToString(); - var content_ = new System.Net.Http.MultipartFormDataContent(boundary_); - content_.Headers.Remove("Content-Type"); - content_.Headers.TryAddWithoutValidation("Content-Type", "multipart/form-data; boundary=" + boundary_); - - if (propertyId == null) - throw new System.ArgumentNullException("propertyId"); - else - { - content_.Add(new System.Net.Http.StringContent(ConvertToString(propertyId, System.Globalization.CultureInfo.InvariantCulture)), "propertyId"); - } - - if (file == null) - throw new System.ArgumentNullException("file"); - else - { - content_.Add(new System.Net.Http.StringContent(ConvertToString(file, System.Globalization.CultureInfo.InvariantCulture)), "file"); - } - request_.Content = content_; - request_.Method = new System.Net.Http.HttpMethod("POST"); - request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/logs/upload" - urlBuilder_.Append("api/logs/upload"); - - PrepareRequest(client_, request_, urlBuilder_); - - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); - - PrepareRequest(client_, request_, url_); - - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } - - ProcessResponse(client_, response_); - - var status_ = (int)response_.StatusCode; - if (status_ == 200) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - return objectResponse_.Object; - } - else - if (status_ == 422) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - throw new FastApiClientException("Validation Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); - } - else - { - var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); - throw new FastApiClientException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally - { - if (disposeResponse_) - response_.Dispose(); - } - } - } - finally - { - if (disposeClient_) - client_.Dispose(); - } - } - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Compare Export - /// - /// Successful Response - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Compare_export_api_compare_export_postAsync(CompareExportBody body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) - { - if (body == null) - throw new System.ArgumentNullException("body"); - - var client_ = _httpClient; - var disposeClient_ = false; - try - { - using (var request_ = new System.Net.Http.HttpRequestMessage()) - { - var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); - var content_ = new System.Net.Http.ByteArrayContent(json_); - content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); - request_.Content = content_; - request_.Method = new System.Net.Http.HttpMethod("POST"); - request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/compare/export" - urlBuilder_.Append("api/compare/export"); - - PrepareRequest(client_, request_, urlBuilder_); - - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); - - PrepareRequest(client_, request_, url_); - - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } - - ProcessResponse(client_, response_); - - var status_ = (int)response_.StatusCode; - if (status_ == 200) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - return objectResponse_.Object; - } - else - if (status_ == 422) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - throw new FastApiClientException("Validation Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); - } - else - { - var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); - throw new FastApiClientException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally - { - if (disposeResponse_) - response_.Dispose(); - } - } - } - finally - { - if (disposeClient_) - client_.Dispose(); - } - } - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Page Coach - /// - /// Successful Response - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Page_coach_api_links_page_coach_postAsync(PageCoachBody body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) - { - if (body == null) - throw new System.ArgumentNullException("body"); - - var client_ = _httpClient; - var disposeClient_ = false; - try - { - using (var request_ = new System.Net.Http.HttpRequestMessage()) - { - var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); - var content_ = new System.Net.Http.ByteArrayContent(json_); - content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); - request_.Content = content_; - request_.Method = new System.Net.Http.HttpMethod("POST"); - request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/links/page-coach" - urlBuilder_.Append("api/links/page-coach"); - - PrepareRequest(client_, request_, urlBuilder_); - - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); - - PrepareRequest(client_, request_, url_); - - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } - - ProcessResponse(client_, response_); - - var status_ = (int)response_.StatusCode; - if (status_ == 200) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - return objectResponse_.Object; - } - else - if (status_ == 422) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - throw new FastApiClientException("Validation Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); - } - else - { - var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); - throw new FastApiClientException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally - { - if (disposeResponse_) - response_.Dispose(); - } - } - } - finally - { - if (disposeClient_) - client_.Dispose(); - } - } - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Run Audit Tool - /// - /// Successful Response - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Run_audit_tool_api_report_audit_tool_postAsync(AuditToolBody body, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) - { - if (body == null) - throw new System.ArgumentNullException("body"); - - var client_ = _httpClient; - var disposeClient_ = false; - try - { - using (var request_ = new System.Net.Http.HttpRequestMessage()) - { - var json_ = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(body, JsonSerializerSettings); - var content_ = new System.Net.Http.ByteArrayContent(json_); - content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json"); - request_.Content = content_; - request_.Method = new System.Net.Http.HttpMethod("POST"); - request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/report/audit-tool" - urlBuilder_.Append("api/report/audit-tool"); - - PrepareRequest(client_, request_, urlBuilder_); - - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); - - PrepareRequest(client_, request_, url_); - - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } - - ProcessResponse(client_, response_); - - var status_ = (int)response_.StatusCode; - if (status_ == 200) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - return objectResponse_.Object; - } - else - if (status_ == 422) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - throw new FastApiClientException("Validation Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); - } - else - { - var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); - throw new FastApiClientException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally - { - if (disposeResponse_) - response_.Dispose(); - } - } - } - finally - { - if (disposeClient_) - client_.Dispose(); - } - } - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Export Report - /// - /// Successful Response - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Export_report_api_report_export_getAsync(string? format = null, Anonymous14? reportId = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) - { - var client_ = _httpClient; - var disposeClient_ = false; - try - { - using (var request_ = new System.Net.Http.HttpRequestMessage()) - { - request_.Method = new System.Net.Http.HttpMethod("GET"); - request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/report/export" - urlBuilder_.Append("api/report/export"); - urlBuilder_.Append('?'); - if (format != null) - { - urlBuilder_.Append(System.Uri.EscapeDataString("format")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(format, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - } - if (reportId != null) - { - urlBuilder_.Append(System.Uri.EscapeDataString("reportId")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(reportId, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - } - urlBuilder_.Length--; - - PrepareRequest(client_, request_, urlBuilder_); - - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); - - PrepareRequest(client_, request_, url_); - - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } - - ProcessResponse(client_, response_); - - var status_ = (int)response_.StatusCode; - if (status_ == 200) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - return objectResponse_.Object; - } - else - if (status_ == 422) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - throw new FastApiClientException("Validation Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); - } - else - { - var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); - throw new FastApiClientException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally - { - if (disposeResponse_) - response_.Dispose(); - } - } - } - finally - { - if (disposeClient_) - client_.Dispose(); - } - } - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Export Sitemap - /// - /// Successful Response - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Export_sitemap_api_report_export_sitemap_getAsync(Anonymous15? reportId = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) - { - var client_ = _httpClient; - var disposeClient_ = false; - try - { - using (var request_ = new System.Net.Http.HttpRequestMessage()) - { - request_.Method = new System.Net.Http.HttpMethod("GET"); - request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/report/export-sitemap" - urlBuilder_.Append("api/report/export-sitemap"); - urlBuilder_.Append('?'); - if (reportId != null) - { - urlBuilder_.Append(System.Uri.EscapeDataString("reportId")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(reportId, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - } - urlBuilder_.Length--; - - PrepareRequest(client_, request_, urlBuilder_); - - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); - - PrepareRequest(client_, request_, url_); - - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } - - ProcessResponse(client_, response_); - - var status_ = (int)response_.StatusCode; - if (status_ == 200) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - return objectResponse_.Object; - } - else - if (status_ == 422) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - throw new FastApiClientException("Validation Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); - } - else - { - var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); - throw new FastApiClientException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally - { - if (disposeResponse_) - response_.Dispose(); - } - } - } - finally - { - if (disposeClient_) - client_.Dispose(); - } - } - - /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. - /// - /// Report Portfolio - /// - /// - /// Return portfolio data — groups, crawl history, summary, or single card. - /// - /// Successful Response - /// A server side error occurred. - public virtual async System.Threading.Tasks.Task Report_portfolio_api_report_portfolio_getAsync(string? widget = null, Ids? ids = null, Anonymous16? reportId = null, Anonymous17? crawlRunId = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) - { - var client_ = _httpClient; - var disposeClient_ = false; - try - { - using (var request_ = new System.Net.Http.HttpRequestMessage()) - { - request_.Method = new System.Net.Http.HttpMethod("GET"); - request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json")); - - var urlBuilder_ = new System.Text.StringBuilder(); - - // Operation Path: "api/report/portfolio" - urlBuilder_.Append("api/report/portfolio"); - urlBuilder_.Append('?'); - if (widget != null) - { - urlBuilder_.Append(System.Uri.EscapeDataString("widget")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(widget, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - } - if (ids != null) - { - urlBuilder_.Append(System.Uri.EscapeDataString("ids")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(ids, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - } - if (reportId != null) - { - urlBuilder_.Append(System.Uri.EscapeDataString("reportId")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(reportId, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - } - if (crawlRunId != null) - { - urlBuilder_.Append(System.Uri.EscapeDataString("crawlRunId")).Append('=').Append(System.Uri.EscapeDataString(ConvertToString(crawlRunId, System.Globalization.CultureInfo.InvariantCulture))).Append('&'); - } - urlBuilder_.Length--; - - PrepareRequest(client_, request_, urlBuilder_); - - var url_ = urlBuilder_.ToString(); - request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute); - - PrepareRequest(client_, request_, url_); - - var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - var disposeResponse_ = true; - try - { - var headers_ = new System.Collections.Generic.Dictionary>(); - foreach (var item_ in response_.Headers) - headers_[item_.Key] = item_.Value; - if (response_.Content != null && response_.Content.Headers != null) - { - foreach (var item_ in response_.Content.Headers) - headers_[item_.Key] = item_.Value; - } - - ProcessResponse(client_, response_); - - var status_ = (int)response_.StatusCode; - if (status_ == 200) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - return objectResponse_.Object; - } - else - if (status_ == 422) - { - var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false); - if (objectResponse_.Object == null) - { - throw new FastApiClientException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null); - } - throw new FastApiClientException("Validation Error", status_, objectResponse_.Text, headers_, objectResponse_.Object, null); - } - else - { - var responseData_ = response_.Content == null ? null : await ReadAsStringAsync(response_.Content, cancellationToken).ConfigureAwait(false); - throw new FastApiClientException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null); - } - } - finally - { - if (disposeResponse_) - response_.Dispose(); - } - } - } - finally - { - if (disposeClient_) - client_.Dispose(); - } - } - - protected struct ObjectResponseResult - { - public ObjectResponseResult(T responseObject, string responseText) - { - this.Object = responseObject; - this.Text = responseText; - } - - public T Object { get; } - - public string Text { get; } - } - - [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] - private static System.Threading.Tasks.Task ReadAsStringAsync(System.Net.Http.HttpContent content, System.Threading.CancellationToken cancellationToken) - { - #if NET5_0_OR_GREATER - return content.ReadAsStringAsync(cancellationToken); - #else - return content.ReadAsStringAsync(); - #endif - } - - [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] - private static System.Threading.Tasks.Task ReadAsStreamAsync(System.Net.Http.HttpContent content, System.Threading.CancellationToken cancellationToken) - { - #if NET5_0_OR_GREATER - return content.ReadAsStreamAsync(cancellationToken); - #else - return content.ReadAsStreamAsync(); - #endif - } - - public bool ReadResponseAsString { get; set; } - - protected virtual async System.Threading.Tasks.Task> ReadObjectResponseAsync(System.Net.Http.HttpResponseMessage response, System.Collections.Generic.IReadOnlyDictionary> headers, System.Threading.CancellationToken cancellationToken) - { - if (response == null || response.Content == null) - { - return new ObjectResponseResult(default(T)!, string.Empty); - } - - if (ReadResponseAsString) - { - var responseText = await ReadAsStringAsync(response.Content, cancellationToken).ConfigureAwait(false); - try - { - var typedBody = System.Text.Json.JsonSerializer.Deserialize(responseText, JsonSerializerSettings); - return new ObjectResponseResult(typedBody!, responseText); - } - catch (System.Text.Json.JsonException exception) - { - var message = "Could not deserialize the response body string as " + typeof(T).FullName + "."; - throw new FastApiClientException(message, (int)response.StatusCode, responseText, headers, exception); - } - } - else - { - try - { - using (var responseStream = await ReadAsStreamAsync(response.Content, cancellationToken).ConfigureAwait(false)) - { - var typedBody = await System.Text.Json.JsonSerializer.DeserializeAsync(responseStream, JsonSerializerSettings, cancellationToken).ConfigureAwait(false); - return new ObjectResponseResult(typedBody!, string.Empty); - } - } - catch (System.Text.Json.JsonException exception) - { - var message = "Could not deserialize the response body stream as " + typeof(T).FullName + "."; - throw new FastApiClientException(message, (int)response.StatusCode, string.Empty, headers, exception); - } - } - } - - private string ConvertToString(object? value, System.Globalization.CultureInfo cultureInfo) - { - if (value == null) - { - return ""; - } - - if (value is System.Enum) - { - var name = System.Enum.GetName(value.GetType(), value); - if (name != null) - { - var field_ = System.Reflection.IntrospectionExtensions.GetTypeInfo(value.GetType()).GetDeclaredField(name); - if (field_ != null) - { - var attribute = System.Reflection.CustomAttributeExtensions.GetCustomAttribute(field_, typeof(System.Runtime.Serialization.EnumMemberAttribute)) - as System.Runtime.Serialization.EnumMemberAttribute; - if (attribute != null) - { - return attribute.Value != null ? attribute.Value : name; - } - } - - var converted = System.Convert.ToString(System.Convert.ChangeType(value, System.Enum.GetUnderlyingType(value.GetType()), cultureInfo)); - return converted == null ? string.Empty : converted; - } - } - else if (value is bool) - { - return System.Convert.ToString((bool)value, cultureInfo).ToLowerInvariant(); - } - else if (value is byte[]) - { - return System.Convert.ToBase64String((byte[]) value); - } - else if (value is string[]) - { - return string.Join(",", (string[])value); - } - else if (value.GetType().IsArray) - { - var valueArray = (System.Array)value; - var valueTextArray = new string[valueArray.Length]; - for (var i = 0; i < valueArray.Length; i++) - { - valueTextArray[i] = ConvertToString(valueArray.GetValue(i), cultureInfo); - } - return string.Join(",", valueTextArray); - } - - var result = System.Convert.ToString(value, cultureInfo); - return result == null ? "" : result; - } - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class AppSettingBody - { - - [System.Text.Json.Serialization.JsonPropertyName("key")] - public string Key { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("value")] - public string Value { get; set; } = default!; - - private System.Collections.Generic.IDictionary? _additionalProperties; - - [System.Text.Json.Serialization.JsonExtensionData] - public System.Collections.Generic.IDictionary AdditionalProperties - { - get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } - set { _additionalProperties = value; } - } - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class AuditToolBody - { - - [System.Text.Json.Serialization.JsonPropertyName("toolName")] - public string ToolName { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("propertyId")] - public int PropertyId { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("reportId")] - public ReportId ReportId { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("args")] - public object Args { get; set; } = default!; - - private System.Collections.Generic.IDictionary? _additionalProperties; - - [System.Text.Json.Serialization.JsonExtensionData] - public System.Collections.Generic.IDictionary AdditionalProperties - { - get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } - set { _additionalProperties = value; } - } - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class Body_logs_upload_api_logs_upload_post - { - - [System.Text.Json.Serialization.JsonPropertyName("propertyId")] - public int PropertyId { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("file")] - public string File { get; set; } = default!; - - private System.Collections.Generic.IDictionary? _additionalProperties; - - [System.Text.Json.Serialization.JsonExtensionData] - public System.Collections.Generic.IDictionary AdditionalProperties - { - get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } - set { _additionalProperties = value; } - } - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class CancelResponse - { - - [System.Text.Json.Serialization.JsonPropertyName("ok")] - public bool Ok { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("status")] - public string Status { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("error")] - public Error2 Error { get; set; } = default!; - - private System.Collections.Generic.IDictionary? _additionalProperties; - - [System.Text.Json.Serialization.JsonExtensionData] - public System.Collections.Generic.IDictionary AdditionalProperties - { - get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } - set { _additionalProperties = value; } - } - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class ChatRequest - { - - [System.Text.Json.Serialization.JsonPropertyName("sessionId")] - public int SessionId { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("propertyId")] - public int PropertyId { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("message")] - public string Message { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("reportId")] - public ReportId2 ReportId { get; set; } = default!; - - private System.Collections.Generic.IDictionary? _additionalProperties; - - [System.Text.Json.Serialization.JsonExtensionData] - public System.Collections.Generic.IDictionary AdditionalProperties - { - get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } - set { _additionalProperties = value; } - } - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class ChatSessionCreate - { - - [System.Text.Json.Serialization.JsonPropertyName("propertyId")] - public int PropertyId { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("title")] - public string Title { get; set; } = "New chat"; - - private System.Collections.Generic.IDictionary? _additionalProperties; - - [System.Text.Json.Serialization.JsonExtensionData] - public System.Collections.Generic.IDictionary AdditionalProperties - { - get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } - set { _additionalProperties = value; } - } - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class CompareExportBody - { - - [System.Text.Json.Serialization.JsonPropertyName("reportIdA")] - public ReportIdA ReportIdA { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("reportIdB")] - public ReportIdB ReportIdB { get; set; } = default!; - - private System.Collections.Generic.IDictionary? _additionalProperties; - - [System.Text.Json.Serialization.JsonExtensionData] - public System.Collections.Generic.IDictionary AdditionalProperties - { - get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } - set { _additionalProperties = value; } - } - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class DashboardAiGenerateBody - { - - [System.Text.Json.Serialization.JsonPropertyName("mode")] - public string Mode { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("prompt")] - public string Prompt { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("catalog")] - public System.Collections.Generic.List Catalog { get; set; } = new System.Collections.Generic.List(); - - [System.Text.Json.Serialization.JsonPropertyName("viz_types")] - public System.Collections.Generic.Dictionary Viz_types { get; set; } = new System.Collections.Generic.Dictionary(); - - [System.Text.Json.Serialization.JsonPropertyName("dashscript_help")] - public string Dashscript_help { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("toolName")] - public ToolName ToolName { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("propertyId")] - public PropertyId PropertyId { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("reportId")] - public ReportId3 ReportId { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("current")] - public Current Current { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("sample")] - public Sample Sample { get; set; } = default!; - - private System.Collections.Generic.IDictionary? _additionalProperties; - - [System.Text.Json.Serialization.JsonExtensionData] - public System.Collections.Generic.IDictionary AdditionalProperties - { - get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } - set { _additionalProperties = value; } - } - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class DashboardCreateBody - { - - [System.Text.Json.Serialization.JsonPropertyName("propertyId")] - public int PropertyId { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("name")] - public Name Name { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("layoutJson")] - public LayoutJson LayoutJson { get; set; } = default!; - - private System.Collections.Generic.IDictionary? _additionalProperties; - - [System.Text.Json.Serialization.JsonExtensionData] - public System.Collections.Generic.IDictionary AdditionalProperties - { - get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } - set { _additionalProperties = value; } - } - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class DashboardUpdateBody - { - - [System.Text.Json.Serialization.JsonPropertyName("propertyId")] - public int PropertyId { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("name")] - public Name2 Name { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("layoutJson")] - public LayoutJson2 LayoutJson { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("isDefault")] - public IsDefault IsDefault { get; set; } = default!; - - private System.Collections.Generic.IDictionary? _additionalProperties; - - [System.Text.Json.Serialization.JsonExtensionData] - public System.Collections.Generic.IDictionary AdditionalProperties - { - get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } - set { _additionalProperties = value; } - } - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class DeletePortfolioBody - { - - [System.Text.Json.Serialization.JsonPropertyName("reportId")] - public ReportId4 ReportId { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("crawlRunId")] - public CrawlRunId CrawlRunId { get; set; } = default!; - - private System.Collections.Generic.IDictionary? _additionalProperties; - - [System.Text.Json.Serialization.JsonExtensionData] - public System.Collections.Generic.IDictionary AdditionalProperties - { - get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } - set { _additionalProperties = value; } - } - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class FilterDeleteBody - { - - [System.Text.Json.Serialization.JsonPropertyName("propertyId")] - public int PropertyId { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("name")] - public string Name { get; set; } = default!; - - private System.Collections.Generic.IDictionary? _additionalProperties; - - [System.Text.Json.Serialization.JsonExtensionData] - public System.Collections.Generic.IDictionary AdditionalProperties - { - get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } - set { _additionalProperties = value; } - } - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class FilterUpsertBody - { - - [System.Text.Json.Serialization.JsonPropertyName("propertyId")] - public int PropertyId { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("name")] - public string Name { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("filterJson")] - public FilterJson FilterJson { get; set; } = default!; - - private System.Collections.Generic.IDictionary? _additionalProperties; - - [System.Text.Json.Serialization.JsonExtensionData] - public System.Collections.Generic.IDictionary AdditionalProperties - { - get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } - set { _additionalProperties = value; } - } - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class GoogleCredentialsPatch - { - - [System.Text.Json.Serialization.JsonPropertyName("refreshToken")] - public RefreshToken RefreshToken { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("authMode")] - public AuthMode AuthMode { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("gscSiteUrl")] - public GscSiteUrl GscSiteUrl { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("ga4PropertyId")] - public Ga4PropertyId Ga4PropertyId { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("dateRangeDays")] - public DateRangeDays DateRangeDays { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("connectedEmail")] - public ConnectedEmail ConnectedEmail { get; set; } = default!; - - private System.Collections.Generic.IDictionary? _additionalProperties; - - [System.Text.Json.Serialization.JsonExtensionData] - public System.Collections.Generic.IDictionary AdditionalProperties - { - get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } - set { _additionalProperties = value; } - } - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class GoogleCredentialsPostBody - { - - [System.Text.Json.Serialization.JsonPropertyName("gscSiteUrl")] - public GscSiteUrl2 GscSiteUrl { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("ga4PropertyId")] - public Ga4PropertyId2 Ga4PropertyId { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("dateRangeDays")] - public DateRangeDays2 DateRangeDays { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("refreshToken")] - public RefreshToken2 RefreshToken { get; set; } = default!; - - private System.Collections.Generic.IDictionary? _additionalProperties; - - [System.Text.Json.Serialization.JsonExtensionData] - public System.Collections.Generic.IDictionary AdditionalProperties - { - get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } - set { _additionalProperties = value; } - } - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class HTTPValidationError - { - - [System.Text.Json.Serialization.JsonPropertyName("detail")] - public System.Collections.Generic.List Detail { get; set; } = default!; - - private System.Collections.Generic.IDictionary? _additionalProperties; - - [System.Text.Json.Serialization.JsonExtensionData] - public System.Collections.Generic.IDictionary AdditionalProperties - { - get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } - set { _additionalProperties = value; } - } - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class JobsListResponse - { - - [System.Text.Json.Serialization.JsonPropertyName("jobs")] - public System.Collections.Generic.List Jobs { get; set; } = new System.Collections.Generic.List(); - - [System.Text.Json.Serialization.JsonPropertyName("active")] - public Active Active { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("reconciled")] - public int Reconciled { get; set; } = 0; - - private System.Collections.Generic.IDictionary? _additionalProperties; - - [System.Text.Json.Serialization.JsonExtensionData] - public System.Collections.Generic.IDictionary AdditionalProperties - { - get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } - set { _additionalProperties = value; } - } - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class LlmConfigBody - { - - [System.Text.Json.Serialization.JsonPropertyName("state")] - public object State { get; set; } = new object(); - - private System.Collections.Generic.IDictionary? _additionalProperties; - - [System.Text.Json.Serialization.JsonExtensionData] - public System.Collections.Generic.IDictionary AdditionalProperties - { - get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } - set { _additionalProperties = value; } - } - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class OpsSettingsBody - { - - [System.Text.Json.Serialization.JsonPropertyName("scheduleCron")] - public ScheduleCron ScheduleCron { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("alertWebhookUrl")] - public AlertWebhookUrl AlertWebhookUrl { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("alertEmail")] - public AlertEmail AlertEmail { get; set; } = default!; - - private System.Collections.Generic.IDictionary? _additionalProperties; - - [System.Text.Json.Serialization.JsonExtensionData] - public System.Collections.Generic.IDictionary AdditionalProperties - { - get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } - set { _additionalProperties = value; } - } - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class PageCoachBody - { - - [System.Text.Json.Serialization.JsonPropertyName("url")] - public Url Url { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("refresh")] - public bool Refresh { get; set; } = false; - - [System.Text.Json.Serialization.JsonPropertyName("currentType")] - public CurrentType CurrentType { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("currentId")] - public CurrentId CurrentId { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("baselineType")] - public BaselineType BaselineType { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("baselineId")] - public BaselineId BaselineId { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("propertyId")] - public PropertyId2 PropertyId { get; set; } = default!; - - private System.Collections.Generic.IDictionary? _additionalProperties; - - [System.Text.Json.Serialization.JsonExtensionData] - public System.Collections.Generic.IDictionary AdditionalProperties - { - get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } - set { _additionalProperties = value; } - } - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class PauseResponse - { - - [System.Text.Json.Serialization.JsonPropertyName("ok")] - public bool Ok { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("error")] - public Error3 Error { get; set; } = default!; - - private System.Collections.Generic.IDictionary? _additionalProperties; - - [System.Text.Json.Serialization.JsonExtensionData] - public System.Collections.Generic.IDictionary AdditionalProperties - { - get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } - set { _additionalProperties = value; } - } - - } - - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class PipelineConfigBody - { - - [System.Text.Json.Serialization.JsonPropertyName("state")] - public object State { get; set; } = new object(); - - [System.Text.Json.Serialization.JsonPropertyName("unknownKeys")] - public UnknownKeys UnknownKeys { get; set; } = default!; - - private System.Collections.Generic.IDictionary? _additionalProperties; - - [System.Text.Json.Serialization.JsonExtensionData] - public System.Collections.Generic.IDictionary AdditionalProperties - { - get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } - set { _additionalProperties = value; } - } - - } + } - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class PresetBody - { + private string ConvertToString(object? value, System.Globalization.CultureInfo cultureInfo) + { + if (value == null) + { + return ""; + } - [System.Text.Json.Serialization.JsonPropertyName("preset")] - public Preset Preset { get; set; } = default!; + if (value is System.Enum) + { + var name = System.Enum.GetName(value.GetType(), value); + if (name != null) + { + var field_ = System.Reflection.IntrospectionExtensions.GetTypeInfo(value.GetType()).GetDeclaredField(name); + if (field_ != null) + { + var attribute = System.Reflection.CustomAttributeExtensions.GetCustomAttribute(field_, typeof(System.Runtime.Serialization.EnumMemberAttribute)) + as System.Runtime.Serialization.EnumMemberAttribute; + if (attribute != null) + { + return attribute.Value != null ? attribute.Value : name; + } + } - private System.Collections.Generic.IDictionary? _additionalProperties; + var converted = System.Convert.ToString(System.Convert.ChangeType(value, System.Enum.GetUnderlyingType(value.GetType()), cultureInfo)); + return converted == null ? string.Empty : converted; + } + } + else if (value is bool) + { + return System.Convert.ToString((bool)value, cultureInfo).ToLowerInvariant(); + } + else if (value is byte[]) + { + return System.Convert.ToBase64String((byte[]) value); + } + else if (value is string[]) + { + return string.Join(",", (string[])value); + } + else if (value.GetType().IsArray) + { + var valueArray = (System.Array)value; + var valueTextArray = new string[valueArray.Length]; + for (var i = 0; i < valueArray.Length; i++) + { + valueTextArray[i] = ConvertToString(valueArray.GetValue(i), cultureInfo); + } + return string.Join(",", valueTextArray); + } - [System.Text.Json.Serialization.JsonExtensionData] - public System.Collections.Generic.IDictionary AdditionalProperties - { - get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } - set { _additionalProperties = value; } + var result = System.Convert.ToString(value, cultureInfo); + return result == null ? "" : result; } - } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class PropertyUpsertBody + public partial class AppSettingBody { - [System.Text.Json.Serialization.JsonPropertyName("name")] - public Name3 Name { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("canonical_domain")] - public Canonical_domain Canonical_domain { get; set; } = default!; + [System.Text.Json.Serialization.JsonPropertyName("key")] + public string Key { get; set; } = default!; - [System.Text.Json.Serialization.JsonPropertyName("site_url")] - public Site_url Site_url { get; set; } = default!; + [System.Text.Json.Serialization.JsonPropertyName("value")] + public string Value { get; set; } = default!; private System.Collections.Generic.IDictionary? _additionalProperties; @@ -11680,17 +8179,20 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class ResumeResponse + public partial class AuditToolBody { - [System.Text.Json.Serialization.JsonPropertyName("ok")] - public bool Ok { get; set; } = default!; + [System.Text.Json.Serialization.JsonPropertyName("toolName")] + public string ToolName { get; set; } = default!; - [System.Text.Json.Serialization.JsonPropertyName("newJobId")] - public NewJobId NewJobId { get; set; } = default!; + [System.Text.Json.Serialization.JsonPropertyName("propertyId")] + public int PropertyId { get; set; } = default!; - [System.Text.Json.Serialization.JsonPropertyName("error")] - public Error4 Error { get; set; } = default!; + [System.Text.Json.Serialization.JsonPropertyName("reportId")] + public ReportId ReportId { get; set; } = default!; + + [System.Text.Json.Serialization.JsonPropertyName("args")] + public object Args { get; set; } = default!; private System.Collections.Generic.IDictionary? _additionalProperties; @@ -11704,29 +8206,14 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class RunPostBody + public partial class Body_logs_upload_api_logs_upload_post { - [System.Text.Json.Serialization.JsonPropertyName("command")] - public Command Command { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("state")] - public State2 State { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("unknownKeys")] - public System.Collections.Generic.List UnknownKeys { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("llmState")] - public LlmState LlmState { get; set; } = default!; - [System.Text.Json.Serialization.JsonPropertyName("propertyId")] - public PropertyId3 PropertyId { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("python")] - public Python Python { get; set; } = default!; + public int PropertyId { get; set; } = default!; - [System.Text.Json.Serialization.JsonPropertyName("repoRoot")] - public RepoRoot RepoRoot { get; set; } = default!; + [System.Text.Json.Serialization.JsonPropertyName("file")] + public string File { get; set; } = default!; private System.Collections.Generic.IDictionary? _additionalProperties; @@ -11740,29 +8227,17 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class RunResponse + public partial class CancelResponse { - [System.Text.Json.Serialization.JsonPropertyName("jobId")] - public string JobId { get; set; } = default!; - - private System.Collections.Generic.IDictionary? _additionalProperties; - - [System.Text.Json.Serialization.JsonExtensionData] - public System.Collections.Generic.IDictionary AdditionalProperties - { - get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } - set { _additionalProperties = value; } - } - - } + [System.Text.Json.Serialization.JsonPropertyName("ok")] + public bool Ok { get; set; } = default!; - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class SecretsBody - { + [System.Text.Json.Serialization.JsonPropertyName("status")] + public string Status { get; set; } = default!; - [System.Text.Json.Serialization.JsonPropertyName("state")] - public object State { get; set; } = new object(); + [System.Text.Json.Serialization.JsonPropertyName("error")] + public Error2 Error { get; set; } = default!; private System.Collections.Generic.IDictionary? _additionalProperties; @@ -11776,14 +8251,14 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class UnknownKeyEntry + public partial class CompareExportBody { - [System.Text.Json.Serialization.JsonPropertyName("key")] - public string Key { get; set; } = default!; + [System.Text.Json.Serialization.JsonPropertyName("reportIdA")] + public ReportIdA ReportIdA { get; set; } = default!; - [System.Text.Json.Serialization.JsonPropertyName("value")] - public string Value { get; set; } = default!; + [System.Text.Json.Serialization.JsonPropertyName("reportIdB")] + public ReportIdB ReportIdB { get; set; } = default!; private System.Collections.Generic.IDictionary? _additionalProperties; @@ -11797,68 +8272,38 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class ValidationError + public partial class DashboardAiGenerateBody { - [System.Text.Json.Serialization.JsonPropertyName("loc")] - public System.Collections.Generic.List Loc { get; set; } = new System.Collections.Generic.List(); - - [System.Text.Json.Serialization.JsonPropertyName("msg")] - public string Msg { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("type")] - public string Type { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("input")] - public object Input { get; set; } = default!; - - [System.Text.Json.Serialization.JsonPropertyName("ctx")] - public object Ctx { get; set; } = default!; - - private System.Collections.Generic.IDictionary? _additionalProperties; - - [System.Text.Json.Serialization.JsonExtensionData] - public System.Collections.Generic.IDictionary AdditionalProperties - { - get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } - set { _additionalProperties = value; } - } - - } + [System.Text.Json.Serialization.JsonPropertyName("mode")] + public string Mode { get; set; } = default!; - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class Reportid - { + [System.Text.Json.Serialization.JsonPropertyName("prompt")] + public string Prompt { get; set; } = default!; - private System.Collections.Generic.IDictionary? _additionalProperties; + [System.Text.Json.Serialization.JsonPropertyName("catalog")] + public System.Collections.Generic.List Catalog { get; set; } = new System.Collections.Generic.List(); - [System.Text.Json.Serialization.JsonExtensionData] - public System.Collections.Generic.IDictionary AdditionalProperties - { - get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } - set { _additionalProperties = value; } - } + [System.Text.Json.Serialization.JsonPropertyName("viz_types")] + public System.Collections.Generic.Dictionary Viz_types { get; set; } = new System.Collections.Generic.Dictionary(); - } + [System.Text.Json.Serialization.JsonPropertyName("dashscript_help")] + public string Dashscript_help { get; set; } = default!; - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class Domain - { + [System.Text.Json.Serialization.JsonPropertyName("toolName")] + public ToolName ToolName { get; set; } = default!; - private System.Collections.Generic.IDictionary? _additionalProperties; + [System.Text.Json.Serialization.JsonPropertyName("propertyId")] + public PropertyId PropertyId { get; set; } = default!; - [System.Text.Json.Serialization.JsonExtensionData] - public System.Collections.Generic.IDictionary AdditionalProperties - { - get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } - set { _additionalProperties = value; } - } + [System.Text.Json.Serialization.JsonPropertyName("reportId")] + public ReportId2 ReportId { get; set; } = default!; - } + [System.Text.Json.Serialization.JsonPropertyName("current")] + public Current Current { get; set; } = default!; - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class Section - { + [System.Text.Json.Serialization.JsonPropertyName("sample")] + public Sample Sample { get; set; } = default!; private System.Collections.Generic.IDictionary? _additionalProperties; @@ -11872,23 +8317,17 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class Propertyid + public partial class DashboardCreateBody { - private System.Collections.Generic.IDictionary? _additionalProperties; - - [System.Text.Json.Serialization.JsonExtensionData] - public System.Collections.Generic.IDictionary AdditionalProperties - { - get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } - set { _additionalProperties = value; } - } + [System.Text.Json.Serialization.JsonPropertyName("propertyId")] + public int PropertyId { get; set; } = default!; - } + [System.Text.Json.Serialization.JsonPropertyName("name")] + public Name Name { get; set; } = default!; - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class Anonymous - { + [System.Text.Json.Serialization.JsonPropertyName("layoutJson")] + public LayoutJson LayoutJson { get; set; } = default!; private System.Collections.Generic.IDictionary? _additionalProperties; @@ -11902,23 +8341,20 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class Crawlrunid + public partial class DashboardUpdateBody { - private System.Collections.Generic.IDictionary? _additionalProperties; - - [System.Text.Json.Serialization.JsonExtensionData] - public System.Collections.Generic.IDictionary AdditionalProperties - { - get { return _additionalProperties ?? (_additionalProperties = new System.Collections.Generic.Dictionary()); } - set { _additionalProperties = value; } - } + [System.Text.Json.Serialization.JsonPropertyName("propertyId")] + public int PropertyId { get; set; } = default!; - } + [System.Text.Json.Serialization.JsonPropertyName("name")] + public Name2 Name { get; set; } = default!; - [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class Id - { + [System.Text.Json.Serialization.JsonPropertyName("layoutJson")] + public LayoutJson2 LayoutJson { get; set; } = default!; + + [System.Text.Json.Serialization.JsonPropertyName("isDefault")] + public IsDefault IsDefault { get; set; } = default!; private System.Collections.Generic.IDictionary? _additionalProperties; @@ -11931,13 +8367,28 @@ public System.Collections.Generic.IDictionary AdditionalProperti } - /// - /// Crawl run ID - /// [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class Anonymous2 + public partial class GoogleCredentialsPatch { + [System.Text.Json.Serialization.JsonPropertyName("refreshToken")] + public RefreshToken RefreshToken { get; set; } = default!; + + [System.Text.Json.Serialization.JsonPropertyName("authMode")] + public AuthMode AuthMode { get; set; } = default!; + + [System.Text.Json.Serialization.JsonPropertyName("gscSiteUrl")] + public GscSiteUrl GscSiteUrl { get; set; } = default!; + + [System.Text.Json.Serialization.JsonPropertyName("ga4PropertyId")] + public Ga4PropertyId Ga4PropertyId { get; set; } = default!; + + [System.Text.Json.Serialization.JsonPropertyName("dateRangeDays")] + public DateRangeDays DateRangeDays { get; set; } = default!; + + [System.Text.Json.Serialization.JsonPropertyName("connectedEmail")] + public ConnectedEmail ConnectedEmail { get; set; } = default!; + private System.Collections.Generic.IDictionary? _additionalProperties; [System.Text.Json.Serialization.JsonExtensionData] @@ -11950,9 +8401,21 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class Anonymous3 + public partial class GoogleCredentialsPostBody { + [System.Text.Json.Serialization.JsonPropertyName("gscSiteUrl")] + public GscSiteUrl2 GscSiteUrl { get; set; } = default!; + + [System.Text.Json.Serialization.JsonPropertyName("ga4PropertyId")] + public Ga4PropertyId2 Ga4PropertyId { get; set; } = default!; + + [System.Text.Json.Serialization.JsonPropertyName("dateRangeDays")] + public DateRangeDays2 DateRangeDays { get; set; } = default!; + + [System.Text.Json.Serialization.JsonPropertyName("refreshToken")] + public RefreshToken2 RefreshToken { get; set; } = default!; + private System.Collections.Generic.IDictionary? _additionalProperties; [System.Text.Json.Serialization.JsonExtensionData] @@ -11965,9 +8428,12 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class Starturl + public partial class HTTPValidationError { + [System.Text.Json.Serialization.JsonPropertyName("detail")] + public System.Collections.Generic.List Detail { get; set; } = default!; + private System.Collections.Generic.IDictionary? _additionalProperties; [System.Text.Json.Serialization.JsonExtensionData] @@ -11980,9 +8446,18 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class Returnto + public partial class JobsListResponse { + [System.Text.Json.Serialization.JsonPropertyName("jobs")] + public System.Collections.Generic.List Jobs { get; set; } = new System.Collections.Generic.List(); + + [System.Text.Json.Serialization.JsonPropertyName("active")] + public Active Active { get; set; } = default!; + + [System.Text.Json.Serialization.JsonPropertyName("reconciled")] + public int Reconciled { get; set; } = 0; + private System.Collections.Generic.IDictionary? _additionalProperties; [System.Text.Json.Serialization.JsonExtensionData] @@ -11995,9 +8470,18 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class Code + public partial class OpsSettingsBody { + [System.Text.Json.Serialization.JsonPropertyName("scheduleCron")] + public ScheduleCron ScheduleCron { get; set; } = default!; + + [System.Text.Json.Serialization.JsonPropertyName("alertWebhookUrl")] + public AlertWebhookUrl AlertWebhookUrl { get; set; } = default!; + + [System.Text.Json.Serialization.JsonPropertyName("alertEmail")] + public AlertEmail AlertEmail { get; set; } = default!; + private System.Collections.Generic.IDictionary? _additionalProperties; [System.Text.Json.Serialization.JsonExtensionData] @@ -12010,9 +8494,15 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class State + public partial class PauseResponse { + [System.Text.Json.Serialization.JsonPropertyName("ok")] + public bool Ok { get; set; } = default!; + + [System.Text.Json.Serialization.JsonPropertyName("error")] + public Error3 Error { get; set; } = default!; + private System.Collections.Generic.IDictionary? _additionalProperties; [System.Text.Json.Serialization.JsonExtensionData] @@ -12025,9 +8515,15 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class Error + public partial class PipelineConfigBody { + [System.Text.Json.Serialization.JsonPropertyName("state")] + public object State { get; set; } = new object(); + + [System.Text.Json.Serialization.JsonPropertyName("unknownKeys")] + public UnknownKeys UnknownKeys { get; set; } = default!; + private System.Collections.Generic.IDictionary? _additionalProperties; [System.Text.Json.Serialization.JsonExtensionData] @@ -12040,9 +8536,12 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class Anonymous4 + public partial class PresetBody { + [System.Text.Json.Serialization.JsonPropertyName("preset")] + public Preset Preset { get; set; } = default!; + private System.Collections.Generic.IDictionary? _additionalProperties; [System.Text.Json.Serialization.JsonExtensionData] @@ -12055,9 +8554,12 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class Googlesnapshotid + public partial class PropertyEnsureBody { + [System.Text.Json.Serialization.JsonPropertyName("startUrl")] + public StartUrl StartUrl { get; set; } = default!; + private System.Collections.Generic.IDictionary? _additionalProperties; [System.Text.Json.Serialization.JsonExtensionData] @@ -12070,9 +8572,18 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class Anonymous5 + public partial class PropertyUpsertBody { + [System.Text.Json.Serialization.JsonPropertyName("name")] + public Name3 Name { get; set; } = default!; + + [System.Text.Json.Serialization.JsonPropertyName("canonical_domain")] + public Canonical_domain Canonical_domain { get; set; } = default!; + + [System.Text.Json.Serialization.JsonPropertyName("site_url")] + public Site_url Site_url { get; set; } = default!; + private System.Collections.Generic.IDictionary? _additionalProperties; [System.Text.Json.Serialization.JsonExtensionData] @@ -12085,9 +8596,18 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class Anonymous6 + public partial class ResumeResponse { + [System.Text.Json.Serialization.JsonPropertyName("ok")] + public bool Ok { get; set; } = default!; + + [System.Text.Json.Serialization.JsonPropertyName("newJobId")] + public NewJobId NewJobId { get; set; } = default!; + + [System.Text.Json.Serialization.JsonPropertyName("error")] + public Error4 Error { get; set; } = default!; + private System.Collections.Generic.IDictionary? _additionalProperties; [System.Text.Json.Serialization.JsonExtensionData] @@ -12100,9 +8620,27 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class Anonymous7 + public partial class RunPostBody { + [System.Text.Json.Serialization.JsonPropertyName("command")] + public Command Command { get; set; } = default!; + + [System.Text.Json.Serialization.JsonPropertyName("state")] + public State2 State { get; set; } = default!; + + [System.Text.Json.Serialization.JsonPropertyName("unknownKeys")] + public System.Collections.Generic.List UnknownKeys { get; set; } = default!; + + [System.Text.Json.Serialization.JsonPropertyName("propertyId")] + public PropertyId2 PropertyId { get; set; } = default!; + + [System.Text.Json.Serialization.JsonPropertyName("python")] + public Python Python { get; set; } = default!; + + [System.Text.Json.Serialization.JsonPropertyName("repoRoot")] + public RepoRoot RepoRoot { get; set; } = default!; + private System.Collections.Generic.IDictionary? _additionalProperties; [System.Text.Json.Serialization.JsonExtensionData] @@ -12115,9 +8653,12 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class Anonymous8 + public partial class RunResponse { + [System.Text.Json.Serialization.JsonPropertyName("jobId")] + public string JobId { get; set; } = default!; + private System.Collections.Generic.IDictionary? _additionalProperties; [System.Text.Json.Serialization.JsonExtensionData] @@ -12130,9 +8671,15 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class Anonymous9 + public partial class UnknownKeyEntry { + [System.Text.Json.Serialization.JsonPropertyName("key")] + public string Key { get; set; } = default!; + + [System.Text.Json.Serialization.JsonPropertyName("value")] + public string Value { get; set; } = default!; + private System.Collections.Generic.IDictionary? _additionalProperties; [System.Text.Json.Serialization.JsonExtensionData] @@ -12145,9 +8692,24 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class Anonymous10 + public partial class ValidationError { + [System.Text.Json.Serialization.JsonPropertyName("loc")] + public System.Collections.Generic.List Loc { get; set; } = new System.Collections.Generic.List(); + + [System.Text.Json.Serialization.JsonPropertyName("msg")] + public string Msg { get; set; } = default!; + + [System.Text.Json.Serialization.JsonPropertyName("type")] + public string Type { get; set; } = default!; + + [System.Text.Json.Serialization.JsonPropertyName("input")] + public object Input { get; set; } = default!; + + [System.Text.Json.Serialization.JsonPropertyName("ctx")] + public object Ctx { get; set; } = default!; + private System.Collections.Generic.IDictionary? _additionalProperties; [System.Text.Json.Serialization.JsonExtensionData] @@ -12159,8 +8721,11 @@ public System.Collections.Generic.IDictionary AdditionalProperti } + /// + /// Crawl run ID + /// [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class Anonymous11 + public partial class Crawlrunid { private System.Collections.Generic.IDictionary? _additionalProperties; @@ -12175,7 +8740,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class Anonymous12 + public partial class Propertyid { private System.Collections.Generic.IDictionary? _additionalProperties; @@ -12190,7 +8755,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class Q + public partial class Starturl { private System.Collections.Generic.IDictionary? _additionalProperties; @@ -12205,7 +8770,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class Anonymous13 + public partial class Returnto { private System.Collections.Generic.IDictionary? _additionalProperties; @@ -12220,7 +8785,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class Anonymous14 + public partial class Code { private System.Collections.Generic.IDictionary? _additionalProperties; @@ -12235,7 +8800,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class Anonymous15 + public partial class State { private System.Collections.Generic.IDictionary? _additionalProperties; @@ -12250,7 +8815,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class Ids + public partial class Error { private System.Collections.Generic.IDictionary? _additionalProperties; @@ -12265,7 +8830,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class Anonymous16 + public partial class Anonymous { private System.Collections.Generic.IDictionary? _additionalProperties; @@ -12280,7 +8845,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class Anonymous17 + public partial class Googlesnapshotid { private System.Collections.Generic.IDictionary? _additionalProperties; @@ -12295,7 +8860,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class ReportId + public partial class Anonymous2 { private System.Collections.Generic.IDictionary? _additionalProperties; @@ -12310,7 +8875,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class Error2 + public partial class Domain { private System.Collections.Generic.IDictionary? _additionalProperties; @@ -12325,7 +8890,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class ReportId2 + public partial class Anonymous3 { private System.Collections.Generic.IDictionary? _additionalProperties; @@ -12340,7 +8905,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class ReportIdA + public partial class Anonymous4 { private System.Collections.Generic.IDictionary? _additionalProperties; @@ -12355,7 +8920,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class ReportIdB + public partial class Anonymous5 { private System.Collections.Generic.IDictionary? _additionalProperties; @@ -12370,7 +8935,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class ToolName + public partial class Anonymous6 { private System.Collections.Generic.IDictionary? _additionalProperties; @@ -12385,7 +8950,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class PropertyId + public partial class Anonymous7 { private System.Collections.Generic.IDictionary? _additionalProperties; @@ -12400,7 +8965,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class ReportId3 + public partial class Anonymous8 { private System.Collections.Generic.IDictionary? _additionalProperties; @@ -12415,7 +8980,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class Current + public partial class Q { private System.Collections.Generic.IDictionary? _additionalProperties; @@ -12430,7 +8995,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class Sample + public partial class Anonymous9 { private System.Collections.Generic.IDictionary? _additionalProperties; @@ -12445,7 +9010,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class Name + public partial class ReportId { private System.Collections.Generic.IDictionary? _additionalProperties; @@ -12460,7 +9025,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class LayoutJson + public partial class Error2 { private System.Collections.Generic.IDictionary? _additionalProperties; @@ -12475,7 +9040,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class Name2 + public partial class ReportIdA { private System.Collections.Generic.IDictionary? _additionalProperties; @@ -12490,7 +9055,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class LayoutJson2 + public partial class ReportIdB { private System.Collections.Generic.IDictionary? _additionalProperties; @@ -12505,7 +9070,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class IsDefault + public partial class ToolName { private System.Collections.Generic.IDictionary? _additionalProperties; @@ -12520,7 +9085,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class ReportId4 + public partial class PropertyId { private System.Collections.Generic.IDictionary? _additionalProperties; @@ -12535,7 +9100,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class CrawlRunId + public partial class ReportId2 { private System.Collections.Generic.IDictionary? _additionalProperties; @@ -12550,7 +9115,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class FilterJson + public partial class Current { private System.Collections.Generic.IDictionary? _additionalProperties; @@ -12565,7 +9130,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class RefreshToken + public partial class Sample { private System.Collections.Generic.IDictionary? _additionalProperties; @@ -12580,7 +9145,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class AuthMode + public partial class Name { private System.Collections.Generic.IDictionary? _additionalProperties; @@ -12595,7 +9160,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class GscSiteUrl + public partial class LayoutJson { private System.Collections.Generic.IDictionary? _additionalProperties; @@ -12610,7 +9175,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class Ga4PropertyId + public partial class Name2 { private System.Collections.Generic.IDictionary? _additionalProperties; @@ -12625,7 +9190,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class DateRangeDays + public partial class LayoutJson2 { private System.Collections.Generic.IDictionary? _additionalProperties; @@ -12640,7 +9205,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class ConnectedEmail + public partial class IsDefault { private System.Collections.Generic.IDictionary? _additionalProperties; @@ -12655,7 +9220,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class GscSiteUrl2 + public partial class RefreshToken { private System.Collections.Generic.IDictionary? _additionalProperties; @@ -12670,7 +9235,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class Ga4PropertyId2 + public partial class AuthMode { private System.Collections.Generic.IDictionary? _additionalProperties; @@ -12685,7 +9250,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class DateRangeDays2 + public partial class GscSiteUrl { private System.Collections.Generic.IDictionary? _additionalProperties; @@ -12700,7 +9265,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class RefreshToken2 + public partial class Ga4PropertyId { private System.Collections.Generic.IDictionary? _additionalProperties; @@ -12715,7 +9280,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class Active + public partial class DateRangeDays { private System.Collections.Generic.IDictionary? _additionalProperties; @@ -12730,7 +9295,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class ScheduleCron + public partial class ConnectedEmail { private System.Collections.Generic.IDictionary? _additionalProperties; @@ -12745,7 +9310,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class AlertWebhookUrl + public partial class GscSiteUrl2 { private System.Collections.Generic.IDictionary? _additionalProperties; @@ -12760,7 +9325,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class AlertEmail + public partial class Ga4PropertyId2 { private System.Collections.Generic.IDictionary? _additionalProperties; @@ -12775,7 +9340,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class Url + public partial class DateRangeDays2 { private System.Collections.Generic.IDictionary? _additionalProperties; @@ -12790,7 +9355,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class CurrentType + public partial class RefreshToken2 { private System.Collections.Generic.IDictionary? _additionalProperties; @@ -12805,7 +9370,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class CurrentId + public partial class Active { private System.Collections.Generic.IDictionary? _additionalProperties; @@ -12820,7 +9385,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class BaselineType + public partial class ScheduleCron { private System.Collections.Generic.IDictionary? _additionalProperties; @@ -12835,7 +9400,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class BaselineId + public partial class AlertWebhookUrl { private System.Collections.Generic.IDictionary? _additionalProperties; @@ -12850,7 +9415,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class PropertyId2 + public partial class AlertEmail { private System.Collections.Generic.IDictionary? _additionalProperties; @@ -12910,7 +9475,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class Name3 + public partial class StartUrl { private System.Collections.Generic.IDictionary? _additionalProperties; @@ -12925,7 +9490,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class Canonical_domain + public partial class Name3 { private System.Collections.Generic.IDictionary? _additionalProperties; @@ -12940,7 +9505,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class Site_url + public partial class Canonical_domain { private System.Collections.Generic.IDictionary? _additionalProperties; @@ -12955,7 +9520,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class NewJobId + public partial class Site_url { private System.Collections.Generic.IDictionary? _additionalProperties; @@ -12970,7 +9535,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class Error4 + public partial class NewJobId { private System.Collections.Generic.IDictionary? _additionalProperties; @@ -12985,7 +9550,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class Command + public partial class Error4 { private System.Collections.Generic.IDictionary? _additionalProperties; @@ -13000,7 +9565,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class State2 + public partial class Command { private System.Collections.Generic.IDictionary? _additionalProperties; @@ -13015,7 +9580,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class LlmState + public partial class State2 { private System.Collections.Generic.IDictionary? _additionalProperties; @@ -13030,7 +9595,7 @@ public System.Collections.Generic.IDictionary AdditionalProperti } [System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "14.7.1.0 (NJsonSchema v11.6.1.0 (Newtonsoft.Json v13.0.0.0))")] - public partial class PropertyId3 + public partial class PropertyId2 { private System.Collections.Generic.IDictionary? _additionalProperties; diff --git a/services/Bff/src/Bff.Application/Options/UpstreamOptions.cs b/services/Bff/src/Bff.Application/Options/UpstreamOptions.cs index b53259b2..8976bb9c 100644 --- a/services/Bff/src/Bff.Application/Options/UpstreamOptions.cs +++ b/services/Bff/src/Bff.Application/Options/UpstreamOptions.cs @@ -20,10 +20,29 @@ public sealed class UpstreamOptions /// Data service base URL (env override: DATA_SERVICE_URL). Internal .NET read service. public string DataBaseUrl { get; set; } = "http://127.0.0.1:8091"; + /// Integrations service base URL (env override: INTEGRATIONS_SERVICE_URL). + public string IntegrationsBaseUrl { get; set; } = "http://127.0.0.1:8093"; + + /// AiService base URL (env override: AI_SERVICE_URL). Internal .NET AI/LLM service. + public string AiBaseUrl { get; set; } = "http://127.0.0.1:8092"; + + /// + /// Comma-separated /api path prefixes routed to the Ai service instead of FastAPI + /// (env override: AI_ROUTES). Empty = AI routes stay on FastAPI (rollback-safe default). + /// + public string[] AiRoutes { get; set; } = []; + /// /// Comma-separated /api path prefixes routed to the Data service instead of FastAPI /// (env override: DATA_ROUTES). Supports GET/HEAD reads and POST/PUT/DELETE mutations on matched /// prefixes. Empty = everything stays on FastAPI (rollback-safe default). /// public string[] DataRoutes { get; set; } = []; + + /// + /// Comma-separated /api path prefixes routed to the Integrations service + /// (env override: INTEGRATIONS_ROUTES). Property-scoped /api/properties/*/google paths are + /// always matched when any Integrations route is configured. + /// + public string[] IntegrationsRoutes { get; set; } = []; } diff --git a/services/Data/Dockerfile b/services/Data/Dockerfile index f6640731..b3b14ede 100644 --- a/services/Data/Dockerfile +++ b/services/Data/Dockerfile @@ -1,12 +1,14 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build WORKDIR /src -COPY Data.slnx ./ -COPY src/Data.Domain/Data.Domain.csproj src/Data.Domain/ -COPY src/Data.Application/Data.Application.csproj src/Data.Application/ -COPY src/Data.Api/Data.Api.csproj src/Data.Api/ -RUN dotnet restore src/Data.Api/Data.Api.csproj -COPY src/ src/ -RUN dotnet publish src/Data.Api/Data.Api.csproj -c Release -o /app/publish --no-restore +COPY Shared/WebsiteProfiling.Contracts/WebsiteProfiling.Contracts.csproj Shared/WebsiteProfiling.Contracts/ +COPY Data/Data.slnx Data/ +COPY Data/src/Data.Domain/Data.Domain.csproj Data/src/Data.Domain/ +COPY Data/src/Data.Application/Data.Application.csproj Data/src/Data.Application/ +COPY Data/src/Data.Api/Data.Api.csproj Data/src/Data.Api/ +RUN dotnet restore Data/src/Data.Api/Data.Api.csproj +COPY Shared/WebsiteProfiling.Contracts/ Shared/WebsiteProfiling.Contracts/ +COPY Data/src/ Data/src/ +RUN dotnet publish Data/src/Data.Api/Data.Api.csproj -c Release -o /app/publish --no-restore FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime WORKDIR /app diff --git a/services/Data/src/Data.Api/Controllers/ReportController.cs b/services/Data/src/Data.Api/Controllers/ReportController.cs index 8320a715..df4c1486 100644 --- a/services/Data/src/Data.Api/Controllers/ReportController.cs +++ b/services/Data/src/Data.Api/Controllers/ReportController.cs @@ -19,11 +19,16 @@ namespace Data.Api.Controllers; public sealed class ReportController : ControllerBase { private readonly IReportRepository _reports; + private readonly IReportSectionService _sections; private readonly IPortfolioService _portfolio; - public ReportController(IReportRepository reports, IPortfolioService portfolio) + public ReportController( + IReportRepository reports, + IReportSectionService sections, + IPortfolioService portfolio) { _reports = reports; + _sections = sections; _portfolio = portfolio; } @@ -55,12 +60,11 @@ public async Task GetPayload( if (section is not null) { - using var doc = JsonDocument.Parse(rawJson); - var filtered = new Dictionary(); - foreach (var field in SectionFields.ByKey[section]) - if (doc.RootElement.TryGetProperty(field, out var val)) - filtered[field] = val.Clone(); - return Ok(new { payload = filtered, section }); + var slice = await _sections.GetSectionPayloadAsync(reportId, domain, section, cancellationToken); + if (slice is null) + return NotFound(new { detail = "Report not found" }); + + return Ok(new { payload = slice, section }); } // Full payload: stream raw JSON without double-parsing (avoids re-serialising multi-MB blobs). diff --git a/services/Data/src/Data.Application/Data.Application.csproj b/services/Data/src/Data.Application/Data.Application.csproj index 29701a6a..12ef7f0f 100644 --- a/services/Data/src/Data.Application/Data.Application.csproj +++ b/services/Data/src/Data.Application/Data.Application.csproj @@ -2,6 +2,7 @@ + diff --git a/services/Data/src/Data.Application/DependencyInjection.cs b/services/Data/src/Data.Application/DependencyInjection.cs index 18bbad1a..a75f8839 100644 --- a/services/Data/src/Data.Application/DependencyInjection.cs +++ b/services/Data/src/Data.Application/DependencyInjection.cs @@ -1,6 +1,7 @@ using Data.Application.Options; using Data.Application.Persistence; using Data.Application.Portfolio; +using Data.Application.Report; using Data.Application.Repositories; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; @@ -49,6 +50,9 @@ public static IServiceCollection AddDataApplication(this IServiceCollection serv }); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/services/Data/src/Data.Application/Mapping/PayloadSliceMapper.cs b/services/Data/src/Data.Application/Mapping/PayloadSliceMapper.cs new file mode 100644 index 00000000..48cc532a --- /dev/null +++ b/services/Data/src/Data.Application/Mapping/PayloadSliceMapper.cs @@ -0,0 +1,131 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using WebsiteProfiling.Contracts.Crawl; +using WebsiteProfiling.Contracts.Google; +using WebsiteProfiling.Contracts.Json; +using WebsiteProfiling.Contracts.Report; + +namespace Data.Application.Mapping; + +public static class PayloadSliceMapper +{ + public static ReportMetaSlice? ToReportMetaSlice(JsonElement? payload) + { + if (payload is not { ValueKind: JsonValueKind.Object } root) + { + return null; + } + + var sources = new List(); + JsonElement meta = default; + var hasMeta = root.TryGetProperty("report_meta", out meta) && meta.ValueKind == JsonValueKind.Object; + if (hasMeta + && meta.TryGetProperty("data_sources", out var arr) + && arr.ValueKind == JsonValueKind.Array) + { + foreach (var item in arr.EnumerateArray()) + { + if (item.ValueKind == JsonValueKind.String && item.GetString() is { Length: > 0 } s) + { + sources.Add(s); + } + } + } + + return new ReportMetaSlice + { + CrawlRunId = JsonCoercion.GetInt(root, "crawl_run_id"), + GeneratedAt = hasMeta ? JsonCoercion.GetString(meta, "generated_at") : null, + ReportGeneratedAt = JsonCoercion.GetString(root, "report_generated_at"), + SiteName = JsonCoercion.GetString(root, "site_name"), + DataSources = sources, + }; + } + + public static IssuesBucketSlice? ToIssuesBucketSlice(JsonElement? payload) + { + if (payload is not { ValueKind: JsonValueKind.Object } root + || !root.TryGetProperty("issues", out var issues) + || issues.ValueKind != JsonValueKind.Object) + { + return null; + } + + return new IssuesBucketSlice + { + Critical = ParseIssueList(issues, "critical"), + High = ParseIssueList(issues, "high"), + Medium = ParseIssueList(issues, "medium"), + Low = ParseIssueList(issues, "low"), + }; + } + + public static GoogleSlice? ToGoogleSlice(JsonObject? raw) + { + if (raw is null) + { + return null; + } + + try + { + return JsonSerializer.Deserialize(raw.ToJsonString(), ContractJsonOptions.Options); + } + catch (JsonException) + { + return null; + } + } + + public static GoogleSlice? ToGoogleSlice(JsonElement? payload) + { + if (payload is not { ValueKind: JsonValueKind.Object }) + { + return null; + } + + try + { + return JsonSerializer.Deserialize(payload.Value.GetRawText(), ContractJsonOptions.Options); + } + catch (JsonException) + { + return null; + } + } + + public static CrawlPreviewDto ToCrawlPreview(long crawlRunId, IReadOnlyList pages) + => new() { Id = crawlRunId, Pages = pages, Total = pages.Count }; + + private static IReadOnlyList ParseIssueList(JsonElement issues, string key) + { + if (!issues.TryGetProperty(key, out var arr) || arr.ValueKind != JsonValueKind.Array) + { + return []; + } + + var list = new List(); + foreach (var item in arr.EnumerateArray()) + { + if (item.ValueKind != JsonValueKind.Object) + { + continue; + } + + try + { + var record = JsonSerializer.Deserialize(item.GetRawText(), ContractJsonOptions.Options); + if (record is not null) + { + list.Add(record); + } + } + catch (JsonException) + { + // skip malformed rows + } + } + + return list; + } +} diff --git a/services/Data/src/Data.Application/Persistence/DataDbContext.cs b/services/Data/src/Data.Application/Persistence/DataDbContext.cs index 1546a071..5e0a27de 100644 --- a/services/Data/src/Data.Application/Persistence/DataDbContext.cs +++ b/services/Data/src/Data.Application/Persistence/DataDbContext.cs @@ -20,6 +20,10 @@ public sealed class DataDbContext(DbContextOptions options) : DbC public DbSet SavedCrawlFilters => Set(); + public DbSet GoogleDataRows => Set(); + + public DbSet Properties => Set(); + protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity(e => @@ -86,5 +90,23 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) e.Property(x => x.FilterJson).HasColumnName("filter_json").HasColumnType("jsonb"); e.Property(x => x.CreatedAt).HasColumnName("created_at"); }); + + modelBuilder.Entity(e => + { + e.ToTable("google_data"); + e.HasKey(x => x.Id); + e.Property(x => x.Id).HasColumnName("id"); + e.Property(x => x.FetchedAt).HasColumnName("fetched_at"); + e.Property(x => x.PropertyId).HasColumnName("property_id"); + e.Property(x => x.Data).HasColumnName("data").HasColumnType("jsonb"); + }); + + modelBuilder.Entity(e => + { + e.ToTable("properties"); + e.HasKey(x => x.Id); + e.Property(x => x.Id).HasColumnName("id"); + e.Property(x => x.CanonicalDomain).HasColumnName("canonical_domain"); + }); } } diff --git a/services/Data/src/Data.Application/Portfolio/PortfolioGrouping.cs b/services/Data/src/Data.Application/Portfolio/PortfolioGrouping.cs index 14853433..17a648b4 100644 --- a/services/Data/src/Data.Application/Portfolio/PortfolioGrouping.cs +++ b/services/Data/src/Data.Application/Portfolio/PortfolioGrouping.cs @@ -1,6 +1,7 @@ using System.Text.Json; using System.Text.Json.Nodes; using Data.Application.Dto.Portfolio; +using Data.Application.Mapping; namespace Data.Application.Portfolio; @@ -137,8 +138,15 @@ private static (string BrandKey, PortfolioGroupDto Group, double GeneratedAtMs) PortfolioMaps maps) { long? runIdInt = null; - if (payload.TryGetProperty("crawl_run_id", out var ridEl) && ridEl.ValueKind == JsonValueKind.Number) + var metaSlice = PayloadSliceMapper.ToReportMetaSlice(payload); + if (metaSlice?.CrawlRunId is int crawlRunId) + { + runIdInt = crawlRunId; + } + else if (payload.TryGetProperty("crawl_run_id", out var ridEl) && ridEl.ValueKind == JsonValueKind.Number) + { runIdInt = ridEl.TryGetInt64(out var l) ? l : ridEl.GetInt32(); + } var runStartUrl = runIdInt is not null && maps.StartUrlByRunId.TryGetValue(runIdInt.Value, out var su) ? su : ""; diff --git a/services/Data/src/Data.Application/Report/IReportSectionService.cs b/services/Data/src/Data.Application/Report/IReportSectionService.cs new file mode 100644 index 00000000..3155955f --- /dev/null +++ b/services/Data/src/Data.Application/Report/IReportSectionService.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Nodes; + +namespace Data.Application.Report; + +public interface IReportSectionService +{ + /// + /// Returns a section slice of report payload, with sidecar merges (e.g. google_data for traffic). + /// + Task GetSectionPayloadAsync( + long? reportId, + string? domain, + string section, + CancellationToken cancellationToken = default); +} diff --git a/services/Data/src/Data.Application/Report/ReportPayloadContext.cs b/services/Data/src/Data.Application/Report/ReportPayloadContext.cs new file mode 100644 index 00000000..b8212a9c --- /dev/null +++ b/services/Data/src/Data.Application/Report/ReportPayloadContext.cs @@ -0,0 +1,3 @@ +namespace Data.Application.Report; + +public sealed record ReportPayloadContext(string DataJson, string? CanonicalDomain); diff --git a/services/Data/src/Data.Application/Report/ReportSectionService.cs b/services/Data/src/Data.Application/Report/ReportSectionService.cs new file mode 100644 index 00000000..20eb06d9 --- /dev/null +++ b/services/Data/src/Data.Application/Report/ReportSectionService.cs @@ -0,0 +1,116 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Data.Application.Repositories; + +namespace Data.Application.Report; + +public sealed class ReportSectionService( + IReportRepository reports, + IGoogleDataRepository googleData, + IPropertyRepository properties) : IReportSectionService +{ + public async Task GetSectionPayloadAsync( + long? reportId, + string? domain, + string section, + CancellationToken cancellationToken = default) + { + if (!SectionFields.ByKey.TryGetValue(section, out var fields)) + { + return null; + } + + var ctx = await reports.GetPayloadContextAsync(reportId, domain, cancellationToken); + if (ctx is null) + { + return null; + } + + using var doc = JsonDocument.Parse(ctx.DataJson); + var slice = new JsonObject(); + foreach (var field in fields) + { + if (doc.RootElement.TryGetProperty(field, out var val)) + { + slice[field] = JsonNode.Parse(val.GetRawText()); + } + } + + if (string.Equals(section, "traffic", StringComparison.Ordinal)) + { + await MergeTrafficGoogleAsync(slice, domain, ctx.CanonicalDomain, cancellationToken); + } + + if (string.Equals(section, "gsc-detail", StringComparison.Ordinal)) + { + await MergeGscDetailAsync(slice, domain, ctx.CanonicalDomain, cancellationToken); + } + + return slice; + } + + private async Task MergeGscDetailAsync( + JsonObject slice, + string? domainQuery, + string? reportCanonicalDomain, + CancellationToken cancellationToken) + { + var propertyId = await ResolvePropertyIdAsync(domainQuery, reportCanonicalDomain, cancellationToken); + if (propertyId is null) + { + return; + } + + var detail = await googleData.GetGscDetailAsync(propertyId, cancellationToken); + if (detail is null) + { + return; + } + + foreach (var (key, value) in detail) + { + slice[key] = value?.DeepClone(); + } + } + + private async Task MergeTrafficGoogleAsync( + JsonObject slice, + string? domainQuery, + string? reportCanonicalDomain, + CancellationToken cancellationToken) + { + var propertyId = await ResolvePropertyIdAsync(domainQuery, reportCanonicalDomain, cancellationToken); + if (propertyId is null) + { + return; + } + + var google = await googleData.GetLatestPayloadAsync(propertyId, cancellationToken); + if (google is not null) + { + slice["google"] = google; + } + } + + private async Task ResolvePropertyIdAsync( + string? domainQuery, + string? reportCanonicalDomain, + CancellationToken cancellationToken) + { + if (!string.IsNullOrWhiteSpace(domainQuery)) + { + var fromQuery = await properties.ResolvePropertyIdByDomainAsync(domainQuery, cancellationToken); + if (fromQuery is not null) + { + return fromQuery; + } + } + + if (!string.IsNullOrWhiteSpace(reportCanonicalDomain)) + { + return await properties.ResolvePropertyIdByDomainAsync(reportCanonicalDomain, cancellationToken); + } + + return null; + } +} diff --git a/services/Data/src/Data.Application/Report/SectionFields.cs b/services/Data/src/Data.Application/Report/SectionFields.cs index 1ded44c9..882c194f 100644 --- a/services/Data/src/Data.Application/Report/SectionFields.cs +++ b/services/Data/src/Data.Application/Report/SectionFields.cs @@ -23,6 +23,7 @@ public static class SectionFields "outbound_link_domains", "outlink_labels", "outlink_counts", ], ["traffic"] = ["google"], + ["gsc-detail"] = [], ["keywords"] = [ "keywords", "keyword_opportunities", "competitor_keyword_gap", diff --git a/services/Data/src/Data.Application/Repositories/GoogleDataRepository.cs b/services/Data/src/Data.Application/Repositories/GoogleDataRepository.cs new file mode 100644 index 00000000..1fad877a --- /dev/null +++ b/services/Data/src/Data.Application/Repositories/GoogleDataRepository.cs @@ -0,0 +1,180 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Data.Application.Mapping; +using Data.Application.Persistence; +using Data.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using WebsiteProfiling.Contracts.Google; +using WebsiteProfiling.Contracts.Json; + +namespace Data.Application.Repositories; + +public sealed class GoogleDataRepository(DataDbContext db) : IGoogleDataRepository +{ + private static readonly HashSet StripKeys = new(StringComparer.Ordinal) + { + "gsc_full", + "ga4_full", + }; + + public async Task GetLatestPayloadAsync( + long? propertyId, + CancellationToken cancellationToken = default) + { + if (propertyId is null or <= 0) + { + return null; + } + + var row = await db.Set() + .Where(g => g.PropertyId == propertyId) + .OrderByDescending(g => g.Id) + .Select(g => g.Data) + .FirstOrDefaultAsync(cancellationToken); + + if (string.IsNullOrWhiteSpace(row)) + { + return null; + } + + try + { + var node = JsonNode.Parse(row); + if (node is not JsonObject obj) + { + return null; + } + + foreach (var key in StripKeys) + { + obj.Remove(key); + } + + return obj; + } + catch (JsonException) + { + return null; + } + } + + public async Task GetGscDetailAsync( + long? propertyId, + CancellationToken cancellationToken = default) + { + if (propertyId is null or <= 0) + { + return null; + } + + var row = await db.Set() + .Where(g => g.PropertyId == propertyId) + .OrderByDescending(g => g.Id) + .Select(g => g.Data) + .FirstOrDefaultAsync(cancellationToken); + + if (string.IsNullOrWhiteSpace(row)) + { + return null; + } + + try + { + using var doc = JsonDocument.Parse(row); + var root = doc.RootElement; + if (!root.TryGetProperty("gsc_full", out var gscFull) + || !gscFull.TryGetProperty("by_page", out var byPage) + || byPage.ValueKind != JsonValueKind.Object) + { + return null; + } + + var summaries = new JsonObject(); + foreach (var page in byPage.EnumerateObject()) + { + if (page.Value.ValueKind != JsonValueKind.Object) + { + continue; + } + + var summary = new JsonObject(); + CopyIfPresent(page.Value, summary, "page"); + CopyIfPresent(page.Value, summary, "clicks"); + CopyIfPresent(page.Value, summary, "impressions"); + CopyIfPresent(page.Value, summary, "ctr"); + CopyIfPresent(page.Value, summary, "position"); + if (page.Value.TryGetProperty("queries", out var queries) && queries.ValueKind == JsonValueKind.Array) + { + summary["query_count"] = queries.GetArrayLength(); + } + + summaries[page.Name] = summary; + } + + var result = new JsonObject { ["by_page"] = summaries }; + if (root.TryGetProperty("fetched_at", out var fetchedAt)) + { + result["fetched_at"] = JsonNode.Parse(fetchedAt.GetRawText()); + } + + if (root.TryGetProperty("date_range", out var dateRange)) + { + result["date_range"] = JsonNode.Parse(dateRange.GetRawText()); + } + + return result; + } + catch (JsonException) + { + return null; + } + } + + public async Task GetLatestGoogleSliceAsync( + long? propertyId, + CancellationToken cancellationToken = default) + => PayloadSliceMapper.ToGoogleSlice(await GetLatestPayloadAsync(propertyId, cancellationToken)); + + public async Task?> GetGscDetailByPageAsync( + long? propertyId, + CancellationToken cancellationToken = default) + { + var json = await GetGscDetailAsync(propertyId, cancellationToken); + if (json?["by_page"] is not JsonObject byPage) + { + return null; + } + + var result = new Dictionary(StringComparer.Ordinal); + foreach (var (key, node) in byPage) + { + if (node is null) + { + continue; + } + + try + { + var detail = JsonSerializer.Deserialize(node.ToJsonString(), ContractJsonOptions.Options); + if (detail is not null) + { + result[key] = detail with { Page = string.IsNullOrEmpty(detail.Page) ? key : detail.Page }; + } + } + catch (JsonException) + { + // skip malformed page + } + } + + return result.Count == 0 ? null : result; + } + + private static void CopyIfPresent(JsonElement source, JsonObject target, string name) + { + if (source.TryGetProperty(name, out var val)) + { + target[name] = JsonNode.Parse(val.GetRawText()); + } + } +} diff --git a/services/Data/src/Data.Application/Repositories/IGoogleDataRepository.cs b/services/Data/src/Data.Application/Repositories/IGoogleDataRepository.cs new file mode 100644 index 00000000..7d4beae4 --- /dev/null +++ b/services/Data/src/Data.Application/Repositories/IGoogleDataRepository.cs @@ -0,0 +1,25 @@ +using System.Text.Json.Nodes; +using WebsiteProfiling.Contracts.Google; + +namespace Data.Application.Repositories; + +public interface IGoogleDataRepository +{ + /// + /// Latest saved Google snapshot for a property, payload-safe (no gsc_full / ga4_full). + /// + Task GetLatestPayloadAsync(long? propertyId, CancellationToken cancellationToken = default); + + /// Typed view of . + Task GetLatestGoogleSliceAsync(long? propertyId, CancellationToken cancellationToken = default); + + /// + /// Page-level GSC summaries from the latest snapshot's gsc_full.by_page. + /// + Task GetGscDetailAsync(long? propertyId, CancellationToken cancellationToken = default); + + /// Typed page-level GSC detail keyed by page URL. + Task?> GetGscDetailByPageAsync( + long? propertyId, + CancellationToken cancellationToken = default); +} diff --git a/services/Data/src/Data.Application/Repositories/IPropertyRepository.cs b/services/Data/src/Data.Application/Repositories/IPropertyRepository.cs new file mode 100644 index 00000000..6c8b13d9 --- /dev/null +++ b/services/Data/src/Data.Application/Repositories/IPropertyRepository.cs @@ -0,0 +1,7 @@ +namespace Data.Application.Repositories; + +public interface IPropertyRepository +{ + /// Resolve property id from domain slug (tries domain and www. variant). + Task ResolvePropertyIdByDomainAsync(string? domainRaw, CancellationToken cancellationToken = default); +} diff --git a/services/Data/src/Data.Application/Repositories/IReportRepository.cs b/services/Data/src/Data.Application/Repositories/IReportRepository.cs index 827047ec..f1619aec 100644 --- a/services/Data/src/Data.Application/Repositories/IReportRepository.cs +++ b/services/Data/src/Data.Application/Repositories/IReportRepository.cs @@ -1,6 +1,7 @@ using System.Text.Json.Nodes; using Data.Application.Dto.Meta; using Data.Application.Dto.Report; +using Data.Application.Report; namespace Data.Application.Repositories; @@ -15,6 +16,9 @@ public interface IReportRepository /// Task GetPayloadDataAsync(long? reportId, string? domain, CancellationToken ct); + /// Report payload JSON plus canonical_domain from the report row. + Task GetPayloadContextAsync(long? reportId, string? domain, CancellationToken ct); + /// /// Port of list_audit_history: ordered by generated_at DESC, optional domain filter /// (exact lower-case or slugified regexp_replace match). propertyId is not supported diff --git a/services/Data/src/Data.Application/Repositories/PropertyRepository.cs b/services/Data/src/Data.Application/Repositories/PropertyRepository.cs new file mode 100644 index 00000000..c5836c61 --- /dev/null +++ b/services/Data/src/Data.Application/Repositories/PropertyRepository.cs @@ -0,0 +1,69 @@ +using Data.Application.Persistence; +using Data.Domain.Entities; +using Microsoft.EntityFrameworkCore; + +namespace Data.Application.Repositories; + +public sealed class PropertyRepository(DataDbContext db) : IPropertyRepository +{ + public async Task ResolvePropertyIdByDomainAsync( + string? domainRaw, + CancellationToken cancellationToken = default) + { + var normalized = NormalizeDomain(domainRaw); + if (string.IsNullOrEmpty(normalized)) + { + return null; + } + + var candidates = new[] + { + normalized, + normalized.StartsWith("www.", StringComparison.Ordinal) + ? normalized[4..] + : $"www.{normalized}", + }; + + foreach (var domain in candidates.Distinct(StringComparer.OrdinalIgnoreCase)) + { + var id = await db.Set() + .Where(p => p.CanonicalDomain != null && + p.CanonicalDomain.ToLower() == domain.ToLowerInvariant()) + .Select(p => (long?)p.Id) + .FirstOrDefaultAsync(cancellationToken); + + if (id is > 0) + { + return id; + } + } + + return null; + } + + public static string NormalizeDomain(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + { + return ""; + } + + var s = raw.Trim().ToLowerInvariant(); + if (s.StartsWith("https://", StringComparison.Ordinal)) + { + s = s["https://".Length..]; + } + else if (s.StartsWith("http://", StringComparison.Ordinal)) + { + s = s["http://".Length..]; + } + + var slash = s.IndexOf('/'); + if (slash >= 0) + { + s = s[..slash]; + } + + return s.TrimEnd('.'); + } +} diff --git a/services/Data/src/Data.Application/Repositories/ReportRepository.cs b/services/Data/src/Data.Application/Repositories/ReportRepository.cs index 59ecede8..6c6218b4 100644 --- a/services/Data/src/Data.Application/Repositories/ReportRepository.cs +++ b/services/Data/src/Data.Application/Repositories/ReportRepository.cs @@ -4,6 +4,8 @@ using Data.Application.Dto.Report; using Data.Application.Json; using Data.Application.Persistence; +using Data.Application.Report; +using Data.Domain.Entities; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -66,6 +68,29 @@ private async Task> ListCrawlRunsAsync(CancellationToken ct) // ── /api/report/payload ────────────────────────────────────────────────── public async Task GetPayloadDataAsync(long? reportId, string? domain, CancellationToken ct) + { + var ctx = await GetPayloadContextAsync(reportId, domain, ct); + return ctx?.DataJson; + } + + public async Task GetPayloadContextAsync( + long? reportId, + string? domain, + CancellationToken ct) + { + var row = await ResolveReportRowAsync(reportId, domain, ct); + if (row is null) + { + return null; + } + + return new ReportPayloadContext(row.Data, row.CanonicalDomain); + } + + private async Task ResolveReportRowAsync( + long? reportId, + string? domain, + CancellationToken ct) { long? resolvedId = reportId; @@ -84,18 +109,15 @@ private async Task> ListCrawlRunsAsync(CancellationToken ct) { return await db.ReportPayloads .Where(r => r.Id == resolvedId.Value) - .Select(r => r.Data) .FirstOrDefaultAsync(ct); } - // No id or domain → latest report (mirrors Python read_report_payload(conn, None)) return await db.ReportPayloads .OrderByDescending(r => r.Id) - .Select(r => r.Data) .FirstOrDefaultAsync(ct); } - // ── /api/report/history ────────────────────────────────────────────────── + // ── /api/report/payload (legacy inline) ───────────────────────────────── public async Task ListAuditHistoryAsync( string? domain, int limit, CancellationToken ct) diff --git a/services/Data/src/Data.Domain/Entities/GoogleData.cs b/services/Data/src/Data.Domain/Entities/GoogleData.cs new file mode 100644 index 00000000..e4be955c --- /dev/null +++ b/services/Data/src/Data.Domain/Entities/GoogleData.cs @@ -0,0 +1,13 @@ +namespace Data.Domain.Entities; + +/// Read-only mapping of google_data (GSC/GA4 snapshots per property). +public sealed class GoogleData +{ + public long Id { get; set; } + + public DateTimeOffset FetchedAt { get; set; } + + public long? PropertyId { get; set; } + + public string Data { get; set; } = "{}"; +} diff --git a/services/Data/src/Data.Domain/Entities/Property.cs b/services/Data/src/Data.Domain/Entities/Property.cs new file mode 100644 index 00000000..e38b7edf --- /dev/null +++ b/services/Data/src/Data.Domain/Entities/Property.cs @@ -0,0 +1,9 @@ +namespace Data.Domain.Entities; + +/// Read-only mapping of properties for domain → property_id lookup. +public sealed class Property +{ + public long Id { get; set; } + + public string? CanonicalDomain { get; set; } +} diff --git a/services/Data/tests/Data.Tests/ApiIntegrationTests.cs b/services/Data/tests/Data.Tests/ApiIntegrationTests.cs index 29837868..3961e71e 100644 --- a/services/Data/tests/Data.Tests/ApiIntegrationTests.cs +++ b/services/Data/tests/Data.Tests/ApiIntegrationTests.cs @@ -8,7 +8,9 @@ using Data.Application.Dto.Portfolio; using Data.Application.Dto.Report; using Data.Application.Portfolio; +using Data.Application.Report; using Data.Application.Repositories; +using WebsiteProfiling.Contracts.Google; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.DependencyInjection; @@ -28,11 +30,15 @@ public ApiIntegrationTests(WebApplicationFactory factory) builder.ConfigureServices(services => { services.RemoveAll(); + services.RemoveAll(); services.RemoveAll(); services.RemoveAll(); services.RemoveAll(); services.RemoveAll(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -588,6 +594,10 @@ public Task GetMetaAsync(CancellationToken cancellationToken public Task GetPayloadDataAsync(long? reportId, string? domain, CancellationToken ct) => Task.FromResult(reportId == 999 ? null : """{"site_name":"example.com"}"""); + public Task GetPayloadContextAsync(long? reportId, string? domain, CancellationToken ct) => + Task.FromResult( + reportId == 999 ? null : new ReportPayloadContext("""{"site_name":"example.com"}""", "example.com")); + public Task ListAuditHistoryAsync(string? domain, int limit, CancellationToken ct) => Task.FromResult(new AuditHistoryResponse { @@ -603,4 +613,27 @@ public Task ListAuditHistoryAsync(string? domain, int limi public Task GetMobileDeltaAsync(long runId, CancellationToken ct) => Task.FromResult(new MobileDeltaResponse()); } + + private sealed class FakeGoogleDataRepository : IGoogleDataRepository + { + public Task GetLatestPayloadAsync(long? propertyId, CancellationToken cancellationToken = default) => + Task.FromResult(null); + + public Task GetLatestGoogleSliceAsync(long? propertyId, CancellationToken cancellationToken = default) => + Task.FromResult(null); + + public Task GetGscDetailAsync(long? propertyId, CancellationToken cancellationToken = default) => + Task.FromResult(null); + + public Task?> GetGscDetailByPageAsync( + long? propertyId, + CancellationToken cancellationToken = default) => + Task.FromResult?>(null); + } + + private sealed class FakePropertyRepository : IPropertyRepository + { + public Task ResolvePropertyIdByDomainAsync(string? domainRaw, CancellationToken cancellationToken = default) => + Task.FromResult(null); + } } diff --git a/services/Data/tests/Data.Tests/ReportSectionServiceTests.cs b/services/Data/tests/Data.Tests/ReportSectionServiceTests.cs new file mode 100644 index 00000000..d10afdeb --- /dev/null +++ b/services/Data/tests/Data.Tests/ReportSectionServiceTests.cs @@ -0,0 +1,161 @@ +using System.Text.Json.Nodes; +using Data.Application.Dto.Meta; +using Data.Application.Dto.Report; +using Data.Application.Report; +using Data.Application.Repositories; +using WebsiteProfiling.Contracts.Google; + +namespace Data.Tests; + +public sealed class PropertyRepositoryTests +{ + [Theory] + [InlineData("codefrydev.in", "codefrydev.in")] + [InlineData("HTTPS://WWW.Example.COM/path", "www.example.com")] + [InlineData("", "")] + public void NormalizeDomain_strips_scheme_and_path(string input, string expected) + { + Assert.Equal(expected, PropertyRepository.NormalizeDomain(input)); + } +} + +public sealed class GoogleDataRepositoryTests +{ + [Fact] + public void StripKeys_removes_full_blobs_from_json() + { + var raw = """ + {"fetched_at":"2026-01-01","gsc":{"summary":{}},"gsc_full":{"big":true},"ga4_full":{"x":1}} + """; + var node = System.Text.Json.JsonSerializer.Deserialize(raw)!; + node.Remove("gsc_full"); + node.Remove("ga4_full"); + Assert.False(node.ContainsKey("gsc_full")); + Assert.False(node.ContainsKey("ga4_full")); + Assert.True(node.ContainsKey("gsc")); + } +} + +public sealed class ReportSectionServiceTests +{ + [Fact] + public async Task Traffic_section_prefers_google_data_over_embedded_report() + { + var reports = new FakeReportRepo( + """{"google":{"fetched_at":"old","gsc":{"summary":{"clicks":1}}}}""", + "codefrydev.in"); + + var googleRepo = new FakeGoogleRepo(new JsonObject + { + ["fetched_at"] = "2026-06-20", + ["gsc"] = new JsonObject { ["summary"] = new JsonObject { ["clicks"] = 99 } }, + }); + + var properties = new FakePropertyRepo(42, "codefrydev.in"); + + var svc = new ReportSectionService(reports, googleRepo, properties); + var slice = await svc.GetSectionPayloadAsync(1, "codefrydev.in", "traffic", CancellationToken.None); + + Assert.NotNull(slice); + var google = slice!["google"]!.AsObject(); + Assert.Equal("2026-06-20", google["fetched_at"]!.GetValue()); + Assert.Equal(99, google["gsc"]!["summary"]!["clicks"]!.GetValue()); + } + + [Fact] + public async Task Traffic_section_falls_back_to_report_when_no_google_data() + { + var reports = new FakeReportRepo( + """{"google":{"fetched_at":"embedded-only"}}""", + "codefrydev.in"); + var googleRepo = new FakeGoogleRepo(null); + var properties = new FakePropertyRepo(42, "codefrydev.in"); + + var svc = new ReportSectionService(reports, googleRepo, properties); + var slice = await svc.GetSectionPayloadAsync(1, "codefrydev.in", "traffic", CancellationToken.None); + + Assert.NotNull(slice); + Assert.Equal("embedded-only", slice!["google"]!["fetched_at"]!.GetValue()); + } + + [Fact] + public async Task Gsc_detail_section_returns_by_page_summaries() + { + var gscDetail = new JsonObject + { + ["by_page"] = new JsonObject + { + ["https://example.com/a"] = new JsonObject + { + ["page"] = "https://example.com/a", + ["clicks"] = 5, + ["impressions"] = 100, + }, + }, + ["fetched_at"] = "2026-06-25", + }; + + var reports = new FakeReportRepo("{}", "example.com"); + var googleRepo = new FakeGoogleRepo(null, gscDetail); + var properties = new FakePropertyRepo(42, "example.com"); + + var svc = new ReportSectionService(reports, googleRepo, properties); + var slice = await svc.GetSectionPayloadAsync(1, "example.com", "gsc-detail", CancellationToken.None); + + Assert.NotNull(slice); + Assert.True(slice!.ContainsKey("by_page")); + Assert.Equal("2026-06-25", slice["fetched_at"]!.GetValue()); + } + + private sealed class FakeReportRepo(string dataJson, string? domain) : IReportRepository + { + public Task GetMetaAsync(CancellationToken cancellationToken) => + throw new NotImplementedException(); + + public Task GetPayloadDataAsync(long? reportId, string? domain, CancellationToken ct) => + Task.FromResult(dataJson); + + public Task GetPayloadContextAsync(long? reportId, string? domain, CancellationToken ct) => + Task.FromResult(new ReportPayloadContext(dataJson, domain)); + + public Task ListAuditHistoryAsync(string? domain, int limit, CancellationToken ct) => + throw new NotImplementedException(); + + public Task GetCrawlPreviewPayloadAsync(long crawlRunId, CancellationToken ct) => + throw new NotImplementedException(); + + public Task GetMobileDeltaAsync(long runId, CancellationToken ct) => + throw new NotImplementedException(); + } + + private sealed class FakeGoogleRepo(JsonObject? payload, JsonObject? gscDetail = null) : IGoogleDataRepository + { + public Task GetLatestPayloadAsync(long? propertyId, CancellationToken cancellationToken = default) => + Task.FromResult(payload); + + public Task GetLatestGoogleSliceAsync(long? propertyId, CancellationToken cancellationToken = default) => + Task.FromResult(null); + + public Task GetGscDetailAsync(long? propertyId, CancellationToken cancellationToken = default) => + Task.FromResult(gscDetail); + + public Task?> GetGscDetailByPageAsync( + long? propertyId, + CancellationToken cancellationToken = default) => + Task.FromResult?>(null); + } + + private sealed class FakePropertyRepo(long id, string domain) : IPropertyRepository + { + public Task ResolvePropertyIdByDomainAsync(string? domainRaw, CancellationToken cancellationToken = default) + { + var norm = PropertyRepository.NormalizeDomain(domainRaw); + if (norm == PropertyRepository.NormalizeDomain(domain)) + { + return Task.FromResult(id); + } + + return Task.FromResult(null); + } + } +} diff --git a/services/FileService/Dockerfile b/services/FileService/Dockerfile index 6783a2c1..163370d8 100644 --- a/services/FileService/Dockerfile +++ b/services/FileService/Dockerfile @@ -1,13 +1,15 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build WORKDIR /src -COPY FileService.slnx ./ -COPY src/FileService.Domain/FileService.Domain.csproj src/FileService.Domain/ -COPY src/FileService.Rendering/FileService.Rendering.csproj src/FileService.Rendering/ -COPY src/FileService.Application/FileService.Application.csproj src/FileService.Application/ -COPY src/FileService.Api/FileService.Api.csproj src/FileService.Api/ -RUN dotnet restore src/FileService.Api/FileService.Api.csproj -COPY src/ src/ -RUN dotnet publish src/FileService.Api/FileService.Api.csproj -c Release -o /app/publish --no-restore +COPY Shared/WebsiteProfiling.Contracts/WebsiteProfiling.Contracts.csproj Shared/WebsiteProfiling.Contracts/ +COPY FileService/FileService.slnx FileService/ +COPY FileService/src/FileService.Domain/FileService.Domain.csproj FileService/src/FileService.Domain/ +COPY FileService/src/FileService.Rendering/FileService.Rendering.csproj FileService/src/FileService.Rendering/ +COPY FileService/src/FileService.Application/FileService.Application.csproj FileService/src/FileService.Application/ +COPY FileService/src/FileService.Api/FileService.Api.csproj FileService/src/FileService.Api/ +RUN dotnet restore FileService/src/FileService.Api/FileService.Api.csproj +COPY Shared/WebsiteProfiling.Contracts/ Shared/WebsiteProfiling.Contracts/ +COPY FileService/src/ FileService/src/ +RUN dotnet publish FileService/src/FileService.Api/FileService.Api.csproj -c Release -o /app/publish --no-restore FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime WORKDIR /app diff --git a/services/FileService/src/FileService.Application/FileService.Application.csproj b/services/FileService/src/FileService.Application/FileService.Application.csproj index 901eb4ae..9925e1c0 100644 --- a/services/FileService/src/FileService.Application/FileService.Application.csproj +++ b/services/FileService/src/FileService.Application/FileService.Application.csproj @@ -1,6 +1,7 @@  + diff --git a/services/FileService/src/FileService.Application/Mapping/AuditReportMapper.cs b/services/FileService/src/FileService.Application/Mapping/AuditReportMapper.cs index 012e6edd..d24e7425 100644 --- a/services/FileService/src/FileService.Application/Mapping/AuditReportMapper.cs +++ b/services/FileService/src/FileService.Application/Mapping/AuditReportMapper.cs @@ -1,6 +1,8 @@ using System.Globalization; using System.Text.Json; using FileService.Domain.Models; +using WebsiteProfiling.Contracts.Json; +using WebsiteProfiling.Contracts.Report; namespace FileService.Application.Mapping; @@ -36,9 +38,9 @@ public static AuditReportModel Map( return new AuditReportModel { ReportId = reportId, - SiteName = JsonHelper.GetString(payload, "site_name") ?? "Site", - ReportTitle = JsonHelper.GetString(payload, "report_title") ?? "Technical SEO Audit Report", - GeneratedAt = FormatReportDate(JsonHelper.GetString(payload, "report_generated_at")), + SiteName = JsonCoercion.GetString(payload, "site_name") ?? "Site", + ReportTitle = JsonCoercion.GetString(payload, "report_title") ?? "Technical SEO Audit Report", + GeneratedAt = FormatReportDate(JsonCoercion.GetString(payload, "report_generated_at")), ExportedAt = exportedAt, HealthScore = healthScore, ScoreBand = ScoreBand(healthScore), @@ -62,9 +64,9 @@ public static AuditReportModel Map( }; } - private static List ExtractIssues(JsonElement payload) + private static List ExtractIssues(JsonElement payload) { - var rows = new List(); + var rows = new List(); if (!payload.TryGetProperty("categories", out var categories) || categories.ValueKind != JsonValueKind.Array) { return rows; @@ -72,25 +74,25 @@ private static List ExtractIssues(JsonElement payload) foreach (var cat in categories.EnumerateArray()) { - var catName = JsonHelper.GetString(cat, "name") ?? ""; + var catName = JsonCoercion.GetString(cat, "name") ?? ""; if (!cat.TryGetProperty("issues", out var issues) || issues.ValueKind != JsonValueKind.Array) { continue; } foreach (var issue in issues.EnumerateArray()) { - var rule = JsonHelper.GetString(issue, "recommendation") ?? ""; - var llm = JsonHelper.GetString(issue, "llm_recommendation") ?? ""; + var rule = JsonCoercion.GetString(issue, "recommendation") ?? ""; + var llm = JsonCoercion.GetString(issue, "llm_recommendation") ?? ""; var rec = !string.IsNullOrWhiteSpace(llm) ? llm : rule; rows.Add(IssueNormalizer.Normalize( CategoryDisplayName(catName), - JsonHelper.GetString(issue, "priority") ?? "", - JsonHelper.GetString(issue, "message") ?? "", - JsonHelper.GetString(issue, "url") ?? "", + JsonCoercion.GetString(issue, "priority") ?? "", + JsonCoercion.GetString(issue, "message") ?? "", + JsonCoercion.GetString(issue, "url") ?? "", rec, - JsonHelper.GetInt(issue, "gsc_clicks"), - JsonHelper.GetInt(issue, "gsc_impressions"), - JsonHelper.GetInt(issue, "impact_score"))); + JsonCoercion.GetInt(issue, "gsc_clicks"), + JsonCoercion.GetInt(issue, "gsc_impressions"), + JsonCoercion.GetInt(issue, "impact_score"))); } } @@ -103,7 +105,7 @@ private static List ExtractIssues(JsonElement payload) return rows; } - private static IReadOnlyList LimitIssues(List allIssues, PdfProfile profile) + private static IReadOnlyList LimitIssues(List allIssues, PdfProfile profile) { var max = profile switch { @@ -116,7 +118,7 @@ private static IReadOnlyList LimitIssues(List allIssues, return allIssues.Take(max).ToList(); } - var result = new List(); + var result = new List(); var perGroup = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var issue in allIssues) { @@ -136,17 +138,17 @@ private static IReadOnlyList LimitIssues(List allIssues, return result; } - private static ExecutiveSummaryModel ExtractExecutiveSummary(JsonElement payload, List allIssues) + private static ExecutiveSummaryModel ExtractExecutiveSummary(JsonElement payload, List allIssues) { var summary = ""; var source = ""; var priorities = new List(); - var topIssues = new List(); + var topIssues = new List(); if (payload.TryGetProperty("executive_summary", out var exec) && exec.ValueKind == JsonValueKind.Object) { - summary = JsonHelper.GetString(exec, "summary") ?? ""; - source = ExecutiveSourceLabel(JsonHelper.GetString(exec, "source")); + summary = JsonCoercion.GetString(exec, "summary") ?? ""; + source = ExecutiveSourceLabel(JsonCoercion.GetString(exec, "source")); if (exec.TryGetProperty("priorities", out var priEl) && priEl.ValueKind == JsonValueKind.Array) { foreach (var p in priEl.EnumerateArray()) @@ -163,11 +165,11 @@ private static ExecutiveSummaryModel ExtractExecutiveSummary(JsonElement payload foreach (var issue in topEl.EnumerateArray().Take(8)) { topIssues.Add(IssueNormalizer.Normalize( - JsonHelper.GetString(issue, "category") ?? "", - JsonHelper.GetString(issue, "priority") ?? "", - JsonHelper.GetString(issue, "message") ?? "", - JsonHelper.GetString(issue, "url") ?? "", - JsonHelper.GetString(issue, "recommendation") ?? "", + JsonCoercion.GetString(issue, "category") ?? "", + JsonCoercion.GetString(issue, "priority") ?? "", + JsonCoercion.GetString(issue, "message") ?? "", + JsonCoercion.GetString(issue, "url") ?? "", + JsonCoercion.GetString(issue, "recommendation") ?? "", null, null, null)); } } @@ -217,8 +219,8 @@ private static IReadOnlyList ExtractCategoryScores(JsonEleme } scores.Add(new CategoryScoreModel { - Name = CategoryDisplayName(JsonHelper.GetString(cat, "name") ?? ""), - Score = JsonHelper.GetInt(cat, "score"), + Name = CategoryDisplayName(JsonCoercion.GetString(cat, "name") ?? ""), + Score = JsonCoercion.GetInt(cat, "score"), IssueCount = issueCount, }); } @@ -237,8 +239,8 @@ private static IReadOnlyList ExtractCategoryScores(JsonEleme } return new CrawlScopeModel { - PagesCrawled = JsonHelper.GetInt(scope, "pages_crawled"), - MaxPagesConfigured = JsonHelper.GetInt(scope, "max_pages_configured"), + PagesCrawled = JsonCoercion.GetInt(scope, "pages_crawled"), + MaxPagesConfigured = JsonCoercion.GetInt(scope, "max_pages_configured"), }; } @@ -259,7 +261,7 @@ private static IReadOnlyList ExtractDataSources(JsonElement payload) .ToList(); } - private static IReadOnlyList BuildTruncationNotes(List all, IReadOnlyList limited) + private static IReadOnlyList BuildTruncationNotes(List all, IReadOnlyList limited) { if (all.Count <= limited.Count) { @@ -268,7 +270,7 @@ private static IReadOnlyList BuildTruncationNotes(List all, return [$"Showing {limited.Count} of {all.Count} issues — export CSV for the full list."]; } - private static Dictionary CountByPriority(IEnumerable issues) + private static Dictionary CountByPriority(IEnumerable issues) { var counts = new Dictionary(StringComparer.OrdinalIgnoreCase) { diff --git a/services/FileService/src/FileService.Application/Mapping/ChapterMappers.cs b/services/FileService/src/FileService.Application/Mapping/ChapterMappers.cs index a1ebb120..ab493c39 100644 --- a/services/FileService/src/FileService.Application/Mapping/ChapterMappers.cs +++ b/services/FileService/src/FileService.Application/Mapping/ChapterMappers.cs @@ -1,6 +1,7 @@ using System.Globalization; using System.Text.Json; using FileService.Domain.Models; +using WebsiteProfiling.Contracts.Json; namespace FileService.Application.Mapping; @@ -13,7 +14,7 @@ public static class ChapterMappers var statusCounts = ExtractStatusCounts(payload); var renderMode = payload.TryGetProperty("report_meta", out var meta) && meta.ValueKind == JsonValueKind.Object && meta.TryGetProperty("crawl_scope", out var scope) && scope.ValueKind == JsonValueKind.Object - ? JsonHelper.GetString(scope, "render_mode") + ? JsonCoercion.GetString(scope, "render_mode") : null; if (!hasSummary && statusCounts.Count == 0 && renderMode is null) @@ -23,12 +24,12 @@ public static class ChapterMappers return new AuditSnapshotModel { - TotalUrls = hasSummary ? JsonHelper.GetInt(summary, "total_urls") : null, - IndexableUrls = hasSummary ? JsonHelper.GetInt(summary, "indexable") : null, - TotalIssues = hasSummary ? JsonHelper.GetInt(summary, "total_issues") : null, - CriticalIssues = hasSummary ? JsonHelper.GetInt(summary, "critical_issues") : null, + TotalUrls = hasSummary ? JsonCoercion.GetInt(summary, "total_urls") : null, + IndexableUrls = hasSummary ? JsonCoercion.GetInt(summary, "indexable") : null, + TotalIssues = hasSummary ? JsonCoercion.GetInt(summary, "total_issues") : null, + CriticalIssues = hasSummary ? JsonCoercion.GetInt(summary, "critical_issues") : null, StatusCounts = statusCounts, - GoogleFetchedAt = meta.ValueKind == JsonValueKind.Object ? JsonHelper.GetString(meta, "google_fetched_at") : null, + GoogleFetchedAt = meta.ValueKind == JsonValueKind.Object ? JsonCoercion.GetString(meta, "google_fetched_at") : null, RenderMode = renderMode, }; } @@ -39,7 +40,7 @@ public static class ChapterMappers { return null; } - var human = JsonHelper.GetString(payload, "lighthouse_human_summary") ?? ""; + var human = JsonCoercion.GetString(payload, "lighthouse_human_summary") ?? ""; var diagnostics = new List(); if (payload.TryGetProperty("lighthouse_diagnostics", out var diagEl) && diagEl.ValueKind == JsonValueKind.Array) { @@ -47,8 +48,8 @@ public static class ChapterMappers { diagnostics.Add(new LighthouseDiagnosticModel { - Title = JsonHelper.GetString(d, "title") ?? JsonHelper.GetString(d, "id") ?? "", - Description = JsonHelper.GetString(d, "description") ?? "", + Title = JsonCoercion.GetString(d, "title") ?? JsonCoercion.GetString(d, "id") ?? "", + Description = JsonCoercion.GetString(d, "description") ?? "", }); } } @@ -56,11 +57,11 @@ public static class ChapterMappers { Summary = new LighthouseSummaryModel { - Url = JsonHelper.GetString(lh, "url") ?? "", - Performance = JsonHelper.GetInt(lh, "performance"), - Accessibility = JsonHelper.GetInt(lh, "accessibility"), - BestPractices = JsonHelper.GetInt(lh, "best_practices"), - Seo = JsonHelper.GetInt(lh, "seo"), + Url = JsonCoercion.GetString(lh, "url") ?? "", + Performance = JsonCoercion.GetInt(lh, "performance"), + Accessibility = JsonCoercion.GetInt(lh, "accessibility"), + BestPractices = JsonCoercion.GetInt(lh, "best_practices"), + Seo = JsonCoercion.GetInt(lh, "seo"), }, HumanSummary = human, Diagnostics = diagnostics, @@ -109,10 +110,10 @@ public static class ChapterMappers } var findings = arr.EnumerateArray().Take(25).Select(f => new SecurityFindingModel { - Severity = JsonHelper.GetString(f, "severity") ?? "medium", - Type = JsonHelper.GetString(f, "finding_type") ?? JsonHelper.GetString(f, "type") ?? "", - Url = JsonHelper.GetString(f, "url") ?? "", - Message = JsonHelper.GetString(f, "message") ?? "", + Severity = JsonCoercion.GetString(f, "severity") ?? "medium", + Type = JsonCoercion.GetString(f, "finding_type") ?? JsonCoercion.GetString(f, "type") ?? "", + Url = JsonCoercion.GetString(f, "url") ?? "", + Message = JsonCoercion.GetString(f, "message") ?? "", }).ToList(); return findings.Count == 0 ? null : new SecurityChapterModel { Findings = findings }; } @@ -131,16 +132,16 @@ public static class ChapterMappers { keywords.Add(new MetricRowModel { - Label = JsonHelper.GetString(kw, "word") ?? "", - Value = JsonHelper.GetString(kw, "count") ?? "", + Label = JsonCoercion.GetString(kw, "word") ?? "", + Value = JsonCoercion.GetString(kw, "count") ?? "", }); } } return new ContentChapterModel { - MeanWordCount = stats.ValueKind == JsonValueKind.Object ? JsonHelper.GetInt(stats, "mean") : null, - MedianWordCount = stats.ValueKind == JsonValueKind.Object ? JsonHelper.GetInt(stats, "median") : null, - ThinContentCount = JsonHelper.GetInt(ca, "thin_content_count"), + MeanWordCount = stats.ValueKind == JsonValueKind.Object ? JsonCoercion.GetInt(stats, "mean") : null, + MedianWordCount = stats.ValueKind == JsonValueKind.Object ? JsonCoercion.GetInt(stats, "median") : null, + ThinContentCount = JsonCoercion.GetInt(ca, "thin_content_count"), TopKeywords = keywords, }; } @@ -153,10 +154,10 @@ public static class ChapterMappers } return new IndexationChapterModel { - Indexable = JsonHelper.GetInt(ic, "indexable"), - NonIndexable = JsonHelper.GetInt(ic, "non_indexable"), - Blocked = JsonHelper.GetInt(ic, "blocked"), - Notes = JsonHelper.GetString(ic, "notes"), + Indexable = JsonCoercion.GetInt(ic, "indexable"), + NonIndexable = JsonCoercion.GetInt(ic, "non_indexable"), + Blocked = JsonCoercion.GetInt(ic, "blocked"), + Notes = JsonCoercion.GetString(ic, "notes"), }; } @@ -168,12 +169,68 @@ public static IReadOnlyList MapLinkSamples(JsonElement payload, } return links.EnumerateArray().Take(limit).Select(l => new LinkSampleModel { - Url = JsonHelper.GetString(l, "url") ?? "", - Status = JsonHelper.GetString(l, "status") ?? "", - Title = JsonHelper.GetString(l, "title") ?? "", + Url = JsonCoercion.GetString(l, "url") ?? "", + Status = JsonCoercion.GetString(l, "status") ?? "", + Title = JsonCoercion.GetString(l, "title") ?? "", }).Where(l => !string.IsNullOrWhiteSpace(l.Url)).ToList(); } + public static IReadOnlyList MapSitemapLinks(JsonElement payload, int maxUrls = 50000) + { + if (!payload.TryGetProperty("links", out var links) || links.ValueKind != JsonValueKind.Array) + { + return []; + } + + var cap = Math.Max(1, maxUrls); + var rows = new List(); + foreach (var row in links.EnumerateArray()) + { + if (row.ValueKind != JsonValueKind.Object || IsTruthy(row, "noindex")) + { + continue; + } + + var status = JsonCoercion.GetString(row, "status") ?? ""; + if (!status.StartsWith('2')) + { + continue; + } + + var url = (JsonCoercion.GetString(row, "url") ?? "").Trim(); + if (url.Length == 0) + { + continue; + } + + rows.Add(new LinkSampleModel { Url = url, Status = status, Title = JsonCoercion.GetString(row, "title") ?? "" }); + if (rows.Count >= cap) + { + break; + } + } + + return rows; + } + + private static bool IsTruthy(JsonElement obj, string name) + { + if (!obj.TryGetProperty(name, out var v)) + { + return false; + } + + return v.ValueKind switch + { + JsonValueKind.True => true, + JsonValueKind.String => !string.IsNullOrEmpty(v.GetString()), + JsonValueKind.Number => v.TryGetDouble(out var d) && d != 0, + JsonValueKind.Array => v.GetArrayLength() > 0, + JsonValueKind.Object => v.EnumerateObject().Any(), + _ => false, + }; + } + private static List MapMetricRows( JsonElement parent, string arrayName, @@ -188,7 +245,7 @@ private static List MapMetricRows( } foreach (var item in arr.EnumerateArray().Take(10)) { - var label = JsonHelper.GetString(item, labelKey) ?? JsonHelper.GetString(item, "url") ?? ""; + var label = JsonCoercion.GetString(item, labelKey) ?? JsonCoercion.GetString(item, "url") ?? ""; if (string.IsNullOrWhiteSpace(label)) { continue; @@ -196,8 +253,8 @@ private static List MapMetricRows( rows.Add(new MetricRowModel { Label = label, - Value = JsonHelper.GetString(item, valueKey) ?? "0", - Secondary = secondaryKey is not null ? JsonHelper.GetString(item, secondaryKey) : null, + Value = JsonCoercion.GetString(item, valueKey) ?? "0", + Secondary = secondaryKey is not null ? JsonCoercion.GetString(item, secondaryKey) : null, }); } return rows; @@ -220,33 +277,3 @@ private static Dictionary ExtractStatusCounts(JsonElement payload) return result; } } - -internal static class JsonHelper -{ - public static string? GetString(JsonElement el, string name) - { - if (!el.TryGetProperty(name, out var prop)) - { - return null; - } - return prop.ValueKind switch - { - JsonValueKind.String => prop.GetString(), - // Normalise numbers to a plain decimal string; GetRawText() would - // leak JSON formatting like scientific notation (e.g. "1E+10"). - JsonValueKind.Number => prop.TryGetInt64(out var l) - ? l.ToString(CultureInfo.InvariantCulture) - : prop.GetDouble().ToString(CultureInfo.InvariantCulture), - _ => null, - }; - } - - public static int? GetInt(JsonElement el, string name) - { - if (!el.TryGetProperty(name, out var prop) || prop.ValueKind != JsonValueKind.Number) - { - return null; - } - return (int)Math.Round(prop.GetDouble()); - } -} diff --git a/services/FileService/src/FileService.Application/Mapping/IssueNormalizer.cs b/services/FileService/src/FileService.Application/Mapping/IssueNormalizer.cs index c984476e..e732d2aa 100644 --- a/services/FileService/src/FileService.Application/Mapping/IssueNormalizer.cs +++ b/services/FileService/src/FileService.Application/Mapping/IssueNormalizer.cs @@ -1,11 +1,12 @@ using System.Text.RegularExpressions; using FileService.Domain.Models; +using WebsiteProfiling.Contracts.Report; namespace FileService.Application.Mapping; public static partial class IssueNormalizer { - public static IssueModel Normalize( + public static IssueRecord Normalize( string category, string priority, string message, @@ -16,7 +17,7 @@ public static IssueModel Normalize( int? impactScore) { var headline = NormalizeHeadline(message, url); - return new IssueModel + return new IssueRecord { Category = category, Priority = priority, diff --git a/services/FileService/src/FileService.Application/Services/ReportExportService.cs b/services/FileService/src/FileService.Application/Services/ReportExportService.cs index b93cc10f..a0d329e3 100644 --- a/services/FileService/src/FileService.Application/Services/ReportExportService.cs +++ b/services/FileService/src/FileService.Application/Services/ReportExportService.cs @@ -1,6 +1,8 @@ using System.Text.Json; using FileService.Application.Clients; using FileService.Application.Domain; +using FileService.Application.Mapping; +using FileService.Domain.Models; using FileService.Rendering.Exports; namespace FileService.Application.Services; @@ -39,10 +41,10 @@ public Task GetJsonByDomainAsync(string domain, CancellationToken cancel RenderByDomainAsync(domain, json.Generate, cancellationToken); public Task GetSitemapByReportIdAsync(int reportId, CancellationToken cancellationToken = default) => - RenderByIdAsync(reportId, p => sitemap.Generate(p), cancellationToken); + RenderSitemapByIdAsync(reportId, cancellationToken); public Task GetSitemapByDomainAsync(string domain, CancellationToken cancellationToken = default) => - RenderByDomainAsync(domain, p => sitemap.Generate(p), cancellationToken); + RenderSitemapByDomainAsync(domain, cancellationToken); private async Task RenderByIdAsync(int reportId, Func render, CancellationToken ct) { @@ -64,4 +66,32 @@ private async Task RenderByDomainAsync(string domain, Func RenderSitemapByIdAsync(int reportId, CancellationToken ct) + { + var payload = await client.GetPayloadAsync(reportId, ct); + if (payload is null) + { + throw new KeyNotFoundException($"Report {reportId} not found"); + } + + var model = new AuditReportModel + { + ReportId = reportId, + LinkSamples = ChapterMappers.MapSitemapLinks(payload.Value), + }; + return sitemap.GenerateFromModel(model); + } + + private async Task RenderSitemapByDomainAsync(string domain, CancellationToken ct) + { + var reports = await client.ListReportsAsync(ct); + var reportId = DomainResolver.ResolveReportId(reports, domain); + if (reportId is null) + { + throw new KeyNotFoundException($"No report found for domain '{domain}'"); + } + + return await RenderSitemapByIdAsync(reportId.Value, ct); + } } diff --git a/services/FileService/src/FileService.Domain/FileService.Domain.csproj b/services/FileService/src/FileService.Domain/FileService.Domain.csproj index b7601447..ee41290f 100644 --- a/services/FileService/src/FileService.Domain/FileService.Domain.csproj +++ b/services/FileService/src/FileService.Domain/FileService.Domain.csproj @@ -1,5 +1,9 @@  + + + + net10.0 enable diff --git a/services/FileService/src/FileService.Domain/Models/AuditReportModel.cs b/services/FileService/src/FileService.Domain/Models/AuditReportModel.cs index 85edb70a..0dadad36 100644 --- a/services/FileService/src/FileService.Domain/Models/AuditReportModel.cs +++ b/services/FileService/src/FileService.Domain/Models/AuditReportModel.cs @@ -1,3 +1,5 @@ +using WebsiteProfiling.Contracts.Report; + namespace FileService.Domain.Models; public enum PdfProfile @@ -22,7 +24,7 @@ public sealed class AuditReportModel public PdfBrandingModel Branding { get; init; } = new(); public ExecutiveSummaryModel ExecutiveSummary { get; init; } = new(); public IReadOnlyList CategoryScores { get; init; } = []; - public IReadOnlyList Issues { get; init; } = []; + public IReadOnlyList Issues { get; init; } = []; public IReadOnlyDictionary IssueCounts { get; init; } = new Dictionary(); public AuditSnapshotModel? Snapshot { get; init; } public LighthouseChapterModel? Lighthouse { get; init; } @@ -42,7 +44,7 @@ public sealed class ExecutiveSummaryModel public string Summary { get; init; } = ""; public string SourceLabel { get; init; } = ""; public IReadOnlyList Priorities { get; init; } = []; - public IReadOnlyList TopIssues { get; init; } = []; + public IReadOnlyList TopIssues { get; init; } = []; } public sealed class CategoryScoreModel @@ -52,20 +54,6 @@ public sealed class CategoryScoreModel public int IssueCount { get; init; } } -public sealed class IssueModel -{ - public string Category { get; init; } = ""; - public string Priority { get; init; } = ""; - public string Message { get; init; } = ""; - public string Headline { get; init; } = ""; - public string Url { get; init; } = ""; - public string UrlPath { get; init; } = ""; - public string Recommendation { get; init; } = ""; - public int? GscClicks { get; init; } - public int? GscImpressions { get; init; } - public int? ImpactScore { get; init; } -} - public sealed class LighthouseSummaryModel { public string Url { get; init; } = ""; diff --git a/services/FileService/src/FileService.Rendering/Exports/ReportSitemapExporter.cs b/services/FileService/src/FileService.Rendering/Exports/ReportSitemapExporter.cs index 34cd4202..d1cac744 100644 --- a/services/FileService/src/FileService.Rendering/Exports/ReportSitemapExporter.cs +++ b/services/FileService/src/FileService.Rendering/Exports/ReportSitemapExporter.cs @@ -1,5 +1,6 @@ using System.Text; using System.Text.Json; +using FileService.Domain.Models; namespace FileService.Rendering.Exports; @@ -57,6 +58,33 @@ public string Generate(JsonElement payload, int maxUrls = 50000) return sb.ToString(); } + /// Build sitemap URLs from a typed (link samples). + public string GenerateFromModel(AuditReportModel model, int maxUrls = 50000) + { + var urls = model.LinkSamples + .Where(l => !string.IsNullOrWhiteSpace(l.Url) && (l.Status ?? "").StartsWith('2')) + .Select(l => l.Url.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + var cap = Math.Max(1, maxUrls); + if (urls.Count > cap) + { + urls = urls.GetRange(0, cap); + } + + var body = string.Join( + "\n", + urls.Select(u => $" {XmlEscape(u)}")); + + var sb = new StringBuilder(); + sb.Append("\n"); + sb.Append("\n"); + sb.Append(body).Append('\n'); + sb.Append("\n"); + return sb.ToString(); + } + // Mirrors xml.sax.saxutils.escape (default escapes only & < >, in that order). private static string XmlEscape(string s) => s.Replace("&", "&").Replace("<", "<").Replace(">", ">"); diff --git a/services/FileService/src/FileService.Rendering/FileService.Rendering.csproj b/services/FileService/src/FileService.Rendering/FileService.Rendering.csproj index f77c6c00..beeb99bc 100644 --- a/services/FileService/src/FileService.Rendering/FileService.Rendering.csproj +++ b/services/FileService/src/FileService.Rendering/FileService.Rendering.csproj @@ -1,6 +1,7 @@  + diff --git a/services/FileService/src/FileService.Rendering/Sections/PdfSections.cs b/services/FileService/src/FileService.Rendering/Sections/PdfSections.cs index 675b9999..1844f8bd 100644 --- a/services/FileService/src/FileService.Rendering/Sections/PdfSections.cs +++ b/services/FileService/src/FileService.Rendering/Sections/PdfSections.cs @@ -1,4 +1,5 @@ using FileService.Domain.Models; +using WebsiteProfiling.Contracts.Report; using FileService.Rendering.Charts; using FileService.Rendering.Composition; using QuestPDF.Fluent; @@ -252,7 +253,7 @@ public void Compose(IContainer container, PdfRenderContext context) foreach (var group in groups) { - IEnumerable> subGroups; + IEnumerable> subGroups; if (group.Count() > 8) { subGroups = group.GroupBy(i => i.Category).OrderBy(g => g.Key); @@ -280,7 +281,7 @@ public void Compose(IContainer container, PdfRenderContext context) }); } - private static void ComposeIssueCard(IContainer container, IssueModel issue, PdfProfile profile) + private static void ComposeIssueCard(IContainer container, IssueRecord issue, PdfProfile profile) { var priorityColor = PdfTheme.PriorityColors.GetValueOrDefault(issue.Priority.ToLowerInvariant(), PdfTheme.MutedColor); container.PaddingVertical(4).Row(row => @@ -320,10 +321,10 @@ private static void ComposeIssueCard(IContainer container, IssueModel issue, Pdf _ => 9, }; - private sealed class SimpleGrouping(string key, IEnumerable items) : IGrouping + private sealed class SimpleGrouping(string key, IEnumerable items) : IGrouping { public string Key { get; } = key; - public IEnumerator GetEnumerator() => items.GetEnumerator(); + public IEnumerator GetEnumerator() => items.GetEnumerator(); System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); } } diff --git a/services/IntegrationsService/.dockerignore b/services/IntegrationsService/.dockerignore new file mode 100644 index 00000000..9d7417a8 --- /dev/null +++ b/services/IntegrationsService/.dockerignore @@ -0,0 +1,6 @@ +**/bin/ +**/obj/ +**/.vs/ +**/*.user +**/*.suo +tests/ diff --git a/services/IntegrationsService/Dockerfile b/services/IntegrationsService/Dockerfile new file mode 100644 index 00000000..16b654e7 --- /dev/null +++ b/services/IntegrationsService/Dockerfile @@ -0,0 +1,22 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY Shared/WebsiteProfiling.Contracts/WebsiteProfiling.Contracts.csproj Shared/WebsiteProfiling.Contracts/ +COPY IntegrationsService/IntegrationsService.slnx IntegrationsService/ +COPY IntegrationsService/src/IntegrationsService.Domain/IntegrationsService.Domain.csproj IntegrationsService/src/IntegrationsService.Domain/ +COPY IntegrationsService/src/IntegrationsService.Application/IntegrationsService.Application.csproj IntegrationsService/src/IntegrationsService.Application/ +COPY IntegrationsService/src/IntegrationsService.Providers/IntegrationsService.Providers.csproj IntegrationsService/src/IntegrationsService.Providers/ +COPY IntegrationsService/src/IntegrationsService.Api/IntegrationsService.Api.csproj IntegrationsService/src/IntegrationsService.Api/ +RUN dotnet restore IntegrationsService/src/IntegrationsService.Api/IntegrationsService.Api.csproj +COPY Shared/WebsiteProfiling.Contracts/ Shared/WebsiteProfiling.Contracts/ +COPY IntegrationsService/src/ IntegrationsService/src/ +RUN dotnet publish IntegrationsService/src/IntegrationsService.Api/IntegrationsService.Api.csproj -c Release -o /app/publish --no-restore + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime +WORKDIR /app +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* +ENV ASPNETCORE_URLS=http://+:8093 +EXPOSE 8093 +COPY --from=build /app/publish . +ENTRYPOINT ["dotnet", "IntegrationsService.Api.dll"] diff --git a/services/IntegrationsService/IntegrationsService.slnx b/services/IntegrationsService/IntegrationsService.slnx new file mode 100644 index 00000000..ccb92923 --- /dev/null +++ b/services/IntegrationsService/IntegrationsService.slnx @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/services/IntegrationsService/README.md b/services/IntegrationsService/README.md new file mode 100644 index 00000000..467934ab --- /dev/null +++ b/services/IntegrationsService/README.md @@ -0,0 +1,56 @@ +# IntegrationsService + +Internal .NET microservice for Google Search Console, GA4, Bing sync, and keyword enrichment. + +- **Port:** 8093 (`INTEGRATIONS_SERVICE_URL`) +- **Stack:** ASP.NET Core 10, EF Core + Npgsql (Postgres), Google API client libraries +- **Consumers:** BFF (browser-facing `/api/*` proxy), Python worker/CLI via `INTEGRATIONS_SERVICE_URL` + +## Run locally + +```bash +cd services/IntegrationsService +DATABASE_URL=postgres://profiling:profiling@127.0.0.1:5432/website_profiling \ + FASTAPI_URL=http://127.0.0.1:8001 \ + USE_FASTAPI_PYTHON_BRIDGE=1 \ + ASPNETCORE_URLS=http://127.0.0.1:8093 \ + dotnet run --project src/IntegrationsService.Api --no-launch-profile +``` + +Or use `./local-run` / `./local-prod` from the repo root (starts IntegrationsService on 8093 when `dotnet` is available). + +## Environment + +| Variable | Purpose | +|----------|---------| +| `DATABASE_URL` | Postgres (property credentials, `google_data`) | +| `FASTAPI_URL` | Python bridge for keyword enrich + GSC link import when `USE_FASTAPI_PYTHON_BRIDGE=1` | +| `USE_FASTAPI_PYTHON_BRIDGE` | `1` in Docker/local prod — IntegrationsService image has no Python | +| `AUTH_SECRET` | Required when `ASPNETCORE_ENVIRONMENT=Production` (OAuth state signing) | +| `GOOGLE_REDIRECT_URI` | OAuth callback (default dev: `http://localhost:8090/api/integrations/google/callback`) | +| `APP_PUBLIC_URL` | Post-login redirect base (default dev: `http://localhost:3000`) | + +## Endpoints + +| Route | Purpose | +|-------|---------| +| `GET /health` | Health check | +| `POST /internal/integrations/google/fetch` | Worker fetch (stores `google_data`) | +| `POST /internal/integrations/keywords/enrich` | Pipeline keyword enrichment | +| `GET/POST /api/properties/{id}/google/*` | Property Google config, credentials, test, disconnect | +| `GET/POST /api/integrations/google/*` | OAuth auth/callback, status, page-data, page-live, url-inspection, keywords | +| `POST /api/integrations/bing/sync` | Bing Webmaster sync | + +BFF routes Google/Bing traffic via `INTEGRATIONS_ROUTES` (see `services/Bff/`). Property `/google/*` paths are auto-proxied when `INTEGRATIONS_SERVICE_URL` is set. + +Swagger UI: `/docs` (Development only). + +## Tests + +```bash +dotnet test IntegrationsService.slnx +``` + +## Docker + +Included in `docker-compose.yml`, `docker-compose.prod.yml`, and `docker-compose.pull.yml`. FastAPI runs with `DEPRECATE_PYTHON_INTEGRATIONS=1`; worker uses `INTEGRATIONS_SERVICE_URL=http://integrations:8093`. diff --git a/services/IntegrationsService/src/IntegrationsService.Api/Controllers/HealthController.cs b/services/IntegrationsService/src/IntegrationsService.Api/Controllers/HealthController.cs new file mode 100644 index 00000000..4a084985 --- /dev/null +++ b/services/IntegrationsService/src/IntegrationsService.Api/Controllers/HealthController.cs @@ -0,0 +1,13 @@ +using Microsoft.AspNetCore.Mvc; + +namespace IntegrationsService.Api.Controllers; + +[ApiController] +[Route("")] +[Tags("Health")] +public sealed class HealthController : ControllerBase +{ + [HttpGet("health")] + [ProducesResponseType(StatusCodes.Status200OK)] + public IActionResult Get() => Ok(new { status = "ok" }); +} diff --git a/services/IntegrationsService/src/IntegrationsService.Api/Controllers/IntegrationsBingController.cs b/services/IntegrationsService/src/IntegrationsService.Api/Controllers/IntegrationsBingController.cs new file mode 100644 index 00000000..396723a4 --- /dev/null +++ b/services/IntegrationsService/src/IntegrationsService.Api/Controllers/IntegrationsBingController.cs @@ -0,0 +1,50 @@ +using IntegrationsService.Application.Google; +using IntegrationsService.Application.Repositories; +using Microsoft.AspNetCore.Mvc; + +namespace IntegrationsService.Api.Controllers; + +[ApiController] +[Route("api/integrations/bing")] +[Tags("Integrations Bing")] +public sealed class IntegrationsBingController( + PipelineConfigRepository pipelineConfig, + BingWebmasterService bing) : ControllerBase +{ + [HttpPost("sync")] + public async Task Sync(CancellationToken cancellationToken) + { + IReadOnlyDictionary state; + try + { + state = await pipelineConfig.ReadKnownAsync(cancellationToken); + } + catch (Exception ex) + { + return StatusCode(500, new { error = ex.Message }); + } + + state.TryGetValue("bing_webmaster_api_key", out var apiKey); + state.TryGetValue("start_url", out var siteUrl); + apiKey = (apiKey ?? "").Trim(); + siteUrl = (siteUrl ?? "").Trim(); + + if (string.IsNullOrEmpty(apiKey) || string.IsNullOrEmpty(siteUrl)) + { + return BadRequest(new + { + error = "Set bing_webmaster_api_key and start_url in pipeline settings.", + }); + } + + try + { + var result = await bing.FetchBacklinksSummaryAsync(apiKey, siteUrl, cancellationToken); + return Ok(result); + } + catch (Exception ex) + { + return StatusCode(500, new { error = ex.Message }); + } + } +} diff --git a/services/IntegrationsService/src/IntegrationsService.Api/Controllers/IntegrationsGoogleController.cs b/services/IntegrationsService/src/IntegrationsService.Api/Controllers/IntegrationsGoogleController.cs new file mode 100644 index 00000000..ca4d5bf7 --- /dev/null +++ b/services/IntegrationsService/src/IntegrationsService.Api/Controllers/IntegrationsGoogleController.cs @@ -0,0 +1,622 @@ +using System.Text.Json; +using IntegrationsService.Application.Google; +using IntegrationsService.Application.Repositories; +using Microsoft.AspNetCore.Mvc; + +namespace IntegrationsService.Api.Controllers; + +[ApiController] +[Route("api/integrations/google")] +[Tags("Integrations Google")] +public sealed class IntegrationsGoogleController( + GoogleAppSettingsRepository appSettings, + GoogleDataReadRepository googleData, + PropertyRepository properties, + GoogleOAuthService oauth, + PageLiveService pageLive, + PageCompareService pageCompare, + PageGoogleSnapshotRepository pageSnapshots, + KeywordDataRepository keywordData, + IGoogleCredentialFactory credentials, + IGscSearchAnalyticsClient gscClient) : ControllerBase +{ + private static readonly Dictionary EmptyPageData = new() + { + ["source"] = "snapshot", + ["snapshotId"] = null, + ["gsc"] = null, + ["ga4"] = null, + ["coverage"] = new { inCrawl = false, inGsc = false, inGa4 = false }, + ["siteBenchmarks"] = new { gsc = (object?)null, ga4 = (object?)null }, + ["dateRange"] = new { }, + ["fetchedAt"] = (string?)null, + }; + + [HttpGet("auth")] + public async Task AuthStart( + [FromQuery] long? propertyId, + [FromQuery] string? startUrl, + [FromQuery] string? returnTo, + CancellationToken cancellationToken) + { + try + { + var url = await oauth.OAuthStartAsync(propertyId, startUrl, returnTo, cancellationToken); + return Redirect(url); + } + catch (GoogleOAuthException ex) + { + return BadRequest(new { error = ex.Message }); + } + } + + [HttpGet("callback")] + public async Task AuthCallback( + [FromQuery] string? code, + [FromQuery] string? state, + [FromQuery] string? error, + CancellationToken cancellationToken) + { + var url = await oauth.OAuthCallbackAsync(code, state, error, cancellationToken); + return Redirect(url); + } + + [HttpGet("status")] + public async Task AppStatus(CancellationToken cancellationToken) + { + var cfg = await appSettings.ReadAsync(cancellationToken); + var hasClientId = !string.IsNullOrWhiteSpace(cfg.ClientId); + var hasClientSecret = !string.IsNullOrWhiteSpace(cfg.ClientSecret); + var hasServiceAccount = await appSettings.HasServiceAccountAsync(cancellationToken); + string? serviceAccountEmail = null; + if (hasServiceAccount) + { + using var sa = await appSettings.ReadServiceAccountJsonAsync(cancellationToken); + if (sa?.RootElement.TryGetProperty("client_email", out var email) == true) + { + serviceAccountEmail = email.GetString(); + } + } + + return Ok(new + { + hasClientId, + hasClientSecret, + hasOAuthApp = hasClientId && hasClientSecret, + hasServiceAccount, + serviceAccountEmail, + dateRangeDays = cfg.DefaultDateRangeDays > 0 ? cfg.DefaultDateRangeDays : 28, + hasDeveloperToken = !string.IsNullOrWhiteSpace(cfg.DeveloperToken), + hasLoginCustomerId = !string.IsNullOrWhiteSpace(cfg.LoginCustomerId), + lastFetchedAt = await googleData.ReadLastFetchedAtGlobalAsync(cancellationToken), + }); + } + + [HttpGet("credentials")] + public async Task AppCredentials(CancellationToken cancellationToken) + { + var cfg = await appSettings.ReadAsync(cancellationToken); + using var sa = await appSettings.ReadServiceAccountJsonAsync(cancellationToken); + object? serviceAccount = null; + if (sa is not null) + { + serviceAccount = JsonSerializer.Deserialize(sa.RootElement.GetRawText()); + } + + return Ok(new + { + clientId = (cfg.ClientId ?? "").Trim(), + clientSecret = (cfg.ClientSecret ?? "").Trim(), + serviceAccount, + dateRangeDays = cfg.DefaultDateRangeDays > 0 ? cfg.DefaultDateRangeDays : 28, + developerToken = (cfg.DeveloperToken ?? "").Trim(), + loginCustomerId = (cfg.LoginCustomerId ?? "").Trim(), + }); + } + + [HttpGet("url-inspection")] + public async Task UrlInspection( + [FromQuery] string url, + [FromQuery] string? propertyId, + [FromQuery] string? domain, + CancellationToken cancellationToken) + { + var pageUrl = (url ?? "").Trim(); + if (string.IsNullOrEmpty(pageUrl)) + { + return BadRequest(new { error = "url parameter is required" }); + } + + var resolvedPropertyId = await properties.ResolvePropertyIdForPageAsync( + pageUrl, propertyId, domain, cancellationToken); + if (resolvedPropertyId is null) + { + return BadRequest(new { error = "propertyId or domain required" }); + } + + var prop = await properties.GetByIdAsync(resolvedPropertyId.Value, cancellationToken); + if (prop is null) + { + return NotFound(new { error = "Property not found" }); + } + + var gscSiteUrl = (prop.GscSiteUrl ?? "").Trim(); + if (string.IsNullOrEmpty(gscSiteUrl)) + { + return BadRequest(new { error = "GSC site URL is not configured for this property." }); + } + + try + { + var cred = await credentials.BuildCredentialsAsync(resolvedPropertyId.Value, cancellationToken); + var result = await gscClient.InspectUrlAsync(cred, gscSiteUrl, pageUrl, cancellationToken); + return Ok(result); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { error = ex.Message }); + } + } + + [HttpGet("page-data")] + public async Task PageData( + [FromQuery] string url, + [FromQuery] long? googleSnapshotId, + [FromQuery] string? propertyId, + [FromQuery] string? domain, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(url)) + { + return BadRequest(new { error = "url parameter required" }); + } + + var resolvedPropertyId = await properties.ResolvePropertyIdForPageAsync( + url, propertyId, domain, cancellationToken); + if (resolvedPropertyId is null) + { + return Ok(EmptyPageData); + } + + var snap = await googleData.ReadSnapshotRowAsync( + resolvedPropertyId.Value, + googleSnapshotId, + cancellationToken); + if (snap is null) + { + return Ok(EmptyPageData); + } + + using var doc = snap.ParseData(); + var slice = PageLookupService.SliceFromGoogleRow(doc.RootElement, url); + return Ok(new Dictionary(EmptyPageData) + { + ["source"] = slice.Source, + ["snapshotId"] = snap.Id, + ["gsc"] = slice.Gsc, + ["ga4"] = slice.Ga4, + ["coverage"] = slice.Coverage, + ["siteBenchmarks"] = slice.SiteBenchmarks, + ["dateRange"] = slice.DateRange, + ["fetchedAt"] = snap.FetchedAt ?? slice.FetchedAt, + }); + } + + [HttpGet("page-data/history")] + public async Task PageDataHistory( + [FromQuery] string url, + [FromQuery] string? propertyId, + [FromQuery] string? domain, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(url)) + { + return BadRequest(new { error = "url parameter required" }); + } + + var resolvedPropertyId = await properties.ResolvePropertyIdForPageAsync( + url, propertyId, domain, cancellationToken); + if (resolvedPropertyId is null) + { + return Ok(new { url, history = Array.Empty() }); + } + + var history = new List(); + foreach (var snap in await googleData.ListSnapshotRowsAsync(resolvedPropertyId.Value, 10, cancellationToken)) + { + using var doc = snap.ParseData(); + var slice = PageLookupService.SliceFromGoogleRow(doc.RootElement, url); + if (slice.Gsc is null && slice.Ga4 is null) + { + continue; + } + + var summary = PageLookupService.SummaryFromSlice(slice.Gsc, slice.Ga4); + history.Add(new + { + id = snap.Id, + fetchedAt = snap.FetchedAt, + type = "snapshot", + gsc = summary.GetValueOrDefault("gsc"), + ga4 = summary.GetValueOrDefault("ga4"), + }); + } + + return Ok(new { url, history }); + } + + [HttpPost("page-live")] + public async Task PageLive( + [FromBody] PageLiveRequestBody body, + CancellationToken cancellationToken) + { + var pageUrl = (body.Url ?? "").Trim(); + if (string.IsNullOrEmpty(pageUrl)) + { + return BadRequest(new { error = "url is required" }); + } + + long? propertyId = body.PropertyId; + if (propertyId is null or <= 0 && !string.IsNullOrWhiteSpace(body.Domain)) + { + propertyId = await properties.GetPropertyIdByDomainAsync(body.Domain!, cancellationToken); + } + + if (propertyId is null or <= 0) + { + propertyId = await properties.ResolvePropertyIdForPageAsync( + pageUrl, + body.PropertyId?.ToString(), + body.Domain, + cancellationToken); + } + + try + { + var data = await pageLive.FetchPageLiveAsync( + pageUrl, + propertyId, + body.Persist ?? true, + cancellationToken); + + if (data.GetValueOrDefault("ok") is false + && data.GetValueOrDefault("gsc") is null + && data.GetValueOrDefault("ga4") is null) + { + return StatusCode(500, new + { + error = (data.GetValueOrDefault("errors") as IEnumerable)?.FirstOrDefault()?.ToString() + ?? "Live fetch failed", + }); + } + + var response = new Dictionary(data); + if (!response.ContainsKey("ok")) + { + response["ok"] = true; + } + + if (response.GetValueOrDefault("fetchedAt") is null) + { + response["fetchedAt"] = DateTimeOffset.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffZ"); + } + + return Ok(response); + } + catch (Exception ex) + { + return StatusCode(500, new { error = ex.Message }); + } + } + + [HttpGet("page-live/history")] + public async Task PageLiveHistory( + [FromQuery] string url, + [FromQuery] int limit = 15, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(url)) + { + return BadRequest(new { error = "url parameter required" }); + } + + try + { + var history = await pageSnapshots.ListApiHistoryAsync(url, limit, cancellationToken); + return Ok(new + { + url, + history = history.Select(h => new + { + h.Id, + h.FetchedAt, + h.Gsc, + h.Ga4, + }), + }); + } + catch (Exception ex) + { + return StatusCode(500, new { error = ex.Message }); + } + } + + [HttpGet("page-compare")] + public async Task PageCompare( + [FromQuery] string url, + [FromQuery] string currentType = "snapshot", + [FromQuery] long currentId = 0, + [FromQuery] string baselineType = "snapshot", + [FromQuery] long baselineId = 0, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(url)) + { + return BadRequest(new { error = "url parameter required" }); + } + + if (currentId <= 0 || baselineId <= 0) + { + return BadRequest(new { error = "currentId and baselineId are required" }); + } + + var current = await pageCompare.LoadArmAsync(currentType, currentId, url, cancellationToken); + var baseline = await pageCompare.LoadArmAsync(baselineType, baselineId, url, cancellationToken); + if (current is null) + { + return NotFound(new { error = "Current snapshot not found" }); + } + + if (baseline is null) + { + return NotFound(new { error = "Baseline snapshot not found" }); + } + + var metrics = PageMetricsCompare.Build(current.ToMetricsPayload(), baseline.ToMetricsPayload()); + + return Ok(new + { + url, + current = new + { + type = current.Type, + id = current.Id, + fetchedAt = current.FetchedAt, + gsc = current.Gsc, + ga4 = current.Ga4, + }, + baseline = new + { + type = baseline.Type, + id = baseline.Id, + fetchedAt = baseline.FetchedAt, + gsc = baseline.Gsc, + ga4 = baseline.Ga4, + }, + metrics, + }); + } + + [HttpGet("keywords/by-page")] + public async Task KeywordsByPage( + [FromQuery] string url, + [FromQuery] string? propertyId, + [FromQuery] string? domain, + CancellationToken cancellationToken) + { + var pageUrl = (url ?? "").Trim(); + if (string.IsNullOrEmpty(pageUrl)) + { + return BadRequest(new { error = "url parameter is required" }); + } + + var resolvedPropertyId = await properties.ResolvePropertyIdForPageAsync( + pageUrl, propertyId, domain, cancellationToken); + if (resolvedPropertyId is null) + { + return BadRequest(new { error = "propertyId or domain required" }); + } + + using var data = await keywordData.ReadLatestAsync(resolvedPropertyId.Value, cancellationToken); + var allRows = ReadRows(data); + var normalizedTarget = UrlJoinBuilder.NormalizeUrl(pageUrl); + + var pageKeywords = allRows + .Where(r => MatchesUrl(GetString(r, "gsc_url"), normalizedTarget)) + .ToList(); + + var cannib = new List(); + if (data?.RootElement.TryGetProperty("cannibalisation", out var cannibArr) == true + && cannibArr.ValueKind == JsonValueKind.Array) + { + foreach (var item in cannibArr.EnumerateArray()) + { + if (item.ValueKind != JsonValueKind.Object + || !item.TryGetProperty("pages", out var pages) + || pages.ValueKind != JsonValueKind.Array) + { + continue; + } + + var matches = pages.EnumerateArray().Any(p => + p.ValueKind == JsonValueKind.Object + && p.TryGetProperty("url", out var pageUrlEl) + && (pageUrlEl.GetString() ?? "").ToLowerInvariant().TrimEnd('/') == normalizedTarget); + + if (matches) + { + cannib.Add(JsonSerializer.Deserialize(item.GetRawText())!); + } + } + } + + return Ok(new + { + url = pageUrl, + propertyId = resolvedPropertyId.Value, + keyword_count = pageKeywords.Count, + keywords = pageKeywords, + cannibalisation = cannib, + fetched_at = data is null ? null : GetRootString(data, "fetched_at"), + }); + } + + [HttpGet("keywords/history")] + public async Task KeywordsHistory( + [FromQuery] string keyword, + [FromQuery] string? propertyId, + [FromQuery] string? domain, + [FromQuery] int limit = 30, + CancellationToken cancellationToken = default) + { + keyword = (keyword ?? "").Trim(); + if (string.IsNullOrEmpty(keyword)) + { + return BadRequest(new { error = "keyword parameter is required" }); + } + + var resolvedPropertyId = await properties.ResolvePropertyIdForPageAsync( + "", propertyId, domain, cancellationToken); + if (resolvedPropertyId is null) + { + return BadRequest(new { error = "propertyId or domain required" }); + } + + limit = Math.Clamp(limit, 1, 90); + var history = await keywordData.ReadHistoryAsync( + resolvedPropertyId.Value, keyword, limit, cancellationToken); + + return Ok(new + { + keyword, + propertyId = resolvedPropertyId.Value, + history = history.Select(h => new + { + fetched_at = h.FetchedAt, + position = h.Position, + clicks = h.Clicks, + impressions = h.Impressions, + ctr = h.Ctr, + }), + }); + } + + [HttpPost("keywords/history/batch")] + public async Task KeywordsHistoryBatch( + [FromBody] KeywordHistoryBatchBody body, + CancellationToken cancellationToken) + { + var keywordsRaw = body.Keywords ?? []; + if (keywordsRaw.Count == 0 || keywordsRaw.Any(k => k is not string)) + { + return BadRequest(new { error = "keywords must be a list" }); + } + + var keywords = keywordsRaw + .OfType() + .Select(k => k.Trim()) + .Where(k => !string.IsNullOrEmpty(k)) + .Take(100) + .ToList(); + var limit = Math.Clamp(body.Limit ?? 30, 1, 90); + + long? propertyId = body.PropertyId; + if (propertyId is null or <= 0 && !string.IsNullOrWhiteSpace(body.Domain)) + { + propertyId = await properties.GetPropertyIdByDomainAsync(body.Domain!, cancellationToken); + } + + if (propertyId is null or <= 0) + { + return BadRequest(new { error = "propertyId or domain required" }); + } + + var results = await keywordData.ReadHistoryBatchAsync( + propertyId.Value, keywords, limit, cancellationToken); + + return Ok(new + { + keywords = results.ToDictionary( + kvp => kvp.Key, + kvp => kvp.Value.Select(h => new + { + fetched_at = h.FetchedAt, + position = h.Position, + clicks = h.Clicks, + impressions = h.Impressions, + ctr = h.Ctr, + }).ToList()), + propertyId = propertyId.Value, + }); + } + + private static bool MatchesUrl(string candidate, string target) + { + if (string.IsNullOrWhiteSpace(candidate)) + { + return false; + } + + var normalized = UrlJoinBuilder.NormalizeUrl(candidate); + var normalizedTarget = UrlJoinBuilder.NormalizeUrl(target); + return normalized == normalizedTarget; + } + + private static List> ReadRows(JsonDocument? data) + { + if (data is null + || !data.RootElement.TryGetProperty("rows", out var rows) + || rows.ValueKind != JsonValueKind.Array) + { + return []; + } + + var result = new List>(); + foreach (var row in rows.EnumerateArray()) + { + if (row.ValueKind != JsonValueKind.Object) + { + continue; + } + + result.Add(JsonSerializer.Deserialize>(row.GetRawText()) ?? []); + } + + if (result.Count > 1000) + { + return result.Take(1000).ToList(); + } + + return result; + } + + private static string GetString(Dictionary row, string key) => + row.GetValueOrDefault(key)?.ToString() ?? ""; + + private static string? GetRootString(JsonDocument data, string key) => + data.RootElement.TryGetProperty(key, out var value) && value.ValueKind == JsonValueKind.String + ? value.GetString() + : null; +} + +public sealed class PageLiveRequestBody +{ + public string? Url { get; init; } + + public long? PropertyId { get; init; } + + public string? Domain { get; init; } + + public bool? Persist { get; init; } +} + +public sealed class KeywordHistoryBatchBody +{ + public List? Keywords { get; init; } + + public int? Limit { get; init; } + + public long? PropertyId { get; init; } + + public string? Domain { get; init; } +} diff --git a/services/IntegrationsService/src/IntegrationsService.Api/Controllers/Internal/GoogleFetchController.cs b/services/IntegrationsService/src/IntegrationsService.Api/Controllers/Internal/GoogleFetchController.cs new file mode 100644 index 00000000..00a9c0be --- /dev/null +++ b/services/IntegrationsService/src/IntegrationsService.Api/Controllers/Internal/GoogleFetchController.cs @@ -0,0 +1,73 @@ +using IntegrationsService.Application.Google; +using IntegrationsService.Application.Repositories; +using Microsoft.AspNetCore.Mvc; + +namespace IntegrationsService.Api.Controllers.Internal; + +[ApiController] +[Route("internal/integrations/google")] +[Tags("Internal")] +public sealed class GoogleFetchController( + GoogleFetchService fetchService, + GoogleDataWriteRepository googleData) : ControllerBase +{ + [HttpPost("fetch")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task Fetch( + [FromBody] GoogleFetchRequestBody body, + CancellationToken cancellationToken) + { + if (body.PropertyId <= 0) + { + return BadRequest(new { error = "propertyId is required" }); + } + + try + { + var request = new GoogleFetchRequest + { + PropertyId = body.PropertyId, + DateRangeDays = body.DateRangeDays, + CrawlUrls = body.CrawlUrls, + StartUrl = body.StartUrl, + Config = body.Config is null + ? null + : new GoogleFetchConfig + { + KeywordGscMaxRows = body.Config.KeywordGscMaxRows ?? 25000, + GoogleUrlGapListLimit = body.Config.GoogleUrlGapListLimit ?? 200, + }, + }; + + var payload = await fetchService.FetchAsync(request, cancellationToken); + var json = fetchService.SerializePayload(payload); + await googleData.InsertAsync(body.PropertyId, json, payload.FetchedAt, cancellationToken); + return Content(json, "application/json"); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { error = ex.Message }); + } + } +} + +public sealed class GoogleFetchRequestBody +{ + public long PropertyId { get; init; } + + public int? DateRangeDays { get; init; } + + public List? CrawlUrls { get; init; } + + public string? StartUrl { get; init; } + + public GoogleFetchConfigBody? Config { get; init; } +} + +public sealed class GoogleFetchConfigBody +{ + public int? KeywordGscMaxRows { get; init; } + + public int? GoogleUrlGapListLimit { get; init; } +} diff --git a/services/IntegrationsService/src/IntegrationsService.Api/Controllers/Internal/KeywordEnrichController.cs b/services/IntegrationsService/src/IntegrationsService.Api/Controllers/Internal/KeywordEnrichController.cs new file mode 100644 index 00000000..be868d41 --- /dev/null +++ b/services/IntegrationsService/src/IntegrationsService.Api/Controllers/Internal/KeywordEnrichController.cs @@ -0,0 +1,65 @@ +using IntegrationsService.Application.Google; +using Microsoft.AspNetCore.Mvc; + +namespace IntegrationsService.Api.Controllers.Internal; + +[ApiController] +[Route("internal/integrations/keywords")] +[Tags("Internal")] +public sealed class KeywordEnrichController(PythonCliRunner python, FastApiPythonBridge fastApiBridge) : ControllerBase +{ + [HttpPost("enrich")] + public async Task Enrich( + [FromBody] KeywordEnrichRequestBody body, + CancellationToken cancellationToken) + { + if (body.PropertyId <= 0) + { + return BadRequest(new { error = "propertyId is required" }); + } + + PythonCliResult result; + if (FastApiPythonBridge.ShouldUseBridge()) + { + result = await fastApiBridge.RunKeywordEnrichAsync(body.PropertyId, cancellationToken); + } + else + { + var env = new Dictionary + { + ["WP_PROPERTY_ID"] = body.PropertyId.ToString(), + }; + if (!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("DATABASE_URL"))) + { + env["DATABASE_URL"] = Environment.GetEnvironmentVariable("DATABASE_URL")!; + } + + result = await python.RunAsync( + ["-m", "src", "keywords", "--enrich-google"], + environment: env, + timeoutSeconds: 120, + cancellationToken: cancellationToken); + } + + if (result.TimedOut) + { + return StatusCode(504, new { ok = false, error = "Keyword enrich timed out after 120s" }); + } + + var combined = result.Stdout + result.Stderr; + var log = combined.Length > 28_000 ? combined[^28_000..] : combined; + + return Ok(new + { + ok = result.ExitCode == 0, + exitCode = result.ExitCode, + log, + propertyId = body.PropertyId, + }); + } +} + +public sealed class KeywordEnrichRequestBody +{ + public long PropertyId { get; init; } +} diff --git a/services/IntegrationsService/src/IntegrationsService.Api/Controllers/PropertyGoogleController.cs b/services/IntegrationsService/src/IntegrationsService.Api/Controllers/PropertyGoogleController.cs new file mode 100644 index 00000000..c046a00c --- /dev/null +++ b/services/IntegrationsService/src/IntegrationsService.Api/Controllers/PropertyGoogleController.cs @@ -0,0 +1,332 @@ +using System.Text.Json; +using IntegrationsService.Application.Google; +using IntegrationsService.Application.Repositories; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace IntegrationsService.Api.Controllers; + +[ApiController] +[Route("api/properties/{propertyId:long}/google")] +[Tags("Property Google")] +public sealed class PropertyGoogleController( + PropertyRepository properties, + GoogleAppSettingsRepository appSettings, + GoogleDataWriteRepository googleData, + GoogleFetchService fetchService, + IGoogleCredentialFactory credentials, + IGscSearchAnalyticsClient gscClient, + IGa4ReportClient ga4Client, + GscLinksDataRepository gscLinks, + PythonCliRunner python, + FastApiPythonBridge fastApiBridge, + ILogger logger) : ControllerBase +{ + [HttpGet("status")] + public async Task Status(long propertyId, CancellationToken cancellationToken) + { + var prop = await properties.GetByIdAsync(propertyId, cancellationToken); + if (prop is null) + { + return NotFound(new { error = "Property not found" }); + } + + var appCfg = await appSettings.ReadAsync(cancellationToken); + var publicStatus = PropertyGoogleStatusMapper.ToPublicStatus(prop); + return Ok(new + { + connected = publicStatus.Connected, + authMode = publicStatus.AuthMode, + gscSiteUrl = publicStatus.GscSiteUrl, + ga4PropertyId = publicStatus.Ga4PropertyId, + dateRangeDays = publicStatus.DateRangeDays, + connectedEmail = publicStatus.ConnectedEmail, + connectedAt = publicStatus.ConnectedAt, + hasClientId = !string.IsNullOrWhiteSpace(appCfg.ClientId), + lastFetchedAt = await googleData.GetLastFetchedAtAsync(propertyId, cancellationToken), + propertyId, + }); + } + + [HttpPost("test")] + public async Task Test(long propertyId, CancellationToken cancellationToken) + { + var prop = await properties.GetByIdAsync(propertyId, cancellationToken); + if (prop is null) + { + return NotFound(new { error = "Property not found" }); + } + + var warnings = new List(); + var log = new List(); + try + { + var cred = await credentials.BuildCredentialsAsync(propertyId, cancellationToken); + log.Add(" Google credentials: OK (token refreshed)"); + + var targets = await properties.GetGoogleTargetsAsync( + propertyId, + await appSettings.DefaultDateRangeDaysAsync(cancellationToken), + cancellationToken); + var gscSiteUrl = targets?.GscSiteUrl ?? ""; + var ga4PropertyId = targets?.Ga4PropertyId ?? ""; + + if (!string.IsNullOrWhiteSpace(gscSiteUrl)) + { + var sites = await gscClient.ListSitesAsync(cred, cancellationToken); + log.Add($" GSC: found {sites.Count} accessible site(s): [{string.Join(", ", sites)}]"); + var (resolved, siteError) = gscClient.ResolveSiteUrl(gscSiteUrl, sites); + if (resolved is not null) + { + if (resolved != gscSiteUrl) + { + log.Add( + $" GSC: NOTE — Configured '{gscSiteUrl}' will use '{resolved}' " + + "(Search Console requires an exact property URL)."); + } + + var (ok, probeMsg) = await gscClient.ProbeSiteAsync(cred, resolved, cancellationToken); + log.Add(ok ? $" GSC: OK — {probeMsg}" : $" GSC: ERROR — {probeMsg}"); + if (!ok) + { + warnings.Add(probeMsg); + } + } + else + { + log.Add($" GSC: ERROR — {siteError}"); + warnings.Add(siteError ?? "GSC site URL mismatch"); + } + } + else + { + log.Add(" GSC: skipped (no GSC site configured for this property)"); + warnings.Add("GSC site URL is not configured."); + } + + if (!string.IsNullOrWhiteSpace(ga4PropertyId)) + { + var (props, listError) = await ga4Client.ListPropertiesAsync(cred, cancellationToken); + if (listError is not null) + { + log.Add($" GA4: NOTE — {listError}"); + } + else if (props.Count > 0) + { + var names = props.Select(p => $"{p.DisplayName} ({p.Id})"); + log.Add($" GA4: found {props.Count} accessible propert(ies): [{string.Join(", ", names)}]"); + } + + var (ok, probeMsg) = await ga4Client.ProbePropertyAsync(cred, ga4PropertyId, cancellationToken); + log.Add(ok ? $" GA4: OK — {probeMsg}" : $" GA4: ERROR — {probeMsg}"); + if (!ok) + { + warnings.Add(probeMsg); + } + } + else + { + log.Add(" GA4: skipped (no GA4 property ID configured for this property)"); + warnings.Add("GA4 property ID is not configured."); + } + + var okResult = warnings.Count == 0; + return Ok(new + { + ok = okResult, + log = string.Join('\n', log), + exitCode = okResult ? 0 : 1, + }); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { ok = false, log = ex.Message, exitCode = 1 }); + } + } + + [HttpGet("properties")] + public async Task ListProperties(long propertyId, CancellationToken cancellationToken) + { + if (await properties.GetByIdAsync(propertyId, cancellationToken) is null) + { + return NotFound(new { error = "Property not found" }); + } + + try + { + var result = await fetchService.ListPropertiesAsync(propertyId, cancellationToken); + return Ok(result); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { error = ex.Message }); + } + } + + [HttpPatch("credentials")] + [HttpPost("credentials")] + public async Task SaveCredentials( + long propertyId, + [FromBody] PropertyGoogleCredentialsBody body, + CancellationToken cancellationToken) + { + if (await properties.GetByIdAsync(propertyId, cancellationToken) is null) + { + return NotFound(new { error = "Property not found" }); + } + + try + { + await properties.ApplyGoogleCredentialsPatchAsync( + propertyId, + new PropertyGoogleCredentialsPatch + { + GscSiteUrl = body.GscSiteUrl, + Ga4PropertyId = body.Ga4PropertyId, + DateRangeDays = body.DateRangeDays, + AuthMode = body.AuthMode, + ConnectedEmail = body.ConnectedEmail, + RefreshToken = body.RefreshToken, + }, + cancellationToken); + + var prop = await properties.GetByIdAsync(propertyId, cancellationToken); + return Ok(new + { + ok = true, + status = prop is null ? null : PropertyGoogleStatusMapper.ToPublicStatus(prop), + }); + } + catch (ArgumentException ex) + { + return BadRequest(new { error = ex.Message }); + } + } + + [HttpPost("disconnect")] + public async Task Disconnect(long propertyId, CancellationToken cancellationToken) + { + if (await properties.GetByIdAsync(propertyId, cancellationToken) is null) + { + return NotFound(new { error = "Property not found" }); + } + + await properties.DisconnectGoogleAsync(propertyId, cancellationToken); + var prop = await properties.GetByIdAsync(propertyId, cancellationToken); + return Ok(new + { + ok = true, + status = prop is null ? null : PropertyGoogleStatusMapper.ToPublicStatus(prop), + }); + } + + [HttpGet("links/status")] + public async Task LinksStatus(long propertyId, CancellationToken cancellationToken) + { + if (await properties.GetByIdAsync(propertyId, cancellationToken) is null) + { + return NotFound(new { error = "Property not found" }); + } + + try + { + var status = await gscLinks.ReadStatusAsync(propertyId, cancellationToken); + return Ok(status); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to read GSC links status for property {PropertyId}", propertyId); + return StatusCode(500, new { error = "Failed to read links status" }); + } + } + + [HttpPost("links/import")] + public async Task LinksImport( + long propertyId, + [FromBody] GscLinksImportBody body, + CancellationToken cancellationToken) + { + if (await properties.GetByIdAsync(propertyId, cancellationToken) is null) + { + return NotFound(new { error = "Property not found" }); + } + + var fileContent = (body.FileContent ?? "").Trim(); + if (string.IsNullOrEmpty(fileContent)) + { + return BadRequest(new { ok = false, error = "fileContent is required" }); + } + + try + { + JsonDocument? result; + if (FastApiPythonBridge.ShouldUseBridge()) + { + result = await fastApiBridge.RunGscLinksImportAsync( + propertyId, + fileContent, + body.FileName, + cancellationToken); + } + else + { + result = await python.RunJsonFromLastLineAsync( + [ + "-m", "src", "gsc-links-import", + "--property-id", propertyId.ToString(), + "--csv-stdin", + "--file-name", body.FileName ?? "", + ], + stdin: fileContent, + timeoutSeconds: 60, + cancellationToken: cancellationToken); + } + + if (result is null) + { + return StatusCode(500, new { ok = false, error = "GSC links import failed" }); + } + + using (result) + { + var root = result.RootElement; + if (root.TryGetProperty("ok", out var okProp) && okProp.ValueKind == JsonValueKind.False) + { + return BadRequest(new + { + ok = false, + error = root.TryGetProperty("error", out var err) ? err.GetString() : "Import failed", + }); + } + + return Ok(JsonSerializer.Deserialize(root.GetRawText())); + } + } + catch (Exception ex) + { + return StatusCode(500, new { ok = false, error = ex.Message }); + } + } +} + +public sealed class PropertyGoogleCredentialsBody +{ + public string? GscSiteUrl { get; init; } + + public string? Ga4PropertyId { get; init; } + + public int? DateRangeDays { get; init; } + + public string? AuthMode { get; init; } + + public string? ConnectedEmail { get; init; } + + public string? RefreshToken { get; init; } +} + +public sealed class GscLinksImportBody +{ + public string? FileContent { get; init; } + + public string? FileName { get; init; } +} diff --git a/services/IntegrationsService/src/IntegrationsService.Api/IntegrationsService.Api.csproj b/services/IntegrationsService/src/IntegrationsService.Api/IntegrationsService.Api.csproj new file mode 100644 index 00000000..f7cb0c11 --- /dev/null +++ b/services/IntegrationsService/src/IntegrationsService.Api/IntegrationsService.Api.csproj @@ -0,0 +1,19 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + diff --git a/services/IntegrationsService/src/IntegrationsService.Api/IntegrationsService.Api.http b/services/IntegrationsService/src/IntegrationsService.Api/IntegrationsService.Api.http new file mode 100644 index 00000000..f25238a6 --- /dev/null +++ b/services/IntegrationsService/src/IntegrationsService.Api/IntegrationsService.Api.http @@ -0,0 +1,6 @@ +@IntegrationsService.Api_HostAddress = http://localhost:5200 + +GET {{IntegrationsService.Api_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/services/IntegrationsService/src/IntegrationsService.Api/Program.cs b/services/IntegrationsService/src/IntegrationsService.Api/Program.cs new file mode 100644 index 00000000..a1a761e0 --- /dev/null +++ b/services/IntegrationsService/src/IntegrationsService.Api/Program.cs @@ -0,0 +1,40 @@ +using IntegrationsService.Application; +using IntegrationsService.Providers; +using Microsoft.OpenApi; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddIntegrationsApplication(); +builder.Services.AddGoogleProviders(); +builder.Services.AddControllers(); + +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(options => +{ + options.SwaggerDoc("v1", new OpenApiInfo + { + Title = "Website Profiling Integrations API", + Version = "v1", + Description = + "Internal Google integrations service (GSC/GA4 fetch, property OAuth state). " + + "Reached by the BFF and worker via INTEGRATIONS_SERVICE_URL.", + }); +}); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(options => + { + options.SwaggerEndpoint("/swagger/v1/swagger.json", "Website Profiling Integrations API v1"); + options.RoutePrefix = "docs"; + }); +} + +app.MapControllers(); + +app.Run(); + +public partial class Program; diff --git a/services/IntegrationsService/src/IntegrationsService.Api/Properties/launchSettings.json b/services/IntegrationsService/src/IntegrationsService.Api/Properties/launchSettings.json new file mode 100644 index 00000000..e7dbbc0f --- /dev/null +++ b/services/IntegrationsService/src/IntegrationsService.Api/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5200", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7247;http://localhost:5200", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/services/IntegrationsService/src/IntegrationsService.Api/appsettings.Development.json b/services/IntegrationsService/src/IntegrationsService.Api/appsettings.Development.json new file mode 100644 index 00000000..ff66ba6b --- /dev/null +++ b/services/IntegrationsService/src/IntegrationsService.Api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/services/IntegrationsService/src/IntegrationsService.Api/appsettings.json b/services/IntegrationsService/src/IntegrationsService.Api/appsettings.json new file mode 100644 index 00000000..ab22b878 --- /dev/null +++ b/services/IntegrationsService/src/IntegrationsService.Api/appsettings.json @@ -0,0 +1,15 @@ +{ + "Database": { + "ConnectionString": "", + "MinPoolSize": 2, + "MaxPoolSize": 20, + "CommandTimeoutSeconds": 120 + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/services/IntegrationsService/src/IntegrationsService.Application/DependencyInjection.cs b/services/IntegrationsService/src/IntegrationsService.Application/DependencyInjection.cs new file mode 100644 index 00000000..dc55267d --- /dev/null +++ b/services/IntegrationsService/src/IntegrationsService.Application/DependencyInjection.cs @@ -0,0 +1,63 @@ +using IntegrationsService.Application.Google; +using IntegrationsService.Application.Options; +using IntegrationsService.Application.Persistence; +using IntegrationsService.Application.Repositories; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Npgsql; + +namespace IntegrationsService.Application; + +public static class DependencyInjection +{ + public static IServiceCollection AddIntegrationsApplication(this IServiceCollection services) + { + services.AddOptions() + .BindConfiguration(DatabaseOptions.SectionName) + .PostConfigure(o => + { + var url = Environment.GetEnvironmentVariable("DATABASE_URL"); + if (!string.IsNullOrWhiteSpace(url)) + { + o.ConnectionString = url.Trim(); + } + }); + + services.AddHttpClient(); + + services.AddSingleton(sp => + { + var o = sp.GetRequiredService>().Value; + var builder = new NpgsqlDataSourceBuilder(NpgsqlDsn.ToNpgsql(o.ConnectionString)); + builder.ConnectionStringBuilder.MinPoolSize = o.MinPoolSize; + builder.ConnectionStringBuilder.MaxPoolSize = o.MaxPoolSize; + return builder.Build(); + }); + + services.AddDbContextPool((sp, options) => + { + var o = sp.GetRequiredService>().Value; + var dataSource = sp.GetRequiredService(); + options.UseNpgsql(dataSource, npg => npg.CommandTimeout(o.CommandTimeoutSeconds)); + }); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddSingleton(); + services.AddSingleton(); + + return services; + } +} diff --git a/services/IntegrationsService/src/IntegrationsService.Application/Google/BingBacklinksFetchResult.cs b/services/IntegrationsService/src/IntegrationsService.Application/Google/BingBacklinksFetchResult.cs new file mode 100644 index 00000000..3a6fff63 --- /dev/null +++ b/services/IntegrationsService/src/IntegrationsService.Application/Google/BingBacklinksFetchResult.cs @@ -0,0 +1,25 @@ +using WebsiteProfiling.Contracts.Integrations; + +namespace IntegrationsService.Application.Google; + +public sealed record BingBacklinksFetchResult +{ + public bool Ok { get; init; } + + public string? Error { get; init; } + + public string SiteUrl { get; init; } = ""; + + public int TotalBacklinks { get; init; } + + public int ReferringDomains { get; init; } + + public int LinkedPageCount { get; init; } + + public BingBacklinksSummary ToSummary() + => new() + { + TotalBacklinks = TotalBacklinks, + ReferringDomains = ReferringDomains, + }; +} diff --git a/services/IntegrationsService/src/IntegrationsService.Application/Google/BingWebmasterService.cs b/services/IntegrationsService/src/IntegrationsService.Application/Google/BingWebmasterService.cs new file mode 100644 index 00000000..57af5a8a --- /dev/null +++ b/services/IntegrationsService/src/IntegrationsService.Application/Google/BingWebmasterService.cs @@ -0,0 +1,190 @@ +using System.Text.Json; + +namespace IntegrationsService.Application.Google; + +public sealed class BingWebmasterService(IHttpClientFactory httpClientFactory) +{ + private const int MaxPages = 50; + + public async Task> FetchBacklinksSummaryAsync( + string apiKey, + string siteUrl, + CancellationToken cancellationToken = default) + { + var key = (apiKey ?? "").Trim(); + var site = (siteUrl ?? "").Trim(); + if (string.IsNullOrEmpty(key) || string.IsNullOrEmpty(site)) + { + return new Dictionary + { + ["ok"] = false, + ["error"] = "Bing API key and site URL required", + ["source"] = "bing_webmaster", + }; + } + + var pages = new List>(); + var totalPages = 1; + var page = 0; + while (page < MaxPages) + { + var raw = await JsonGetAsync("GetLinkCounts", key, cancellationToken, ("siteUrl", site), ("page", page.ToString())); + if (raw.TryGetValue("error", out var err) && err is not null) + { + if (page == 0) + { + return new Dictionary + { + ["ok"] = false, + ["error"] = err.ToString(), + ["source"] = "bing_webmaster", + ["site_url"] = site, + }; + } + + break; + } + + JsonElement payload = default; + if (raw.TryGetValue("d", out var dObj) && dObj is JsonElement dEl && dEl.ValueKind == JsonValueKind.Object) + { + payload = dEl; + } + else if (raw.Count > 0) + { + using var tmp = JsonDocument.Parse(JsonSerializer.Serialize(raw)); + payload = tmp.RootElement; + } + + if (payload.ValueKind == JsonValueKind.Object + && payload.TryGetProperty("TotalPages", out var tp) + && tp.TryGetInt32(out var tpInt)) + { + totalPages = tpInt; + } + + if (payload.ValueKind != JsonValueKind.Object + || !payload.TryGetProperty("Links", out var links) + || links.ValueKind != JsonValueKind.Array + || links.GetArrayLength() == 0) + { + break; + } + + foreach (var row in links.EnumerateArray()) + { + if (row.ValueKind != JsonValueKind.Object) + { + continue; + } + + pages.Add(new Dictionary + { + ["url"] = row.TryGetProperty("Url", out var url) ? url.GetString() : null, + ["inbound_links"] = row.TryGetProperty("Count", out var count) && count.TryGetInt32(out var c) ? c : 0, + }); + } + + page++; + if (page >= totalPages) + { + break; + } + } + + var totalInbound = pages.Sum(p => Convert.ToInt32(p.GetValueOrDefault("inbound_links") ?? 0)); + return new Dictionary + { + ["ok"] = true, + ["source"] = "bing_webmaster", + ["site_url"] = site, + ["linked_pages"] = pages.Take(100).ToList(), + ["linked_page_count"] = pages.Count, + ["total_inbound_links"] = totalInbound, + ["total_pages"] = totalPages, + ["provenance"] = "Bing Webmaster", + }; + } + + public async Task FetchBacklinksSummaryTypedAsync( + string apiKey, + string siteUrl, + CancellationToken cancellationToken = default) + { + var raw = await FetchBacklinksSummaryAsync(apiKey, siteUrl, cancellationToken); + if (raw.TryGetValue("ok", out var ok) && ok is false) + { + return new BingBacklinksFetchResult + { + Ok = false, + Error = raw.GetValueOrDefault("error")?.ToString(), + SiteUrl = siteUrl, + }; + } + + return new BingBacklinksFetchResult + { + Ok = true, + SiteUrl = raw.GetValueOrDefault("site_url")?.ToString() ?? siteUrl, + TotalBacklinks = Convert.ToInt32(raw.GetValueOrDefault("total_inbound_links") ?? 0), + ReferringDomains = Convert.ToInt32(raw.GetValueOrDefault("linked_page_count") ?? 0), + LinkedPageCount = Convert.ToInt32(raw.GetValueOrDefault("linked_page_count") ?? 0), + }; + } + + private async Task> JsonGetAsync( + string method, + string apiKey, + CancellationToken cancellationToken, + params (string Key, string Value)[] parameters) + { + var query = string.Join( + "&", + parameters + .Append(("apikey", apiKey)) + .Select(p => $"{Uri.EscapeDataString(p.Item1)}={Uri.EscapeDataString(p.Item2)}")); + var url = $"https://ssl.bing.com/webmaster/api.svc/json/{method}?{query}"; + var client = httpClientFactory.CreateClient(nameof(BingWebmasterService)); + + try + { + using var response = await client.GetAsync(url, cancellationToken); + var body = await response.Content.ReadAsStringAsync(cancellationToken); + using var doc = JsonDocument.Parse(string.IsNullOrWhiteSpace(body) ? "{}" : body); + return ObjectToDictionary(doc.RootElement); + } + catch (Exception ex) + { + return new Dictionary { ["error"] = ex.Message }; + } + } + + private static Dictionary ObjectToDictionary(JsonElement value) + { + var dict = new Dictionary(StringComparer.Ordinal); + if (value.ValueKind != JsonValueKind.Object) + { + return dict; + } + + foreach (var prop in value.EnumerateObject()) + { + dict[prop.Name] = JsonElementToObject(prop.Value); + } + + return dict; + } + + private static object? JsonElementToObject(JsonElement value) => + value.ValueKind switch + { + JsonValueKind.String => value.GetString(), + JsonValueKind.Number => value.TryGetInt64(out var l) ? l : value.GetDouble(), + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Null => null, + JsonValueKind.Array => value.EnumerateArray().Select(JsonElementToObject).ToList(), + JsonValueKind.Object => ObjectToDictionary(value), + _ => value.GetRawText(), + }; +} diff --git a/services/IntegrationsService/src/IntegrationsService.Application/Google/FastApiPythonBridge.cs b/services/IntegrationsService/src/IntegrationsService.Application/Google/FastApiPythonBridge.cs new file mode 100644 index 00000000..d0363d36 --- /dev/null +++ b/services/IntegrationsService/src/IntegrationsService.Application/Google/FastApiPythonBridge.cs @@ -0,0 +1,118 @@ +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; + +namespace IntegrationsService.Application.Google; + +/// +/// Delegates Python-only tasks to the FastAPI container when IntegrationsService has no Python runtime (Docker). +/// +public sealed class FastApiPythonBridge(IHttpClientFactory httpClientFactory) +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + public static bool ShouldUseBridge() + { + if (string.Equals( + Environment.GetEnvironmentVariable("USE_FASTAPI_PYTHON_BRIDGE"), + "1", + StringComparison.Ordinal)) + { + return true; + } + + var python = (Environment.GetEnvironmentVariable("PYTHON_EXECUTABLE") + ?? Environment.GetEnvironmentVariable("PYTHON") + ?? "python3").Trim(); + if (string.IsNullOrEmpty(python)) + { + return true; + } + + var repoRoot = ResolveRepoRoot(); + return !Directory.Exists(Path.Combine(repoRoot, "src", "website_profiling")); + } + + public async Task RunKeywordEnrichAsync( + long propertyId, + CancellationToken cancellationToken = default) + { + var client = CreateClient(); + using var response = await client.PostAsJsonAsync( + "/internal/integrations/keywords/enrich", + new { propertyId }, + JsonOptions, + cancellationToken); + + var body = await response.Content.ReadAsStringAsync(cancellationToken); + if (!response.IsSuccessStatusCode) + { + return new PythonCliResult((int)response.StatusCode, body, ""); + } + + try + { + using var doc = JsonDocument.Parse(body); + var exitCode = doc.RootElement.TryGetProperty("exitCode", out var ec) && ec.TryGetInt32(out var code) + ? code + : 0; + var log = doc.RootElement.TryGetProperty("log", out var logEl) ? logEl.GetString() ?? body : body; + return new PythonCliResult(exitCode, log, ""); + } + catch (JsonException) + { + return new PythonCliResult(0, body, ""); + } + } + + public async Task RunGscLinksImportAsync( + long propertyId, + string fileContent, + string? fileName, + CancellationToken cancellationToken = default) + { + var client = CreateClient(); + using var content = new StringContent( + JsonSerializer.Serialize(new { propertyId, fileContent, fileName }, JsonOptions), + Encoding.UTF8, + "application/json"); + using var response = await client.PostAsync("/internal/integrations/gsc-links/import", content, cancellationToken); + var body = await response.Content.ReadAsStringAsync(cancellationToken); + if (!response.IsSuccessStatusCode) + { + return null; + } + + try + { + return JsonDocument.Parse(body); + } + catch (JsonException) + { + return null; + } + } + + private HttpClient CreateClient() + { + var client = httpClientFactory.CreateClient(nameof(FastApiPythonBridge)); + var baseUrl = (Environment.GetEnvironmentVariable("FASTAPI_URL") ?? "http://127.0.0.1:8001").Trim().TrimEnd('/'); + client.BaseAddress = new Uri(baseUrl + "/"); + client.Timeout = TimeSpan.FromSeconds(120); + return client; + } + + private static string ResolveRepoRoot() + { + var env = Environment.GetEnvironmentVariable("WEBSITE_PROFILING_ROOT"); + if (!string.IsNullOrWhiteSpace(env)) + { + return env.Trim(); + } + + return Directory.GetCurrentDirectory(); + } +} diff --git a/services/IntegrationsService/src/IntegrationsService.Application/Google/Ga4PropertySummary.cs b/services/IntegrationsService/src/IntegrationsService.Application/Google/Ga4PropertySummary.cs new file mode 100644 index 00000000..50cc6ac2 --- /dev/null +++ b/services/IntegrationsService/src/IntegrationsService.Application/Google/Ga4PropertySummary.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace IntegrationsService.Application.Google; + +public sealed class Ga4PropertySummary +{ + [JsonPropertyName("id")] + public string Id { get; init; } = ""; + + [JsonPropertyName("displayName")] + public string DisplayName { get; init; } = ""; + + [JsonPropertyName("accountName")] + public string AccountName { get; init; } = ""; +} diff --git a/services/IntegrationsService/src/IntegrationsService.Application/Google/GoogleFetchService.cs b/services/IntegrationsService/src/IntegrationsService.Application/Google/GoogleFetchService.cs new file mode 100644 index 00000000..383f4a17 --- /dev/null +++ b/services/IntegrationsService/src/IntegrationsService.Application/Google/GoogleFetchService.cs @@ -0,0 +1,232 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using IntegrationsService.Application.Repositories; + +namespace IntegrationsService.Application.Google; + +public sealed class GoogleFetchService( + IGoogleCredentialFactory credentials, + IGscSearchAnalyticsClient gscClient, + IGa4ReportClient ga4Client, + PropertyRepository properties, + GoogleAppSettingsRepository appSettings) +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + public async Task FetchAsync( + GoogleFetchRequest request, + CancellationToken cancellationToken = default) + { + var defaultDays = await appSettings.DefaultDateRangeDaysAsync(cancellationToken); + var targets = await properties.GetGoogleTargetsAsync(request.PropertyId, defaultDays, cancellationToken) + ?? throw new InvalidOperationException($"Property id {request.PropertyId} not found."); + + var (gscSiteUrl, ga4PropertyId, resolvedDays) = targets; + var dateRangeDays = request.DateRangeDays.GetValueOrDefault() > 0 + ? request.DateRangeDays!.Value + : resolvedDays; + + var errors = new List(); + GscFetchResult? gscData = null; + Ga4FetchResult? ga4Data = null; + + var cred = await credentials.BuildCredentialsAsync(request.PropertyId, cancellationToken); + + if (!string.IsNullOrWhiteSpace(gscSiteUrl)) + { + try + { + var sites = await gscClient.ListSitesAsync(cred, cancellationToken); + var (resolvedSite, siteError) = gscClient.ResolveSiteUrl(gscSiteUrl, sites); + if (resolvedSite is null) + { + errors.Add($"GSC: {siteError}"); + } + else + { + var maxRows = request.Config?.KeywordGscMaxRows ?? 25000; + gscData = await gscClient.FetchDataAsync( + cred, + resolvedSite, + dateRangeDays, + maxRows: maxRows, + cancellationToken: cancellationToken); + } + } + catch (Exception ex) when (ex is InvalidOperationException or HttpRequestException) + { + errors.Add($"GSC: {ex.Message}"); + } + catch (Exception ex) + { + errors.Add($"GSC: {ex.Message}"); + } + } + else + { + errors.Add("GSC: no site URL configured (set in Integrations > Website in Search Console)"); + } + + if (!string.IsNullOrWhiteSpace(ga4PropertyId)) + { + try + { + ga4Data = await ga4Client.FetchDataAsync( + cred, + ga4PropertyId, + dateRangeDays, + request.StartUrl ?? "", + cancellationToken); + } + catch (Exception ex) when (ex is InvalidOperationException or HttpRequestException) + { + errors.Add($"GA4: {ex.Message}"); + } + catch (Exception ex) + { + errors.Add($"GA4: {ex.Message}"); + } + } + else + { + errors.Add("GA4: no property ID configured (set in Integrations > Analytics property)"); + } + + var dateStart = gscData?.DateStart ?? ga4Data?.DateStart ?? ""; + var dateEnd = gscData?.DateEnd ?? ga4Data?.DateEnd ?? ""; + + var urlJoin = new UrlJoinResult + { + Matched = 0, + CrawlOnly = 0, + GscOnly = 0, + Ga4Only = 0, + Lists = new UrlJoinLists(), + ListsTotal = new UrlJoinListTotals(), + ListLimit = 200, + }; + + if (request.CrawlUrls is { Count: > 0 } && (gscData is not null || ga4Data is not null)) + { + var listLimit = request.Config?.GoogleUrlGapListLimit ?? 200; + urlJoin = UrlJoinBuilder.ComputeUrlJoin( + request.CrawlUrls, + gscData?.ByPage.Keys.ToList() ?? [], + ga4Data?.ByPath.Keys.ToList() ?? [], + request.StartUrl ?? "", + gscData?.ByPage.ToDictionary( + kv => kv.Key, + kv => new JsonElementMetrics + { + Clicks = kv.Value.Clicks, + Impressions = kv.Value.Impressions, + }), + ga4Data?.ByPath.ToDictionary( + kv => kv.Key, + kv => new JsonElementMetrics { Sessions = kv.Value.Sessions }), + listLimit); + } + + return new GoogleFetchPayload + { + FetchedAt = DateTimeOffset.UtcNow, + DateRange = new DateRangePayload { Start = dateStart, End = dateEnd }, + Gsc = gscData?.ToSummaryPayload(), + GscFull = gscData?.ToFullPayload(), + Ga4 = ga4Data?.ToSummaryPayload(), + Ga4Full = ga4Data?.ToFullPayload(), + UrlJoin = urlJoin, + Errors = errors, + }; + } + + public async Task ListPropertiesAsync( + long propertyId, + CancellationToken cancellationToken = default) + { + var cred = await credentials.BuildCredentialsAsync(propertyId, cancellationToken); + var gscSites = await gscClient.ListSitesAsync(cred, cancellationToken); + var (ga4Properties, ga4ListError) = await ga4Client.ListPropertiesAsync(cred, cancellationToken); + return new GooglePropertyListResult + { + GscSites = gscSites, + Ga4Properties = ga4Properties, + Ga4ListError = ga4ListError, + }; + } + + public string SerializePayload(GoogleFetchPayload payload) => + JsonSerializer.Serialize(payload, JsonOptions); +} + +public sealed class GoogleFetchRequest +{ + public long PropertyId { get; init; } + + public int? DateRangeDays { get; init; } + + public IReadOnlyList? CrawlUrls { get; init; } + + public string? StartUrl { get; init; } + + public GoogleFetchConfig? Config { get; init; } +} + +public sealed class GoogleFetchConfig +{ + public int KeywordGscMaxRows { get; init; } = 25000; + + public int GoogleUrlGapListLimit { get; init; } = 200; +} + +public sealed class GoogleFetchPayload +{ + [JsonPropertyName("fetched_at")] + public DateTimeOffset FetchedAt { get; init; } + + [JsonPropertyName("date_range")] + public DateRangePayload DateRange { get; init; } = new(); + + [JsonPropertyName("gsc")] + public object? Gsc { get; init; } + + [JsonPropertyName("gsc_full")] + public object? GscFull { get; init; } + + [JsonPropertyName("ga4")] + public object? Ga4 { get; init; } + + [JsonPropertyName("ga4_full")] + public object? Ga4Full { get; init; } + + [JsonPropertyName("url_join")] + public UrlJoinResult UrlJoin { get; init; } = new(); + + [JsonPropertyName("errors")] + public IReadOnlyList Errors { get; init; } = []; +} + +public sealed class DateRangePayload +{ + [JsonPropertyName("start")] + public string Start { get; init; } = ""; + + [JsonPropertyName("end")] + public string End { get; init; } = ""; +} + +public sealed class GooglePropertyListResult +{ + [JsonPropertyName("gscSites")] + public IReadOnlyList GscSites { get; init; } = []; + + [JsonPropertyName("ga4Properties")] + public IReadOnlyList Ga4Properties { get; init; } = []; + + [JsonPropertyName("ga4ListError")] + public string? Ga4ListError { get; init; } +} diff --git a/services/IntegrationsService/src/IntegrationsService.Application/Google/GoogleModels.cs b/services/IntegrationsService/src/IntegrationsService.Application/Google/GoogleModels.cs new file mode 100644 index 00000000..1114681f --- /dev/null +++ b/services/IntegrationsService/src/IntegrationsService.Application/Google/GoogleModels.cs @@ -0,0 +1,87 @@ +using WebsiteProfiling.Contracts.Google; + +namespace IntegrationsService.Application.Google; + +public sealed class GscFetchResult +{ + public string SiteUrl { get; init; } = ""; + + public GscSummary Summary { get; init; } = new(); + + public IReadOnlyList TopQueries { get; init; } = []; + + public IReadOnlyList TopPages { get; init; } = []; + + public Dictionary ByPage { get; init; } = new(StringComparer.Ordinal); + + public IReadOnlyList Daily { get; init; } = []; + + public string DateStart { get; init; } = ""; + + public string DateEnd { get; init; } = ""; + + public object ToSummaryPayload() => new + { + site_url = SiteUrl, + summary = Summary, + top_queries = TopQueries.Take(100), + top_pages = TopPages.Take(100), + daily = Daily, + }; + + public object ToFullPayload() => new + { + site_url = SiteUrl, + summary = Summary, + top_queries = TopQueries, + top_pages = TopPages, + by_page = ByPage, + daily = Daily, + date_start = DateStart, + date_end = DateEnd, + }; +} + +public sealed class Ga4FetchResult +{ + public string PropertyId { get; init; } = ""; + + public Ga4Summary Summary { get; init; } = new(); + + public IReadOnlyList TopPages { get; init; } = []; + + public Dictionary ByPath { get; init; } = new(StringComparer.Ordinal); + + public IReadOnlyList Daily { get; init; } = []; + + public IReadOnlyList ByChannel { get; init; } = []; + + public IReadOnlyList ByDevice { get; init; } = []; + + public string DateStart { get; init; } = ""; + + public string DateEnd { get; init; } = ""; + + public object ToSummaryPayload() => new + { + property_id = PropertyId, + summary = Summary, + top_pages = TopPages.Take(100), + daily = Daily, + by_channel = ByChannel, + by_device = ByDevice, + }; + + public object ToFullPayload() => new + { + property_id = PropertyId, + summary = Summary, + top_pages = TopPages, + by_path = ByPath, + daily = Daily, + by_channel = ByChannel, + by_device = ByDevice, + date_start = DateStart, + date_end = DateEnd, + }; +} diff --git a/services/IntegrationsService/src/IntegrationsService.Application/Google/GoogleOAuthService.cs b/services/IntegrationsService/src/IntegrationsService.Application/Google/GoogleOAuthService.cs new file mode 100644 index 00000000..56cf446e --- /dev/null +++ b/services/IntegrationsService/src/IntegrationsService.Application/Google/GoogleOAuthService.cs @@ -0,0 +1,303 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using IntegrationsService.Application.Repositories; + +namespace IntegrationsService.Application.Google; + +public sealed class GoogleOAuthService( + PropertyRepository properties, + GoogleAppSettingsRepository appSettings, + IHttpClientFactory httpClientFactory) +{ + private const string GoogleAuthEndpoint = "https://accounts.google.com/o/oauth2/v2/auth"; + private const string GoogleTokenEndpoint = "https://oauth2.googleapis.com/token"; + private const int StateTtlSeconds = 600; + private const string DevStateSecret = "google-oauth-dev-state-secret"; + + public static string RedirectUri() => + (Environment.GetEnvironmentVariable("GOOGLE_REDIRECT_URI") + ?? "http://localhost:8090/api/integrations/google/callback").Trim(); + + public static string AppBase() => + (Environment.GetEnvironmentVariable("APP_PUBLIC_URL") ?? "http://localhost:3000").TrimEnd('/'); + + public static string SignState(long propertyId, string returnPath, DateTimeOffset? now = null) + { + var payload = new + { + p = propertyId, + r = returnPath, + e = (long)(now ?? DateTimeOffset.UtcNow).ToUnixTimeSeconds() + StateTtlSeconds, + }; + var body = Base64UrlEncode(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(payload))); + var sig = ComputeStateSignature(body); + return $"{body}.{sig}"; + } + + public static Dictionary? VerifyState(string? state, DateTimeOffset? now = null) + { + if (string.IsNullOrWhiteSpace(state) || !state.Contains('.', StringComparison.Ordinal)) + { + return null; + } + + var dot = state.IndexOf('.', StringComparison.Ordinal); + var body = state[..dot]; + var sig = state[(dot + 1)..]; + var expected = ComputeStateSignature(body); + if (!CryptographicOperations.FixedTimeEquals( + Encoding.ASCII.GetBytes(sig), + Encoding.ASCII.GetBytes(expected))) + { + return null; + } + + try + { + var json = Encoding.UTF8.GetString(Base64UrlDecode(body)); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement.Clone(); + if (!root.TryGetProperty("e", out var expiry) + || expiry.GetInt64() < (long)(now ?? DateTimeOffset.UtcNow).ToUnixTimeSeconds()) + { + return null; + } + + return new Dictionary + { + ["p"] = root.GetProperty("p"), + ["r"] = root.TryGetProperty("r", out var r) ? r : default, + ["e"] = expiry, + }; + } + catch (JsonException) + { + return null; + } + } + + public static string BuildConsentUrl(string clientId, string state) + { + var parameters = new Dictionary + { + ["client_id"] = clientId, + ["redirect_uri"] = RedirectUri(), + ["response_type"] = "code", + ["scope"] = string.Join(' ', GoogleAppSettingsRepository.GoogleScopes), + ["access_type"] = "offline", + ["prompt"] = "consent", + ["include_granted_scopes"] = "true", + ["state"] = state, + }; + var query = string.Join( + "&", + parameters.Select(kvp => + $"{Uri.EscapeDataString(kvp.Key)}={Uri.EscapeDataString(kvp.Value)}")); + return $"{GoogleAuthEndpoint}?{query}"; + } + + public async Task ExchangeCodeAsync( + string code, + string clientId, + string clientSecret, + CancellationToken cancellationToken = default) + { + var client = httpClientFactory.CreateClient(nameof(GoogleOAuthService)); + using var content = new FormUrlEncodedContent(new Dictionary + { + ["code"] = code, + ["client_id"] = clientId, + ["client_secret"] = clientSecret, + ["redirect_uri"] = RedirectUri(), + ["grant_type"] = "authorization_code", + }); + + using var response = await client.PostAsync(GoogleTokenEndpoint, content, cancellationToken); + if (!response.IsSuccessStatusCode) + { + return null; + } + + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken); + return doc.RootElement.TryGetProperty("refresh_token", out var token) + ? token.GetString() + : null; + } + + public async Task OAuthStartAsync( + long? propertyId, + string? startUrl, + string? returnTo, + CancellationToken cancellationToken = default) + { + long? pid = propertyId; + if (pid is null or <= 0 && !string.IsNullOrWhiteSpace(startUrl)) + { + pid = await properties.EnsureFromStartUrlAsync(startUrl.Trim(), cancellationToken); + } + + if (pid is null or <= 0) + { + throw new GoogleOAuthException( + "propertyId is required. Set Site URL and connect from Integrations."); + } + + var cfg = await appSettings.ReadAsync(cancellationToken); + var clientId = (cfg.ClientId ?? "").Trim(); + if (string.IsNullOrEmpty(clientId)) + { + throw new GoogleOAuthException( + "Google client ID missing. Complete Step 1 in Integrations."); + } + + var state = SignState(pid.Value, SafeReturnPath(returnTo)); + return BuildConsentUrl(clientId, state); + } + + public async Task OAuthCallbackAsync( + string? code, + string? state, + string? error, + CancellationToken cancellationToken = default) + { + var payload = VerifyState(state); + var returnPath = SafeReturnPath( + payload is not null && payload.TryGetValue("r", out var r) && r.ValueKind == JsonValueKind.String + ? r.GetString() + : null); + + if (!string.IsNullOrEmpty(error)) + { + return UiRedirect(returnPath, new Dictionary + { + ["integrations"] = "open", + ["auth"] = "error", + ["reason"] = error, + }); + } + + if (payload is null) + { + return UiRedirect(returnPath, new Dictionary + { + ["integrations"] = "open", + ["auth"] = "error", + ["reason"] = "Invalid or expired state.", + }); + } + + if (string.IsNullOrEmpty(code)) + { + return UiRedirect(returnPath, new Dictionary + { + ["integrations"] = "open", + ["auth"] = "error", + ["reason"] = "No authorization code received.", + }); + } + + string clientId; + string clientSecret; + try + { + (clientId, clientSecret) = await appSettings.AppClientCredentialsAsync(cancellationToken); + } + catch (InvalidOperationException) + { + return UiRedirect(returnPath, new Dictionary + { + ["integrations"] = "open", + ["auth"] = "error", + ["reason"] = "Client credentials missing.", + }); + } + + var refreshToken = await ExchangeCodeAsync(code, clientId, clientSecret, cancellationToken); + if (string.IsNullOrEmpty(refreshToken)) + { + return UiRedirect(returnPath, new Dictionary + { + ["integrations"] = "open", + ["auth"] = "error", + ["reason"] = "Token exchange failed.", + }); + } + + var propertyId = payload["p"].GetInt64(); + await properties.ApplyGoogleCredentialsPatchAsync( + propertyId, + new PropertyGoogleCredentialsPatch + { + RefreshToken = refreshToken, + AuthMode = "oauth", + }, + cancellationToken); + + return UiRedirect(returnPath, new Dictionary + { + ["integrations"] = "open", + ["auth"] = "success", + }); + } + + public static string SafeReturnPath(string? raw) + { + if (string.IsNullOrEmpty(raw) || !raw.StartsWith('/') || raw.StartsWith("//", StringComparison.Ordinal)) + { + return "/"; + } + + return raw; + } + + public static string UiRedirect(string returnPath, IReadOnlyDictionary parameters) + { + var sep = returnPath.Contains('?', StringComparison.Ordinal) ? "&" : "?"; + var query = string.Join( + "&", + parameters.Select(kvp => + $"{Uri.EscapeDataString(kvp.Key)}={Uri.EscapeDataString(kvp.Value)}")); + return $"{AppBase()}{returnPath}{sep}{query}"; + } + + private static string StateSecret() + { + var secret = (Environment.GetEnvironmentVariable("AUTH_SECRET") + ?? Environment.GetEnvironmentVariable("SESSION_SECRET") + ?? "").Trim(); + if (!string.IsNullOrEmpty(secret)) + { + return secret; + } + + var env = (Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "").Trim(); + if (string.Equals(env, "Production", StringComparison.OrdinalIgnoreCase)) + { + throw new GoogleOAuthException( + "AUTH_SECRET or SESSION_SECRET is required for Google OAuth in Production."); + } + + return DevStateSecret; + } + + private static string ComputeStateSignature(string body) + { + using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(StateSecret())); + var hash = hmac.ComputeHash(Encoding.ASCII.GetBytes(body)); + return Convert.ToHexString(hash).ToLowerInvariant(); + } + + private static string Base64UrlEncode(ReadOnlySpan data) => + Convert.ToBase64String(data).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + + private static byte[] Base64UrlDecode(string text) + { + var pad = new string('=', (-text.Length % 4 + 4) % 4); + var b64 = text.Replace('-', '+').Replace('_', '/') + pad; + return Convert.FromBase64String(b64); + } +} + +public sealed class GoogleOAuthException(string message) : Exception(message); diff --git a/services/IntegrationsService/src/IntegrationsService.Application/Google/GscRowMappers.cs b/services/IntegrationsService/src/IntegrationsService.Application/Google/GscRowMappers.cs new file mode 100644 index 00000000..158f699f --- /dev/null +++ b/services/IntegrationsService/src/IntegrationsService.Application/Google/GscRowMappers.cs @@ -0,0 +1,29 @@ +using WebsiteProfiling.Contracts.Google; + +namespace IntegrationsService.Application.Google; + +/// +/// GSC Search Analytics row mappers — parity with Python gsc._to_query_record / _to_page_record. +/// +public static class GscRowMappers +{ + public static GscQueryRecord ToQueryRecord(string query, int clicks, int impressions, double ctr, double position) => + new() + { + Query = query, + Clicks = clicks, + Impressions = impressions, + Ctr = Math.Round(ctr * 100, 2), + Position = Math.Round(position, 1), + }; + + public static GscPageRecord ToPageRecord(string page, int clicks, int impressions, double ctr, double position) => + new() + { + Page = page, + Clicks = clicks, + Impressions = impressions, + Ctr = Math.Round(ctr * 100, 2), + Position = Math.Round(position, 1), + }; +} diff --git a/services/IntegrationsService/src/IntegrationsService.Application/Google/IGoogleClients.cs b/services/IntegrationsService/src/IntegrationsService.Application/Google/IGoogleClients.cs new file mode 100644 index 00000000..07064a96 --- /dev/null +++ b/services/IntegrationsService/src/IntegrationsService.Application/Google/IGoogleClients.cs @@ -0,0 +1,72 @@ +using Google.Apis.Http; + +namespace IntegrationsService.Application.Google; + +public interface IGoogleCredentialFactory +{ + Task BuildCredentialsAsync( + long propertyId, + CancellationToken cancellationToken = default); +} + +public interface IGscSearchAnalyticsClient +{ + Task> ListSitesAsync( + IConfigurableHttpClientInitializer credential, + CancellationToken cancellationToken = default); + + (string? ResolvedSite, string? Error) ResolveSiteUrl(string configured, IReadOnlyList sites); + + Task FetchDataAsync( + IConfigurableHttpClientInitializer credential, + string siteUrl, + int dateRangeDays, + int rowLimit = 1000, + int maxRows = 25000, + CancellationToken cancellationToken = default); + + Task<(bool Ok, string Message)> ProbeSiteAsync( + IConfigurableHttpClientInitializer credential, + string siteUrl, + CancellationToken cancellationToken = default); + + Task<(Dictionary? PageData, IReadOnlyList Errors)> FetchPageLiveAsync( + IConfigurableHttpClientInitializer credential, + string siteUrl, + string pageUrl, + int dateRangeDays, + CancellationToken cancellationToken = default); + + Task> InspectUrlAsync( + IConfigurableHttpClientInitializer credential, + string siteUrl, + string url, + CancellationToken cancellationToken = default); +} + +public interface IGa4ReportClient +{ + Task FetchDataAsync( + IConfigurableHttpClientInitializer credential, + string propertyId, + int dateRangeDays, + string startUrl, + CancellationToken cancellationToken = default); + + Task<(IReadOnlyList Properties, string? Error)> ListPropertiesAsync( + IConfigurableHttpClientInitializer credential, + CancellationToken cancellationToken = default); + + Task<(bool Ok, string Message)> ProbePropertyAsync( + IConfigurableHttpClientInitializer credential, + string propertyId, + CancellationToken cancellationToken = default); + + Task<(Dictionary? PageData, IReadOnlyList Errors)> FetchPageLiveAsync( + IConfigurableHttpClientInitializer credential, + string propertyId, + string pageUrl, + string startUrl, + int dateRangeDays, + CancellationToken cancellationToken = default); +} diff --git a/services/IntegrationsService/src/IntegrationsService.Application/Google/PageCompareService.cs b/services/IntegrationsService/src/IntegrationsService.Application/Google/PageCompareService.cs new file mode 100644 index 00000000..edbe03db --- /dev/null +++ b/services/IntegrationsService/src/IntegrationsService.Application/Google/PageCompareService.cs @@ -0,0 +1,59 @@ +using System.Text.Json; +using IntegrationsService.Application.Repositories; + +namespace IntegrationsService.Application.Google; + +public sealed class PageCompareService( + GoogleDataReadRepository googleData, + PageGoogleSnapshotRepository pageSnapshots) +{ + public async Task LoadArmAsync( + string snapType, + long id, + string pageUrl, + CancellationToken cancellationToken = default) + { + if (id <= 0) + { + return null; + } + + var isLive = string.Equals(snapType, "live", StringComparison.OrdinalIgnoreCase); + if (isLive) + { + var row = await pageSnapshots.ReadCompareRowAsync(id, cancellationToken); + if (row is null) + { + return null; + } + + using var doc = JsonDocument.Parse(row.DataJson); + var root = doc.RootElement; + return new PageCompareArm + { + Type = "live", + Id = row.Id, + FetchedAt = row.FetchedAt, + Gsc = PageLookupService.ReadOptionalObject(root, "gsc"), + Ga4 = PageLookupService.ReadOptionalObject(root, "ga4"), + }; + } + + var siteRow = await googleData.ReadSnapshotByIdAsync(id, cancellationToken); + if (siteRow is null) + { + return null; + } + + using var siteDoc = siteRow.ParseData(); + var slice = PageLookupService.SliceFromGoogleRow(siteDoc.RootElement, pageUrl); + return new PageCompareArm + { + Type = "snapshot", + Id = siteRow.Id, + FetchedAt = siteRow.FetchedAt ?? slice.FetchedAt?.ToString(), + Gsc = slice.Gsc, + Ga4 = slice.Ga4, + }; + } +} diff --git a/services/IntegrationsService/src/IntegrationsService.Application/Google/PageLiveService.cs b/services/IntegrationsService/src/IntegrationsService.Application/Google/PageLiveService.cs new file mode 100644 index 00000000..c19f79c6 --- /dev/null +++ b/services/IntegrationsService/src/IntegrationsService.Application/Google/PageLiveService.cs @@ -0,0 +1,157 @@ +using System.Text.Json; +using IntegrationsService.Application.Repositories; + +namespace IntegrationsService.Application.Google; + +public sealed class PageLiveService( + PropertyRepository properties, + GoogleAppSettingsRepository appSettings, + IGoogleCredentialFactory credentials, + IGscSearchAnalyticsClient gscClient, + IGa4ReportClient ga4Client, + PageGoogleSnapshotRepository snapshots) +{ + public async Task> FetchPageLiveAsync( + string pageUrl, + long? propertyId = null, + bool persist = true, + CancellationToken cancellationToken = default) + { + pageUrl = pageUrl.Trim(); + var errors = new List(); + long? pid = propertyId; + + if (pid is null or <= 0) + { + errors.Add("propertyId is required for live fetch."); + return EmptyResult(pageUrl, errors); + } + + var prop = await properties.GetByIdAsync(pid.Value, cancellationToken); + if (prop is null) + { + errors.Add($"Property id {pid} not found."); + return EmptyResult(pageUrl, errors); + } + + var defaultDays = await appSettings.DefaultDateRangeDaysAsync(cancellationToken); + var targets = await properties.GetGoogleTargetsAsync(pid.Value, defaultDays, cancellationToken); + var gscSite = targets?.GscSiteUrl ?? ""; + var ga4Property = targets?.Ga4PropertyId ?? ""; + var dateRangeDays = targets?.DateRangeDays ?? defaultDays; + var startUrl = (prop.SiteUrl ?? "").Trim(); + + Dictionary? gscData = null; + Dictionary? ga4Data = null; + + try + { + var cred = await credentials.BuildCredentialsAsync(pid.Value, cancellationToken); + + if (!string.IsNullOrWhiteSpace(gscSite)) + { + try + { + var sites = await gscClient.ListSitesAsync(cred, cancellationToken); + var (resolved, siteError) = gscClient.ResolveSiteUrl(gscSite, sites); + if (resolved is null) + { + errors.Add($"GSC: {siteError}"); + } + else + { + var (data, gscErrs) = await gscClient.FetchPageLiveAsync( + cred, resolved, pageUrl, dateRangeDays, cancellationToken); + gscData = data; + errors.AddRange(gscErrs); + } + } + catch (Exception ex) + { + errors.Add($"GSC: {ex.Message}"); + } + } + else + { + errors.Add("GSC: no site URL configured."); + } + + if (!string.IsNullOrWhiteSpace(ga4Property)) + { + try + { + var (data, ga4Errs) = await ga4Client.FetchPageLiveAsync( + cred, ga4Property, pageUrl, startUrl, dateRangeDays, cancellationToken); + ga4Data = data; + errors.AddRange(ga4Errs); + } + catch (Exception ex) + { + errors.Add($"GA4: {ex.Message}"); + } + } + else + { + errors.Add("GA4: no property ID configured."); + } + } + catch (InvalidOperationException ex) + { + errors.Add(ex.Message); + } + + var endGsc = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(-3); + var startGsc = endGsc.AddDays(-(Math.Max(1, dateRangeDays) - 1)); + var dateRange = new Dictionary + { + ["start"] = startGsc.ToString("yyyy-MM-dd"), + ["end"] = endGsc.ToString("yyyy-MM-dd"), + }; + + var publicGsc = PageLookupService.PublicGscPageFromDict(gscData); + var publicGa4 = PageLookupService.PublicGa4PageFromDict(ga4Data); + + long? snapshotId = null; + if (persist) + { + var payload = new Dictionary + { + ["source"] = "live", + ["page_url"] = pageUrl, + ["gsc"] = publicGsc, + ["ga4"] = publicGa4, + ["date_range"] = dateRange, + ["errors"] = errors.Where(e => !string.IsNullOrWhiteSpace(e)).ToList(), + }; + using var doc = JsonDocument.Parse(JsonSerializer.Serialize(payload)); + snapshotId = await snapshots.WriteAsync(pageUrl, doc, cancellationToken); + } + + return new Dictionary + { + ["ok"] = gscData is not null || ga4Data is not null, + ["snapshotId"] = snapshotId, + ["source"] = "live", + ["pageUrl"] = pageUrl, + ["gsc"] = publicGsc, + ["ga4"] = publicGa4, + ["dateRange"] = dateRange, + ["fetchedAt"] = null, + ["errors"] = errors.Where(e => !string.IsNullOrWhiteSpace(e)).ToList(), + }; + } + + private static Dictionary EmptyResult(string pageUrl, List errors) => + new() + { + ["ok"] = false, + ["snapshotId"] = null, + ["source"] = "live", + ["pageUrl"] = pageUrl, + ["gsc"] = null, + ["ga4"] = null, + ["dateRange"] = new Dictionary(), + ["fetchedAt"] = null, + ["errors"] = errors, + }; +} diff --git a/services/IntegrationsService/src/IntegrationsService.Application/Google/PageLookupMapper.cs b/services/IntegrationsService/src/IntegrationsService.Application/Google/PageLookupMapper.cs new file mode 100644 index 00000000..9cee9e0c --- /dev/null +++ b/services/IntegrationsService/src/IntegrationsService.Application/Google/PageLookupMapper.cs @@ -0,0 +1,53 @@ +using System.Text.Json; +using WebsiteProfiling.Contracts.Google; +using WebsiteProfiling.Contracts.Integrations; +using WebsiteProfiling.Contracts.Json; + +namespace IntegrationsService.Application.Google; + +public static class PageLookupMapper +{ + public static PageMetricsRow? ToGscMetrics(JsonElement page) + { + if (page.ValueKind != JsonValueKind.Object) + { + return null; + } + + return new PageMetricsRow + { + Url = JsonCoercion.GetString(page, "page") ?? "", + Clicks = JsonCoercion.GetInt(page, "clicks") ?? 0, + Impressions = JsonCoercion.GetInt(page, "impressions") ?? 0, + Ctr = JsonCoercion.GetDouble(page, "ctr") ?? 0, + Position = JsonCoercion.GetDouble(page, "position") ?? 0, + }; + } + + public static PageMetricsRow? ToGa4Metrics(JsonElement page) + { + if (page.ValueKind != JsonValueKind.Object) + { + return null; + } + + return new PageMetricsRow + { + Path = JsonCoercion.GetString(page, "path") ?? "", + Url = JsonCoercion.GetString(page, "full_url") ?? JsonCoercion.GetString(page, "path") ?? "", + Sessions = JsonCoercion.GetInt(page, "sessions") ?? 0, + ActiveUsers = JsonCoercion.GetInt(page, "activeUsers") ?? JsonCoercion.GetInt(page, "active_users") ?? 0, + ScreenPageViews = JsonCoercion.GetInt(page, "screenPageViews") ?? JsonCoercion.GetInt(page, "screen_page_views") ?? 0, + }; + } + + public static PageLookupResult ToPageLookupResult(string url, JsonElement? gscPage, JsonElement? ga4Page, string? note = null) + => new() + { + Url = url, + Found = gscPage is not null || ga4Page is not null, + Gsc = gscPage is { ValueKind: JsonValueKind.Object } gsc ? ToGscMetrics(gsc) : null, + Ga4 = ga4Page is { ValueKind: JsonValueKind.Object } ga4 ? ToGa4Metrics(ga4) : null, + Note = note, + }; +} diff --git a/services/IntegrationsService/src/IntegrationsService.Application/Google/PageLookupService.cs b/services/IntegrationsService/src/IntegrationsService.Application/Google/PageLookupService.cs new file mode 100644 index 00000000..696e1c9c --- /dev/null +++ b/services/IntegrationsService/src/IntegrationsService.Application/Google/PageLookupService.cs @@ -0,0 +1,403 @@ +using System.Text.Json; +using IntegrationsService.Application.Google; +using WebsiteProfiling.Contracts.Integrations; + +namespace IntegrationsService.Application.Repositories; + +public static class PageLookupService +{ + public static PageSliceResult SliceFromGoogleRow(JsonElement raw, string pageUrl) + { + var gscBlob = GscFullBlob(raw); + var ga4Blob = Ga4FullBlob(raw); + var byPage = ReadObjectDict(gscBlob, "by_page"); + var byPath = ReadObjectDict(ga4Blob, "by_path"); + + var gscPage = MatchGscPage(byPage, gscBlob, pageUrl); + var ga4Page = MatchGa4Path(byPath, ga4Blob, pageUrl); + + var urlJoin = raw.TryGetProperty("url_join", out var uj) && uj.ValueKind == JsonValueKind.Object + ? uj + : default; + var norm = UrlJoinBuilder.NormalizeUrl(pageUrl); + var inGsc = gscPage is not null; + var inGa4 = ga4Page is not null; + const bool inCrawl = false; + + if (urlJoin.ValueKind == JsonValueKind.Object + && urlJoin.TryGetProperty("lists", out var lists) + && lists.ValueKind == JsonValueKind.Object) + { + foreach (var cat in new[] { "crawl_only", "gsc_only", "ga4_only" }) + { + if (!lists.TryGetProperty(cat, out var items) || items.ValueKind != JsonValueKind.Array) + { + continue; + } + + foreach (var item in items.EnumerateArray()) + { + var u = item.ValueKind == JsonValueKind.Object && item.TryGetProperty("url", out var urlEl) + ? urlEl.GetString() + : item.ValueKind == JsonValueKind.String ? item.GetString() : null; + if (string.IsNullOrEmpty(u) || UrlJoinBuilder.NormalizeUrl(u) != norm) + { + continue; + } + + if (cat == "gsc_only") + { + inGsc = true; + } + else if (cat == "ga4_only") + { + inGa4 = true; + } + + break; + } + } + } + + var dateRange = raw.TryGetProperty("date_range", out var dr) && dr.ValueKind == JsonValueKind.Object + ? ObjectToDictionary(dr) + : new Dictionary(); + + if (!dateRange.ContainsKey("start") + && gscBlob.TryGetProperty("date_start", out var ds) + && ds.ValueKind == JsonValueKind.String) + { + dateRange["start"] = ds.GetString(); + if (gscBlob.TryGetProperty("date_end", out var de) && de.ValueKind == JsonValueKind.String) + { + dateRange["end"] = de.GetString(); + } + } + + var fetchedAt = raw.TryGetProperty("fetched_at", out var fa) ? JsonElementToObject(fa) : null; + + return new PageSliceResult + { + Source = "snapshot", + Gsc = gscPage is { ValueKind: JsonValueKind.Object } gscEl ? PublicGscPage(gscEl) : null, + Ga4 = ga4Page is { ValueKind: JsonValueKind.Object } ga4El ? PublicGa4Page(ga4El) : null, + Coverage = new PageCoverage + { + InCrawl = inCrawl, + InGsc = inGsc, + InGa4 = inGa4, + }, + SiteBenchmarks = new PageSiteBenchmarks + { + Gsc = gscBlob.TryGetProperty("summary", out var gs) ? JsonElementToObject(gs) : null, + Ga4 = ga4Blob.TryGetProperty("summary", out var gas) ? JsonElementToObject(gas) : null, + }, + DateRange = dateRange, + FetchedAt = fetchedAt?.ToString(), + Typed = PageLookupMapper.ToPageLookupResult(pageUrl, gscPage, ga4Page), + }; + } + + public static Dictionary SummaryFromSlice(object? gsc, object? ga4) + { + var gscDict = gsc as Dictionary; + var ga4Dict = ga4 as Dictionary; + return new Dictionary + { + ["gsc"] = gscDict is null + ? null + : new Dictionary + { + ["clicks"] = gscDict.GetValueOrDefault("clicks"), + ["impressions"] = gscDict.GetValueOrDefault("impressions"), + ["position"] = gscDict.GetValueOrDefault("position"), + }, + ["ga4"] = ga4Dict is null + ? null + : new Dictionary + { + ["sessions"] = ga4Dict.GetValueOrDefault("sessions"), + ["engagementRate"] = ga4Dict.GetValueOrDefault("engagementRate"), + }, + }; + } + + public static object? ReadOptionalObject(JsonElement root, string key) + { + return root.TryGetProperty(key, out var value) ? JsonElementToObject(value) : null; + } + + public static Dictionary? PublicGscPageFromDict(Dictionary? page) + { + if (page is null) + { + return null; + } + + using var doc = JsonDocument.Parse(JsonSerializer.Serialize(page)); + return PublicGscPage(doc.RootElement); + } + + public static Dictionary? PublicGa4PageFromDict(Dictionary? page) + { + if (page is null) + { + return null; + } + + using var doc = JsonDocument.Parse(JsonSerializer.Serialize(page)); + return PublicGa4Page(doc.RootElement); + } + + private static JsonElement GscFullBlob(JsonElement raw) + { + if (raw.TryGetProperty("gsc_full", out var full) && full.ValueKind == JsonValueKind.Object) + { + return full; + } + + return raw.TryGetProperty("gsc", out var gsc) && gsc.ValueKind == JsonValueKind.Object ? gsc : default; + } + + private static JsonElement Ga4FullBlob(JsonElement raw) + { + if (raw.TryGetProperty("ga4_full", out var full) && full.ValueKind == JsonValueKind.Object) + { + return full; + } + + return raw.TryGetProperty("ga4", out var ga4) && ga4.ValueKind == JsonValueKind.Object ? ga4 : default; + } + + private static Dictionary ReadObjectDict(JsonElement blob, string key) + { + var result = new Dictionary(StringComparer.Ordinal); + if (blob.ValueKind != JsonValueKind.Object + || !blob.TryGetProperty(key, out var map) + || map.ValueKind != JsonValueKind.Object) + { + return result; + } + + foreach (var prop in map.EnumerateObject()) + { + result[prop.Name] = prop.Value; + } + + return result; + } + + private static JsonElement? MatchGscPage( + Dictionary byPage, + JsonElement gscBlob, + string pageUrl) + { + if (byPage.TryGetValue(pageUrl, out var direct)) + { + return direct; + } + + var norm = UrlJoinBuilder.NormalizeUrl(pageUrl); + foreach (var (key, val) in byPage) + { + if (UrlJoinBuilder.NormalizeUrl(key) == norm) + { + return val; + } + } + + if (gscBlob.TryGetProperty("top_pages", out var topPages) && topPages.ValueKind == JsonValueKind.Array) + { + foreach (var row in topPages.EnumerateArray()) + { + if (row.ValueKind != JsonValueKind.Object + || !row.TryGetProperty("page", out var pageEl)) + { + continue; + } + + if (UrlJoinBuilder.NormalizeUrl(pageEl.GetString() ?? "") == norm) + { + return row; + } + } + } + + return null; + } + + private static JsonElement? MatchGa4Path( + Dictionary byPath, + JsonElement ga4Blob, + string pageUrl) + { + var path = UrlJoinBuilder.UrlToPath(pageUrl); + if (byPath.TryGetValue(path, out var direct)) + { + return direct; + } + + var norm = UrlJoinBuilder.NormalizeUrl(pageUrl); + foreach (var (p, val) in byPath) + { + if (val.ValueKind == JsonValueKind.Object + && val.TryGetProperty("full_url", out var full) + && !string.IsNullOrEmpty(full.GetString()) + && UrlJoinBuilder.NormalizeUrl(full.GetString()!) == norm) + { + return val; + } + + if (UrlJoinBuilder.NormalizeUrl(p) == norm || p == path) + { + return val; + } + } + + if (ga4Blob.TryGetProperty("top_pages", out var topPages) && topPages.ValueKind == JsonValueKind.Array) + { + foreach (var row in topPages.EnumerateArray()) + { + if (row.ValueKind != JsonValueKind.Object) + { + continue; + } + + var fu = row.TryGetProperty("full_url", out var fullUrlEl) ? fullUrlEl.GetString() : ""; + if (!string.IsNullOrEmpty(fu) && UrlJoinBuilder.NormalizeUrl(fu) == norm) + { + return row; + } + + if (row.TryGetProperty("path", out var pathEl) + && UrlJoinBuilder.NormalizeUrl(pathEl.GetString() ?? "") == norm) + { + return row; + } + } + } + + return null; + } + + private static Dictionary? PublicGscPage(JsonElement page) + { + if (page.ValueKind != JsonValueKind.Object) + { + return null; + } + + var p = page; + var queries = new List>(); + if (p.TryGetProperty("queries", out var qArr) && qArr.ValueKind == JsonValueKind.Array) + { + foreach (var q in qArr.EnumerateArray()) + { + if (q.ValueKind == JsonValueKind.Object) + { + queries.Add(ObjectToDictionary(q)); + } + } + } + + queries = queries + .OrderByDescending(q => Convert.ToInt32(q.GetValueOrDefault("impressions") ?? 0)) + .Take(25) + .ToList(); + + return new Dictionary + { + ["page"] = p.TryGetProperty("page", out var pageEl) ? pageEl.GetString() : null, + ["clicks"] = ReadInt(p, "clicks"), + ["impressions"] = ReadInt(p, "impressions"), + ["ctr"] = ReadDouble(p, "ctr"), + ["position"] = ReadDouble(p, "position"), + ["queries"] = queries, + }; + } + + private static Dictionary? PublicGa4Page(JsonElement page) + { + if (page.ValueKind != JsonValueKind.Object) + { + return null; + } + + var p = page; + return new Dictionary + { + ["path"] = p.TryGetProperty("path", out var pathEl) ? pathEl.GetString() : null, + ["full_url"] = p.TryGetProperty("full_url", out var fullEl) ? fullEl.GetString() : null, + ["sessions"] = ReadInt(p, "sessions"), + ["activeUsers"] = p.TryGetProperty("activeUsers", out var au) ? ReadInt(p, "activeUsers") : ReadInt(p, "active_users"), + ["screenPageViews"] = p.TryGetProperty("screenPageViews", out _) ? ReadInt(p, "screenPageViews") : ReadInt(p, "screen_page_views"), + ["engagementRate"] = ReadDouble(p, "engagementRate"), + ["avgSessionDuration"] = p.TryGetProperty("avgSessionDuration", out _) ? ReadDouble(p, "avgSessionDuration") : ReadDouble(p, "avg_session_duration"), + }; + } + + private static int ReadInt(JsonElement obj, string key) => + obj.TryGetProperty(key, out var v) && v.TryGetInt32(out var i) ? i : 0; + + private static double ReadDouble(JsonElement obj, string key) => + obj.TryGetProperty(key, out var v) && v.TryGetDouble(out var d) ? d : 0.0; + + private static Dictionary ObjectToDictionary(JsonElement obj) + { + var dict = new Dictionary(StringComparer.Ordinal); + foreach (var prop in obj.EnumerateObject()) + { + dict[prop.Name] = JsonElementToObject(prop.Value); + } + + return dict; + } + + private static object? JsonElementToObject(JsonElement value) => + value.ValueKind switch + { + JsonValueKind.String => value.GetString(), + JsonValueKind.Number => value.TryGetInt64(out var l) ? l : value.GetDouble(), + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Null => null, + JsonValueKind.Array => value.EnumerateArray().Select(JsonElementToObject).ToList(), + JsonValueKind.Object => ObjectToDictionary(value), + _ => value.GetRawText(), + }; +} + +public sealed class PageSliceResult +{ + public string Source { get; init; } = "snapshot"; + + public object? Gsc { get; init; } + + public object? Ga4 { get; init; } + + public PageCoverage Coverage { get; init; } = new(); + + public PageSiteBenchmarks SiteBenchmarks { get; init; } = new(); + + public Dictionary DateRange { get; init; } = []; + + public string? FetchedAt { get; init; } + + public PageLookupResult? Typed { get; init; } +} + +public sealed class PageCoverage +{ + public bool InCrawl { get; init; } + + public bool InGsc { get; init; } + + public bool InGa4 { get; init; } +} + +public sealed class PageSiteBenchmarks +{ + public object? Gsc { get; init; } + + public object? Ga4 { get; init; } +} diff --git a/services/IntegrationsService/src/IntegrationsService.Application/Google/PageMetricsCompare.cs b/services/IntegrationsService/src/IntegrationsService.Application/Google/PageMetricsCompare.cs new file mode 100644 index 00000000..be6e8198 --- /dev/null +++ b/services/IntegrationsService/src/IntegrationsService.Application/Google/PageMetricsCompare.cs @@ -0,0 +1,177 @@ +namespace IntegrationsService.Application.Google; + +/// +/// Parity with web/src/lib/pageMetricsCompare.ts — page-level GSC/GA4 compare rows. +/// +public static class PageMetricsCompare +{ + public static IReadOnlyList Build( + PageMetricsPayload current, + PageMetricsPayload baseline) + { + var rows = new List(); + if (current.Gsc is not null || baseline.Gsc is not null) + { + rows.Add(DeltaRow("gsc_clicks", "Clicks", current.Gsc?.Clicks, baseline.Gsc?.Clicks, higherIsBetter: true)); + rows.Add(DeltaRow("gsc_impr", "Impressions", current.Gsc?.Impressions, baseline.Gsc?.Impressions, higherIsBetter: true)); + rows.Add(DeltaRow("gsc_ctr", "CTR %", current.Gsc?.Ctr, baseline.Gsc?.Ctr, higherIsBetter: true, format: "percent")); + rows.Add(DeltaRow("gsc_pos", "Avg position", current.Gsc?.Position, baseline.Gsc?.Position, higherIsBetter: false)); + } + + if (current.Ga4 is not null || baseline.Ga4 is not null) + { + rows.Add(DeltaRow("ga4_sessions", "Sessions", current.Ga4?.Sessions, baseline.Ga4?.Sessions, higherIsBetter: true)); + rows.Add(DeltaRow("ga4_users", "Users", current.Ga4?.ActiveUsers, baseline.Ga4?.ActiveUsers, higherIsBetter: true)); + rows.Add(DeltaRow("ga4_views", "Page views", current.Ga4?.ScreenPageViews, baseline.Ga4?.ScreenPageViews, higherIsBetter: true)); + rows.Add(DeltaRow("ga4_engagement", "Engagement rate", current.Ga4?.EngagementRate, baseline.Ga4?.EngagementRate, higherIsBetter: true, format: "percent")); + rows.Add(DeltaRow("ga4_duration", "Avg session (s)", current.Ga4?.AvgSessionDuration, baseline.Ga4?.AvgSessionDuration, higherIsBetter: true)); + } + + return rows.Where(r => r.Current is not null || r.Baseline is not null).ToList(); + } + + private static PageCompareMetricRow DeltaRow( + string id, + string label, + double? current, + double? baseline, + bool higherIsBetter, + string format = "count") + { + double? delta = current is not null && baseline is not null + ? Math.Round(current.Value - baseline.Value, 1) + : null; + double? deltaPct = null; + if (current is not null && baseline is not null && baseline.Value != 0) + { + deltaPct = Math.Round((current.Value - baseline.Value) / Math.Abs(baseline.Value) * 1000) / 10; + } + + return new PageCompareMetricRow + { + Id = id, + Label = label, + Current = current, + Baseline = baseline, + Delta = delta, + HigherIsBetter = higherIsBetter, + Format = format, + DeltaPct = deltaPct, + }; + } +} + +public sealed class PageMetricsPayload +{ + public PageGscMetrics? Gsc { get; init; } + + public PageGa4Metrics? Ga4 { get; init; } +} + +public sealed class PageGscMetrics +{ + public double? Clicks { get; init; } + + public double? Impressions { get; init; } + + public double? Ctr { get; init; } + + public double? Position { get; init; } +} + +public sealed class PageGa4Metrics +{ + public double? Sessions { get; init; } + + public double? ActiveUsers { get; init; } + + public double? ScreenPageViews { get; init; } + + public double? EngagementRate { get; init; } + + public double? AvgSessionDuration { get; init; } +} + +public sealed class PageCompareMetricRow +{ + public string Id { get; init; } = ""; + + public string Label { get; init; } = ""; + + public double? Current { get; init; } + + public double? Baseline { get; init; } + + public double? Delta { get; init; } + + public bool HigherIsBetter { get; init; } + + public string Format { get; init; } = "count"; + + public double? DeltaPct { get; init; } +} + +public sealed class PageCompareArm +{ + public string Type { get; init; } = "snapshot"; + + public long Id { get; init; } + + public string? FetchedAt { get; init; } + + public object? Gsc { get; init; } + + public object? Ga4 { get; init; } + + public PageMetricsPayload ToMetricsPayload() => + new() + { + Gsc = PageCompareMetricsParser.FromGscObject(Gsc), + Ga4 = PageCompareMetricsParser.FromGa4Object(Ga4), + }; +} + +public static class PageCompareMetricsParser +{ + public static PageGscMetrics? FromGscObject(object? gsc) => + gsc is Dictionary dict + ? new PageGscMetrics + { + Clicks = ReadDouble(dict, "clicks"), + Impressions = ReadDouble(dict, "impressions"), + Ctr = ReadDouble(dict, "ctr"), + Position = ReadDouble(dict, "position"), + } + : null; + + public static PageGa4Metrics? FromGa4Object(object? ga4) => + ga4 is Dictionary dict + ? new PageGa4Metrics + { + Sessions = ReadDouble(dict, "sessions"), + ActiveUsers = ReadDouble(dict, "activeUsers") ?? ReadDouble(dict, "active_users"), + ScreenPageViews = ReadDouble(dict, "screenPageViews") ?? ReadDouble(dict, "screen_page_views"), + EngagementRate = ReadDouble(dict, "engagementRate") ?? ReadDouble(dict, "engagement_rate"), + AvgSessionDuration = ReadDouble(dict, "avgSessionDuration") ?? ReadDouble(dict, "avg_session_duration"), + } + : null; + + private static double? ReadDouble(Dictionary dict, string key) + { + if (!dict.TryGetValue(key, out var raw) || raw is null) + { + return null; + } + + return raw switch + { + double d => d, + float f => f, + int i => i, + long l => l, + decimal m => (double)m, + string s when double.TryParse(s, out var parsed) => parsed, + _ => null, + }; + } +} diff --git a/services/IntegrationsService/src/IntegrationsService.Application/Google/PropertyDomainHelper.cs b/services/IntegrationsService/src/IntegrationsService.Application/Google/PropertyDomainHelper.cs new file mode 100644 index 00000000..158c4c7d --- /dev/null +++ b/services/IntegrationsService/src/IntegrationsService.Application/Google/PropertyDomainHelper.cs @@ -0,0 +1,83 @@ +using System.Text.RegularExpressions; + +namespace IntegrationsService.Application.Google; + +public static partial class PropertyDomainHelper +{ + private static readonly HashSet Reserved = new(StringComparer.OrdinalIgnoreCase) + { + "http", "https", "www", + }; + + [GeneratedRegex(@"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$", RegexOptions.CultureInvariant)] + private static partial Regex LabelRegex(); + + public static string ExtractHostname(string url) + { + try + { + if (!Uri.TryCreate(url, UriKind.Absolute, out var parsed)) + { + return ""; + } + + return parsed.Host.ToLowerInvariant(); + } + catch + { + return ""; + } + } + + public static string CanonicalDomainFromStartUrl(string startUrl) + { + var raw = (startUrl ?? "").Trim(); + if (string.IsNullOrEmpty(raw)) + { + return ""; + } + + var href = raw.StartsWith("http://", StringComparison.OrdinalIgnoreCase) + || raw.StartsWith("https://", StringComparison.OrdinalIgnoreCase) + ? raw + : $"https://{raw}"; + + return ExtractHostname(href); + } + + public static string DerivePropertyName(string domain, string siteUrl = "") + { + if (!string.IsNullOrWhiteSpace(domain)) + { + return domain; + } + + var host = ExtractHostname(siteUrl); + return string.IsNullOrEmpty(host) ? "Site" : host; + } + + public static bool IsValidCanonicalDomain(string domain) + { + var d = (domain ?? "").Trim().ToLowerInvariant().TrimEnd('.'); + if (d.Length < 4 || !d.Contains('.', StringComparison.Ordinal) || Reserved.Contains(d)) + { + return false; + } + + var labels = d.Split('.'); + if (labels.Length < 2) + { + return false; + } + + foreach (var label in labels) + { + if (string.IsNullOrEmpty(label) || label.Length > 63 || !LabelRegex().IsMatch(label)) + { + return false; + } + } + + return labels[^1].Length >= 2; + } +} diff --git a/services/IntegrationsService/src/IntegrationsService.Application/Google/PythonCliRunner.cs b/services/IntegrationsService/src/IntegrationsService.Application/Google/PythonCliRunner.cs new file mode 100644 index 00000000..0b3b409b --- /dev/null +++ b/services/IntegrationsService/src/IntegrationsService.Application/Google/PythonCliRunner.cs @@ -0,0 +1,147 @@ +using System.Diagnostics; +using System.Text; +using System.Text.Json; + +namespace IntegrationsService.Application.Google; + +public sealed class PythonCliRunner +{ + public async Task RunAsync( + IReadOnlyList arguments, + string? stdin = null, + IReadOnlyDictionary? environment = null, + int timeoutSeconds = 45, + CancellationToken cancellationToken = default) + { + var python = ResolvePythonExecutable(); + var repoRoot = ResolveRepoRoot(); + var psi = new ProcessStartInfo + { + FileName = python, + WorkingDirectory = repoRoot, + RedirectStandardOutput = true, + RedirectStandardError = true, + RedirectStandardInput = stdin is not null, + UseShellExecute = false, + CreateNoWindow = true, + }; + + foreach (var arg in arguments) + { + psi.ArgumentList.Add(arg); + } + + if (environment is not null) + { + foreach (var (key, value) in environment) + { + psi.Environment[key] = value; + } + } + + using var process = new Process { StartInfo = psi }; + var stdout = new StringBuilder(); + var stderr = new StringBuilder(); + process.OutputDataReceived += (_, e) => + { + if (e.Data is not null) + { + stdout.AppendLine(e.Data); + } + }; + process.ErrorDataReceived += (_, e) => + { + if (e.Data is not null) + { + stderr.AppendLine(e.Data); + } + }; + + process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + if (stdin is not null) + { + await process.StandardInput.WriteAsync(stdin); + process.StandardInput.Close(); + } + + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(TimeSpan.FromSeconds(timeoutSeconds)); + try + { + await process.WaitForExitAsync(timeoutCts.Token); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + try + { + process.Kill(entireProcessTree: true); + } + catch + { + // ignore kill failures on timeout + } + + return new PythonCliResult(-1, stdout.ToString(), stderr.ToString(), TimedOut: true); + } + + return new PythonCliResult(process.ExitCode, stdout.ToString(), stderr.ToString()); + } + + public async Task RunJsonFromLastLineAsync( + IReadOnlyList arguments, + string? stdin = null, + IReadOnlyDictionary? environment = null, + int timeoutSeconds = 45, + CancellationToken cancellationToken = default) + { + var result = await RunAsync(arguments, stdin, environment, timeoutSeconds, cancellationToken); + var lines = result.Stdout + .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var last = lines.Length > 0 ? lines[^1] : "{}"; + try + { + return JsonDocument.Parse(last); + } + catch (JsonException) + { + return null; + } + } + + private static string ResolvePythonExecutable() => + (Environment.GetEnvironmentVariable("PYTHON_EXECUTABLE") + ?? Environment.GetEnvironmentVariable("PYTHON") + ?? "python3").Trim(); + + private static string ResolveRepoRoot() + { + var env = Environment.GetEnvironmentVariable("WEBSITE_PROFILING_ROOT"); + if (!string.IsNullOrWhiteSpace(env)) + { + return env.Trim(); + } + + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null) + { + if (File.Exists(Path.Combine(dir.FullName, "pyproject.toml")) + || Directory.Exists(Path.Combine(dir.FullName, "src", "website_profiling"))) + { + return dir.FullName; + } + + dir = dir.Parent; + } + + return Directory.GetCurrentDirectory(); + } +} + +public sealed record PythonCliResult( + int ExitCode, + string Stdout, + string Stderr, + bool TimedOut = false); diff --git a/services/IntegrationsService/src/IntegrationsService.Application/Google/UrlJoinBuilder.cs b/services/IntegrationsService/src/IntegrationsService.Application/Google/UrlJoinBuilder.cs new file mode 100644 index 00000000..394e9b5d --- /dev/null +++ b/services/IntegrationsService/src/IntegrationsService.Application/Google/UrlJoinBuilder.cs @@ -0,0 +1,233 @@ +using System.Text.Json.Serialization; + +namespace IntegrationsService.Application.Google; + +public static class UrlJoinBuilder +{ + public static string NormalizeUrl(string url) + { + url = url.Trim(); + if (!Uri.TryCreate(url, UriKind.Absolute, out var parsed)) + { + return url.ToLowerInvariant(); + } + + var host = StripWwwPrefix(parsed.Host.ToLowerInvariant()); + var path = parsed.AbsolutePath.TrimEnd('/'); + if (string.IsNullOrEmpty(path)) + { + path = "/"; + } + + return $"{host}{path}"; + } + + public static string UrlToPath(string url) + { + return Uri.TryCreate(url, UriKind.Absolute, out var parsed) + ? parsed.AbsolutePath.Length > 0 ? parsed.AbsolutePath : "/" + : url; + } + + public static string PathToUrl(string path, string startUrl) + { + if (!Uri.TryCreate(startUrl, UriKind.Absolute, out var parsed)) + { + return path; + } + + var origin = $"{parsed.Scheme}://{parsed.Host}"; + return origin + (path.StartsWith('/') ? path : "/" + path); + } + + public static UrlJoinResult ComputeUrlJoin( + IReadOnlyList crawlUrls, + IReadOnlyList gscPages, + IReadOnlyList ga4Paths, + string startUrl, + IReadOnlyDictionary? gscByPage = null, + IReadOnlyDictionary? ga4ByPath = null, + int listLimit = 200) + { + var crawlNorm = new Dictionary(StringComparer.Ordinal); + foreach (var u in crawlUrls) + { + if (string.IsNullOrWhiteSpace(u)) + { + continue; + } + + crawlNorm[NormalizeUrl(u)] = u; + } + + var gscNorm = new Dictionary(StringComparer.Ordinal); + foreach (var url in gscPages) + { + if (string.IsNullOrWhiteSpace(url)) + { + continue; + } + + var gscMetrics = gscByPage is not null && gscByPage.TryGetValue(url, out var gm) + ? gm + : JsonElementMetrics.Empty; + gscNorm[NormalizeUrl(url)] = (url, gscMetrics); + } + + var ga4Norm = new Dictionary(StringComparer.Ordinal); + foreach (var path in ga4Paths) + { + if (string.IsNullOrWhiteSpace(path)) + { + continue; + } + + var full = PathToUrl(path, startUrl); + var ga4Metrics = ga4ByPath is not null && ga4ByPath.TryGetValue(path, out var gm) + ? gm + : JsonElementMetrics.Empty; + ga4Norm[NormalizeUrl(full)] = (full, ga4Metrics); + } + + var crawlKeys = crawlNorm.Keys.ToHashSet(StringComparer.Ordinal); + var gscKeys = gscNorm.Keys.ToHashSet(StringComparer.Ordinal); + var ga4Keys = ga4Norm.Keys.ToHashSet(StringComparer.Ordinal); + + var matched = crawlKeys.Intersect(gscKeys.Union(ga4Keys)).Count(); + var crawlOnlyKeys = crawlKeys.Except(gscKeys).Except(ga4Keys).ToList(); + var gscOnlyKeys = gscKeys.Except(crawlKeys).ToList(); + var ga4OnlyKeys = ga4Keys.Except(crawlKeys).ToList(); + + var crawlOnlyList = crawlOnlyKeys + .Select(k => new UrlOnlyEntry { Url = crawlNorm[k] }) + .Take(listLimit) + .ToList(); + + var gscOnlySorted = gscOnlyKeys + .Select(k => + { + var (url, metrics) = gscNorm[k]; + return new GscOnlyEntry + { + Url = url, + Clicks = metrics.Clicks, + Impressions = metrics.Impressions, + }; + }) + .OrderByDescending(r => r.Impressions) + .ToList(); + + var ga4OnlySorted = ga4OnlyKeys + .Select(k => + { + var (url, metrics) = ga4Norm[k]; + return new Ga4OnlyEntry + { + Url = url, + Sessions = metrics.Sessions, + }; + }) + .OrderByDescending(r => r.Sessions) + .ToList(); + + var gscOnlyList = gscOnlySorted.Take(listLimit).ToList(); + var ga4OnlyList = ga4OnlySorted.Take(listLimit).ToList(); + + return new UrlJoinResult + { + Matched = matched, + CrawlOnly = crawlOnlyKeys.Count, + GscOnly = gscOnlyKeys.Count, + Ga4Only = ga4OnlyKeys.Count, + Lists = new UrlJoinLists + { + CrawlOnly = crawlOnlyList, + GscOnly = gscOnlyList, + Ga4Only = ga4OnlyList, + }, + ListsTotal = new UrlJoinListTotals + { + CrawlOnly = crawlOnlyKeys.Count, + GscOnly = gscOnlySorted.Count, + Ga4Only = ga4OnlySorted.Count, + }, + ListLimit = listLimit, + }; + } + + private static string StripWwwPrefix(string host) => + host.StartsWith("www.", StringComparison.OrdinalIgnoreCase) ? host[4..] : host; +} + +public sealed class JsonElementMetrics +{ + public static JsonElementMetrics Empty { get; } = new(); + + public int Clicks { get; init; } + + public int Impressions { get; init; } + + public int Sessions { get; init; } +} + +public sealed class UrlJoinResult +{ + public int Matched { get; init; } + + public int CrawlOnly { get; init; } + + public int GscOnly { get; init; } + + public int Ga4Only { get; init; } + + public UrlJoinLists Lists { get; init; } = new(); + + public UrlJoinListTotals ListsTotal { get; init; } = new(); + + public int ListLimit { get; init; } +} + +public sealed class UrlJoinLists +{ + public IReadOnlyList CrawlOnly { get; init; } = []; + + public IReadOnlyList GscOnly { get; init; } = []; + + public IReadOnlyList Ga4Only { get; init; } = []; +} + +public sealed class UrlJoinListTotals +{ + public int CrawlOnly { get; init; } + + public int GscOnly { get; init; } + + public int Ga4Only { get; init; } +} + +public sealed class UrlOnlyEntry +{ + [JsonPropertyName("url")] + public string Url { get; init; } = ""; +} + +public sealed class GscOnlyEntry +{ + [JsonPropertyName("url")] + public string Url { get; init; } = ""; + + [JsonPropertyName("clicks")] + public int Clicks { get; init; } + + [JsonPropertyName("impressions")] + public int Impressions { get; init; } +} + +public sealed class Ga4OnlyEntry +{ + [JsonPropertyName("url")] + public string Url { get; init; } = ""; + + [JsonPropertyName("sessions")] + public int Sessions { get; init; } +} diff --git a/services/IntegrationsService/src/IntegrationsService.Application/IntegrationsService.Application.csproj b/services/IntegrationsService/src/IntegrationsService.Application/IntegrationsService.Application.csproj new file mode 100644 index 00000000..fdf299b7 --- /dev/null +++ b/services/IntegrationsService/src/IntegrationsService.Application/IntegrationsService.Application.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + + + + + + + diff --git a/services/IntegrationsService/src/IntegrationsService.Application/Options/DatabaseOptions.cs b/services/IntegrationsService/src/IntegrationsService.Application/Options/DatabaseOptions.cs new file mode 100644 index 00000000..8a32952a --- /dev/null +++ b/services/IntegrationsService/src/IntegrationsService.Application/Options/DatabaseOptions.cs @@ -0,0 +1,14 @@ +namespace IntegrationsService.Application.Options; + +public sealed class DatabaseOptions +{ + public const string SectionName = "Database"; + + public string ConnectionString { get; set; } = ""; + + public int MinPoolSize { get; set; } = 2; + + public int MaxPoolSize { get; set; } = 20; + + public int CommandTimeoutSeconds { get; set; } = 30; +} diff --git a/services/IntegrationsService/src/IntegrationsService.Application/Persistence/IntegrationsDbContext.cs b/services/IntegrationsService/src/IntegrationsService.Application/Persistence/IntegrationsDbContext.cs new file mode 100644 index 00000000..f12c9c9e --- /dev/null +++ b/services/IntegrationsService/src/IntegrationsService.Application/Persistence/IntegrationsDbContext.cs @@ -0,0 +1,75 @@ +using IntegrationsService.Domain.Entities; +using Microsoft.EntityFrameworkCore; + +namespace IntegrationsService.Application.Persistence; + +/// +/// EF Core context over the Alembic-owned schema. Does not run migrations; tables are owned by Python Alembic. +/// +public sealed class IntegrationsDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet GoogleDataRows => Set(); + + public DbSet Properties => Set(); + + public DbSet GoogleAppSettings => Set(); + + public DbSet PageGoogleSnapshots => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(e => + { + e.ToTable("google_data"); + e.HasKey(x => x.Id); + e.Property(x => x.Id).HasColumnName("id"); + e.Property(x => x.FetchedAt).HasColumnName("fetched_at"); + e.Property(x => x.PropertyId).HasColumnName("property_id"); + e.Property(x => x.Data).HasColumnName("data").HasColumnType("jsonb"); + }); + + modelBuilder.Entity(e => + { + e.ToTable("properties"); + e.HasKey(x => x.Id); + e.Property(x => x.Id).HasColumnName("id"); + e.Property(x => x.Name).HasColumnName("name"); + e.Property(x => x.CanonicalDomain).HasColumnName("canonical_domain"); + e.Property(x => x.SiteUrl).HasColumnName("site_url"); + e.Property(x => x.GscSiteUrl).HasColumnName("gsc_site_url"); + e.Property(x => x.Ga4PropertyId).HasColumnName("ga4_property_id"); + e.Property(x => x.GoogleAuthMode).HasColumnName("google_auth_mode"); + e.Property(x => x.GoogleRefreshToken).HasColumnName("google_refresh_token"); + e.Property(x => x.GoogleConnectedAt).HasColumnName("google_connected_at"); + e.Property(x => x.GoogleConnectedEmail).HasColumnName("google_connected_email"); + e.Property(x => x.GoogleDateRangeDays).HasColumnName("google_date_range_days"); + e.Property(x => x.DefaultCrawlPreset).HasColumnName("default_crawl_preset"); + e.Property(x => x.CrawlAuthorizedAt).HasColumnName("crawl_authorized_at"); + }); + + modelBuilder.Entity(e => + { + e.ToTable("google_app_settings"); + e.HasKey(x => x.Id); + e.Property(x => x.Id).HasColumnName("id"); + e.Property(x => x.ClientId).HasColumnName("client_id"); + e.Property(x => x.ClientSecret).HasColumnName("client_secret"); + e.Property(x => x.ServiceAccountJson).HasColumnName("service_account_json").HasColumnType("jsonb"); + e.Property(x => x.DefaultDateRangeDays).HasColumnName("default_date_range_days"); + e.Property(x => x.UpdatedAt).HasColumnName("updated_at"); + e.Property(x => x.DeveloperToken).HasColumnName("developer_token"); + e.Property(x => x.LoginCustomerId).HasColumnName("login_customer_id"); + }); + + modelBuilder.Entity(e => + { + e.ToTable("page_google_snapshots"); + e.HasKey(x => x.Id); + e.Property(x => x.Id).HasColumnName("id"); + e.Property(x => x.PageUrl).HasColumnName("page_url"); + e.Property(x => x.UrlNorm).HasColumnName("url_norm"); + e.Property(x => x.FetchedAt).HasColumnName("fetched_at"); + e.Property(x => x.Data).HasColumnName("data").HasColumnType("jsonb"); + }); + } +} diff --git a/services/IntegrationsService/src/IntegrationsService.Application/Persistence/NpgsqlDsn.cs b/services/IntegrationsService/src/IntegrationsService.Application/Persistence/NpgsqlDsn.cs new file mode 100644 index 00000000..957cc8a8 --- /dev/null +++ b/services/IntegrationsService/src/IntegrationsService.Application/Persistence/NpgsqlDsn.cs @@ -0,0 +1,86 @@ +using Npgsql; + +namespace IntegrationsService.Application.Persistence; + +/// +/// Converts a libpq connection URI into an Npgsql keyword connection string. +/// +public static class NpgsqlDsn +{ + public static string ToNpgsql(string raw) + { + if (string.IsNullOrWhiteSpace(raw)) + { + throw new InvalidOperationException( + "DATABASE_URL is not set. Example: postgres://user:pass@host:5432/website_profiling"); + } + + var s = raw.Trim(); + var isUri = s.StartsWith("postgres://", StringComparison.OrdinalIgnoreCase) + || s.StartsWith("postgresql://", StringComparison.OrdinalIgnoreCase); + if (!isUri) + { + return s; + } + + var uri = new Uri(s); + var b = new NpgsqlConnectionStringBuilder + { + Host = Uri.UnescapeDataString(uri.Host), + Port = uri.IsDefaultPort || uri.Port <= 0 ? 5432 : uri.Port, + Database = Uri.UnescapeDataString(uri.AbsolutePath.TrimStart('/')), + }; + + var userInfo = uri.UserInfo.Split(':', 2); + if (userInfo.Length > 0 && userInfo[0].Length > 0) + { + b.Username = Uri.UnescapeDataString(userInfo[0]); + } + if (userInfo.Length > 1) + { + b.Password = Uri.UnescapeDataString(userInfo[1]); + } + + foreach (var (key, value) in ParseQuery(uri.Query)) + { + switch (key.ToLowerInvariant()) + { + case "connect_timeout": + if (int.TryParse(value, out var t)) + { + b.Timeout = t; + } + break; + case "sslmode": + if (Enum.TryParse(value, ignoreCase: true, out var mode)) + { + b.SslMode = mode; + } + break; + case "application_name": + b.ApplicationName = value; + break; + } + } + + return b.ConnectionString; + } + + private static IEnumerable> ParseQuery(string query) + { + var q = query.TrimStart('?'); + if (q.Length == 0) + { + yield break; + } + foreach (var part in q.Split('&', StringSplitOptions.RemoveEmptyEntries)) + { + var idx = part.IndexOf('='); + yield return idx < 0 + ? new KeyValuePair(Uri.UnescapeDataString(part), "") + : new KeyValuePair( + Uri.UnescapeDataString(part[..idx]), + Uri.UnescapeDataString(part[(idx + 1)..])); + } + } +} diff --git a/services/IntegrationsService/src/IntegrationsService.Application/Repositories/GoogleAppSettingsRepository.cs b/services/IntegrationsService/src/IntegrationsService.Application/Repositories/GoogleAppSettingsRepository.cs new file mode 100644 index 00000000..113842be --- /dev/null +++ b/services/IntegrationsService/src/IntegrationsService.Application/Repositories/GoogleAppSettingsRepository.cs @@ -0,0 +1,74 @@ +using System.Text.Json; +using IntegrationsService.Application.Persistence; +using IntegrationsService.Domain.Entities; +using Microsoft.EntityFrameworkCore; + +namespace IntegrationsService.Application.Repositories; + +public sealed class GoogleAppSettingsRepository(IntegrationsDbContext db) +{ + public const int SingletonId = 1; + + private static readonly string[] Scopes = + [ + "https://www.googleapis.com/auth/webmasters.readonly", + "https://www.googleapis.com/auth/analytics.readonly", + "https://www.googleapis.com/auth/adwords", + ]; + + public static IReadOnlyList GoogleScopes => Scopes; + + public async Task ReadAsync(CancellationToken cancellationToken = default) + { + var row = await db.GoogleAppSettings.AsNoTracking() + .FirstOrDefaultAsync(s => s.Id == SingletonId, cancellationToken); + + return row ?? new GoogleAppSettings { Id = SingletonId, DefaultDateRangeDays = 28 }; + } + + public async Task HasServiceAccountAsync(CancellationToken cancellationToken = default) + { + var row = await ReadAsync(cancellationToken); + return !string.IsNullOrWhiteSpace(row.ServiceAccountJson) + && row.ServiceAccountJson != "null"; + } + + public async Task<(string ClientId, string ClientSecret)> AppClientCredentialsAsync( + CancellationToken cancellationToken = default) + { + var row = await ReadAsync(cancellationToken); + var clientId = (row.ClientId ?? Environment.GetEnvironmentVariable("GOOGLE_CLIENT_ID") ?? "").Trim(); + var clientSecret = (row.ClientSecret ?? Environment.GetEnvironmentVariable("GOOGLE_CLIENT_SECRET") ?? "").Trim(); + if (string.IsNullOrEmpty(clientId) || string.IsNullOrEmpty(clientSecret)) + { + throw new InvalidOperationException( + "Google Client ID or Secret missing. Complete Step 1 in Integrations."); + } + + return (clientId, clientSecret); + } + + public async Task DefaultDateRangeDaysAsync(CancellationToken cancellationToken = default) + { + var row = await ReadAsync(cancellationToken); + return row.DefaultDateRangeDays > 0 ? row.DefaultDateRangeDays : 28; + } + + public async Task ReadServiceAccountJsonAsync(CancellationToken cancellationToken = default) + { + var row = await ReadAsync(cancellationToken); + if (string.IsNullOrWhiteSpace(row.ServiceAccountJson) || row.ServiceAccountJson == "null") + { + return null; + } + + try + { + return JsonDocument.Parse(row.ServiceAccountJson); + } + catch (JsonException) + { + return null; + } + } +} diff --git a/services/IntegrationsService/src/IntegrationsService.Application/Repositories/GoogleDataReadRepository.cs b/services/IntegrationsService/src/IntegrationsService.Application/Repositories/GoogleDataReadRepository.cs new file mode 100644 index 00000000..33a62e58 --- /dev/null +++ b/services/IntegrationsService/src/IntegrationsService.Application/Repositories/GoogleDataReadRepository.cs @@ -0,0 +1,80 @@ +using System.Text.Json; +using IntegrationsService.Application.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace IntegrationsService.Application.Repositories; + +public sealed class GoogleDataReadRepository(IntegrationsDbContext db) +{ + public async Task ReadSnapshotRowAsync( + long propertyId, + long? snapshotId = null, + CancellationToken cancellationToken = default) + { + var query = db.GoogleDataRows.AsNoTracking().Where(g => g.PropertyId == propertyId); + query = snapshotId is not null + ? query.Where(g => g.Id == snapshotId) + : query.OrderByDescending(g => g.Id); + + var row = await query + .Select(g => new { g.Id, g.FetchedAt, g.Data }) + .FirstOrDefaultAsync(cancellationToken); + + if (row is null) + { + return null; + } + + return new GoogleSnapshotRow(row.Id, FormatFetchedAt(row.FetchedAt), row.Data); + } + + public async Task ReadSnapshotByIdAsync( + long snapshotId, + CancellationToken cancellationToken = default) + { + var row = await db.GoogleDataRows.AsNoTracking() + .Where(g => g.Id == snapshotId) + .Select(g => new { g.Id, g.FetchedAt, g.Data }) + .FirstOrDefaultAsync(cancellationToken); + + return row is null + ? null + : new GoogleSnapshotRow(row.Id, FormatFetchedAt(row.FetchedAt), row.Data); + } + + public async Task> ListSnapshotRowsAsync( + long propertyId, + int limit = 10, + CancellationToken cancellationToken = default) + { + limit = Math.Clamp(limit, 1, 50); + var rows = await db.GoogleDataRows.AsNoTracking() + .Where(g => g.PropertyId == propertyId) + .OrderByDescending(g => g.Id) + .Take(limit) + .Select(g => new { g.Id, g.FetchedAt, g.Data }) + .ToListAsync(cancellationToken); + + return rows + .Select(r => new GoogleSnapshotRow(r.Id, FormatFetchedAt(r.FetchedAt), r.Data)) + .ToList(); + } + + public async Task ReadLastFetchedAtGlobalAsync(CancellationToken cancellationToken = default) + { + var fetchedAt = await db.GoogleDataRows.AsNoTracking() + .OrderByDescending(g => g.Id) + .Select(g => (DateTimeOffset?)g.FetchedAt) + .FirstOrDefaultAsync(cancellationToken); + + return fetchedAt is null ? null : FormatFetchedAt(fetchedAt.Value); + } + + private static string FormatFetchedAt(DateTimeOffset fetchedAt) => + fetchedAt.ToString("O"); +} + +public sealed record GoogleSnapshotRow(long Id, string FetchedAt, string DataJson) +{ + public JsonDocument ParseData() => JsonDocument.Parse(DataJson); +} diff --git a/services/IntegrationsService/src/IntegrationsService.Application/Repositories/GoogleDataWriteRepository.cs b/services/IntegrationsService/src/IntegrationsService.Application/Repositories/GoogleDataWriteRepository.cs new file mode 100644 index 00000000..1ed6d28a --- /dev/null +++ b/services/IntegrationsService/src/IntegrationsService.Application/Repositories/GoogleDataWriteRepository.cs @@ -0,0 +1,34 @@ +using IntegrationsService.Application.Persistence; +using IntegrationsService.Domain.Entities; +using Microsoft.EntityFrameworkCore; + +namespace IntegrationsService.Application.Repositories; + +public sealed class GoogleDataWriteRepository(IntegrationsDbContext db) +{ + public async Task InsertAsync( + long propertyId, + string dataJson, + DateTimeOffset? fetchedAt = null, + CancellationToken cancellationToken = default) + { + var row = new GoogleData + { + PropertyId = propertyId, + FetchedAt = fetchedAt ?? DateTimeOffset.UtcNow, + Data = dataJson, + }; + db.GoogleDataRows.Add(row); + await db.SaveChangesAsync(cancellationToken); + return row.Id; + } + + public async Task GetLastFetchedAtAsync( + long propertyId, + CancellationToken cancellationToken = default) => + await db.GoogleDataRows + .Where(g => g.PropertyId == propertyId) + .OrderByDescending(g => g.Id) + .Select(g => (DateTimeOffset?)g.FetchedAt) + .FirstOrDefaultAsync(cancellationToken); +} diff --git a/services/IntegrationsService/src/IntegrationsService.Application/Repositories/GscLinksDataRepository.cs b/services/IntegrationsService/src/IntegrationsService.Application/Repositories/GscLinksDataRepository.cs new file mode 100644 index 00000000..5f4b3167 --- /dev/null +++ b/services/IntegrationsService/src/IntegrationsService.Application/Repositories/GscLinksDataRepository.cs @@ -0,0 +1,98 @@ +using System.Text.Json; +using Npgsql; + +namespace IntegrationsService.Application.Repositories; + +public sealed class GscLinksDataRepository(NpgsqlDataSource dataSource) +{ + public async Task> ReadStatusAsync( + long propertyId, + CancellationToken cancellationToken = default) + { + var data = await ReadLatestAsync(propertyId, cancellationToken); + if (data is null) + { + return new Dictionary { ["hasData"] = false }; + } + + return new Dictionary + { + ["hasData"] = true, + ["lastImportedAt"] = GetString(data, "imported_at"), + ["exportTypes"] = GetStringArray(data, "export_types"), + ["rowCounts"] = GetObject(data, "row_counts") ?? new Dictionary(), + ["referringDomainCount"] = GetArrayCount(data, "top_linking_sites"), + ["topLinkedPageCount"] = GetArrayCount(data, "top_linked_pages"), + ["sampleLinkCount"] = GetArrayCount(data, "sample_links"), + ["latestLinkCount"] = GetArrayCount(data, "latest_links"), + }; + } + + public async Task ReadLatestAsync( + long propertyId, + CancellationToken cancellationToken = default) + { + await using var conn = await dataSource.OpenConnectionAsync(cancellationToken); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + SELECT data FROM gsc_links_data + WHERE property_id = @property_id + ORDER BY id DESC LIMIT 1 + """; + cmd.Parameters.AddWithValue("property_id", propertyId); + var result = await cmd.ExecuteScalarAsync(cancellationToken); + if (result is not string json || string.IsNullOrWhiteSpace(json)) + { + return null; + } + + return JsonDocument.Parse(json); + } + + private static string? GetString(JsonDocument doc, string key) + { + if (!doc.RootElement.TryGetProperty(key, out var value)) + { + return null; + } + + return value.ValueKind switch + { + JsonValueKind.String => value.GetString(), + JsonValueKind.Null => null, + _ => value.GetRawText(), + }; + } + + private static object? GetObject(JsonDocument doc, string key) + { + if (!doc.RootElement.TryGetProperty(key, out var value) || value.ValueKind != JsonValueKind.Object) + { + return null; + } + + return JsonSerializer.Deserialize>(value.GetRawText()); + } + + private static IReadOnlyList GetStringArray(JsonDocument doc, string key) + { + if (!doc.RootElement.TryGetProperty(key, out var value) || value.ValueKind != JsonValueKind.Array) + { + return []; + } + + return value.EnumerateArray() + .Select(e => e.ValueKind == JsonValueKind.String ? e.GetString() ?? "" : e.GetRawText()) + .ToList(); + } + + private static int GetArrayCount(JsonDocument doc, string key) + { + if (!doc.RootElement.TryGetProperty(key, out var value) || value.ValueKind != JsonValueKind.Array) + { + return 0; + } + + return value.GetArrayLength(); + } +} diff --git a/services/IntegrationsService/src/IntegrationsService.Application/Repositories/KeywordDataRepository.cs b/services/IntegrationsService/src/IntegrationsService.Application/Repositories/KeywordDataRepository.cs new file mode 100644 index 00000000..75d69d91 --- /dev/null +++ b/services/IntegrationsService/src/IntegrationsService.Application/Repositories/KeywordDataRepository.cs @@ -0,0 +1,93 @@ +using System.Text.Json; +using IntegrationsService.Application.Google; + +namespace IntegrationsService.Application.Repositories; + +public sealed class KeywordDataRepository(Npgsql.NpgsqlDataSource dataSource) +{ + public async Task ReadLatestAsync( + long propertyId, + CancellationToken cancellationToken = default) + { + await using var conn = await dataSource.OpenConnectionAsync(cancellationToken); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + SELECT data FROM keyword_data + WHERE property_id = @property_id + ORDER BY id DESC LIMIT 1 + """; + cmd.Parameters.AddWithValue("property_id", propertyId); + var result = await cmd.ExecuteScalarAsync(cancellationToken); + if (result is not string json || string.IsNullOrWhiteSpace(json)) + { + return null; + } + + return JsonDocument.Parse(json); + } + + public async Task> ReadHistoryAsync( + long propertyId, + string keyword, + int limit = 30, + CancellationToken cancellationToken = default) + { + limit = Math.Clamp(limit, 1, 90); + await using var conn = await dataSource.OpenConnectionAsync(cancellationToken); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + SELECT fetched_at, position, clicks, impressions, ctr + FROM keyword_history + WHERE property_id = @property_id AND keyword = @keyword + ORDER BY id DESC LIMIT @limit + """; + cmd.Parameters.AddWithValue("property_id", propertyId); + cmd.Parameters.AddWithValue("keyword", keyword); + cmd.Parameters.AddWithValue("limit", limit); + + var rows = new List(); + await using var reader = await cmd.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + rows.Add(new KeywordHistoryPoint + { + FetchedAt = reader.GetFieldValue(0).ToString("O"), + Position = reader.IsDBNull(1) ? null : reader.GetDouble(1), + Clicks = reader.IsDBNull(2) ? null : reader.GetInt64(2), + Impressions = reader.IsDBNull(3) ? null : reader.GetInt64(3), + Ctr = reader.IsDBNull(4) ? null : reader.GetDouble(4), + }); + } + + rows.Reverse(); + return rows; + } + + public async Task>> ReadHistoryBatchAsync( + long propertyId, + IReadOnlyList keywords, + int limit = 30, + CancellationToken cancellationToken = default) + { + var results = new Dictionary>(StringComparer.Ordinal); + foreach (var keyword in keywords) + { + results[keyword] = await ReadHistoryAsync(propertyId, keyword, limit, cancellationToken); + } + + return results; + } +} + +public sealed class KeywordHistoryPoint +{ + public string FetchedAt { get; init; } = ""; + + public double? Position { get; init; } + + public long? Clicks { get; init; } + + public long? Impressions { get; init; } + + public double? Ctr { get; init; } +} diff --git a/services/IntegrationsService/src/IntegrationsService.Application/Repositories/PageGoogleSnapshotRepository.cs b/services/IntegrationsService/src/IntegrationsService.Application/Repositories/PageGoogleSnapshotRepository.cs new file mode 100644 index 00000000..44117d93 --- /dev/null +++ b/services/IntegrationsService/src/IntegrationsService.Application/Repositories/PageGoogleSnapshotRepository.cs @@ -0,0 +1,110 @@ +using System.Text.Json; +using IntegrationsService.Application.Google; +using IntegrationsService.Application.Persistence; +using IntegrationsService.Domain.Entities; +using Microsoft.EntityFrameworkCore; + +namespace IntegrationsService.Application.Repositories; + +public sealed class PageGoogleSnapshotRepository(IntegrationsDbContext db) +{ + public static int MaxSnapshotsPerUrl() + { + var raw = (Environment.GetEnvironmentVariable("PAGE_SNAPSHOT_MAX_PER_URL") ?? "30").Trim(); + return int.TryParse(raw, out var n) ? Math.Clamp(n, 1, 200) : 30; + } + + public async Task WriteAsync( + string pageUrl, + JsonDocument data, + CancellationToken cancellationToken = default) + { + var urlNorm = UrlJoinBuilder.NormalizeUrl(pageUrl); + var row = new PageGoogleSnapshot + { + PageUrl = pageUrl.Trim(), + UrlNorm = urlNorm, + FetchedAt = DateTimeOffset.UtcNow, + Data = data.RootElement.GetRawText(), + }; + db.PageGoogleSnapshots.Add(row); + await db.SaveChangesAsync(cancellationToken); + + var limit = MaxSnapshotsPerUrl(); + var keepIds = await db.PageGoogleSnapshots.AsNoTracking() + .Where(p => p.UrlNorm == urlNorm) + .OrderByDescending(p => p.FetchedAt) + .ThenByDescending(p => p.Id) + .Take(limit) + .Select(p => p.Id) + .ToListAsync(cancellationToken); + + var stale = await db.PageGoogleSnapshots + .Where(p => p.UrlNorm == urlNorm && !keepIds.Contains(p.Id)) + .ToListAsync(cancellationToken); + if (stale.Count > 0) + { + db.PageGoogleSnapshots.RemoveRange(stale); + await db.SaveChangesAsync(cancellationToken); + } + + return row.Id; + } + + public async Task ReadCompareRowAsync( + long snapshotId, + CancellationToken cancellationToken = default) + { + var row = await db.PageGoogleSnapshots.AsNoTracking() + .Where(p => p.Id == snapshotId) + .Select(p => new { p.Id, p.FetchedAt, p.Data }) + .FirstOrDefaultAsync(cancellationToken); + + return row is null + ? null + : new GoogleSnapshotRow(row.Id, row.FetchedAt.ToString("O"), row.Data); + } + + public async Task> ListApiHistoryAsync( + string pageUrl, + int limit = 15, + CancellationToken cancellationToken = default) + { + limit = Math.Clamp(limit, 1, 50); + var urlNorm = UrlJoinBuilder.NormalizeUrl(pageUrl); + var rows = await db.PageGoogleSnapshots.AsNoTracking() + .Where(p => p.UrlNorm == urlNorm) + .OrderByDescending(p => p.FetchedAt) + .ThenByDescending(p => p.Id) + .Take(limit) + .Select(p => new { p.Id, p.FetchedAt, p.Data }) + .ToListAsync(cancellationToken); + + var result = new List(); + foreach (var row in rows) + { + using var doc = JsonDocument.Parse(row.Data); + var root = doc.RootElement; + result.Add(new PageLiveHistoryItem + { + Id = row.Id, + FetchedAt = row.FetchedAt.ToString("O"), + Gsc = PageLookupService.ReadOptionalObject(root, "gsc"), + Ga4 = PageLookupService.ReadOptionalObject(root, "ga4"), + }); + } + + return result; + } +} + +public sealed class PageLiveHistoryItem +{ + public long Id { get; init; } + + public string FetchedAt { get; init; } = ""; + + public object? Gsc { get; init; } + + public object? Ga4 { get; init; } +} diff --git a/services/IntegrationsService/src/IntegrationsService.Application/Repositories/PipelineConfigRepository.cs b/services/IntegrationsService/src/IntegrationsService.Application/Repositories/PipelineConfigRepository.cs new file mode 100644 index 00000000..6b4bbc93 --- /dev/null +++ b/services/IntegrationsService/src/IntegrationsService.Application/Repositories/PipelineConfigRepository.cs @@ -0,0 +1,25 @@ +namespace IntegrationsService.Application.Repositories; + +public sealed class PipelineConfigRepository(Npgsql.NpgsqlDataSource dataSource) +{ + public async Task> ReadKnownAsync( + CancellationToken cancellationToken = default) + { + await using var conn = await dataSource.OpenConnectionAsync(cancellationToken); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + SELECT key, value FROM pipeline_config + WHERE is_unknown = false + ORDER BY key + """; + + var result = new Dictionary(StringComparer.Ordinal); + await using var reader = await cmd.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + result[reader.GetString(0)] = reader.GetString(1); + } + + return result; + } +} diff --git a/services/IntegrationsService/src/IntegrationsService.Application/Repositories/PropertyRepository.cs b/services/IntegrationsService/src/IntegrationsService.Application/Repositories/PropertyRepository.cs new file mode 100644 index 00000000..7dac0d23 --- /dev/null +++ b/services/IntegrationsService/src/IntegrationsService.Application/Repositories/PropertyRepository.cs @@ -0,0 +1,243 @@ +using System.Text.Json; +using IntegrationsService.Application.Google; +using IntegrationsService.Application.Persistence; +using IntegrationsService.Domain.Entities; +using Microsoft.EntityFrameworkCore; + +namespace IntegrationsService.Application.Repositories; + +public sealed class PropertyRepository(IntegrationsDbContext db) +{ + public async Task GetByIdAsync(long propertyId, CancellationToken cancellationToken = default) => + await db.Properties.AsNoTracking().FirstOrDefaultAsync(p => p.Id == propertyId, cancellationToken); + + public async Task GetByIdTrackedAsync(long propertyId, CancellationToken cancellationToken = default) => + await db.Properties.FirstOrDefaultAsync(p => p.Id == propertyId, cancellationToken); + + public async Task GetByDomainAsync(string domain, CancellationToken cancellationToken = default) + { + var normalized = (domain ?? "").Trim().ToLowerInvariant(); + if (string.IsNullOrEmpty(normalized)) + { + return null; + } + + return await db.Properties.AsNoTracking() + .FirstOrDefaultAsync(p => p.CanonicalDomain == normalized, cancellationToken); + } + + public async Task GetPropertyIdByDomainAsync( + string domain, + CancellationToken cancellationToken = default) + { + var prop = await GetByDomainAsync(domain, cancellationToken); + return prop?.Id; + } + + public async Task EnsureFromStartUrlAsync( + string startUrl, + CancellationToken cancellationToken = default) + { + var domain = PropertyDomainHelper.CanonicalDomainFromStartUrl(startUrl); + if (string.IsNullOrEmpty(domain) || !PropertyDomainHelper.IsValidCanonicalDomain(domain)) + { + return null; + } + + var existing = await GetByDomainAsync(domain, cancellationToken); + if (existing is not null) + { + return existing.Id; + } + + var name = PropertyDomainHelper.DerivePropertyName(domain, startUrl); + var prop = new Property + { + Name = name, + CanonicalDomain = domain, + SiteUrl = string.IsNullOrWhiteSpace(startUrl) ? null : startUrl.Trim(), + }; + db.Properties.Add(prop); + await db.SaveChangesAsync(cancellationToken); + return prop.Id; + } + + public async Task ResolvePropertyIdForPageAsync( + string pageUrl, + string? propertyIdStr, + string? domainStr, + CancellationToken cancellationToken = default) + { + if (!string.IsNullOrWhiteSpace(propertyIdStr) + && long.TryParse(propertyIdStr, out var explicitId) + && explicitId > 0) + { + return explicitId; + } + + if (!string.IsNullOrWhiteSpace(domainStr)) + { + var byDomain = await GetPropertyIdByDomainAsync(domainStr, cancellationToken); + if (byDomain is not null) + { + return byDomain; + } + } + + var host = PropertyDomainHelper.ExtractHostname(pageUrl); + if (string.IsNullOrEmpty(host)) + { + return null; + } + + return await GetPropertyIdByDomainAsync(host, cancellationToken); + } + + public async Task<(string GscSiteUrl, string Ga4PropertyId, int DateRangeDays)?> GetGoogleTargetsAsync( + long propertyId, + int defaultDateRangeDays, + CancellationToken cancellationToken = default) + { + var prop = await GetByIdAsync(propertyId, cancellationToken); + if (prop is null) + { + return null; + } + + var days = prop.GoogleDateRangeDays.GetValueOrDefault() > 0 + ? prop.GoogleDateRangeDays!.Value + : defaultDateRangeDays; + + return ( + (prop.GscSiteUrl ?? "").Trim(), + (prop.Ga4PropertyId ?? "").Trim(), + days); + } + + public async Task ApplyGoogleCredentialsPatchAsync( + long propertyId, + PropertyGoogleCredentialsPatch patch, + CancellationToken cancellationToken = default) + { + var prop = await GetByIdTrackedAsync(propertyId, cancellationToken) + ?? throw new InvalidOperationException($"Property id {propertyId} not found."); + + if (patch.GscSiteUrl is not null) + { + prop.GscSiteUrl = string.IsNullOrWhiteSpace(patch.GscSiteUrl) ? null : patch.GscSiteUrl.Trim(); + } + + if (patch.Ga4PropertyId is not null) + { + var v = patch.Ga4PropertyId.Trim(); + if (!string.IsNullOrEmpty(v) && !v.All(char.IsDigit)) + { + throw new ArgumentException( + "Analytics property ID must be a numeric ID (e.g. 123456789). " + + "The G-XXXXXXX code is a Measurement ID."); + } + + prop.Ga4PropertyId = string.IsNullOrEmpty(v) ? null : v; + } + + if (patch.DateRangeDays is > 0) + { + prop.GoogleDateRangeDays = patch.DateRangeDays; + } + + if (patch.AuthMode is not null) + { + prop.GoogleAuthMode = string.IsNullOrWhiteSpace(patch.AuthMode) ? null : patch.AuthMode; + } + + if (patch.ConnectedEmail is not null) + { + prop.GoogleConnectedEmail = string.IsNullOrWhiteSpace(patch.ConnectedEmail) + ? null + : patch.ConnectedEmail.Trim(); + } + + if (patch.RefreshToken is not null) + { + var token = patch.RefreshToken.Trim(); + prop.GoogleRefreshToken = string.IsNullOrEmpty(token) ? null : token; + if (!string.IsNullOrEmpty(token)) + { + prop.GoogleConnectedAt = DateTimeOffset.UtcNow; + } + else + { + prop.GoogleConnectedAt = null; + if (patch.ConnectedEmail is null) + { + prop.GoogleConnectedEmail = null; + } + } + } + + await db.SaveChangesAsync(cancellationToken); + } + + public async Task DisconnectGoogleAsync(long propertyId, CancellationToken cancellationToken = default) + { + await ApplyGoogleCredentialsPatchAsync( + propertyId, + new PropertyGoogleCredentialsPatch + { + RefreshToken = "", + AuthMode = null, + }, + cancellationToken); + } +} + +public sealed class PropertyGoogleCredentialsPatch +{ + public string? RefreshToken { get; init; } + + public string? AuthMode { get; init; } + + public string? GscSiteUrl { get; init; } + + public string? Ga4PropertyId { get; init; } + + public int? DateRangeDays { get; init; } + + public string? ConnectedEmail { get; init; } +} + +public sealed class PropertyGooglePublicStatus +{ + public bool Connected { get; init; } + + public string? AuthMode { get; init; } + + public string? GscSiteUrl { get; init; } + + public string? Ga4PropertyId { get; init; } + + public int DateRangeDays { get; init; } = 28; + + public string? ConnectedEmail { get; init; } + + public DateTimeOffset? ConnectedAt { get; init; } +} + +public static class PropertyGoogleStatusMapper +{ + public static PropertyGooglePublicStatus ToPublicStatus(Property prop) + { + return new PropertyGooglePublicStatus + { + Connected = prop.GoogleConnectedAt is not null, + AuthMode = prop.GoogleAuthMode, + GscSiteUrl = prop.GscSiteUrl, + Ga4PropertyId = prop.Ga4PropertyId, + DateRangeDays = prop.GoogleDateRangeDays.GetValueOrDefault() > 0 + ? prop.GoogleDateRangeDays!.Value + : 28, + ConnectedEmail = prop.GoogleConnectedEmail, + ConnectedAt = prop.GoogleConnectedAt, + }; + } +} diff --git a/services/IntegrationsService/src/IntegrationsService.Domain/Entities/GoogleAppSettings.cs b/services/IntegrationsService/src/IntegrationsService.Domain/Entities/GoogleAppSettings.cs new file mode 100644 index 00000000..7e445138 --- /dev/null +++ b/services/IntegrationsService/src/IntegrationsService.Domain/Entities/GoogleAppSettings.cs @@ -0,0 +1,21 @@ +namespace IntegrationsService.Domain.Entities; + +/// Singleton OAuth app settings row (google_app_settings, id=1). +public sealed class GoogleAppSettings +{ + public int Id { get; set; } = 1; + + public string? ClientId { get; set; } + + public string? ClientSecret { get; set; } + + public string? ServiceAccountJson { get; set; } + + public int DefaultDateRangeDays { get; set; } = 28; + + public DateTimeOffset? UpdatedAt { get; set; } + + public string? DeveloperToken { get; set; } + + public string? LoginCustomerId { get; set; } +} diff --git a/services/IntegrationsService/src/IntegrationsService.Domain/Entities/GoogleData.cs b/services/IntegrationsService/src/IntegrationsService.Domain/Entities/GoogleData.cs new file mode 100644 index 00000000..20721b62 --- /dev/null +++ b/services/IntegrationsService/src/IntegrationsService.Domain/Entities/GoogleData.cs @@ -0,0 +1,13 @@ +namespace IntegrationsService.Domain.Entities; + +/// GSC/GA4 snapshot row in google_data. +public sealed class GoogleData +{ + public long Id { get; set; } + + public DateTimeOffset FetchedAt { get; set; } + + public long? PropertyId { get; set; } + + public string Data { get; set; } = "{}"; +} diff --git a/services/IntegrationsService/src/IntegrationsService.Domain/Entities/PageGoogleSnapshot.cs b/services/IntegrationsService/src/IntegrationsService.Domain/Entities/PageGoogleSnapshot.cs new file mode 100644 index 00000000..1fa386e7 --- /dev/null +++ b/services/IntegrationsService/src/IntegrationsService.Domain/Entities/PageGoogleSnapshot.cs @@ -0,0 +1,15 @@ +namespace IntegrationsService.Domain.Entities; + +/// Per-URL live Google fetch history in page_google_snapshots. +public sealed class PageGoogleSnapshot +{ + public long Id { get; set; } + + public string PageUrl { get; set; } = ""; + + public string UrlNorm { get; set; } = ""; + + public DateTimeOffset FetchedAt { get; set; } + + public string Data { get; set; } = "{}"; +} diff --git a/services/IntegrationsService/src/IntegrationsService.Domain/Entities/Property.cs b/services/IntegrationsService/src/IntegrationsService.Domain/Entities/Property.cs new file mode 100644 index 00000000..8d83c062 --- /dev/null +++ b/services/IntegrationsService/src/IntegrationsService.Domain/Entities/Property.cs @@ -0,0 +1,31 @@ +namespace IntegrationsService.Domain.Entities; + +/// Property row with Google integration columns from properties. +public sealed class Property +{ + public long Id { get; set; } + + public string? Name { get; set; } + + public string? CanonicalDomain { get; set; } + + public string? SiteUrl { get; set; } + + public string? GscSiteUrl { get; set; } + + public string? Ga4PropertyId { get; set; } + + public string? GoogleAuthMode { get; set; } + + public string? GoogleRefreshToken { get; set; } + + public DateTimeOffset? GoogleConnectedAt { get; set; } + + public string? GoogleConnectedEmail { get; set; } + + public int? GoogleDateRangeDays { get; set; } + + public string? DefaultCrawlPreset { get; set; } + + public DateTimeOffset? CrawlAuthorizedAt { get; set; } +} diff --git a/services/IntegrationsService/src/IntegrationsService.Domain/IntegrationsService.Domain.csproj b/services/IntegrationsService/src/IntegrationsService.Domain/IntegrationsService.Domain.csproj new file mode 100644 index 00000000..b7601447 --- /dev/null +++ b/services/IntegrationsService/src/IntegrationsService.Domain/IntegrationsService.Domain.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/services/IntegrationsService/src/IntegrationsService.Providers/DependencyInjection.cs b/services/IntegrationsService/src/IntegrationsService.Providers/DependencyInjection.cs new file mode 100644 index 00000000..be248b71 --- /dev/null +++ b/services/IntegrationsService/src/IntegrationsService.Providers/DependencyInjection.cs @@ -0,0 +1,16 @@ +using IntegrationsService.Application.Google; +using IntegrationsService.Providers.Google; +using Microsoft.Extensions.DependencyInjection; + +namespace IntegrationsService.Providers; + +public static class DependencyInjection +{ + public static IServiceCollection AddGoogleProviders(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + return services; + } +} diff --git a/services/IntegrationsService/src/IntegrationsService.Providers/Google/Ga4ReportClient.cs b/services/IntegrationsService/src/IntegrationsService.Providers/Google/Ga4ReportClient.cs new file mode 100644 index 00000000..378b6e2e --- /dev/null +++ b/services/IntegrationsService/src/IntegrationsService.Providers/Google/Ga4ReportClient.cs @@ -0,0 +1,436 @@ +using Google.Apis.GoogleAnalyticsAdmin.v1alpha; +using Google.Apis.GoogleAnalyticsAdmin.v1alpha.Data; +using Google.Apis.AnalyticsData.v1beta; +using Google.Apis.AnalyticsData.v1beta.Data; +using Google.Apis.Http; +using Google.Apis.Services; +using IntegrationsService.Application.Google; +using WebsiteProfiling.Contracts.Google; + +namespace IntegrationsService.Providers.Google; + +public sealed class Ga4ReportClient : IGa4ReportClient +{ + public async Task FetchDataAsync( + IConfigurableHttpClientInitializer credential, + string propertyId, + int dateRangeDays, + string startUrl, + CancellationToken cancellationToken = default) + { + var client = new AnalyticsDataService(new BaseClientService.Initializer + { + HttpClientInitializer = credential, + ApplicationName = "WebsiteProfiling", + }); + + var end = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(-1); + var start = end.AddDays(-(dateRangeDays - 1)); + var dateRange = new List + { + new() { StartDate = start.ToString("yyyy-MM-dd"), EndDate = end.ToString("yyyy-MM-dd") }, + }; + var coreMetrics = new List + { + new() { Name = "sessions" }, + new() { Name = "activeUsers" }, + new() { Name = "screenPageViews" }, + }; + + async Task RunReportAsync( + IList dimensions, + IList metrics, + int limit, + IList? orderBys = null) + { + var request = new RunReportRequest + { + DateRanges = dateRange, + Dimensions = dimensions.Select(d => new Dimension { Name = d }).ToList(), + Metrics = metrics, + Limit = limit, + OrderBys = orderBys, + }; + return await CallWithRetry( + () => client.Properties.RunReport(request, $"properties/{propertyId}").ExecuteAsync(cancellationToken), + cancellationToken); + } + + var pagesResponse = await RunReportAsync( + ["pagePath"], + [ + new Metric { Name = "sessions" }, + new Metric { Name = "activeUsers" }, + new Metric { Name = "screenPageViews" }, + new Metric { Name = "engagementRate" }, + new Metric { Name = "averageSessionDuration" }, + ], + 1000, + [new OrderBy { Metric = new MetricOrderBy { MetricName = "sessions" }, Desc = true }]); + + var rows = new List(); + foreach (var row in pagesResponse.Rows ?? []) + { + var path = row.DimensionValues?.FirstOrDefault()?.Value ?? ""; + var vals = row.MetricValues?.Select(v => v.Value).ToList() ?? []; + var fullUrl = !string.IsNullOrEmpty(startUrl) && !string.IsNullOrEmpty(path) + ? UrlJoinBuilder.PathToUrl(path, startUrl) + : ""; + rows.Add(new Ga4PageRecord + { + Path = path, + FullUrl = fullUrl, + Sessions = ParseInt(vals, 0), + ActiveUsers = ParseInt(vals, 1), + ScreenPageViews = ParseInt(vals, 2), + EngagementRate = ParseDouble(vals, 3, 4), + AvgSessionDuration = ParseDouble(vals, 4, 1), + }); + } + + var dailyResponse = await RunReportAsync( + ["date"], + coreMetrics, + 400, + [new OrderBy { Dimension = new DimensionOrderBy { DimensionName = "date" } }]); + + var daily = (dailyResponse.Rows ?? []) + .Select(row => + { + var d = row.DimensionValues?.FirstOrDefault()?.Value ?? ""; + var vals = row.MetricValues?.Select(v => v.Value).ToList() ?? []; + return new Ga4DailyRecord + { + Date = d, + Sessions = ParseInt(vals, 0), + ActiveUsers = ParseInt(vals, 1), + ScreenPageViews = ParseInt(vals, 2), + }; + }) + .Where(r => !string.IsNullOrEmpty(r.Date)) + .OrderBy(r => r.Date) + .ToList(); + + var channelResponse = await RunReportAsync( + ["sessionDefaultChannelGroup"], + coreMetrics, + 20, + [new OrderBy { Metric = new MetricOrderBy { MetricName = "sessions" }, Desc = true }]); + + var byChannel = (channelResponse.Rows ?? []) + .Select(row => + { + var ch = row.DimensionValues?.FirstOrDefault()?.Value ?? ""; + var vals = row.MetricValues?.Select(v => v.Value).ToList() ?? []; + return new Ga4ChannelRecord + { + Channel = ch, + Sessions = ParseInt(vals, 0), + ActiveUsers = ParseInt(vals, 1), + ScreenPageViews = ParseInt(vals, 2), + }; + }) + .ToList(); + + var deviceResponse = await RunReportAsync( + ["deviceCategory"], + coreMetrics, + 10, + [new OrderBy { Metric = new MetricOrderBy { MetricName = "sessions" }, Desc = true }]); + + var byDevice = (deviceResponse.Rows ?? []) + .Select(row => + { + var dev = row.DimensionValues?.FirstOrDefault()?.Value ?? ""; + var vals = row.MetricValues?.Select(v => v.Value).ToList() ?? []; + return new Ga4DeviceRecord + { + Device = dev, + Sessions = ParseInt(vals, 0), + ActiveUsers = ParseInt(vals, 1), + ScreenPageViews = ParseInt(vals, 2), + }; + }) + .ToList(); + + return new Ga4FetchResult + { + PropertyId = propertyId, + Summary = new Ga4Summary + { + Sessions = rows.Sum(r => r.Sessions), + ActiveUsers = rows.Sum(r => r.ActiveUsers), + ScreenPageViews = rows.Sum(r => r.ScreenPageViews), + }, + TopPages = rows, + ByPath = rows.Where(r => !string.IsNullOrEmpty(r.Path)) + .ToDictionary(r => r.Path, r => r, StringComparer.Ordinal), + Daily = daily, + ByChannel = byChannel, + ByDevice = byDevice, + DateStart = start.ToString("yyyy-MM-dd"), + DateEnd = end.ToString("yyyy-MM-dd"), + }; + } + + public async Task<(IReadOnlyList Properties, string? Error)> ListPropertiesAsync( + IConfigurableHttpClientInitializer credential, + CancellationToken cancellationToken = default) + { + try + { + var client = new GoogleAnalyticsAdminService(new BaseClientService.Initializer + { + HttpClientInitializer = credential, + ApplicationName = "WebsiteProfiling", + }); + + var results = new List(); + var request = client.AccountSummaries.List(); + do + { + var response = await request.ExecuteAsync(cancellationToken); + foreach (var accountSummary in response.AccountSummaries ?? []) + { + foreach (var prop in accountSummary.PropertySummaries ?? []) + { + var propId = prop.Property?.Split('/').LastOrDefault() ?? ""; + results.Add(new Ga4PropertySummary + { + Id = propId, + DisplayName = prop.DisplayName ?? propId, + AccountName = accountSummary.DisplayName ?? "", + }); + } + } + + request.PageToken = response.NextPageToken; + } while (!string.IsNullOrEmpty(request.PageToken)); + + if (results.Count == 0) + { + return ([], "No GA4 properties returned for this Google account. " + + "Confirm the account has Analytics access, or enter the numeric property ID " + + "from GA4 Admin > Property Settings and run Test connection."); + } + + return (results, null); + } + catch (Exception ex) + { + return ([], $"Could not list GA4 properties: {ex.Message}"); + } + } + + public async Task<(bool Ok, string Message)> ProbePropertyAsync( + IConfigurableHttpClientInitializer credential, + string propertyId, + CancellationToken cancellationToken = default) + { + propertyId = (propertyId ?? "").Trim(); + if (string.IsNullOrEmpty(propertyId)) + { + return (false, "No GA4 property ID configured."); + } + + try + { + var client = new AnalyticsDataService(new BaseClientService.Initializer + { + HttpClientInitializer = credential, + ApplicationName = "WebsiteProfiling", + }); + var request = new RunReportRequest + { + DateRanges = [new DateRange { StartDate = "7daysAgo", EndDate = "yesterday" }], + Metrics = [new Metric { Name = "sessions" }], + Limit = 1, + }; + var response = await CallWithRetry( + () => client.Properties.RunReport(request, $"properties/{propertyId}").ExecuteAsync(cancellationToken), + cancellationToken); + var rowCount = response.Rows?.Count ?? 0; + if (rowCount == 0) + { + return (true, + $"GA4 property {propertyId} is accessible, but returned 0 rows for the last 7 days. " + + "The property may be new, have no traffic yet, or use a different date range."); + } + + var sessions = 0; + if (response.Rows![0].MetricValues?.Count > 0) + { + sessions = ParseInt([response.Rows[0].MetricValues[0].Value], 0); + } + + return (true, + $"GA4 property {propertyId} is accessible " + + $"(sample: {rowCount} row(s), {sessions} session(s) in probe window)."); + } + catch (Exception ex) + { + var msg = ex.Message; + if (msg.Contains("PERMISSION_DENIED", StringComparison.Ordinal) || msg.Contains("403", StringComparison.Ordinal)) + { + return (false, + $"GA4 property {propertyId} is not accessible with the connected Google account. " + + "Open GA4 Admin and confirm this account has at least Viewer access to the property."); + } + + if (msg.Contains("NOT_FOUND", StringComparison.Ordinal) || msg.Contains("404", StringComparison.Ordinal)) + { + return (false, + $"GA4 property {propertyId} was not found. " + + "Use the numeric Property ID from GA4 Admin > Property Settings (not the G-XXXXXXX Measurement ID)."); + } + + return (false, $"GA4 property {propertyId} probe failed: {msg}"); + } + } + + public async Task<(Dictionary? PageData, IReadOnlyList Errors)> FetchPageLiveAsync( + IConfigurableHttpClientInitializer credential, + string propertyId, + string pageUrl, + string startUrl, + int dateRangeDays, + CancellationToken cancellationToken = default) + { + var errors = new List(); + var path = UrlJoinBuilder.UrlToPath(pageUrl); + var end = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(-1); + var start = end.AddDays(-(Math.Max(1, dateRangeDays) - 1)); + + var client = new AnalyticsDataService(new BaseClientService.Initializer + { + HttpClientInitializer = credential, + ApplicationName = "WebsiteProfiling", + }); + + var request = new RunReportRequest + { + DateRanges = + [ + new DateRange + { + StartDate = start.ToString("yyyy-MM-dd"), + EndDate = end.ToString("yyyy-MM-dd"), + }, + ], + Dimensions = [new Dimension { Name = "pagePath" }], + Metrics = + [ + new Metric { Name = "sessions" }, + new Metric { Name = "activeUsers" }, + new Metric { Name = "screenPageViews" }, + new Metric { Name = "engagementRate" }, + new Metric { Name = "averageSessionDuration" }, + ], + DimensionFilter = new FilterExpression + { + Filter = new Filter + { + FieldName = "pagePath", + StringFilter = new StringFilter + { + Value = path, + MatchType = "EXACT", + }, + }, + }, + Limit = 5, + }; + + try + { + var response = await CallWithRetry( + () => client.Properties.RunReport(request, $"properties/{propertyId}").ExecuteAsync(cancellationToken), + cancellationToken); + + if (response.Rows is null || response.Rows.Count == 0) + { + return (null, [$"No GA4 data for path {path} in date range."]); + } + + var row = response.Rows[0]; + var vals = row.MetricValues?.Select(v => v.Value).ToList() ?? []; + var pageData = new Dictionary + { + ["path"] = path, + ["full_url"] = !string.IsNullOrEmpty(startUrl) + ? UrlJoinBuilder.PathToUrl(path, startUrl) + : pageUrl, + ["sessions"] = ParseInt(vals, 0), + ["activeUsers"] = ParseInt(vals, 1), + ["screenPageViews"] = ParseInt(vals, 2), + ["engagementRate"] = ParseDouble(vals, 3, 4), + ["avgSessionDuration"] = ParseDouble(vals, 4, 1), + }; + return (pageData, errors); + } + catch (Exception ex) + { + return (null, [ex.Message]); + } + } + + private static int ParseInt(IReadOnlyList vals, int index) + { + if (index >= vals.Count || string.IsNullOrWhiteSpace(vals[index])) + { + return 0; + } + + return int.TryParse(vals[index], out var i) + ? i + : (int)Math.Round(double.Parse(vals[index]!, System.Globalization.CultureInfo.InvariantCulture)); + } + + private static double ParseDouble(IReadOnlyList vals, int index, int decimals) + { + if (index >= vals.Count || string.IsNullOrWhiteSpace(vals[index])) + { + return 0.0; + } + + var value = double.Parse(vals[index]!, System.Globalization.CultureInfo.InvariantCulture); + return Math.Round(value, decimals); + } + + private static async Task CallWithRetry( + Func> fn, + CancellationToken cancellationToken, + int maxRetries = 3, + double baseDelaySeconds = 2.0) + { + for (var attempt = 0; attempt < maxRetries; attempt++) + { + try + { + return await fn(); + } + catch (Exception ex) when (attempt < maxRetries - 1 && IsRetryable(ex)) + { + var delay = TimeSpan.FromSeconds(baseDelaySeconds * Math.Pow(2, attempt)); + await Task.Delay(delay, cancellationToken); + } + catch (Exception ex) when (ex.Message.Contains("RESOURCE_EXHAUSTED", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "GA4 quota exceeded — try again tomorrow. " + + "(Google Analytics Data API daily token quota reached.)"); + } + } + + return await fn(); + } + + private static bool IsRetryable(Exception ex) + { + var msg = ex.Message; + return msg.Contains("ServiceUnavailable", StringComparison.OrdinalIgnoreCase) + || msg.Contains("TooManyRequests", StringComparison.OrdinalIgnoreCase) + || msg.Contains("503", StringComparison.Ordinal) + || msg.Contains("429", StringComparison.Ordinal); + } +} diff --git a/services/IntegrationsService/src/IntegrationsService.Providers/Google/GoogleCredentialFactory.cs b/services/IntegrationsService/src/IntegrationsService.Providers/Google/GoogleCredentialFactory.cs new file mode 100644 index 00000000..55358b87 --- /dev/null +++ b/services/IntegrationsService/src/IntegrationsService.Providers/Google/GoogleCredentialFactory.cs @@ -0,0 +1,72 @@ +using Google.Apis.Auth.OAuth2; +using Google.Apis.Auth.OAuth2.Flows; +using Google.Apis.Auth.OAuth2.Responses; +using Google.Apis.Http; +using IntegrationsService.Application.Google; +using IntegrationsService.Application.Repositories; + +namespace IntegrationsService.Providers.Google; + +public sealed class GoogleCredentialFactory( + PropertyRepository properties, + GoogleAppSettingsRepository appSettings) : IGoogleCredentialFactory +{ + public async Task BuildCredentialsAsync( + long propertyId, + CancellationToken cancellationToken = default) + { + var prop = await properties.GetByIdAsync(propertyId, cancellationToken) + ?? throw new InvalidOperationException($"Property id {propertyId} not found."); + + var token = (prop.GoogleRefreshToken ?? "").Trim(); + var authMode = prop.GoogleAuthMode; + var domain = prop.CanonicalDomain ?? "this site"; + + if (authMode == "service_account" || (string.IsNullOrEmpty(token) && await appSettings.HasServiceAccountAsync(cancellationToken))) + { + return await BuildServiceAccountCredentialsAsync(cancellationToken); + } + + if (string.IsNullOrEmpty(token)) + { + throw new InvalidOperationException( + $"Google not connected for {domain}. " + + "Set Site URL, open Integrations, and click Connect with Google for this site, " + + "or upload an app-wide service account JSON in Integrations."); + } + + var (clientId, clientSecret) = await appSettings.AppClientCredentialsAsync(cancellationToken); + var flow = new GoogleAuthorizationCodeFlow(new GoogleAuthorizationCodeFlow.Initializer + { + ClientSecrets = new ClientSecrets + { + ClientId = clientId, + ClientSecret = clientSecret, + }, + Scopes = GoogleAppSettingsRepository.GoogleScopes, + }); + + var credential = new UserCredential( + flow, + propertyId.ToString(), + new TokenResponse { RefreshToken = token }); + + if (!await credential.RefreshTokenAsync(cancellationToken)) + { + throw new InvalidOperationException( + "Google connection expired — reconnect Google for this site."); + } + + return credential; + } + + private async Task BuildServiceAccountCredentialsAsync( + CancellationToken cancellationToken) + { + using var saDoc = await appSettings.ReadServiceAccountJsonAsync(cancellationToken) + ?? throw new InvalidOperationException("No service account configured in google_app_settings."); + + return GoogleCredential.FromJson(saDoc.RootElement.GetRawText()) + .CreateScoped(GoogleAppSettingsRepository.GoogleScopes); + } +} diff --git a/services/IntegrationsService/src/IntegrationsService.Providers/Google/GscSearchAnalyticsClient.cs b/services/IntegrationsService/src/IntegrationsService.Providers/Google/GscSearchAnalyticsClient.cs new file mode 100644 index 00000000..6db40a3e --- /dev/null +++ b/services/IntegrationsService/src/IntegrationsService.Providers/Google/GscSearchAnalyticsClient.cs @@ -0,0 +1,614 @@ +using Google.Apis.Http; +using Google.Apis.SearchConsole.v1; +using Google.Apis.SearchConsole.v1.Data; +using Google.Apis.Services; +using IntegrationsService.Application.Google; +using WebsiteProfiling.Contracts.Google; + +namespace IntegrationsService.Providers.Google; + +public sealed class GscSearchAnalyticsClient : IGscSearchAnalyticsClient +{ + private const int DefaultRowLimit = 1000; + + public async Task> ListSitesAsync( + IConfigurableHttpClientInitializer credential, + CancellationToken cancellationToken = default) + { + var service = BuildService(credential); + var resp = await CallWithRetry( + () => service.Sites.List().ExecuteAsync(cancellationToken), + cancellationToken); + return resp.SiteEntry? + .Where(s => !string.IsNullOrWhiteSpace(s.SiteUrl)) + .Select(s => s.SiteUrl!) + .ToList() ?? []; + } + + public (string? ResolvedSite, string? Error) ResolveSiteUrl(string configured, IReadOnlyList sites) + { + configured = (configured ?? "").Trim(); + if (string.IsNullOrEmpty(configured)) + { + return (null, "No GSC site URL configured."); + } + + if (sites.Contains(configured)) + { + return (configured, null); + } + + var configuredKey = UrlPrefixKey(configured); + if (configuredKey is not null) + { + foreach (var site in sites) + { + if (UrlPrefixKey(site) == configuredKey) + { + return (site, null); + } + } + } + + var configuredDomain = DomainFromSiteUrl(configured); + if (configuredDomain is not null) + { + foreach (var site in sites) + { + if (DomainFromSiteUrl(site) == configuredDomain) + { + return (site, null); + } + } + } + + var siteList = sites.Count > 0 ? string.Join(", ", sites) : "(none)"; + var hint = ""; + if (configured.StartsWith("http://", StringComparison.OrdinalIgnoreCase) + || configured.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + { + if (!configured.EndsWith('/')) + { + var trailing = configured + "/"; + if (sites.Contains(trailing)) + { + hint = $" Use the exact URL '{trailing}' from Search Console."; + } + } + } + else if (configured.EndsWith('/')) + { + var noTrailing = configured.TrimEnd('/'); + if (sites.Contains(noTrailing)) + { + hint = $" Use the exact URL '{noTrailing}' from Search Console."; + } + } + + return ( + null, + $"Configured GSC site '{configured}' does not match any accessible property.{hint} " + + $"Accessible sites: [{siteList}]. " + + "Open Integrations, click 'Load from account', pick the site from the dropdown, and Save settings."); + } + + public async Task FetchDataAsync( + IConfigurableHttpClientInitializer credential, + string siteUrl, + int dateRangeDays, + int rowLimit = DefaultRowLimit, + int maxRows = 25000, + CancellationToken cancellationToken = default) + { + var service = BuildService(credential); + var end = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(-3); + var start = end.AddDays(-(dateRangeDays - 1)); + + async Task> QueryAsync(IList dimensions, int pageLimit) + { + var allRows = new List(); + var startRow = 0; + while (allRows.Count < maxRows) + { + var body = new SearchAnalyticsQueryRequest + { + StartDate = start.ToString("yyyy-MM-dd"), + EndDate = end.ToString("yyyy-MM-dd"), + Dimensions = dimensions, + RowLimit = Math.Min(pageLimit, maxRows - allRows.Count), + StartRow = startRow, + }; + var resp = await CallWithRetry( + () => service.Searchanalytics.Query(body, siteUrl).ExecuteAsync(cancellationToken), + cancellationToken); + var page = resp.Rows ?? []; + if (page.Count == 0) + { + break; + } + + allRows.AddRange(page); + if (page.Count < pageLimit) + { + break; + } + + startRow += page.Count; + } + + return allRows; + } + + var queryRows = await QueryAsync(["query"], rowLimit); + var pageRows = await QueryAsync(["page"], rowLimit); + var pageQueryCap = Math.Min(maxRows, 15000); + var pageQueryRows = (await QueryAsync(["page", "query"], rowLimit)).Take(pageQueryCap).ToList(); + var dailyRows = await QueryAsync(["date"], 100); + + var allQueries = queryRows.Select(ToQueryRecord).ToList(); + var allPages = pageRows.Select(ToPageRecord).ToList(); + var daily = dailyRows + .Select(ToDailyRecord) + .Where(r => !string.IsNullOrEmpty(r.Date)) + .OrderBy(r => r.Date) + .ToList(); + + var totalClicks = allPages.Sum(r => r.Clicks); + var totalImpressions = allPages.Sum(r => r.Impressions); + var avgCtr = totalImpressions > 0 + ? Math.Round(totalClicks / (double)totalImpressions * 100, 2) + : 0.0; + var avgPosition = allPages.Count > 0 + ? Math.Round(allPages.Average(r => r.Position), 1) + : 0.0; + + var byPage = allPages + .Where(r => !string.IsNullOrEmpty(r.Page)) + .ToDictionary( + r => r.Page, + r => new GscPageDetail + { + Page = r.Page, + Clicks = r.Clicks, + Impressions = r.Impressions, + Ctr = r.Ctr, + Position = r.Position, + Queries = [], + }, + StringComparer.Ordinal); + + foreach (var raw in pageQueryRows) + { + var keys = raw.Keys ?? []; + if (keys.Count < 2) + { + continue; + } + + var pageUrl = keys[0]; + var queryText = keys[1]; + if (string.IsNullOrEmpty(pageUrl) || string.IsNullOrEmpty(queryText)) + { + continue; + } + + var qrec = ToQueryRecord(raw); + if (!byPage.TryGetValue(pageUrl, out var detail)) + { + detail = new GscPageDetail + { + Page = pageUrl, + Queries = [], + }; + byPage[pageUrl] = detail; + } + + detail.Queries.Add(qrec); + } + + return new GscFetchResult + { + SiteUrl = siteUrl, + Summary = new GscSummary + { + Clicks = totalClicks, + Impressions = totalImpressions, + Ctr = avgCtr, + Position = avgPosition, + }, + TopQueries = allQueries, + TopPages = allPages, + ByPage = byPage, + Daily = daily, + DateStart = start.ToString("yyyy-MM-dd"), + DateEnd = end.ToString("yyyy-MM-dd"), + }; + } + + public async Task<(bool Ok, string Message)> ProbeSiteAsync( + IConfigurableHttpClientInitializer credential, + string siteUrl, + CancellationToken cancellationToken = default) + { + siteUrl = (siteUrl ?? "").Trim(); + if (string.IsNullOrEmpty(siteUrl)) + { + return (false, "No GSC site URL configured."); + } + + try + { + var service = BuildService(credential); + var end = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(-3); + var start = end.AddDays(-6); + var body = new SearchAnalyticsQueryRequest + { + StartDate = start.ToString("yyyy-MM-dd"), + EndDate = end.ToString("yyyy-MM-dd"), + Dimensions = ["query"], + RowLimit = 1, + }; + var resp = await CallWithRetry( + () => service.Searchanalytics.Query(body, siteUrl).ExecuteAsync(cancellationToken), + cancellationToken); + var rows = resp.Rows ?? []; + if (rows.Count == 0) + { + return (true, + $"Site '{siteUrl}' is accessible, but returned 0 search rows for the last 7 days " + + "(new property, low traffic, or indexing still in progress)."); + } + + var row = rows[0]; + var query = row.Keys?.FirstOrDefault() ?? ""; + var impressions = (int)(row.Impressions ?? 0); + return (true, + $"Site '{siteUrl}' is accessible " + + $"(sample query: '{query}', {impressions} impression(s) in probe window)."); + } + catch (Exception ex) + { + var msg = ex.Message; + if (msg.Contains("403", StringComparison.Ordinal) || msg.Contains("Forbidden", StringComparison.OrdinalIgnoreCase)) + { + return (false, + $"Site '{siteUrl}' is not accessible with the connected Google account. " + + "Confirm the account has access in Search Console, or pick the site from " + + "Integrations > Load from account."); + } + + if (msg.Contains("404", StringComparison.Ordinal) || msg.Contains("not found", StringComparison.OrdinalIgnoreCase)) + { + return (false, + $"Site '{siteUrl}' was not found. Search Console requires the exact property URL " + + "(URL-prefix properties usually end with a trailing slash)."); + } + + return (false, $"GSC probe for '{siteUrl}' failed: {msg}"); + } + } + + private static SearchConsoleService BuildService(IConfigurableHttpClientInitializer credential) => + new(new BaseClientService.Initializer + { + HttpClientInitializer = credential, + ApplicationName = "WebsiteProfiling", + }); + + private static async Task CallWithRetry( + Func> fn, + CancellationToken cancellationToken, + int maxRetries = 3, + double baseDelaySeconds = 2.0) + { + for (var attempt = 0; attempt < maxRetries; attempt++) + { + try + { + return await fn(); + } + catch (Exception ex) when (attempt < maxRetries - 1 && IsRetryable(ex)) + { + var delay = TimeSpan.FromSeconds(baseDelaySeconds * Math.Pow(2, attempt)); + await Task.Delay(delay, cancellationToken); + } + } + + return await fn(); + } + + private static bool IsRetryable(Exception ex) + { + var msg = ex.Message; + return msg.Contains("429", StringComparison.Ordinal) || msg.Contains("503", StringComparison.Ordinal); + } + + private static GscQueryRecord ToQueryRecord(ApiDataRow row) + { + var keys = row.Keys ?? []; + return new GscQueryRecord + { + Query = keys.Count > 0 ? keys[0] : "", + Clicks = (int)(row.Clicks ?? 0), + Impressions = (int)(row.Impressions ?? 0), + Ctr = Math.Round((row.Ctr ?? 0) * 100, 2), + Position = Math.Round(row.Position ?? 0, 1), + }; + } + + private static GscPageRecord ToPageRecord(ApiDataRow row) + { + var keys = row.Keys ?? []; + return new GscPageRecord + { + Page = keys.Count > 0 ? keys[0] : "", + Clicks = (int)(row.Clicks ?? 0), + Impressions = (int)(row.Impressions ?? 0), + Ctr = Math.Round((row.Ctr ?? 0) * 100, 2), + Position = Math.Round(row.Position ?? 0, 1), + }; + } + + private static GscDailyRecord ToDailyRecord(ApiDataRow row) + { + var keys = row.Keys ?? []; + return new GscDailyRecord + { + Date = keys.Count > 0 ? keys[0] : "", + Clicks = (int)(row.Clicks ?? 0), + Impressions = (int)(row.Impressions ?? 0), + Ctr = Math.Round((row.Ctr ?? 0) * 100, 2), + Position = Math.Round(row.Position ?? 0, 1), + }; + } + + private static string? UrlPrefixKey(string siteUrl) + { + siteUrl = siteUrl.Trim(); + if (siteUrl.StartsWith("sc-domain:", StringComparison.OrdinalIgnoreCase)) + { + return siteUrl.ToLowerInvariant(); + } + + if (!siteUrl.StartsWith("http://", StringComparison.OrdinalIgnoreCase) + && !siteUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + if (!Uri.TryCreate(siteUrl, UriKind.Absolute, out var parsed)) + { + return null; + } + + var host = StripWwwPrefix(parsed.Host.ToLowerInvariant()); + var path = parsed.AbsolutePath.TrimEnd('/'); + return $"{parsed.Scheme.ToLowerInvariant()}://{host}{path}/"; + } + + private static string? DomainFromSiteUrl(string siteUrl) + { + siteUrl = siteUrl.Trim(); + if (siteUrl.StartsWith("sc-domain:", StringComparison.OrdinalIgnoreCase)) + { + return StripWwwPrefix(siteUrl.Split(':', 2)[1].ToLowerInvariant()); + } + + if (siteUrl.StartsWith("http://", StringComparison.OrdinalIgnoreCase) + || siteUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + { + return Uri.TryCreate(siteUrl, UriKind.Absolute, out var parsed) + ? StripWwwPrefix(parsed.Host.ToLowerInvariant()) + : null; + } + + return null; + } + + private static string StripWwwPrefix(string host) => + host.StartsWith("www.", StringComparison.OrdinalIgnoreCase) ? host[4..] : host; + + public async Task<(Dictionary? PageData, IReadOnlyList Errors)> FetchPageLiveAsync( + IConfigurableHttpClientInitializer credential, + string siteUrl, + string pageUrl, + int dateRangeDays, + CancellationToken cancellationToken = default) + { + var errors = new List(); + try + { + var service = BuildService(credential); + var end = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(-3); + var start = end.AddDays(-(Math.Max(1, dateRangeDays) - 1)); + + async Task> QueryAsync(IList dimensions, int rowLimit) + { + var body = new SearchAnalyticsQueryRequest + { + StartDate = start.ToString("yyyy-MM-dd"), + EndDate = end.ToString("yyyy-MM-dd"), + Dimensions = dimensions, + DimensionFilterGroups = + [ + new() + { + Filters = + [ + new() + { + Dimension = "page", + Operator__ = "equals", + Expression = pageUrl, + }, + ], + }, + ], + RowLimit = rowLimit, + }; + var resp = await CallWithRetry( + () => service.Searchanalytics.Query(body, siteUrl).ExecuteAsync(cancellationToken), + cancellationToken); + return resp.Rows ?? []; + } + + var pageRows = (await QueryAsync(["page"], 5)).ToList(); + var queryRows = (await QueryAsync(["page", "query"], 100)).ToList(); + + if (pageRows.Count == 0 && queryRows.Count == 0) + { + var alt = pageUrl.EndsWith('/') ? pageUrl.TrimEnd('/') : pageUrl + "/"; + if (alt != pageUrl) + { + pageUrl = alt; + pageRows = (await QueryAsync(["page"], 5)).ToList(); + queryRows = (await QueryAsync(["page", "query"], 100)).ToList(); + } + } + + if (pageRows.Count == 0 && queryRows.Count == 0) + { + return (null, ["No GSC data for page in date range."]); + } + + var clicks = 0; + var impressions = 0; + var ctrSum = 0.0; + var posSum = 0.0; + var n = 0; + foreach (var row in pageRows) + { + clicks += (int)(row.Clicks ?? 0); + impressions += (int)(row.Impressions ?? 0); + ctrSum += row.Ctr ?? 0; + posSum += row.Position ?? 0; + n++; + } + + var queries = new List>(); + foreach (var row in queryRows) + { + var keys = row.Keys ?? []; + if (keys.Count < 2) + { + continue; + } + + queries.Add(new Dictionary + { + ["query"] = keys[1], + ["clicks"] = (int)(row.Clicks ?? 0), + ["impressions"] = (int)(row.Impressions ?? 0), + ["ctr"] = Math.Round((row.Ctr ?? 0) * 100, 2), + ["position"] = Math.Round(row.Position ?? 0, 1), + }); + } + + queries = queries + .OrderByDescending(q => Convert.ToInt32(q["impressions"] ?? 0)) + .Take(25) + .ToList(); + + var pageData = new Dictionary + { + ["page"] = pageUrl, + ["clicks"] = clicks, + ["impressions"] = impressions, + ["ctr"] = Math.Round(impressions > 0 ? clicks / (double)impressions * 100 : 0.0, 2), + ["position"] = Math.Round(n > 0 ? posSum / n : 0.0, 1), + ["queries"] = queries, + }; + return (pageData, errors); + } + catch (Exception ex) + { + return (null, [ex.Message]); + } + } + + public async Task> InspectUrlAsync( + IConfigurableHttpClientInitializer credential, + string siteUrl, + string url, + CancellationToken cancellationToken = default) + { + url = (url ?? "").Trim(); + var sites = await ListSitesAsync(credential, cancellationToken); + var (resolved, err) = ResolveSiteUrl(siteUrl, sites); + if (resolved is null) + { + return new Dictionary + { + ["ok"] = false, + ["url"] = url, + ["error"] = err ?? "GSC site URL not accessible.", + ["provenance"] = "Search Console", + }; + } + + try + { + var service = BuildService(credential); + var body = new InspectUrlIndexRequest + { + InspectionUrl = url, + SiteUrl = resolved, + }; + var resp = await CallWithRetry( + () => service.UrlInspection.Index.Inspect(body).ExecuteAsync(cancellationToken), + cancellationToken); + + var inspection = resp.InspectionResult; + var indexStatus = inspection?.IndexStatusResult; + var rich = inspection?.RichResultsResult; + var verdict = rich?.Verdict ?? "UNKNOWN"; + var types = new List(); + if (rich?.DetectedItems is not null) + { + foreach (var item in rich.DetectedItems) + { + if (!string.IsNullOrWhiteSpace(item.RichResultType)) + { + types.Add(item.RichResultType); + } + } + } + + var issues = new List(); + // RichResultsInspectionResult in Google.Apis.SearchConsole.v1 has no Issues collection. + + return new Dictionary + { + ["ok"] = true, + ["url"] = url, + ["site_url"] = resolved, + ["indexing"] = new Dictionary + { + ["verdict"] = indexStatus?.Verdict, + ["coverage_state"] = indexStatus?.CoverageState, + ["robots_txt_state"] = indexStatus?.RobotsTxtState, + ["indexing_state"] = indexStatus?.IndexingState, + ["last_crawl_time"] = indexStatus?.LastCrawlTimeRaw, + ["page_fetch_state"] = indexStatus?.PageFetchState, + }, + ["rich_results"] = new Dictionary + { + ["verdict"] = verdict, + ["schema_types"] = types.Take(10).ToList(), + ["issues"] = issues, + }, + ["provenance"] = "Search Console", + }; + } + catch (Exception ex) + { + return new Dictionary + { + ["ok"] = false, + ["url"] = url, + ["error"] = ex.Message, + ["provenance"] = "Search Console", + }; + } + } +} diff --git a/services/IntegrationsService/src/IntegrationsService.Providers/IntegrationsService.Providers.csproj b/services/IntegrationsService/src/IntegrationsService.Providers/IntegrationsService.Providers.csproj new file mode 100644 index 00000000..fd91cb72 --- /dev/null +++ b/services/IntegrationsService/src/IntegrationsService.Providers/IntegrationsService.Providers.csproj @@ -0,0 +1,23 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + + + + diff --git a/services/IntegrationsService/tests/IntegrationsService.Tests/GoogleFetchPayloadTests.cs b/services/IntegrationsService/tests/IntegrationsService.Tests/GoogleFetchPayloadTests.cs new file mode 100644 index 00000000..88e80441 --- /dev/null +++ b/services/IntegrationsService/tests/IntegrationsService.Tests/GoogleFetchPayloadTests.cs @@ -0,0 +1,41 @@ +using System.Text.Json; +using IntegrationsService.Application.Google; + +namespace IntegrationsService.Tests; + +public sealed class GoogleFetchPayloadTests +{ + [Fact] + public void SerializePayload_has_golden_top_level_keys() + { + var service = new GoogleFetchService(null!, null!, null!, null!, null!); + var payload = new GoogleFetchPayload + { + FetchedAt = DateTimeOffset.Parse("2026-01-15T12:00:00+00:00"), + DateRange = new DateRangePayload { Start = "2026-01-01", End = "2026-01-28" }, + Gsc = new { site_url = "https://example.com/" }, + GscFull = new { by_page = new { } }, + Ga4 = new { property_id = "123" }, + Ga4Full = new { by_path = new { } }, + UrlJoin = new UrlJoinResult { Matched = 3 }, + Errors = ["GA4: no property ID configured"], + }; + + var json = service.SerializePayload(payload); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + Assert.True(root.TryGetProperty("fetched_at", out _)); + Assert.True(root.TryGetProperty("date_range", out var dateRange)); + Assert.True(dateRange.TryGetProperty("start", out _)); + Assert.True(dateRange.TryGetProperty("end", out _)); + Assert.True(root.TryGetProperty("gsc", out _)); + Assert.True(root.TryGetProperty("gsc_full", out _)); + Assert.True(root.TryGetProperty("ga4", out _)); + Assert.True(root.TryGetProperty("ga4_full", out _)); + Assert.True(root.TryGetProperty("url_join", out var urlJoin)); + Assert.Equal(3, urlJoin.GetProperty("matched").GetInt32()); + Assert.True(root.TryGetProperty("errors", out var errors)); + Assert.Equal(JsonValueKind.Array, errors.ValueKind); + } +} diff --git a/services/IntegrationsService/tests/IntegrationsService.Tests/GoogleOAuthServiceTests.cs b/services/IntegrationsService/tests/IntegrationsService.Tests/GoogleOAuthServiceTests.cs new file mode 100644 index 00000000..cad36702 --- /dev/null +++ b/services/IntegrationsService/tests/IntegrationsService.Tests/GoogleOAuthServiceTests.cs @@ -0,0 +1,31 @@ +using IntegrationsService.Application.Google; + +namespace IntegrationsService.Tests; + +public sealed class GoogleOAuthServiceTests +{ + [Fact] + public void SignState_and_VerifyState_round_trip() + { + var state = GoogleOAuthService.SignState(42, "/integrations"); + var payload = GoogleOAuthService.VerifyState(state); + Assert.NotNull(payload); + Assert.Equal(42, payload!["p"].GetInt64()); + Assert.Equal("/integrations", payload["r"].GetString()); + } + + [Fact] + public void VerifyState_rejects_tampered_signature() + { + var state = GoogleOAuthService.SignState(1, "/"); + var tampered = state[..^4] + "dead"; + Assert.Null(GoogleOAuthService.VerifyState(tampered)); + } + + [Fact] + public void SafeReturnPath_blocks_open_redirects() + { + Assert.Equal("/", GoogleOAuthService.SafeReturnPath("https://evil.example")); + Assert.Equal("/settings", GoogleOAuthService.SafeReturnPath("/settings")); + } +} diff --git a/services/IntegrationsService/tests/IntegrationsService.Tests/GscRowMappersTests.cs b/services/IntegrationsService/tests/IntegrationsService.Tests/GscRowMappersTests.cs new file mode 100644 index 00000000..bb85011c --- /dev/null +++ b/services/IntegrationsService/tests/IntegrationsService.Tests/GscRowMappersTests.cs @@ -0,0 +1,33 @@ +using IntegrationsService.Application.Google; + +namespace IntegrationsService.Tests; + +/// +/// Golden parity with Python gsc._to_query_record / _to_page_record (ctr stored as percent). +/// +public sealed class GscRowMappersTests +{ + [Fact] + public void ToQueryRecord_matches_python_golden_values() + { + var row = GscRowMappers.ToQueryRecord("seo audit", 42, 1000, 0.042, 8.37); + + Assert.Equal("seo audit", row.Query); + Assert.Equal(42, row.Clicks); + Assert.Equal(1000, row.Impressions); + Assert.Equal(4.2, row.Ctr); + Assert.Equal(8.4, row.Position); + } + + [Fact] + public void ToPageRecord_matches_python_golden_values() + { + var row = GscRowMappers.ToPageRecord("https://example.com/blog/", 10, 500, 0.02, 8.37); + + Assert.Equal("https://example.com/blog/", row.Page); + Assert.Equal(10, row.Clicks); + Assert.Equal(500, row.Impressions); + Assert.Equal(2.0, row.Ctr); + Assert.Equal(8.4, row.Position); + } +} diff --git a/services/IntegrationsService/tests/IntegrationsService.Tests/IntegrationsService.Tests.csproj b/services/IntegrationsService/tests/IntegrationsService.Tests/IntegrationsService.Tests.csproj new file mode 100644 index 00000000..73b418bb --- /dev/null +++ b/services/IntegrationsService/tests/IntegrationsService.Tests/IntegrationsService.Tests.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + diff --git a/services/IntegrationsService/tests/IntegrationsService.Tests/PageLookupGoldenTests.cs b/services/IntegrationsService/tests/IntegrationsService.Tests/PageLookupGoldenTests.cs new file mode 100644 index 00000000..7da26b90 --- /dev/null +++ b/services/IntegrationsService/tests/IntegrationsService.Tests/PageLookupGoldenTests.cs @@ -0,0 +1,56 @@ +using System.Text.Json; +using IntegrationsService.Application.Repositories; + +namespace IntegrationsService.Tests; + +public sealed class PageLookupGoldenTests +{ + private const string GoldenBlob = """ + { + "fetched_at": "2026-06-20T10:00:00Z", + "date_range": { "start": "2026-05-23", "end": "2026-06-19" }, + "gsc_full": { + "summary": { "clicks": 100, "impressions": 5000 }, + "by_page": { + "https://example.com/page-a": { + "page": "https://example.com/page-a", + "clicks": 12, + "impressions": 300, + "ctr": 4.0, + "position": 5.2, + "queries": [{ "query": "test query", "clicks": 5, "impressions": 100, "ctr": 5.0, "position": 4.0 }] + } + } + }, + "ga4_full": { + "summary": { "sessions": 200 }, + "by_path": { + "/page-a": { + "path": "/page-a", + "full_url": "https://example.com/page-a", + "sessions": 50, + "users": 40, + "pageviews": 60 + } + } + }, + "url_join": { "matched": 1, "lists": { "crawl_only": [], "gsc_only": [], "ga4_only": [] } } + } + """; + + [Fact] + public void SliceFromGoogleRow_matches_golden_page_metrics() + { + using var doc = JsonDocument.Parse(GoldenBlob); + var slice = PageLookupService.SliceFromGoogleRow(doc.RootElement, "https://example.com/page-a"); + + Assert.Equal("snapshot", slice.Source); + var gsc = Assert.IsType>(slice.Gsc); + Assert.Equal(12, Convert.ToInt32(gsc["clicks"])); + Assert.Equal(300, Convert.ToInt32(gsc["impressions"])); + var ga4 = Assert.IsType>(slice.Ga4); + Assert.Equal(50, Convert.ToInt32(ga4["sessions"])); + Assert.True(slice.Coverage.InGsc); + Assert.True(slice.Coverage.InGa4); + } +} diff --git a/services/IntegrationsService/tests/IntegrationsService.Tests/PageMetricsCompareTests.cs b/services/IntegrationsService/tests/IntegrationsService.Tests/PageMetricsCompareTests.cs new file mode 100644 index 00000000..3d1bebc5 --- /dev/null +++ b/services/IntegrationsService/tests/IntegrationsService.Tests/PageMetricsCompareTests.cs @@ -0,0 +1,35 @@ +using IntegrationsService.Application.Google; + +namespace IntegrationsService.Tests; + +public sealed class PageMetricsCompareTests +{ + [Fact] + public void Build_computes_gsc_deltas() + { + var rows = PageMetricsCompare.Build( + new PageMetricsPayload + { + Gsc = new PageGscMetrics { Clicks = 120, Impressions = 1000, Ctr = 5, Position = 8.2 }, + }, + new PageMetricsPayload + { + Gsc = new PageGscMetrics { Clicks = 100, Impressions = 800, Ctr = 4, Position = 9.5 }, + }); + + var clicks = rows.First(r => r.Id == "gsc_clicks"); + Assert.Equal(20, clicks.Delta); + Assert.Equal(20, clicks.DeltaPct); + + var pos = rows.First(r => r.Id == "gsc_pos"); + Assert.False(pos.HigherIsBetter); + Assert.Equal(-1.3, pos.Delta); + } + + [Fact] + public void Build_omits_rows_when_both_sides_empty() + { + var rows = PageMetricsCompare.Build(new PageMetricsPayload(), new PageMetricsPayload()); + Assert.Empty(rows); + } +} diff --git a/services/IntegrationsService/tests/IntegrationsService.Tests/UrlJoinBuilderTests.cs b/services/IntegrationsService/tests/IntegrationsService.Tests/UrlJoinBuilderTests.cs new file mode 100644 index 00000000..38401a6d --- /dev/null +++ b/services/IntegrationsService/tests/IntegrationsService.Tests/UrlJoinBuilderTests.cs @@ -0,0 +1,54 @@ +using IntegrationsService.Application.Google; + +namespace IntegrationsService.Tests; + +public sealed class UrlJoinBuilderTests +{ + [Theory] + [InlineData("https://WWW.Example.com/page/", "example.com/page")] + [InlineData("https://example.com", "example.com/")] + [InlineData("https://example.com/blog/post/", "example.com/blog/post")] + public void NormalizeUrl_strips_scheme_www_and_trailing_slash(string input, string expected) + { + Assert.Equal(expected, UrlJoinBuilder.NormalizeUrl(input)); + } + + [Fact] + public void PathToUrl_uses_start_url_origin() + { + Assert.Equal( + "https://www.example.com/blog/post", + UrlJoinBuilder.PathToUrl("/blog/post", "https://www.example.com/")); + } + + [Fact] + public void ComputeUrlJoin_counts_gaps_and_caps_lists() + { + var result = UrlJoinBuilder.ComputeUrlJoin( + crawlUrls: ["https://example.com/a", "https://example.com/only-crawl"], + gscPages: ["https://example.com/a", "https://example.com/only-gsc"], + ga4Paths: ["/a", "/only-ga4"], + startUrl: "https://example.com", + gscByPage: new Dictionary + { + ["https://example.com/only-gsc"] = new() { Clicks = 1, Impressions = 50 }, + }, + ga4ByPath: new Dictionary + { + ["/only-ga4"] = new() { Sessions = 10 }, + }, + listLimit: 1); + + Assert.Equal(1, result.Matched); + Assert.Equal(1, result.CrawlOnly); + Assert.Equal(1, result.GscOnly); + Assert.Equal(1, result.Ga4Only); + Assert.Single(result.Lists.CrawlOnly); + Assert.Single(result.Lists.GscOnly); + Assert.Single(result.Lists.Ga4Only); + Assert.Equal(1, result.ListsTotal.CrawlOnly); + Assert.Equal(1, result.ListsTotal.GscOnly); + Assert.Equal(1, result.ListsTotal.Ga4Only); + Assert.Equal(1, result.ListLimit); + } +} diff --git a/services/Shared/TYPED_MODELS.md b/services/Shared/TYPED_MODELS.md new file mode 100644 index 00000000..81e579b9 --- /dev/null +++ b/services/Shared/TYPED_MODELS.md @@ -0,0 +1,26 @@ +# Typed models conventions (.NET services) + +Shared contracts live in `services/Shared/WebsiteProfiling.Contracts/`. + +## Rules + +1. **Public tool handler signature stays `Task`** (MCP + 369-tool catalog compatibility). +2. **Inside handlers:** parse args with `ToolArgsMapper` → typed logic → serialize with `ToolResultMapper`. +3. **Domain shapes** (`IssueRecord`, `CrawlRow`, `GoogleSlice`, …) belong in Contracts; service-specific API DTOs stay in each service's `Dto/` folder. +4. **JSON only at boundaries:** DB JSONB, LLM text, MCP wire, Python bridge (until removed). +5. **Do not model full `report_payload`** — use slice records (`ReportMetaSlice`, `IssuesBucketSlice`, `GoogleSlice`). + +## Per-service mappers + +| Service | Mapper location | +|---------|-----------------| +| AiService tools | `AiService.Tools/Mapping/` | +| Data | `Data.Application/Mapping/PayloadSliceMapper.cs` | +| IntegrationsService | `IntegrationsService.Application/Google/PageLookupMapper.cs` | +| FileService | `FileService.Application/Mapping/` (`AuditReportMapper`, `ChapterMappers`) | + +## Coercion + +Use `WebsiteProfiling.Contracts.Json.JsonCoercion` for safe scalar reads from `JsonNode` / `JsonElement`. + +Serialize/deserialize with `ContractJsonOptions.Options` (snake_case JSON matching DB/API payloads). diff --git a/services/Shared/WebsiteProfiling.Contracts/Chat/PersistedToolResultDto.cs b/services/Shared/WebsiteProfiling.Contracts/Chat/PersistedToolResultDto.cs new file mode 100644 index 00000000..7188fee0 --- /dev/null +++ b/services/Shared/WebsiteProfiling.Contracts/Chat/PersistedToolResultDto.cs @@ -0,0 +1,45 @@ +using System.Text.Json.Serialization; + +namespace WebsiteProfiling.Contracts.Chat; + +public sealed record PersistedToolResultDto +{ + [JsonPropertyName("narrative")] + public PersistedNarrativeDto? Narrative { get; init; } + + [JsonPropertyName("tool_events")] + public IReadOnlyList? ToolEvents { get; init; } + + [JsonPropertyName("agent_error")] + public string? AgentError { get; init; } +} + +public sealed record PersistedNarrativeDto +{ + [JsonPropertyName("power_insights")] + public IReadOnlyList PowerInsights { get; init; } = []; + + [JsonPropertyName("recommended_actions")] + public IReadOnlyList RecommendedActions { get; init; } = []; +} + +public sealed record PersistedToolEventDto +{ + [JsonPropertyName("name")] + public string Name { get; init; } = ""; + + [JsonPropertyName("args")] + public System.Text.Json.Nodes.JsonNode? Args { get; init; } + + [JsonPropertyName("result")] + public System.Text.Json.Nodes.JsonNode? Result { get; init; } +} + +public sealed record LlmNarrativeResponse +{ + [JsonPropertyName("power_insights")] + public IReadOnlyList PowerInsights { get; init; } = []; + + [JsonPropertyName("recommended_actions")] + public IReadOnlyList RecommendedActions { get; init; } = []; +} diff --git a/services/Shared/WebsiteProfiling.Contracts/Crawl/CrawlRow.cs b/services/Shared/WebsiteProfiling.Contracts/Crawl/CrawlRow.cs new file mode 100644 index 00000000..8103dcd9 --- /dev/null +++ b/services/Shared/WebsiteProfiling.Contracts/Crawl/CrawlRow.cs @@ -0,0 +1,35 @@ +using System.Text.Json.Serialization; + +namespace WebsiteProfiling.Contracts.Crawl; + +/// +/// One crawl result row (records-orient from crawl_results JSONB merged with url/fetch_method). +/// +public sealed record CrawlRow +{ + [JsonPropertyName("url")] + public string Url { get; init; } = ""; + + [JsonPropertyName("fetch_method")] + public string FetchMethod { get; init; } = ""; + + [JsonPropertyName("status")] + public string Status { get; init; } = ""; + + [JsonPropertyName("title")] + public string Title { get; init; } = ""; + + [JsonPropertyName("has_schema")] + public bool HasSchema { get; init; } + + [JsonPropertyName("schema_types")] + public IReadOnlyList SchemaTypes { get; init; } = []; + + /// Raw page_analysis object or double-encoded JSON string source. + [JsonPropertyName("page_analysis")] + public string? PageAnalysisJson { get; init; } + + /// Additional crawl data fields not modeled explicitly. + [JsonExtensionData] + public Dictionary? ExtensionData { get; init; } +} diff --git a/services/Shared/WebsiteProfiling.Contracts/Google/GoogleRecords.cs b/services/Shared/WebsiteProfiling.Contracts/Google/GoogleRecords.cs new file mode 100644 index 00000000..eca6448e --- /dev/null +++ b/services/Shared/WebsiteProfiling.Contracts/Google/GoogleRecords.cs @@ -0,0 +1,217 @@ +using System.Text.Json.Serialization; + +namespace WebsiteProfiling.Contracts.Google; + +public sealed record GscSummary +{ + [JsonPropertyName("clicks")] + public int Clicks { get; init; } + + [JsonPropertyName("impressions")] + public int Impressions { get; init; } + + [JsonPropertyName("ctr")] + public double Ctr { get; init; } + + [JsonPropertyName("position")] + public double Position { get; init; } +} + +public sealed record GscQueryRecord +{ + [JsonPropertyName("query")] + public string Query { get; init; } = ""; + + [JsonPropertyName("clicks")] + public int Clicks { get; init; } + + [JsonPropertyName("impressions")] + public int Impressions { get; init; } + + [JsonPropertyName("ctr")] + public double Ctr { get; init; } + + [JsonPropertyName("position")] + public double Position { get; init; } +} + +public sealed record GscPageRecord +{ + [JsonPropertyName("page")] + public string Page { get; init; } = ""; + + [JsonPropertyName("clicks")] + public int Clicks { get; init; } + + [JsonPropertyName("impressions")] + public int Impressions { get; init; } + + [JsonPropertyName("ctr")] + public double Ctr { get; init; } + + [JsonPropertyName("position")] + public double Position { get; init; } +} + +public sealed record GscPageDetail +{ + [JsonPropertyName("page")] + public string Page { get; init; } = ""; + + [JsonPropertyName("clicks")] + public int Clicks { get; init; } + + [JsonPropertyName("impressions")] + public int Impressions { get; init; } + + [JsonPropertyName("ctr")] + public double Ctr { get; init; } + + [JsonPropertyName("position")] + public double Position { get; init; } + + [JsonPropertyName("queries")] + public List Queries { get; init; } = []; +} + +public sealed record GscDailyRecord +{ + [JsonPropertyName("date")] + public string Date { get; init; } = ""; + + [JsonPropertyName("clicks")] + public int Clicks { get; init; } + + [JsonPropertyName("impressions")] + public int Impressions { get; init; } + + [JsonPropertyName("ctr")] + public double Ctr { get; init; } + + [JsonPropertyName("position")] + public double Position { get; init; } +} + +public sealed record Ga4Summary +{ + [JsonPropertyName("sessions")] + public int Sessions { get; init; } + + [JsonPropertyName("activeUsers")] + public int ActiveUsers { get; init; } + + [JsonPropertyName("screenPageViews")] + public int ScreenPageViews { get; init; } +} + +public sealed record Ga4PageRecord +{ + [JsonPropertyName("path")] + public string Path { get; init; } = ""; + + [JsonPropertyName("full_url")] + public string FullUrl { get; init; } = ""; + + [JsonPropertyName("sessions")] + public int Sessions { get; init; } + + [JsonPropertyName("activeUsers")] + public int ActiveUsers { get; init; } + + [JsonPropertyName("screenPageViews")] + public int ScreenPageViews { get; init; } + + [JsonPropertyName("engagementRate")] + public double EngagementRate { get; init; } + + [JsonPropertyName("avgSessionDuration")] + public double AvgSessionDuration { get; init; } +} + +public sealed record Ga4DailyRecord +{ + [JsonPropertyName("date")] + public string Date { get; init; } = ""; + + [JsonPropertyName("sessions")] + public int Sessions { get; init; } + + [JsonPropertyName("activeUsers")] + public int ActiveUsers { get; init; } + + [JsonPropertyName("screenPageViews")] + public int ScreenPageViews { get; init; } +} + +public sealed record Ga4ChannelRecord +{ + [JsonPropertyName("channel")] + public string Channel { get; init; } = ""; + + [JsonPropertyName("sessions")] + public int Sessions { get; init; } + + [JsonPropertyName("activeUsers")] + public int ActiveUsers { get; init; } + + [JsonPropertyName("screenPageViews")] + public int ScreenPageViews { get; init; } +} + +public sealed record Ga4DeviceRecord +{ + [JsonPropertyName("device")] + public string Device { get; init; } = ""; + + [JsonPropertyName("sessions")] + public int Sessions { get; init; } + + [JsonPropertyName("activeUsers")] + public int ActiveUsers { get; init; } + + [JsonPropertyName("screenPageViews")] + public int ScreenPageViews { get; init; } +} + +/// Subset of google snapshot used by insight and portfolio tools. +public sealed record GoogleSlice +{ + [JsonPropertyName("gsc")] + public GscBlob? Gsc { get; init; } + + [JsonPropertyName("ga4")] + public Ga4Blob? Ga4 { get; init; } + + [JsonPropertyName("fetched_at")] + public string? FetchedAt { get; init; } + + public sealed record GscBlob + { + [JsonPropertyName("summary")] + public GscSummary? Summary { get; init; } + + [JsonPropertyName("by_page")] + public Dictionary ByPage { get; init; } = new(StringComparer.Ordinal); + } + + public sealed record Ga4Blob + { + [JsonPropertyName("summary")] + public Ga4Summary? Summary { get; init; } + + [JsonPropertyName("by_path")] + public Dictionary ByPath { get; init; } = new(StringComparer.Ordinal); + } +} + +public sealed record ProvenanceBlock +{ + [JsonPropertyName("sources")] + public IReadOnlyList Sources { get; init; } = []; + + [JsonPropertyName("fetched_at")] + public string? FetchedAt { get; init; } + + [JsonPropertyName("confidence")] + public string Confidence { get; init; } = "high"; +} diff --git a/services/Shared/WebsiteProfiling.Contracts/Integrations/PageLookupRecords.cs b/services/Shared/WebsiteProfiling.Contracts/Integrations/PageLookupRecords.cs new file mode 100644 index 00000000..472ac57c --- /dev/null +++ b/services/Shared/WebsiteProfiling.Contracts/Integrations/PageLookupRecords.cs @@ -0,0 +1,63 @@ +using System.Text.Json.Serialization; + +namespace WebsiteProfiling.Contracts.Integrations; + +public sealed record PageMetricsRow +{ + [JsonPropertyName("url")] + public string Url { get; init; } = ""; + + [JsonPropertyName("path")] + public string Path { get; init; } = ""; + + [JsonPropertyName("clicks")] + public int Clicks { get; init; } + + [JsonPropertyName("impressions")] + public int Impressions { get; init; } + + [JsonPropertyName("ctr")] + public double Ctr { get; init; } + + [JsonPropertyName("position")] + public double Position { get; init; } + + [JsonPropertyName("sessions")] + public int Sessions { get; init; } + + [JsonPropertyName("active_users")] + public int ActiveUsers { get; init; } + + [JsonPropertyName("screen_page_views")] + public int ScreenPageViews { get; init; } +} + +public sealed record PageLookupResult +{ + [JsonPropertyName("url")] + public string Url { get; init; } = ""; + + [JsonPropertyName("found")] + public bool Found { get; init; } + + [JsonPropertyName("gsc")] + public PageMetricsRow? Gsc { get; init; } + + [JsonPropertyName("ga4")] + public PageMetricsRow? Ga4 { get; init; } + + [JsonPropertyName("note")] + public string? Note { get; init; } +} + +public sealed record BingBacklinksSummary +{ + [JsonPropertyName("total_backlinks")] + public int TotalBacklinks { get; init; } + + [JsonPropertyName("referring_domains")] + public int ReferringDomains { get; init; } + + [JsonPropertyName("fetched_at")] + public string? FetchedAt { get; init; } +} diff --git a/services/Shared/WebsiteProfiling.Contracts/Json/ContractJsonOptions.cs b/services/Shared/WebsiteProfiling.Contracts/Json/ContractJsonOptions.cs new file mode 100644 index 00000000..e4aa1ecf --- /dev/null +++ b/services/Shared/WebsiteProfiling.Contracts/Json/ContractJsonOptions.cs @@ -0,0 +1,22 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace WebsiteProfiling.Contracts.Json; + +/// Shared for DB/API payloads (snake_case keys). +public static class ContractJsonOptions +{ + public static JsonSerializerOptions Options { get; } = Create(); + + public static JsonSerializerOptions Create() + { + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = false, + }; + options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower)); + return options; + } +} diff --git a/services/Shared/WebsiteProfiling.Contracts/Json/JsonCoercion.cs b/services/Shared/WebsiteProfiling.Contracts/Json/JsonCoercion.cs new file mode 100644 index 00000000..ebd7527c --- /dev/null +++ b/services/Shared/WebsiteProfiling.Contracts/Json/JsonCoercion.cs @@ -0,0 +1,101 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace WebsiteProfiling.Contracts.Json; + +/// +/// Safe scalar extraction from JSON values. Tool results and report payloads can carry +/// unexpected types (e.g. a string where a number is expected). These helpers return +/// defaults on mismatch instead of throwing. +/// +public static class JsonCoercion +{ + /// Returns the value as a string only when the node is a JSON string; otherwise null. + public static string? AsString(JsonNode? node) + => node is JsonValue value && value.TryGetValue(out var s) ? s : null; + + /// Returns the value as a double when the node is a JSON number; otherwise null. + public static double? AsDouble(JsonNode? node) + => node is JsonValue value && value.TryGetValue(out var d) ? d : null; + + /// Returns the value as an int when the node is a JSON number (rounding floats); otherwise null. + public static int? AsInt(JsonNode? node) + => AsDouble(node) is { } d ? (int)Math.Round(d) : null; + + /// Coerce a JSON scalar to a double, mirroring Python float(val) with a default. + public static double Num(JsonNode? node, double @default = 0.0) + { + if (node is not JsonValue value) + { + return @default; + } + + if (value.TryGetValue(out var d)) + { + return d; + } + + if (value.TryGetValue(out var s) + && double.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out var parsed)) + { + return parsed; + } + + return @default; + } + + public static string? GetString(JsonElement el, string name) + { + if (!el.TryGetProperty(name, out var prop)) + { + return null; + } + + return prop.ValueKind switch + { + JsonValueKind.String => prop.GetString(), + JsonValueKind.Number => prop.TryGetInt64(out var l) + ? l.ToString(CultureInfo.InvariantCulture) + : prop.GetDouble().ToString(CultureInfo.InvariantCulture), + _ => null, + }; + } + + public static int? GetInt(JsonElement el, string name) + { + if (!el.TryGetProperty(name, out var prop) || prop.ValueKind != JsonValueKind.Number) + { + return null; + } + + return (int)Math.Round(prop.GetDouble()); + } + + public static double? GetDouble(JsonElement el, string name) + { + if (!el.TryGetProperty(name, out var prop) || prop.ValueKind != JsonValueKind.Number) + { + return null; + } + + return prop.GetDouble(); + } + + public static bool GetBool(JsonElement el, string name, bool @default = false) + { + if (!el.TryGetProperty(name, out var prop)) + { + return @default; + } + + return prop.ValueKind switch + { + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.String => prop.GetString()?.ToLowerInvariant() is "true" or "1" or "yes", + JsonValueKind.Number => prop.TryGetInt32(out var i) && i != 0, + _ => @default, + }; + } +} diff --git a/services/Shared/WebsiteProfiling.Contracts/Report/CategoryScoreRecord.cs b/services/Shared/WebsiteProfiling.Contracts/Report/CategoryScoreRecord.cs new file mode 100644 index 00000000..b6008f11 --- /dev/null +++ b/services/Shared/WebsiteProfiling.Contracts/Report/CategoryScoreRecord.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace WebsiteProfiling.Contracts.Report; + +public sealed record CategoryScoreRecord +{ + [JsonPropertyName("name")] + public string Name { get; init; } = ""; + + [JsonPropertyName("score")] + public int? Score { get; init; } + + [JsonPropertyName("issue_count")] + public int IssueCount { get; init; } +} diff --git a/services/Shared/WebsiteProfiling.Contracts/Report/CrawlPreviewDto.cs b/services/Shared/WebsiteProfiling.Contracts/Report/CrawlPreviewDto.cs new file mode 100644 index 00000000..7075a7f2 --- /dev/null +++ b/services/Shared/WebsiteProfiling.Contracts/Report/CrawlPreviewDto.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; +using WebsiteProfiling.Contracts.Crawl; + +namespace WebsiteProfiling.Contracts.Report; + +public sealed record CrawlPreviewDto +{ + [JsonPropertyName("id")] + public long Id { get; init; } + + [JsonPropertyName("pages")] + public IReadOnlyList Pages { get; init; } = []; + + [JsonPropertyName("total")] + public int Total { get; init; } +} diff --git a/services/Shared/WebsiteProfiling.Contracts/Report/IssueRecord.cs b/services/Shared/WebsiteProfiling.Contracts/Report/IssueRecord.cs new file mode 100644 index 00000000..3fc8e85b --- /dev/null +++ b/services/Shared/WebsiteProfiling.Contracts/Report/IssueRecord.cs @@ -0,0 +1,36 @@ +using System.Text.Json.Serialization; + +namespace WebsiteProfiling.Contracts.Report; + +public sealed record IssueRecord +{ + [JsonPropertyName("category")] + public string Category { get; init; } = ""; + + [JsonPropertyName("priority")] + public string Priority { get; init; } = ""; + + [JsonPropertyName("message")] + public string Message { get; init; } = ""; + + [JsonPropertyName("headline")] + public string Headline { get; init; } = ""; + + [JsonPropertyName("url")] + public string Url { get; init; } = ""; + + [JsonPropertyName("url_path")] + public string UrlPath { get; init; } = ""; + + [JsonPropertyName("recommendation")] + public string Recommendation { get; init; } = ""; + + [JsonPropertyName("gsc_clicks")] + public int? GscClicks { get; init; } + + [JsonPropertyName("gsc_impressions")] + public int? GscImpressions { get; init; } + + [JsonPropertyName("impact_score")] + public int? ImpactScore { get; init; } +} diff --git a/services/Shared/WebsiteProfiling.Contracts/Report/IssuesBucketSlice.cs b/services/Shared/WebsiteProfiling.Contracts/Report/IssuesBucketSlice.cs new file mode 100644 index 00000000..933c0dee --- /dev/null +++ b/services/Shared/WebsiteProfiling.Contracts/Report/IssuesBucketSlice.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; + +namespace WebsiteProfiling.Contracts.Report; + +public sealed record IssuesBucketSlice +{ + [JsonPropertyName("critical")] + public IReadOnlyList Critical { get; init; } = []; + + [JsonPropertyName("high")] + public IReadOnlyList High { get; init; } = []; + + [JsonPropertyName("medium")] + public IReadOnlyList Medium { get; init; } = []; + + [JsonPropertyName("low")] + public IReadOnlyList Low { get; init; } = []; + + public IEnumerable AllIssues() + => Critical.Concat(High).Concat(Medium).Concat(Low); +} diff --git a/services/Shared/WebsiteProfiling.Contracts/Report/ReportMetaSlice.cs b/services/Shared/WebsiteProfiling.Contracts/Report/ReportMetaSlice.cs new file mode 100644 index 00000000..ff64120c --- /dev/null +++ b/services/Shared/WebsiteProfiling.Contracts/Report/ReportMetaSlice.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; + +namespace WebsiteProfiling.Contracts.Report; + +public sealed record ReportMetaSlice +{ + [JsonPropertyName("crawl_run_id")] + public int? CrawlRunId { get; init; } + + [JsonPropertyName("generated_at")] + public string? GeneratedAt { get; init; } + + [JsonPropertyName("data_sources")] + public IReadOnlyList DataSources { get; init; } = []; + + [JsonPropertyName("site_name")] + public string? SiteName { get; init; } + + [JsonPropertyName("report_generated_at")] + public string? ReportGeneratedAt { get; init; } +} diff --git a/services/Shared/WebsiteProfiling.Contracts/WebsiteProfiling.Contracts.csproj b/services/Shared/WebsiteProfiling.Contracts/WebsiteProfiling.Contracts.csproj new file mode 100644 index 00000000..51404264 --- /dev/null +++ b/services/Shared/WebsiteProfiling.Contracts/WebsiteProfiling.Contracts.csproj @@ -0,0 +1,10 @@ + + + + net10.0 + enable + enable + WebsiteProfiling.Contracts + + + diff --git a/src/website_profiling/api/main.py b/src/website_profiling/api/main.py index 48f97d2e..ac596136 100644 --- a/src/website_profiling/api/main.py +++ b/src/website_profiling/api/main.py @@ -10,7 +10,6 @@ from .routers import ( alerts, - chat, compare, config, content, @@ -18,12 +17,9 @@ dashboards, health, integrations, - issues, + internal_integrations, keywords, logs, - mcp_tools, - ollama, - page_coach, page_markdown, pipeline, properties, @@ -36,7 +32,6 @@ @asynccontextmanager async def _lifespan(app: FastAPI) -> AsyncIterator[None]: yield - # Close the psycopg connection pool on shutdown. try: from website_profiling.db.pool import close_db_pool @@ -52,7 +47,6 @@ async def _lifespan(app: FastAPI) -> AsyncIterator[None]: lifespan=_lifespan, ) -# CORS — only added when FASTAPI_ALLOWED_ORIGINS is set (local Swagger in dev). _origins_raw = os.getenv("FASTAPI_ALLOWED_ORIGINS", "").strip() if _origins_raw: _origins = [o.strip() for o in _origins_raw.split(",") if o.strip()] @@ -69,41 +63,31 @@ async def _lifespan(app: FastAPI) -> AsyncIterator[None]: allow_headers=["*"], ) -# ── Core routes ─────────────────────────────────────────────────────────────── +# Core + crawl/pipeline app.include_router(health.router, prefix="/api") app.include_router(report.router, prefix="/api") - -# ── Batch B: Pipeline jobs ──────────────────────────────────────────────────── app.include_router(pipeline.router, prefix="/api") - -# ── Batch C: Chat (SSE + sessions) ─────────────────────────────────────────── -app.include_router(chat.router, prefix="/api") - -# ── Batch D: Crawl ─────────────────────────────────────────────────────────── app.include_router(crawl.router, prefix="/api") -# ── Batch E: Config (pipeline, LLM, secrets, app-settings) ─────────────────── +# Config: pipeline-config + app-settings only (secrets/llm-config → AiService via BFF) app.include_router(config.router, prefix="/api") -# ── Batch F: Properties ────────────────────────────────────────────────────── app.include_router(properties.router, prefix="/api") - -# ── Batch G: Dashboards ────────────────────────────────────────────────────── app.include_router(dashboards.router, prefix="/api") - -# ── Batch H: Google + Bing integrations ────────────────────────────────────── app.include_router(integrations.router, prefix="/api") - -# ── Batch I: Issues, keywords, content, page markdown, long-tail ───────────── -app.include_router(issues.router, prefix="/api") +app.include_router(internal_integrations.router) app.include_router(keywords.router, prefix="/api") app.include_router(content.router, prefix="/api") app.include_router(page_markdown.router, prefix="/api") -app.include_router(ollama.router, prefix="/api") -app.include_router(mcp_tools.router, prefix="/api") app.include_router(alerts.router, prefix="/api") app.include_router(schedule.router, prefix="/api") app.include_router(logs.router, prefix="/api") app.include_router(compare.router, prefix="/api") -app.include_router(page_coach.router, prefix="/api") + +# Audit tool dispatch — internal bridge for AiService unported tools app.include_router(report_audit_tool.router, prefix="/api") + +# AI + secrets routes removed — served by services/AiService (.NET) via BFF: +# chat, issues/fix-suggestion, issues/action-plan, ai/fix-suggestion, +# dashboards/ai-generate, links/page-coach, llm-config, secrets, ollama/status, mcp-tools +# App-level Google credential writes (POST integrations/google/credentials*) → use /api/secrets (AiService) diff --git a/src/website_profiling/api/routers/config.py b/src/website_profiling/api/routers/config.py index 70c08f5a..4b232cd1 100644 --- a/src/website_profiling/api/routers/config.py +++ b/src/website_profiling/api/routers/config.py @@ -1,4 +1,7 @@ -"""Config routes: pipeline-config, llm-config, secrets, app-settings.""" +"""Config routes: pipeline-config and app-settings. + +Secrets, llm-config, and app-level Google credential writes are served by AiService via BFF. +""" from __future__ import annotations from typing import Annotated, Any, Optional @@ -11,44 +14,12 @@ router = APIRouter(tags=["config"]) -_MASK = "*" - # --------------------------------------------------------------------------- # helpers # --------------------------------------------------------------------------- -def _mask_secrets(data: dict[str, Any]) -> dict[str, Any]: - """Return a copy of *data* with secret-ish values replaced by ``'*'``.""" - masked: dict[str, Any] = {} - for k, v in data.items(): - val_str = str(v) if v is not None else "" - if val_str and (_is_secret_key(k)): - masked[k] = _MASK - else: - masked[k] = v - return masked - - -def _is_secret_key(key: str) -> bool: - key_lower = key.lower() - return ( - key_lower.endswith("_secret") - or key_lower.endswith("_api_key") - or key_lower.endswith("_key") - or "api_key" in key_lower - or "secret" in key_lower - or "password" in key_lower - or "token" in key_lower - ) - - -def _read_llm_config_full(conn: Connection) -> list[dict[str, Any]]: - from website_profiling.db.config_store import read_llm_config_full - return read_llm_config_full(conn) - - def _read_app_setting(conn: Connection, key: str) -> Optional[str]: from website_profiling.db.config_store import read_app_setting return read_app_setting(conn, key) @@ -90,144 +61,6 @@ def put_pipeline_config( return {"ok": True, "source": "db"} -# --------------------------------------------------------------------------- -# llm-config -# --------------------------------------------------------------------------- - - -@router.get("/llm-config") -def get_llm_config(conn: Annotated[Connection, Depends(get_db)]) -> dict[str, Any]: - rows = _read_llm_config_full(conn) - state: dict[str, Any] = {} - for row in rows: - k = str(row["key"]) - v = str(row["value"]) - is_secret = bool(row.get("is_secret")) - state[k] = _MASK if (is_secret and v) else v - return {"state": state, "source": "db"} - - -class LlmConfigBody(BaseModel): - state: dict[str, Any] - - -@router.put("/llm-config") -def put_llm_config( - body: LlmConfigBody, - conn: Annotated[Connection, Depends(get_db)], -) -> dict[str, Any]: - from website_profiling.db.config_store import write_llm_config - - # Preserve existing secret values when client sends "*" (masked sentinel) - existing_rows = _read_llm_config_full(conn) - existing: dict[str, str] = {str(r["key"]): str(r["value"]) for r in existing_rows} - existing_secrets: set[str] = {str(r["key"]) for r in existing_rows if r.get("is_secret")} - - entries: dict[str, str] = {} - secret_keys: set[str] = set() - - for k, v in body.state.items(): - val = str(v) if v is not None else "" - is_masked_sentinel = val.strip() in (_MASK, "••••") or ( - val.strip().startswith("*") and len(val.strip()) <= 4 - ) - if is_masked_sentinel and k in existing: - # Keep original value - entries[k] = existing[k] - else: - entries[k] = val - - if k in existing_secrets or _is_secret_key(k): - secret_keys.add(k) - - write_llm_config(conn, entries, secret_keys) - return {"ok": True} - - -# --------------------------------------------------------------------------- -# secrets -# --------------------------------------------------------------------------- - - -@router.get("/secrets") -def get_secrets(conn: Annotated[Connection, Depends(get_db)]) -> dict[str, Any]: - from website_profiling.db.google_app_store import read_google_app_settings - - llm_rows = _read_llm_config_full(conn) - state: dict[str, Any] = {} - for row in llm_rows: - k = str(row["key"]) - v = str(row["value"]) - is_secret = bool(row.get("is_secret")) or _is_secret_key(k) - if is_secret and v: - state[k] = _MASK - state[f"{k}_masked"] = True - elif v: - state[k] = v - - google = read_google_app_settings(conn) - for field in ("client_id", "client_secret", "developer_token", "login_customer_id"): - raw = str(google.get(field) or "") - if raw: - state[f"google_{field}"] = _MASK if _is_secret_key(field) else raw - if _is_secret_key(field): - state[f"google_{field}_masked"] = True - state["google_has_service_account"] = bool(google.get("service_account_json")) - - return {"state": state, "source": "db"} - - -class SecretsBody(BaseModel): - state: dict[str, Any] - - -@router.put("/secrets") -def put_secrets( - body: SecretsBody, - conn: Annotated[Connection, Depends(get_db)], -) -> dict[str, Any]: - from website_profiling.db.config_store import read_llm_config, write_llm_config - from website_profiling.db.google_app_store import read_google_app_settings, save_google_app_settings - - existing_llm = read_llm_config(conn) - existing_rows = _read_llm_config_full(conn) - existing_secrets_set: set[str] = {str(r["key"]) for r in existing_rows if r.get("is_secret")} - - llm_updates: dict[str, str] = dict(existing_llm) - llm_secret_keys: set[str] = set(existing_secrets_set) - google_patch: dict[str, Any] = {} - - for k, v in body.state.items(): - if k.endswith("_masked") or k == "google_has_service_account": - continue - - val = str(v) if v is not None else "" - is_masked_sentinel = val.strip() in (_MASK, "••••") or ( - val.strip().startswith("*") and len(val.strip()) <= 4 - ) - - if k.startswith("google_"): - field = k[len("google_"):] - if field in ("client_id", "client_secret", "developer_token", "login_customer_id"): - if not is_masked_sentinel: - google_patch[field] = val - else: - if is_masked_sentinel: - # Preserve existing - pass - else: - llm_updates[k] = val - if _is_secret_key(k): - llm_secret_keys.add(k) - - write_llm_config(conn, llm_updates, llm_secret_keys) - - if google_patch: - save_google_app_settings(conn, google_patch) - - return {"ok": True} - - # --------------------------------------------------------------------------- # app-settings # --------------------------------------------------------------------------- diff --git a/src/website_profiling/api/routers/integrations.py b/src/website_profiling/api/routers/integrations.py index 13bc2531..99d3f5f8 100644 --- a/src/website_profiling/api/routers/integrations.py +++ b/src/website_profiling/api/routers/integrations.py @@ -2,6 +2,7 @@ from __future__ import annotations import json +import os import sys from typing import Annotated, Any, Optional @@ -14,6 +15,17 @@ DbDep = Annotated[Connection, Depends(get_db)] +_MIGRATED_DETAIL = ( + "This endpoint moved to IntegrationsService. " + "Configure INTEGRATIONS_ROUTES on the BFF to proxy /api/integrations/* and /api/properties/*/google." +) + + +def _raise_if_migrated() -> None: + if os.environ.get("DEPRECATE_PYTHON_INTEGRATIONS", "").strip() == "1": + raise HTTPException(status_code=410, detail=_MIGRATED_DETAIL) + + # ── Helpers ──────────────────────────────────────────────────────────────────── def _google_public_status(conn: Connection) -> dict[str, Any]: @@ -60,6 +72,7 @@ def get_google_credentials(conn: DbDep) -> dict[str, Any]: @router.get("/google/status") def google_status(conn: DbDep) -> dict[str, Any]: + _raise_if_migrated() from website_profiling.integrations.google.store import read_last_google_fetched_at status = _google_public_status(conn) @@ -67,82 +80,13 @@ def google_status(conn: DbDep) -> dict[str, Any]: return status -# ── POST /api/integrations/google/credentials ───────────────────────────────── - -@router.post("/google/credentials") -def save_google_credentials( - conn: DbDep, - body: dict[str, Any] = Body(default={}), -) -> dict[str, Any]: - _PROPERTY_ONLY_MSG = ( - "Per-site settings (GSC, GA4, refresh token) must be saved via property " - "Integrations when a Site URL is set." - ) - if any(k in body for k in ("refreshToken", "gscSiteUrl", "ga4PropertyId")): - raise HTTPException(status_code=400, detail=_PROPERTY_ONLY_MSG) - - from website_profiling.db.google_app_store import save_google_app_settings - - patch: dict[str, Any] = {} - if isinstance(body.get("clientId"), str) and body["clientId"].strip(): - patch["client_id"] = body["clientId"].strip() - if isinstance(body.get("clientSecret"), str) and body["clientSecret"].strip(): - patch["client_secret"] = body["clientSecret"].strip() - if isinstance(body.get("dateRangeDays"), (int, float)) and body["dateRangeDays"] > 0: - patch["default_date_range_days"] = int(body["dateRangeDays"]) - if isinstance(body.get("developerToken"), str) and body["developerToken"].strip(): - patch["developer_token"] = body["developerToken"].strip() - if isinstance(body.get("loginCustomerId"), str) and body["loginCustomerId"].strip(): - patch["login_customer_id"] = body["loginCustomerId"].strip().replace("-", "") - - if not patch: - raise HTTPException(status_code=400, detail="No valid fields provided") - - save_google_app_settings(conn, patch) - return {"ok": True, "status": _google_public_status(conn)} - - -# ── POST /api/integrations/google/credentials/upload ────────────────────────── - -@router.post("/google/credentials/upload") -def upload_google_credentials( - conn: DbDep, - body: dict[str, Any] = Body(default={}), -) -> dict[str, Any]: - from website_profiling.db.google_app_store import save_google_app_settings - - raw = body.get("fileContent") - if not raw or not isinstance(raw, str): - raise HTTPException(status_code=400, detail="fileContent is required") - - try: - parsed = json.loads(raw) - except Exception: - raise HTTPException(status_code=400, detail="This doesn't look like a valid JSON file.") - - if ( - not isinstance(parsed, dict) - or parsed.get("type") != "service_account" - or not isinstance(parsed.get("client_email"), str) - or not isinstance(parsed.get("private_key"), str) - ): - raise HTTPException( - status_code=400, - detail=( - "This doesn't look like a Google service account key file. " - "Make sure you downloaded the JSON key from Google Cloud Console > " - "IAM & Admin > Service Accounts." - ), - ) - - save_google_app_settings(conn, {"service_account_json": parsed}) - return {"ok": True, "status": _google_public_status(conn)} - +# App-level Google credential writes (OAuth client, service account) → AiService PUT /api/secrets via BFF. # ── POST /api/integrations/google/disconnect ────────────────────────────────── @router.post("/google/disconnect") def google_disconnect(conn: DbDep) -> dict[str, Any]: + _raise_if_migrated() """Global disconnect is deprecated — use per-property disconnect.""" return { "ok": False, @@ -165,6 +109,7 @@ def google_oauth_start( startUrl: Optional[str] = Query(default=None), returnTo: Optional[str] = Query(default=None), ) -> Any: + _raise_if_migrated() from fastapi.responses import RedirectResponse from website_profiling.integrations.google.oauth import OAuthError, oauth_start @@ -182,6 +127,7 @@ def google_oauth_callback( state: Optional[str] = Query(default=None), error: Optional[str] = Query(default=None), ) -> Any: + _raise_if_migrated() from fastapi.responses import RedirectResponse from website_profiling.integrations.google.oauth import oauth_callback @@ -195,6 +141,7 @@ def google_oauth_callback( def google_properties_deprecated( property_id: Optional[int] = Query(None, alias="propertyId"), ) -> dict[str, Any]: + _raise_if_migrated() """Deprecated — use /api/properties/{id}/google/properties.""" if not property_id: raise HTTPException( @@ -211,6 +158,7 @@ def google_properties_deprecated( @router.post("/google/test") def google_test() -> dict[str, Any]: + _raise_if_migrated() """Run `python -m src google --test` and return stdout log.""" import subprocess import sys @@ -240,6 +188,7 @@ def google_page_data( propertyId: Optional[str] = Query(None), domain: Optional[str] = Query(None), ) -> dict[str, Any]: + _raise_if_migrated() from website_profiling.db.property_store import resolve_property_id_for_page from website_profiling.integrations.google.page_lookup import slice_from_google_row from website_profiling.integrations.google.store import read_google_snapshot_row @@ -288,6 +237,7 @@ def google_page_data_history( propertyId: Optional[str] = Query(None), domain: Optional[str] = Query(None), ) -> dict[str, Any]: + _raise_if_migrated() from website_profiling.db.property_store import resolve_property_id_for_page from website_profiling.integrations.google.page_lookup import ( slice_from_google_row, @@ -325,6 +275,7 @@ def google_page_data_history( def google_page_live( body: dict[str, Any] = Body(default={}), ) -> dict[str, Any]: + _raise_if_migrated() url = str(body.get("url") or "").strip() if not url: raise HTTPException(status_code=400, detail="url is required") @@ -365,6 +316,7 @@ def google_keywords_by_page( propertyId: Optional[str] = Query(None), domain: Optional[str] = Query(None), ) -> dict[str, Any]: + _raise_if_migrated() from website_profiling.db.property_store import resolve_property_id_for_page from website_profiling.integrations.google.keyword_store import read_latest_keyword_data @@ -419,6 +371,7 @@ def google_keywords_history( domain: Optional[str] = Query(None), limit: int = Query(30, ge=1, le=90), ) -> dict[str, Any]: + _raise_if_migrated() from website_profiling.db.property_store import resolve_property_id_for_page from website_profiling.integrations.google.keyword_store import read_keyword_history @@ -438,6 +391,7 @@ def google_keywords_history( @router.post("/bing/sync") def bing_sync(conn: DbDep) -> dict[str, Any]: + _raise_if_migrated() """Fetch Bing Webmaster backlinks summary using config from DB.""" from website_profiling.db.config_store import read_pipeline_config @@ -475,6 +429,7 @@ def google_page_compare( baselineType: str = Query("snapshot"), baselineId: int = Query(...), ) -> dict[str, Any]: + _raise_if_migrated() """Compare two page Google data snapshots.""" from website_profiling.integrations.google.page_snapshot_store import read_page_snapshot_compare @@ -495,6 +450,7 @@ def google_page_live_history( url: str = Query(...), limit: int = Query(15, ge=1, le=50), ) -> dict[str, Any]: + _raise_if_migrated() """Return history of page Google snapshots for a URL.""" from website_profiling.integrations.google.page_snapshot_store import list_page_snapshot_api_history @@ -512,6 +468,7 @@ def google_keywords_history_batch( conn: DbDep, body: dict[str, Any], ) -> dict[str, Any]: + _raise_if_migrated() """Batch keyword history: { keywords: str[], limit?: int, propertyId?: int, domain?: str }""" from website_profiling.db.property_store import get_property_id_by_domain from website_profiling.integrations.google.keyword_store import read_keyword_history_batch diff --git a/src/website_profiling/api/routers/internal_integrations.py b/src/website_profiling/api/routers/internal_integrations.py new file mode 100644 index 00000000..443c2490 --- /dev/null +++ b/src/website_profiling/api/routers/internal_integrations.py @@ -0,0 +1,93 @@ +"""Internal-only bridges for IntegrationsService when it has no Python runtime (Docker).""" +from __future__ import annotations + +import os +import subprocess +import sys +from typing import Annotated, Any + +from fastapi import APIRouter, Body, Depends, HTTPException +from psycopg import Connection + +from ..deps import get_db + +router = APIRouter(prefix="/internal/integrations", tags=["internal-integrations"]) + +DbDep = Annotated[Connection, Depends(get_db)] + + +@router.post("/keywords/enrich") +def internal_keyword_enrich(body: dict[str, Any]) -> dict[str, Any]: + property_id = body.get("propertyId") + try: + pid = int(property_id) + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail="propertyId is required") from None + if pid <= 0: + raise HTTPException(status_code=400, detail="propertyId is required") + + env = os.environ.copy() + env["WP_PROPERTY_ID"] = str(pid) + try: + result = subprocess.run( + [sys.executable, "-m", "src", "keywords", "--enrich-google"], + capture_output=True, + text=True, + timeout=120, + env=env, + cwd=os.environ.get("WEBSITE_PROFILING_ROOT") or None, + ) + except subprocess.TimeoutExpired: + return {"ok": False, "exitCode": -1, "log": "Keyword enrich timed out after 120s", "propertyId": pid} + + combined = result.stdout + result.stderr + log = combined[-28_000:] + return { + "ok": result.returncode == 0, + "exitCode": result.returncode, + "log": log, + "propertyId": pid, + } + + +@router.post("/gsc-links/import") +def internal_gsc_links_import(body: dict[str, Any] = Body(...), conn: DbDep = ...) -> dict[str, Any]: + property_id = body.get("propertyId") + file_content = str(body.get("fileContent") or "").strip() + file_name = str(body.get("fileName") or "") + try: + pid = int(property_id) + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail="propertyId is required") from None + if pid <= 0: + raise HTTPException(status_code=400, detail="propertyId is required") + if not file_content: + raise HTTPException(status_code=400, detail="fileContent is required") + + from website_profiling.db import get_latest_crawl_run_id, read_crawl + from website_profiling.db.property_store import get_property_by_id + from website_profiling.integrations.google.gsc_links_store import import_gsc_links_csv + + if not get_property_by_id(conn, pid): + raise HTTPException(status_code=404, detail="Property not found") + + crawl_urls: list[str] = [] + try: + run_id = get_latest_crawl_run_id(conn) + if run_id is not None: + df = read_crawl(conn, run_id) + if "url" in df.columns: + crawl_urls = df["url"].dropna().astype(str).str.strip().tolist() + except Exception: + pass + + try: + return import_gsc_links_csv( + conn, + pid, + file_content, + crawl_urls=crawl_urls, + file_name=file_name, + ) + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc diff --git a/src/website_profiling/api/routers/pipeline.py b/src/website_profiling/api/routers/pipeline.py index bd30d04d..1de49248 100644 --- a/src/website_profiling/api/routers/pipeline.py +++ b/src/website_profiling/api/routers/pipeline.py @@ -18,7 +18,6 @@ ResumeResponse, RunPostBody, RunResponse, - coerce_llm_state, coerce_pipeline_state, validate_pipeline_run, ) @@ -52,8 +51,6 @@ def _get_pipeline_jobs_db(conn: Connection): def run_pipeline(body: RunPostBody, conn: DbDep) -> dict[str, Any]: from website_profiling.db.config_store import ( read_pipeline_config, - read_llm_config, - write_llm_config, write_pipeline_config, ) from website_profiling.db.pipeline_jobs import enqueue_job, reconcile_stale_jobs @@ -102,15 +99,9 @@ def run_pipeline(body: RunPostBody, conn: DbDep) -> dict[str, Any]: hostname = urlparse(start_url).hostname or "" if hostname: try: - from website_profiling.db.property_store import ( - canonical_domain_from_start_url, - upsert_property_by_domain, - ) - domain = canonical_domain_from_start_url(start_url) - if domain: - property_id = upsert_property_by_domain( - conn, domain, domain, start_url - ) + from website_profiling.db.property_store import ensure_property_from_start_url + + property_id = ensure_property_from_start_url(conn, start_url) or property_id except Exception: pass state["active_property_id"] = str(property_id or "") @@ -127,15 +118,6 @@ def run_pipeline(body: RunPostBody, conn: DbDep) -> dict[str, Any]: except Exception as exc: raise HTTPException(status_code=500, detail=f"Failed to save config: {exc}") - # Save LLM config if provided - if body.llmState and isinstance(body.llmState, dict): - llm_coerced = coerce_llm_state(body.llmState) - str_llm = {k: str(v) for k, v in llm_coerced.items() if not str(k).endswith("_masked")} - try: - write_llm_config(conn, str_llm) - except Exception as exc: - raise HTTPException(status_code=500, detail=f"Failed to save LLM config: {exc}") - # Enqueue job job_id = str(uuid.uuid4()) try: diff --git a/src/website_profiling/api/routers/properties.py b/src/website_profiling/api/routers/properties.py index 43f68197..03c2a18c 100644 --- a/src/website_profiling/api/routers/properties.py +++ b/src/website_profiling/api/routers/properties.py @@ -1,6 +1,7 @@ """Properties router — /api/properties/*""" from __future__ import annotations +import os from typing import Annotated, Any, Optional from fastapi import APIRouter, Depends, HTTPException, Query @@ -13,6 +14,16 @@ DbDep = Annotated[Connection, Depends(get_db)] +_GOOGLE_MIGRATED_DETAIL = ( + "Property Google endpoints moved to IntegrationsService. " + "Configure INTEGRATIONS_ROUTES on the BFF." +) + + +def _raise_if_google_migrated() -> None: + if os.environ.get("DEPRECATE_PYTHON_INTEGRATIONS", "").strip() == "1": + raise HTTPException(status_code=410, detail=_GOOGLE_MIGRATED_DETAIL) + class PropertyUpsertBody(BaseModel): name: Optional[str] = None @@ -20,6 +31,10 @@ class PropertyUpsertBody(BaseModel): site_url: Optional[str] = None +class PropertyEnsureBody(BaseModel): + startUrl: Optional[str] = None + + class OpsSettingsBody(BaseModel): scheduleCron: Optional[str] = None alertWebhookUrl: Optional[str] = None @@ -66,6 +81,32 @@ def create_property(body: PropertyUpsertBody, conn: DbDep) -> dict[str, Any]: return {"id": prop_id, "name": name, "canonical_domain": domain} +@router.post("/properties/ensure", status_code=200) +def ensure_property(body: PropertyEnsureBody, conn: DbDep) -> dict[str, Any]: + """Create a property row when the URL is complete (OAuth / explicit actions only).""" + from website_profiling.db.property_store import ( + canonical_domain_from_start_url, + ensure_property_from_start_url, + get_property_by_domain, + ) + + start_url = (body.startUrl or "").strip() + if not start_url: + raise HTTPException(status_code=400, detail="startUrl required") + + prop_id = ensure_property_from_start_url(conn, start_url) + if prop_id is None: + raise HTTPException(status_code=400, detail="Valid site URL with a domain is required") + + domain = canonical_domain_from_start_url(start_url) + prop = get_property_by_domain(conn, domain) if domain else None + return { + "id": prop_id, + "canonical_domain": domain, + "default_crawl_preset": prop.get("default_crawl_preset") if prop else None, + } + + @router.get("/properties/resolve") def resolve_property( conn: DbDep, @@ -74,14 +115,14 @@ def resolve_property( from website_profiling.db.property_store import ( canonical_domain_from_start_url, get_property_by_domain, - resolve_property_id_from_start_url, + lookup_property_id_from_start_url, ) start_url = startUrl.strip() if not start_url: raise HTTPException(status_code=400, detail="startUrl required") - prop_id = resolve_property_id_from_start_url(conn, start_url) + prop_id = lookup_property_id_from_start_url(conn, start_url) domain = canonical_domain_from_start_url(start_url) prop = get_property_by_domain(conn, domain) if domain else None return { @@ -171,6 +212,7 @@ def authorize_property_crawl_route(property_id: int, conn: DbDep) -> dict[str, A @router.get("/properties/{property_id}/google/status") def property_google_status(property_id: int, conn: DbDep) -> dict[str, Any]: + _raise_if_google_migrated() from website_profiling.db.property_store import get_property_google_status status = get_property_google_status(conn, property_id) @@ -181,6 +223,7 @@ def property_google_status(property_id: int, conn: DbDep) -> dict[str, Any]: @router.post("/properties/{property_id}/google/test") def property_google_test(property_id: int, conn: DbDep) -> dict[str, Any]: + _raise_if_google_migrated() from website_profiling.db.property_store import get_property_by_id if not get_property_by_id(conn, property_id): @@ -197,6 +240,7 @@ def property_google_test(property_id: int, conn: DbDep) -> dict[str, Any]: @router.get("/properties/{property_id}/google/properties") def property_google_properties(property_id: int, conn: DbDep) -> dict[str, Any]: + _raise_if_google_migrated() from website_profiling.db.property_store import get_property_by_id if not get_property_by_id(conn, property_id): @@ -213,6 +257,7 @@ def property_google_properties(property_id: int, conn: DbDep) -> dict[str, Any]: @router.get("/properties/{property_id}/google/links/status") def property_google_links_status(property_id: int, conn: DbDep) -> dict[str, Any]: + _raise_if_google_migrated() from website_profiling.db.property_store import get_property_by_id if not get_property_by_id(conn, property_id): @@ -226,6 +271,7 @@ def property_google_links_status(property_id: int, conn: DbDep) -> dict[str, Any @router.post("/properties/{property_id}/google/links/import") def property_google_links_import(property_id: int, conn: DbDep) -> dict[str, Any]: + _raise_if_google_migrated() from website_profiling.db.property_store import get_property_by_id if not get_property_by_id(conn, property_id): @@ -281,6 +327,7 @@ def _apply_google_credentials_from_patch( def patch_property_google_credentials( property_id: int, body: GoogleCredentialsPatch, conn: DbDep ) -> dict[str, Any]: + _raise_if_google_migrated() from website_profiling.db.property_store import get_property_by_id if not get_property_by_id(conn, property_id): @@ -293,6 +340,7 @@ def patch_property_google_credentials( def post_property_google_credentials( property_id: int, body: GoogleCredentialsPostBody, conn: DbDep ) -> dict[str, Any]: + _raise_if_google_migrated() from website_profiling.db.property_store import get_property_by_id, get_property_google_public_status if not get_property_by_id(conn, property_id): @@ -316,6 +364,7 @@ def post_property_google_credentials( @router.post("/properties/{property_id}/google/disconnect") def post_property_google_disconnect(property_id: int, conn: DbDep) -> dict[str, Any]: + _raise_if_google_migrated() from website_profiling.db.property_store import disconnect_property_google, get_property_by_id if not get_property_by_id(conn, property_id): diff --git a/src/website_profiling/api/schemas/pipeline.py b/src/website_profiling/api/schemas/pipeline.py index 6580def9..9ac9f411 100644 --- a/src/website_profiling/api/schemas/pipeline.py +++ b/src/website_profiling/api/schemas/pipeline.py @@ -108,7 +108,6 @@ class RunPostBody(BaseModel): command: Optional[str] = None state: Optional[dict[str, Any]] = None unknownKeys: list[UnknownKeyEntry] = Field(default_factory=list) - llmState: Optional[dict[str, Any]] = None propertyId: Optional[int] = None python: Optional[str] = None repoRoot: Optional[str] = None diff --git a/src/website_profiling/commands/chat_cmd.py b/src/website_profiling/commands/chat_cmd.py index 25c63b96..b430f2af 100644 --- a/src/website_profiling/commands/chat_cmd.py +++ b/src/website_profiling/commands/chat_cmd.py @@ -1,55 +1,14 @@ -"""CLI: chat --stdin-json — agent turn for in-app chat (NDJSON events on stdout).""" +"""CLI chat — delegated to AiService (.NET).""" from __future__ import annotations import argparse -import json import sys -from ..text_sanitize import sanitize_unicode_deep -from ..tools.audit_tools import AuditToolContext -from ..llm.agent import run_agent_turn - -def run(_cfg: dict, args: argparse.Namespace) -> None: - if not getattr(args, "stdin_json", False): - print("Error: chat requires --stdin-json", file=sys.stderr) - sys.exit(1) - - try: - payload = json.load(sys.stdin) - except json.JSONDecodeError as e: - print(json.dumps({"type": "error", "message": f"Invalid stdin JSON: {e}"})) - sys.exit(1) - - messages = payload.get("messages") or [] - if not isinstance(messages, list): - messages = [] - - property_id = payload.get("property_id") - report_id = payload.get("report_id") - try: - pid = int(property_id) if property_id is not None else None - except (TypeError, ValueError): - pid = None - try: - rid = int(report_id) if report_id is not None else None - except (TypeError, ValueError): - rid = None - - ctx = AuditToolContext(property_id=pid, report_id=rid) - - def on_event(event: dict) -> None: - print(json.dumps(sanitize_unicode_deep(event), default=str), flush=True) - - try: - result = run_agent_turn(messages, ctx, on_event=on_event) - except Exception as e: - msg = str(e).strip() or type(e).__name__ - print(json.dumps({"type": "error", "message": msg}), flush=True) - sys.exit(1) - - if not result.get("ok"): - err = result.get("error", "Agent failed") - print(json.dumps({"type": "error", "message": err}), flush=True) - sys.exit(1) - sys.exit(0) +def run(cfg: dict, args: argparse.Namespace) -> None: + _ = cfg, args + print( + "In-app chat is served by AiService (.NET). Use the web UI (/chat) or POST /api/chat via the BFF.", + file=sys.stderr, + ) + sys.exit(1) diff --git a/src/website_profiling/commands/enrich_cmd.py b/src/website_profiling/commands/enrich_cmd.py index 13b3644f..4f09ce57 100644 --- a/src/website_profiling/commands/enrich_cmd.py +++ b/src/website_profiling/commands/enrich_cmd.py @@ -6,7 +6,7 @@ from ..analysis import merge_analysis_into_payload, merge_bundles, run_local_enrichment from ..db import db_session, get_latest_crawl_run_id, read_crawl, read_report_payload, write_report_payload -from ..llm.enrich import run_llm_enrichment +from ..llm_client_http import run_llm_enrichment from ..llm_config import load_llm_config_from_db, llm_is_enabled diff --git a/src/website_profiling/commands/google_cmd.py b/src/website_profiling/commands/google_cmd.py index a57aace0..9c220c4a 100644 --- a/src/website_profiling/commands/google_cmd.py +++ b/src/website_profiling/commands/google_cmd.py @@ -15,13 +15,16 @@ def _resolved_property_id(cfg: dict, args: argparse.Namespace) -> int | None: def run(cfg: dict, cwd: str, path: PathFn, args: argparse.Namespace) -> None: - from ..integrations.google.fetch import fetch_google_data, list_properties - property_id = _resolved_property_id(cfg, args) if getattr(args, "list_properties", False): try: - props = list_properties(property_id=property_id) + if _integrations_url(): + props = _list_properties_via_integrations(property_id) + else: + from ..integrations.google.fetch import list_properties + + props = list_properties(property_id=property_id) import json as _json print(_json.dumps(props), flush=True) @@ -36,7 +39,6 @@ def run(cfg: dict, cwd: str, path: PathFn, args: argparse.Namespace) -> None: print("Site Audit: Google fetch...", flush=True) from ..db import db_session, get_latest_crawl_run_id, read_crawl - from ..integrations.google.store import write_google_data date_range_days = get_int(cfg, "google_date_range_days", 28) or 28 @@ -52,28 +54,47 @@ def run(cfg: dict, cwd: str, path: PathFn, args: argparse.Namespace) -> None: except Exception as e: print(f" Warning: could not read crawl URLs for join stats: {e}", flush=True) - try: - import google.auth.exceptions as _gae + integrations_url = _integrations_url() + if integrations_url: + try: + google_data = _fetch_via_integrations( + integrations_url, + property_id=property_id, + date_range_days=date_range_days, + crawl_urls=crawl_urls, + start_url=start_url_for_join, + config=cfg, + ) + except RuntimeError as e: + print(f"Google fetch error: {e}", file=sys.stderr) + sys.exit(1) + else: + from ..integrations.google.store import write_google_data - google_data = fetch_google_data( - date_range_days=date_range_days, - crawl_urls=crawl_urls, - start_url=start_url_for_join, - config=cfg, - property_id=property_id, - ) - except _gae.RefreshError: - print( - "Google connection expired -- reconnect Google for this site.", - file=sys.stderr, - ) - sys.exit(1) - except RuntimeError as e: - print(f"Google fetch error: {e}", file=sys.stderr) - sys.exit(1) + try: + import google.auth.exceptions as _gae + + from ..integrations.google.fetch import fetch_google_data - with db_session() as conn: - write_google_data(conn, google_data, property_id=property_id) + google_data = fetch_google_data( + date_range_days=date_range_days, + crawl_urls=crawl_urls, + start_url=start_url_for_join, + config=cfg, + property_id=property_id, + ) + except _gae.RefreshError: + print( + "Google connection expired -- reconnect Google for this site.", + file=sys.stderr, + ) + sys.exit(1) + except RuntimeError as e: + print(f"Google fetch error: {e}", file=sys.stderr) + sys.exit(1) + + with db_session() as conn: + write_google_data(conn, google_data, property_id=property_id) if google_data.get("errors"): print(" Partial errors:", flush=True) @@ -85,6 +106,31 @@ def run(cfg: dict, cwd: str, path: PathFn, args: argparse.Namespace) -> None: def _run_google_test(property_id: int | None) -> None: + integrations_url = _integrations_url() + if integrations_url and property_id: + try: + import json + import urllib.error + import urllib.request + + req = urllib.request.Request( + f"{integrations_url}/api/properties/{property_id}/google/test", + method="POST", + ) + with urllib.request.urlopen(req, timeout=120) as resp: + result = json.loads(resp.read().decode("utf-8")) + log = str(result.get("log") or "") + if log: + print(log, flush=True) + sys.exit(int(result.get("exitCode") or (0 if result.get("ok") else 1))) + except urllib.error.HTTPError as exc: + detail = exc.read().decode("utf-8", errors="replace") + print(detail or f"Google test failed: HTTP {exc.code}", file=sys.stderr) + sys.exit(1) + except Exception as e: + print(f"Google test failed: {e}", file=sys.stderr) + sys.exit(1) + print("Site Audit: Google credentials test...", flush=True) from ..integrations.google.auth import build_credentials, resolve_google_targets @@ -187,3 +233,70 @@ def _run_google_test(property_id: int | None) -> None: except Exception as e: print(f"Google test failed: {e}", file=sys.stderr) sys.exit(1) + + +def _integrations_url() -> str: + import os + + return (os.environ.get("INTEGRATIONS_SERVICE_URL") or "").strip().rstrip("/") + + +def _fetch_via_integrations( + base_url: str, + *, + property_id: int | None, + date_range_days: int, + crawl_urls: list[str], + start_url: str, + config: dict, +) -> dict: + import json + import urllib.error + import urllib.request + + if property_id is None: + raise RuntimeError("No property selected for Google fetch.") + + body = { + "propertyId": property_id, + "dateRangeDays": date_range_days, + "crawlUrls": crawl_urls, + "startUrl": start_url, + "config": { + "keywordGscMaxRows": config.get("keyword_gsc_max_rows") or 25000, + "googleUrlGapListLimit": config.get("google_url_gap_list_limit") or 200, + }, + } + req = urllib.request.Request( + f"{base_url}/internal/integrations/google/fetch", + data=json.dumps(body).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=300) as resp: + return json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + detail = exc.read().decode("utf-8", errors="replace") + raise RuntimeError(detail or f"Integrations fetch failed: HTTP {exc.code}") from exc + + +def _list_properties_via_integrations(property_id: int | None) -> dict: + import json + import urllib.error + import urllib.request + + if property_id is None: + raise RuntimeError("property_id is required to list Google properties.") + + base = _integrations_url() + req = urllib.request.Request( + f"{base}/api/properties/{property_id}/google/properties", + method="GET", + ) + try: + with urllib.request.urlopen(req, timeout=120) as resp: + return json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + detail = exc.read().decode("utf-8", errors="replace") + raise RuntimeError(detail or f"Integrations list failed: HTTP {exc.code}") from exc diff --git a/src/website_profiling/commands/help_cmd.py b/src/website_profiling/commands/help_cmd.py index 492aed9e..cca799a3 100644 --- a/src/website_profiling/commands/help_cmd.py +++ b/src/website_profiling/commands/help_cmd.py @@ -1,41 +1,14 @@ -"""CLI: help --stdin-json — single-turn help chat (NDJSON events on stdout).""" +"""CLI help — delegated to AiService (.NET).""" from __future__ import annotations import argparse -import json import sys -from ..text_sanitize import sanitize_unicode_deep -from ..llm.help_agent import run_help_turn - -def run(_cfg: dict, args: argparse.Namespace) -> None: - if not getattr(args, "stdin_json", False): - print("Error: help requires --stdin-json", file=sys.stderr) - sys.exit(1) - - try: - payload = json.load(sys.stdin) - except json.JSONDecodeError as e: - print(json.dumps({"type": "error", "message": f"Invalid stdin JSON: {e}"})) - sys.exit(1) - - messages = payload.get("messages") or [] - if not isinstance(messages, list): - messages = [] - - def on_event(event: dict) -> None: - print(json.dumps(sanitize_unicode_deep(event), default=str), flush=True) - - try: - result = run_help_turn(messages, on_event=on_event) - except Exception as e: - msg = str(e).strip() or type(e).__name__ - print(json.dumps({"type": "error", "message": msg}), flush=True) - sys.exit(1) - - if not result.get("ok"): - err = result.get("error", "Help agent failed") - print(json.dumps({"type": "error", "message": err}), flush=True) - sys.exit(1) - sys.exit(0) +def run(cfg: dict, args: argparse.Namespace) -> None: + _ = cfg, args + print( + "Help chat is served by AiService (.NET). Use the web UI or configure AI in Run audit → AI settings.", + file=sys.stderr, + ) + sys.exit(1) diff --git a/src/website_profiling/commands/page_coach_cmd.py b/src/website_profiling/commands/page_coach_cmd.py index b3fa87a6..8be8496d 100644 --- a/src/website_profiling/commands/page_coach_cmd.py +++ b/src/website_profiling/commands/page_coach_cmd.py @@ -25,7 +25,7 @@ def _parse_ref(raw: str) -> tuple[str | None, int | None]: def run(cfg: dict, cwd: str, args: argparse.Namespace) -> None: - from ..llm.page_coach import run_page_coach + from ..llm_client_http import run_page_coach url = (getattr(args, "url", None) or "").strip() if not url: @@ -40,7 +40,6 @@ def run(cfg: dict, cwd: str, args: argparse.Namespace) -> None: result = run_page_coach( url, - cfg, refresh=refresh, current_type=current_type, current_id=current_id, diff --git a/src/website_profiling/commands/pipeline_cmd.py b/src/website_profiling/commands/pipeline_cmd.py index 06853905..bf15a682 100644 --- a/src/website_profiling/commands/pipeline_cmd.py +++ b/src/website_profiling/commands/pipeline_cmd.py @@ -2,11 +2,12 @@ from __future__ import annotations import argparse +import os import re import sys from collections.abc import Callable from dataclasses import dataclass -from typing import Literal +from typing import Any, Literal import pandas as pd @@ -45,13 +46,15 @@ class PhaseResult: name: str status: Literal["ok", "failed"] error: str | None = None + crawl_run_id: int | None = None -def run_pipeline_phase(name: str, fn: Callable[[], None]) -> PhaseResult: +def run_pipeline_phase(name: str, fn: Callable[[], Any]) -> PhaseResult: """Run one pipeline phase; failures are recorded and do not abort the process.""" try: - fn() - return PhaseResult(name, "ok") + payload = fn() + crawl_run_id = payload if name == "crawl" and isinstance(payload, int) else None + return PhaseResult(name, "ok", crawl_run_id=crawl_run_id) except Exception as e: emit_progress(name, "error", message=str(e)) console_print(f"[{name}] failed: {e}", file=sys.stderr) @@ -156,14 +159,19 @@ def run(cfg: dict, args: argparse.Namespace) -> None: emit_phase_done("config") phase_results: list[PhaseResult] = [] + pipeline_crawl_run_id: int | None = None resume_run_id = getattr(args, "resume_run_id", None) if resume_run_id is not None: - phase_results.append( - run_pipeline_phase("crawl", lambda: _run_crawl(cfg, use_database, resume_run_id=resume_run_id)) + crawl_phase = run_pipeline_phase( + "crawl", lambda: _run_crawl(cfg, use_database, resume_run_id=resume_run_id) ) + phase_results.append(crawl_phase) + pipeline_crawl_run_id = crawl_phase.crawl_run_id elif run_crawl: - phase_results.append(run_pipeline_phase("crawl", lambda: _run_crawl(cfg, use_database))) + crawl_phase = run_pipeline_phase("crawl", lambda: _run_crawl(cfg, use_database)) + phase_results.append(crawl_phase) + pipeline_crawl_run_id = crawl_phase.crawl_run_id if run_content_analysis and use_database: phase_results.append( @@ -171,10 +179,11 @@ def run(cfg: dict, args: argparse.Namespace) -> None: ) if run_lighthouse_on_pages and use_database: + lh_crawl_run_id = pipeline_crawl_run_id phase_results.append( run_pipeline_phase( "lighthouse", - lambda: _run_lighthouse_on_pages(cfg, lighthouse_max_pages), + lambda: _run_lighthouse_on_pages(cfg, lighthouse_max_pages, crawl_run_id=lh_crawl_run_id), ) ) @@ -214,7 +223,7 @@ def _finalize_pipeline_run(phase_results: list[PhaseResult]) -> None: sys.exit(1) -def _run_crawl(cfg: dict, use_database: bool, resume_run_id: int | None = None) -> None: +def _run_crawl(cfg: dict, use_database: bool, resume_run_id: int | None = None) -> int | None: from ..crawl.crawler import run_crawler console_print("[Crawl] Starting...", flush=True) @@ -262,7 +271,7 @@ def _run_crawl(cfg: dict, use_database: bool, resume_run_id: int | None = None) custom_extractors = parse_extractors_config(cfg.get("custom_extractors")) enable_axe = get_bool(cfg, "enable_axe", False) console_print("Crawling...") - run_crawler( + _, crawl_run_id = run_crawler( start_url=start_url, max_pages=max_pages, concurrency=concurrency, @@ -314,6 +323,7 @@ def _run_crawl(cfg: dict, use_database: bool, resume_run_id: int | None = None) console_print("[Crawl] Done.", flush=True) emit_phase_done("crawl") console_print("Crawl results: PostgreSQL") + return crawl_run_id if use_database else None def _run_content_analysis(cfg: dict, use_database: bool) -> None: @@ -343,23 +353,53 @@ def _run_content_analysis(cfg: dict, use_database: bool) -> None: console_print("[Content analysis] Done.", flush=True) -def _run_lighthouse_on_pages(cfg: dict, lighthouse_max_pages: int) -> None: - from ..db import db_session, get_latest_crawl_run_id, read_crawl +def _run_lighthouse_on_pages( + cfg: dict, + lighthouse_max_pages: int, + *, + crawl_run_id: int | None = None, +) -> None: + from ..db import db_session, read_crawl, resolve_crawl_run_id_for_cfg from ..lighthouse.runner import run_lighthouse_on_pages as do_lighthouse_on_pages + from .config_resolve import active_property_id_from_cfg console_print("[Lighthouse on pages] Starting...", flush=True) emit_phase_start("lighthouse", message="Lighthouse on pages") + property_id = active_property_id_from_cfg(cfg) + start_url = (cfg.get("start_url") or "").strip() + run_id = crawl_run_id with db_session() as conn: - run_id = get_latest_crawl_run_id(conn) - df = read_crawl(conn, run_id) + if run_id is None: + run_id = resolve_crawl_run_id_for_cfg( + conn, + property_id=property_id, + start_url=start_url or None, + ) + if run_id is None: + df = read_crawl(conn, None) + else: + df = read_crawl(conn, run_id) google_data = None try: from ..integrations.google.store import read_latest_google_data - from .config_resolve import active_property_id_from_cfg - google_data = read_latest_google_data(conn, property_id=active_property_id_from_cfg(cfg)) + google_data = read_latest_google_data(conn, property_id=property_id) except Exception: google_data = None + if run_id is not None: + try: + from ..db import get_crawl_run_info + + with db_session() as conn: + info = get_crawl_run_info(conn, run_id) + if info and info.get("start_url"): + source = "this pipeline run" if crawl_run_id is not None else "database" + console_print( + f"[Lighthouse on pages] Using crawl run {run_id} ({info['start_url']}, {source})", + flush=True, + ) + except Exception: + pass crawl_urls = select_lighthouse_urls_from_crawl(df, lighthouse_max_pages * 3) urls_200 = select_lighthouse_urls_from_gsc(google_data, crawl_urls, lighthouse_max_pages) if not urls_200: @@ -489,10 +529,37 @@ def _run_report(cfg: dict, use_database: bool) -> None: source_label = "Search Console" if google_db_has_gsc(cfg) else "Keyword Planner" console_print(f"[Keywords] Post-audit keyword research ({source_label} data)...", flush=True) emit_phase_start("keywords") - from ..integrations.google.keyword_enrich import run_enrichment - + integrations_url = (os.environ.get("INTEGRATIONS_SERVICE_URL") or "").strip().rstrip("/") + property_id = active_property_id_from_cfg(cfg) try: - run_enrichment(cfg) + enriched = False + if integrations_url and property_id: + import json + import urllib.error + import urllib.request + + req = urllib.request.Request( + f"{integrations_url}/internal/integrations/keywords/enrich", + data=json.dumps({"propertyId": int(property_id)}).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=120) as resp: + result = json.loads(resp.read().decode("utf-8")) + if not result.get("ok"): + raise RuntimeError(result.get("log") or "Keyword enrich failed") + enriched = True + except Exception: + console_print( + "Warning: Integrations keyword enrich failed; falling back to in-process enrich.", + file=sys.stderr, + ) + + if not enriched: + from ..integrations.google.keyword_enrich import run_enrichment + + run_enrichment(cfg) console_print("[Keywords] Post-audit keyword research done.", flush=True) emit_phase_done("keywords") except Exception as e: diff --git a/src/website_profiling/content_studio/agent.py b/src/website_profiling/content_studio/agent.py deleted file mode 100644 index 438cfea3..00000000 --- a/src/website_profiling/content_studio/agent.py +++ /dev/null @@ -1,260 +0,0 @@ -"""Content Studio analyze agent — fixed tool set, structured JSON output.""" -from __future__ import annotations - -import json -from typing import Any - -from ..concurrency import map_parallel, tool_concurrency -from ..llm.base import ChatResult, ToolCall, get_llm_client, parse_json_response -from ..text_sanitize import sanitize_unicode_deep, strip_surrogates -from .context import ContentStudioContext -from .tools import ( - REQUIRED_CONTENT_STUDIO_TOOLS, - dispatch_content_studio_tool, - openai_tools_schema, - run_all_content_studio_tools, -) - -MAX_TOOL_ROUNDS = 8 - -CONTENT_STUDIO_AGENT_SYSTEM = """You are an SEO content editor analyzing a draft article in Content Studio. - -Workflow (strict): -1. Call EVERY analyze tool exactly once before your final answer: - get_draft_seo_score, get_term_coverage, get_onpage_checks, get_keyword_gsc_context, get_draft_structure -2. Base suggestions ONLY on tool results. Do not invent SERP rankings, competitor data, or traffic numbers. -3. When all tools have been called, respond with valid JSON only (no markdown fences): -{ - "summary": "2-3 sentences on draft quality and top priority", - "suggestions": [{"text": "specific actionable suggestion", "priority": "high|medium|low", "type": "term|structure|seo|readability"}], - "outline": ["optional H2 heading ideas"], - "title_ideas": ["optional title tag ideas"] -} -Prioritize missing high-importance terms, failed on-page checks, and clarity improvements. Keep suggestions concise and actionable.""" - - -def _supports_native_tools(client: Any) -> bool: - return callable(getattr(client, "chat_with_tools", None)) - - -def _uses_ollama_tool_format(client: Any) -> bool: - return client.__class__.__name__ == "OllamaClient" - - -def _react_step(client: Any, messages: list[dict[str, Any]]) -> ChatResult: - tools_desc = "\n".join( - f"- {t['function']['name']}: {t['function']['description']}" - for t in openai_tools_schema() - ) - convo = "\n".join( - f"{m.get('role')}: {m.get('content')}" - for m in messages - if m.get("role") in ("user", "assistant", "system") and m.get("content") - ) - user = ( - f"Available tools:\n{tools_desc}\n\nConversation:\n{convo}\n\n" - 'Respond with JSON only: {"action":"tool","name":"","args":{}} ' - 'or {"action":"answer","text":""}' - ) - data = client.complete_json(CONTENT_STUDIO_AGENT_SYSTEM, user) - action = str(data.get("action") or "").lower() - if action == "tool": - return ChatResult( - tool_calls=[ToolCall( - id="react-0", - name=str(data.get("name") or ""), - arguments=data.get("args") if isinstance(data.get("args"), dict) else {}, - )], - ) - text = str(data.get("text") or data.get("answer") or data.get("content") or "") - if text.strip().startswith("{"): - return ChatResult(content=text) - return ChatResult(content=text) - - -def _inject_missing_tools( - openai_messages: list[dict[str, Any]], - ctx: ContentStudioContext, - called: set[str], - ollama_format: bool, - tool_events: list[dict[str, Any]], -) -> None: - for name in sorted(REQUIRED_CONTENT_STUDIO_TOOLS - called): - result = sanitize_unicode_deep(dispatch_content_studio_tool(name, ctx)) - called.add(name) - # Record the result for tool_events here too, so the caller need not - # dispatch each missing tool a second time (score_content_draft opens a - # DB session and re-parses the HTML on every dispatch). - tool_events.append({"name": name, "args": {}, "result": result}) - if ollama_format: - openai_messages.append({ - "role": "tool", - "tool_name": name, - "content": json.dumps(result, default=str), - }) - else: - openai_messages.append({ - "role": "tool", - "tool_call_id": f"auto-{name}", - "content": json.dumps(result, default=str), - }) - openai_messages.append({ - "role": "user", - "content": ( - "All analyze tools have now run. Output your final JSON object only " - "(summary, suggestions, outline, title_ideas)." - ), - }) - - -def _parse_final_json(content: str) -> dict[str, Any]: - text = strip_surrogates(content or "").strip() - if not text: - return {} - if text.startswith("```"): - text = text.strip("`").strip() - if text.lower().startswith("json"): - text = text[4:].strip() - data = parse_json_response(text) - return data if isinstance(data, dict) else {} - - -def run_content_studio_analyze( - ctx: ContentStudioContext, - cfg: dict[str, str], -) -> dict[str, Any]: - """ - Run tool-calling analyze loop. Returns: - {ok, ai_block, tool_events, error?} - """ - try: - client = get_llm_client(cfg) - except ValueError as e: - return {"ok": False, "error": str(e), "tool_events": []} - - tools = openai_tools_schema() - ollama_format = _uses_ollama_tool_format(client) - openai_messages: list[dict[str, Any]] = [ - {"role": "system", "content": CONTENT_STUDIO_AGENT_SYSTEM}, - { - "role": "user", - "content": ( - f"Analyze this draft for target keyword “{ctx.keyword.strip()}”. " - f"Draft title: {ctx.title or '(untitled)'}. " - "Call each analyze tool, then return the final JSON." - ), - }, - ] - tool_events: list[dict[str, Any]] = [] - called: set[str] = set() - - for _round in range(MAX_TOOL_ROUNDS): - try: - llm_messages = sanitize_unicode_deep(openai_messages) - if _supports_native_tools(client): - result = client.chat_with_tools(llm_messages, tools) - else: - result = _react_step(client, llm_messages) - except Exception as e: - return {"ok": False, "error": str(e), "tool_events": tool_events} - - if result.tool_calls: - assistant_tool_calls = [] - for i, tc in enumerate(result.tool_calls): - if ollama_format: - assistant_tool_calls.append({ - "type": "function", - "function": { - "index": i, - "name": tc.name, - "arguments": sanitize_unicode_deep(tc.arguments), - }, - }) - else: - assistant_tool_calls.append({ - "id": tc.id, - "type": "function", - "function": { - "name": tc.name, - "arguments": json.dumps(tc.arguments or {}), - }, - }) - - if _supports_native_tools(client): - openai_messages.append({ - "role": "assistant", - "content": strip_surrogates(result.content or ""), - "tool_calls": assistant_tool_calls, - }) - else: - openai_messages.append({ - "role": "assistant", - "content": f"Calling tool {result.tool_calls[0].name}", - }) - - # Parallel tool execution: the analyze tools are independent, so dispatch - # them concurrently (bounded), then apply results in request order. - cs_results = map_parallel( - result.tool_calls, - lambda tc: sanitize_unicode_deep(dispatch_content_studio_tool(tc.name, ctx)), - max_workers=tool_concurrency(), - ) - for tc, tool_result in zip(result.tool_calls, cs_results): - called.add(tc.name) - tool_events.append({"name": tc.name, "args": tc.arguments, "result": tool_result}) - payload = json.dumps(tool_result, default=str) - if ollama_format: - openai_messages.append({ - "role": "tool", - "tool_name": tc.name, - "content": payload, - }) - else: - openai_messages.append({ - "role": "tool", - "tool_call_id": tc.id, - "content": payload, - }) - continue - - if called >= REQUIRED_CONTENT_STUDIO_TOOLS: - ai_block = _parse_final_json(result.content) - if ai_block: - return {"ok": True, "ai_block": ai_block, "tool_events": tool_events} - return { - "ok": False, - "error": "Model returned no valid JSON after tool calls.", - "tool_events": tool_events, - } - - if called: - # Populates both the model messages and tool_events in one dispatch - # pass (previously every missing tool was dispatched twice). - _inject_missing_tools(openai_messages, ctx, called, ollama_format, tool_events) - continue - - break - - # Deterministic fallback: run all tools, single JSON synthesis - tool_events = run_all_content_studio_tools(ctx) - called = set(REQUIRED_CONTENT_STUDIO_TOOLS) - tool_payload = {e["name"]: e["result"] for e in tool_events} - try: - user = json.dumps({ - "keyword": ctx.keyword, - "title": ctx.title, - "tool_results": tool_payload, - }, indent=2, default=str)[:14000] - ai_block = client.complete_json(CONTENT_STUDIO_AGENT_SYSTEM, user) - if not isinstance(ai_block, dict): - ai_block = parse_json_response(str(ai_block)) or {} - if ai_block: - return {"ok": True, "ai_block": ai_block, "tool_events": tool_events, "fallback": True} - except Exception as e: - return {"ok": False, "error": str(e), "tool_events": tool_events} - - return { - "ok": False, - "error": "Content analyze agent stopped without a final answer.", - "tool_events": tool_events, - } diff --git a/src/website_profiling/content_studio/ai_suggest.py b/src/website_profiling/content_studio/ai_suggest.py index 49b56453..7a775606 100644 --- a/src/website_profiling/content_studio/ai_suggest.py +++ b/src/website_profiling/content_studio/ai_suggest.py @@ -6,15 +6,38 @@ import re from typing import Any +from ..db.llm_cache_store import read_llm_cache, write_llm_cache from ..llm_config import load_llm_config_from_db, llm_is_enabled -from ..llm.enrich import _read_cache, _write_cache -from ..llm.prompts import PROMPT_VERSION -from .agent import run_content_studio_analyze + +PROMPT_VERSION = "v2" + +from ..llm_client_http import call_ai_api from .context import ContentStudioContext from .score import score_content_draft from .tools import run_all_content_studio_tools +def _read_cache(cache_key: str) -> dict[str, Any] | None: + from ..db import db_session + + with db_session() as conn: + raw = read_llm_cache(conn, cache_key) + if not raw: + return None + try: + data = json.loads(raw) + return data if isinstance(data, dict) else None + except json.JSONDecodeError: + return None + + +def _write_cache(cache_key: str, payload: dict[str, Any]) -> None: + from ..db import db_session + + with db_session() as conn: + write_llm_cache(conn, cache_key, json.dumps(payload, default=str)) + + def _rule_suggestions(score: dict[str, Any]) -> list[dict[str, Any]]: items: list[dict[str, Any]] = [] for term in score.get("terms") or []: @@ -179,7 +202,24 @@ def analyze_content_draft( tool_events = cached.get("tool_events") if isinstance(cached.get("tool_events"), list) else [] if not ai_block: - agent_result = run_content_studio_analyze(ctx, cfg) + try: + remote = call_ai_api( + "/api/content/analyze", + { + "keyword": keyword, + "propertyId": property_id, + "bodyHtml": body_html, + "titleTag": title_tag, + "metaDescription": meta_description, + "landingUrl": landing_url, + "title": title, + "useAi": True, + "refresh": refresh, + }, + ) + agent_result = remote.get("analysis") if isinstance(remote.get("analysis"), dict) else remote + except Exception as exc: + agent_result = {"ok": False, "error": str(exc)} tool_events = agent_result.get("tool_events") if isinstance(agent_result.get("tool_events"), list) else [] result["tools_used"] = [str(e.get("name") or "") for e in tool_events if e.get("name")] result["tool_events"] = tool_events diff --git a/src/website_profiling/content_studio/wizard.py b/src/website_profiling/content_studio/wizard.py index 96f97b46..e9d5457c 100644 --- a/src/website_profiling/content_studio/wizard.py +++ b/src/website_profiling/content_studio/wizard.py @@ -13,7 +13,7 @@ import re from typing import Any -from ..llm.base import get_llm_client, parse_json_response +from ..llm_client_http import complete_json, parse_json_response from ..llm_config import load_llm_config_from_db, llm_is_enabled from ..text_sanitize import strip_surrogates @@ -51,20 +51,17 @@ def _content_studio_ai_on(cfg: dict[str, str]) -> bool: return str(cfg.get("llm_enable_content_studio", "true")).lower() in ("true", "1", "yes") -def _get_client() -> tuple[Any, dict[str, Any] | None]: - """Return (client, None) when AI is usable, else (None, error_dict).""" +def _get_client() -> tuple[bool, dict[str, Any] | None]: + """Return (ai_ok, error_dict).""" cfg = load_llm_config_from_db() if not llm_is_enabled(cfg) or not _content_studio_ai_on(cfg): - return None, {"ok": False, "error": "AI is disabled. Enable it in Run audit → AI settings."} - try: - return get_llm_client(cfg), None - except ValueError as e: - return None, {"ok": False, "error": str(e)} + return False, {"ok": False, "error": "AI is disabled. Enable it in Run audit → AI settings."} + return True, None -def _safe_complete(client: Any, system: str, user: str) -> dict[str, Any]: +def _safe_complete(_client: Any, system: str, user: str) -> dict[str, Any]: try: - data = client.complete_json(system, user) + data = complete_json(system, user) except Exception: return {} if isinstance(data, dict): @@ -161,7 +158,7 @@ def _fallback_outline(title: str) -> list[dict[str, str]]: def suggest_intents(keyword: str, locale: str = "en-US") -> dict[str, Any]: - client, err = _get_client() + ai_ok, err = _get_client() if err: return err kw = (keyword or "").strip() @@ -172,13 +169,13 @@ def suggest_intents(keyword: str, locale: str = "en-US") -> dict[str, Any]: "search intents a reader might have. Return JSON: " '{"intents":[{"label":"short intent label","description":"one sentence"}]}' ) - data = _safe_complete(client, _JSON_SYSTEM, user) + data = _safe_complete(ai_ok, _JSON_SYSTEM, user) options = _normalize_options(data.get("intents")) or _fallback_intents(kw) return {"ok": True, "options": options[:_MAX_OPTIONS]} def suggest_content_types(keyword: str, intent: str) -> dict[str, Any]: - client, err = _get_client() + ai_ok, err = _get_client() if err: return err user = ( @@ -186,13 +183,13 @@ def suggest_content_types(keyword: str, intent: str) -> dict[str, Any]: f'"{intent.strip()}". Recommend up to {_MAX_OPTIONS} content types that best serve this, ' 'best first. Return JSON: {"content_types":[{"label":"type","description":"why it fits"}]}' ) - data = _safe_complete(client, _JSON_SYSTEM, user) + data = _safe_complete(ai_ok, _JSON_SYSTEM, user) options = _normalize_options(data.get("content_types")) or _options_from_pairs(_FALLBACK_CONTENT_TYPES) return {"ok": True, "options": options[:_MAX_OPTIONS]} def suggest_tones(keyword: str, intent: str, content_type: str) -> dict[str, Any]: - client, err = _get_client() + ai_ok, err = _get_client() if err: return err user = ( @@ -200,13 +197,13 @@ def suggest_tones(keyword: str, intent: str, content_type: str) -> dict[str, Any f"recommend up to {_MAX_OPTIONS} writing tones, best first. " 'Return JSON: {"tones":[{"label":"tone","description":"when to use it"}]}' ) - data = _safe_complete(client, _JSON_SYSTEM, user) + data = _safe_complete(ai_ok, _JSON_SYSTEM, user) options = _normalize_options(data.get("tones")) or _options_from_pairs(_FALLBACK_TONES) return {"ok": True, "options": options[:_MAX_OPTIONS]} def suggest_titles(keyword: str, intent: str, content_type: str, tone: str) -> dict[str, Any]: - client, err = _get_client() + ai_ok, err = _get_client() if err: return err kw = (keyword or "").strip() @@ -216,7 +213,7 @@ def suggest_titles(keyword: str, intent: str, content_type: str, tone: str) -> d "Keep each under 60 characters where possible and include the keyword naturally. " 'Return JSON: {"titles":["title one","title two"]}' ) - data = _safe_complete(client, _JSON_SYSTEM, user) + data = _safe_complete(ai_ok, _JSON_SYSTEM, user) titles = _normalize_str_list(data.get("titles")) or _fallback_titles(kw) return {"ok": True, "titles": titles[:_MAX_TITLES]} @@ -244,7 +241,7 @@ def _fallback_sources() -> list[dict[str, str]]: def research_panel(keyword: str, intent: str = "", title: str = "") -> dict[str, Any]: """People-Also-Ask style questions + suggested reference sources for a keyword.""" - client, err = _get_client() + ai_ok, err = _get_client() if err: return err kw = (keyword or "").strip() @@ -258,14 +255,14 @@ def research_panel(keyword: str, intent: str = "", title: str = "") -> dict[str, '{"label":"source name or type","description":"what to cite it for"}. ' 'Return JSON: {"questions":["..."],"sources":[{"label":"...","description":"..."}]}' ) - data = _safe_complete(client, _JSON_SYSTEM, user) + data = _safe_complete(ai_ok, _JSON_SYSTEM, user) questions = _normalize_str_list(data.get("questions")) or _fallback_questions(kw) sources = _normalize_options(data.get("sources")) or _fallback_sources() return {"ok": True, "questions": questions[:8], "sources": sources[:6]} def suggest_outline(keyword: str, intent: str, content_type: str, tone: str, title: str) -> dict[str, Any]: - client, err = _get_client() + ai_ok, err = _get_client() if err: return err user = ( @@ -274,7 +271,7 @@ def suggest_outline(keyword: str, intent: str, content_type: str, tone: str, tit "Use h2 for main sections and h3 for sub-points. Do not include the title as a heading. " 'Return JSON: {"outline":[{"level":"h2","text":"Section heading"},{"level":"h3","text":"Sub-point"}]}' ) - data = _safe_complete(client, _JSON_SYSTEM, user) + data = _safe_complete(ai_ok, _JSON_SYSTEM, user) outline = _normalize_outline(data.get("outline"), title) return {"ok": True, "outline": outline} @@ -302,7 +299,7 @@ def generate_draft( title: str, outline: list[dict[str, Any]], ) -> dict[str, Any]: - client, err = _get_client() + ai_ok, err = _get_client() if err: return err @@ -319,7 +316,7 @@ def generate_draft( '"sections":["prose for heading 1","prose for heading 2", ...]} ' "with one sections entry per heading, in the same order." ) - data = _safe_complete(client, _JSON_SYSTEM, user) + data = _safe_complete(ai_ok, _JSON_SYSTEM, user) title_tag = (_clean(data.get("title_tag")) or h1_text)[:70] meta = (_clean(data.get("meta_description")) or f"{h1_text}. Learn about {keyword.strip()}.")[:170] diff --git a/src/website_profiling/crawl/crawler.py b/src/website_profiling/crawl/crawler.py index 7114a08b..0a260016 100644 --- a/src/website_profiling/crawl/crawler.py +++ b/src/website_profiling/crawl/crawler.py @@ -641,8 +641,11 @@ def run_crawler( enable_axe: bool = False, compare_mobile_desktop: bool = False, resume_run_id: Optional[int] = None, -) -> pd.DataFrame: - """Run crawler and optionally save to CSV/JSON or PostgreSQL. Returns DataFrame.""" +) -> tuple[pd.DataFrame, Optional[int]]: + """Run crawler and optionally save to CSV/JSON or PostgreSQL. + + Returns ``(dataframe, crawl_run_id)``. ``crawl_run_id`` is set when ``output_db`` is true. + """ _resume_pause_state: Optional[dict] = None if resume_run_id is not None: from ..db import db_session @@ -887,4 +890,5 @@ def run_crawler( df.to_json(output_csv, orient="records", indent=2, date_format="iso", default_handler=str) else: df.to_csv(output_csv, index=False) - return df + db_run_id = int(run_id) if output_db and run_id is not None else None + return df, db_run_id diff --git a/src/website_profiling/db/crawl_store.py b/src/website_profiling/db/crawl_store.py index 263a7d17..492009f7 100644 --- a/src/website_profiling/db/crawl_store.py +++ b/src/website_profiling/db/crawl_store.py @@ -52,6 +52,67 @@ def get_latest_crawl_run_id(conn: Connection) -> Optional[int]: return None +def _normalize_start_url_key(url: str) -> str: + trimmed = (url or "").strip() + if not trimmed: + return "" + if not trimmed.startswith(("http://", "https://")): + trimmed = f"https://{trimmed}" + return trimmed.rstrip("/").lower() + + +def get_latest_crawl_run_id_for_property(conn: Connection, property_id: int) -> Optional[int]: + """Latest crawl run scoped to a property, or None if none exist.""" + try: + cur = conn.execute( + "SELECT id FROM crawl_runs WHERE property_id = %s ORDER BY id DESC LIMIT 1", + (int(property_id),), + ) + row = cur.fetchone() + return int(row["id"]) if row else None + except Exception: + return None + + +def get_latest_crawl_run_id_for_start_url(conn: Connection, start_url: str) -> Optional[int]: + """Latest crawl run whose start_url matches (scheme-insensitive, trailing slash ignored).""" + target = _normalize_start_url_key(start_url) + if not target: + return None + try: + cur = conn.execute( + """SELECT id, start_url FROM crawl_runs + WHERE start_url IS NOT NULL AND trim(start_url) <> '' + ORDER BY id DESC + LIMIT 100""", + ) + for row in cur.fetchall() or []: + if _normalize_start_url_key(str(row.get("start_url") or "")) == target: + return int(row["id"]) + return None + except Exception: + return None + + +def resolve_crawl_run_id_for_cfg( + conn: Connection, + *, + property_id: Optional[int] = None, + start_url: Optional[str] = None, +) -> Optional[int]: + """Pick crawl run for pipeline/report/Lighthouse: property, then start URL, then global latest.""" + if property_id is not None: + rid = get_latest_crawl_run_id_for_property(conn, property_id) + if rid is not None: + return rid + site = (start_url or "").strip() + if site: + rid = get_latest_crawl_run_id_for_start_url(conn, site) + if rid is not None: + return rid + return get_latest_crawl_run_id(conn) + + def get_crawl_run_info(conn: Connection, run_id: int) -> Optional[dict[str, Any]]: try: cur = conn.execute( diff --git a/src/website_profiling/db/pipeline_jobs.py b/src/website_profiling/db/pipeline_jobs.py index 0eb85f2d..54ed546d 100644 --- a/src/website_profiling/db/pipeline_jobs.py +++ b/src/website_profiling/db/pipeline_jobs.py @@ -190,8 +190,7 @@ def reconcile_stale_jobs(conn: Connection) -> int: (str(_STALE_PENDING_MINUTES),), ) count += len(cur2.fetchall()) - if count: - conn.commit() + conn.commit() return count diff --git a/src/website_profiling/db/property_store.py b/src/website_profiling/db/property_store.py index 85da80a0..4f152835 100644 --- a/src/website_profiling/db/property_store.py +++ b/src/website_profiling/db/property_store.py @@ -1,6 +1,7 @@ """Properties table: per-domain Google OAuth and GSC/GA4 mapping.""" from __future__ import annotations +import re from typing import Any, Optional from urllib.parse import urlparse @@ -8,6 +9,9 @@ from ._common import _row_field +_LABEL_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$") +_RESERVED = frozenset({"http", "https", "www"}) + def _extract_hostname(url: str) -> str: try: @@ -33,15 +37,32 @@ def derive_property_name(domain: str, site_url: str = "") -> str: return host or "Site" +def is_valid_canonical_domain(domain: str) -> bool: + """Reject partial URLs / keystroke fragments (e.g. ``http``, ``codefrydev.i``).""" + d = (domain or "").strip().lower().rstrip(".") + if len(d) < 4 or "." not in d or d in _RESERVED: + return False + labels = d.split(".") + for label in labels: + if not label or len(label) > 63 or not _LABEL_RE.match(label): + return False + # Real TLDs are at least two characters (excludes ``codefrydev.i`` while typing). + if len(labels[-1]) < 2: + return False + return True + + def upsert_property_by_domain( conn: Connection, name: str, canonical_domain: str, site_url: str | None = None, ) -> int: - domain = (canonical_domain or "").strip().lower() + domain = (canonical_domain or "").strip().lower().rstrip(".") if not domain: raise ValueError("canonical_domain is required") + if not is_valid_canonical_domain(domain): + raise ValueError(f"canonical_domain is not a valid domain: {domain!r}") cur = conn.execute( """ INSERT INTO properties (name, canonical_domain, site_url, updated_at) @@ -59,9 +80,19 @@ def upsert_property_by_domain( return int(_row_field(row, "id", index=0)) -def resolve_property_id_from_start_url(conn: Connection, start_url: str) -> int | None: +def lookup_property_id_from_start_url(conn: Connection, start_url: str) -> int | None: + """Read-only: resolve an existing property from a start URL (no insert).""" domain = canonical_domain_from_start_url(start_url) - if not domain: + if not domain or not is_valid_canonical_domain(domain): + return None + prop = get_property_by_domain(conn, domain) + return int(prop["id"]) if prop else None + + +def ensure_property_from_start_url(conn: Connection, start_url: str) -> int | None: + """Create or return a property when the user explicitly connects or runs a job.""" + domain = canonical_domain_from_start_url(start_url) + if not domain or not is_valid_canonical_domain(domain): return None prop = get_property_by_domain(conn, domain) if prop: @@ -74,6 +105,11 @@ def resolve_property_id_from_start_url(conn: Connection, start_url: str) -> int ) +def resolve_property_id_from_start_url(conn: Connection, start_url: str) -> int | None: + """Backward-compatible alias for read-only lookup (does not create rows).""" + return lookup_property_id_from_start_url(conn, start_url) + + def get_property_by_id(conn: Connection, property_id: int) -> dict[str, Any] | None: cur = conn.execute( """ diff --git a/src/website_profiling/db/storage.py b/src/website_profiling/db/storage.py index 0999017a..d6b2fdc9 100644 --- a/src/website_profiling/db/storage.py +++ b/src/website_profiling/db/storage.py @@ -24,6 +24,9 @@ create_crawl_run, get_crawl_run_info, get_latest_crawl_run_id, + get_latest_crawl_run_id_for_property, + get_latest_crawl_run_id_for_start_url, + resolve_crawl_run_id_for_cfg, read_crawl, read_edges, read_nodes, @@ -76,6 +79,9 @@ "get_session", "get_database_url", "get_latest_crawl_run_id", + "get_latest_crawl_run_id_for_property", + "get_latest_crawl_run_id_for_start_url", + "resolve_crawl_run_id_for_cfg", "init_schema", "list_sessions", "merge_crawl_result_fields_batch", diff --git a/src/website_profiling/integrations/google/oauth.py b/src/website_profiling/integrations/google/oauth.py index 2e94398b..e25bcd2b 100644 --- a/src/website_profiling/integrations/google/oauth.py +++ b/src/website_profiling/integrations/google/oauth.py @@ -141,11 +141,11 @@ def _ui_redirect(return_path: str, params: dict[str, str]) -> str: def oauth_start(conn: Any, property_id: int | None, start_url: str | None, return_to: str | None) -> str: """Resolve the property, build the Google consent URL. Raises OAuthError on bad input.""" from ...db.google_app_store import app_client_credentials - from ...db.property_store import resolve_property_id_from_start_url + from ...db.property_store import ensure_property_from_start_url pid = property_id if pid is None and start_url: - pid = resolve_property_id_from_start_url(conn, start_url.strip()) + pid = ensure_property_from_start_url(conn, start_url.strip()) if pid is None or pid <= 0: raise OAuthError("propertyId is required. Set Site URL and connect from Integrations.") diff --git a/src/website_profiling/integrations/google/page_lookup.py b/src/website_profiling/integrations/google/page_lookup.py index 9347f148..c39b3e0e 100644 --- a/src/website_profiling/integrations/google/page_lookup.py +++ b/src/website_profiling/integrations/google/page_lookup.py @@ -164,3 +164,34 @@ def summary_from_slice(gsc: dict | None, ga4: dict | None) -> dict[str, Any]: if ga4 else None, } + + +def metric_deltas(current: dict[str, Any], baseline: dict[str, Any]) -> list[dict[str, Any]]: + """Compare GSC/GA4 page metrics between current and baseline slices.""" + rows: list[dict[str, Any]] = [] + pairs = [ + ("gsc_clicks", "gsc", "clicks", True), + ("gsc_impressions", "gsc", "impressions", True), + ("gsc_ctr", "gsc", "ctr", True), + ("gsc_position", "gsc", "position", False), + ("ga4_sessions", "ga4", "sessions", True), + ("ga4_engagement", "ga4", "engagementRate", True), + ] + for mid, blob, key, higher in pairs: + c = (current.get(blob) or {}).get(key) + b = (baseline.get(blob) or {}).get(key) + if c is None and b is None: + continue + try: + c_f, b_f = float(c or 0), float(b or 0) + delta = round(c_f - b_f, 2) + rows.append({ + "id": mid, + "current": c_f, + "baseline": b_f, + "delta": delta, + "higher_is_better": higher, + }) + except (TypeError, ValueError): + continue + return rows diff --git a/src/website_profiling/lighthouse/audit_text.py b/src/website_profiling/lighthouse/audit_text.py new file mode 100644 index 00000000..5ecad0be --- /dev/null +++ b/src/website_profiling/lighthouse/audit_text.py @@ -0,0 +1,75 @@ +"""Normalize Lighthouse audit text across LHR schema versions.""" +from __future__ import annotations + +from typing import Any + +_CWV_IMPACTS = frozenset({"LCP", "CLS", "FID"}) + + +def audit_title(audit: dict[str, Any], audit_id: str = "") -> str: + """Short display title from a Lighthouse audit dict.""" + return str(audit.get("title") or audit_id or "Audit failed").strip() + + +def audit_help_text(audit: dict[str, Any]) -> str: + """Longer help text: modern ``description``, legacy ``helpText``.""" + return str(audit.get("description") or audit.get("helpText") or "").strip() + + +def failure_help_text(failure: dict[str, Any]) -> str: + """Help text from a ``top_failures`` row (supports both field names).""" + return str(failure.get("helpText") or failure.get("description") or "").strip() + + +def failure_display_message(failure: dict[str, Any]) -> str: + """Build a user-facing issue message from a ``top_failures`` row.""" + aid = str(failure.get("id") or "").strip() + title = str(failure.get("title") or "").strip() + help_text = failure_help_text(failure) + if not title and aid: + title = aid.replace("-", " ").title() + if title and help_text and title.casefold() != help_text.casefold(): + return f"{title}: {help_text}"[:240] + if title: + return title[:240] + if help_text: + return help_text[:240] + return "Audit failed" + + +def failure_row_from_audit( + audit_id: str, + audit: dict[str, Any], + *, + category: str | None = None, + impact: str | None = None, + evidence: list[str] | None = None, +) -> dict[str, Any]: + """Build a normalized ``top_failures`` entry from a Lighthouse audit.""" + title = audit_title(audit, audit_id) + help_text = audit_help_text(audit) + row: dict[str, Any] = { + "id": audit_id, + "score": audit.get("score"), + "title": title, + "helpText": help_text, + "impact": impact, + "evidence": evidence or [], + } + if category: + row["category"] = category + return row + + +def is_core_web_vitals_failure(failure: dict[str, Any], *, resolve_impact) -> bool: + """True when a failure belongs in the Core Web Vitals category.""" + category = str(failure.get("category") or failure.get("category_id") or "").strip() + if category: + return category == "performance" + impact = str(failure.get("impact") or "").strip() + if not impact: + aid = str(failure.get("id") or "") + title = str(failure.get("title") or "") + help_text = failure_help_text(failure) + impact = resolve_impact(aid, title, help_text) + return impact in _CWV_IMPACTS diff --git a/src/website_profiling/lighthouse/result_parser.py b/src/website_profiling/lighthouse/result_parser.py index f3f195d5..9929fb69 100644 --- a/src/website_profiling/lighthouse/result_parser.py +++ b/src/website_profiling/lighthouse/result_parser.py @@ -5,6 +5,9 @@ import statistics +from .audit_text import audit_help_text, audit_title, failure_row_from_audit +from .schema import _audit_id_to_category + def _evidence_from_audit(audit: dict[str, Any]) -> list[str]: """Extract resource URLs or selectors from audit details.""" evidence: list[str] = [] @@ -73,6 +76,7 @@ def extract_from_lighthouse_json(data: dict) -> dict[str, Any]: # Resolve impact from warning_mapper for each failure from ..tools.warnings import resolve_impact + audit_to_cat = _audit_id_to_category(cats) if isinstance(cats, dict) else {} failures = [] for aid, a in audits.items(): if a is None: @@ -81,17 +85,18 @@ def extract_from_lighthouse_json(data: dict) -> dict[str, Any]: if score is None: continue if score < 1: - title = a.get("title") or aid - help_text = a.get("helpText") or "" + title = audit_title(a, aid) + help_text = audit_help_text(a) impact = resolve_impact(aid, title, help_text) - evidence = _evidence_from_audit(a) - failures.append({ - "id": aid, - "score": score, - "helpText": help_text, - "impact": impact, - "evidence": evidence, - }) + failures.append( + failure_row_from_audit( + aid, + a, + category=audit_to_cat.get(aid), + impact=impact, + evidence=_evidence_from_audit(a), + ) + ) failures.sort(key=lambda x: (x["score"] or 0)) out["top_failures"] = failures[:10] diff --git a/src/website_profiling/llm/__init__.py b/src/website_profiling/llm/__init__.py deleted file mode 100644 index 59a56d38..00000000 --- a/src/website_profiling/llm/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from .enrich import cluster_keywords_llm, run_llm_enrichment -from .base import get_llm_client - -__all__ = ["cluster_keywords_llm", "get_llm_client", "run_llm_enrichment"] diff --git a/src/website_profiling/llm/agent.py b/src/website_profiling/llm/agent.py deleted file mode 100644 index 936677d3..00000000 --- a/src/website_profiling/llm/agent.py +++ /dev/null @@ -1,456 +0,0 @@ -"""Agent loop for in-app chat and MCP — tool calling with streaming events.""" -from __future__ import annotations - -import json -import os -from typing import Any, Callable - -from ..concurrency import map_parallel, tool_concurrency -from ..llm_config import llm_is_enabled, load_llm_config_from_db -from ..text_sanitize import sanitize_unicode_deep, strip_surrogates -from ..tools.audit_tools import AuditToolContext -from ..tools.audit_tools.crawl.crawl_actions import CHAT_CRAWL_TOOL -from ..tools.audit_tools.registry import ( - TOOL_DEFINITIONS, - _normalize_tool_args, - dispatch_tool, - openai_tools_schema, -) -from ..tools.audit_tools.tool_selector import ( - apply_tool_cap, - chat_tool_mode, - chat_tool_search_cap, - select_tools_for_turn, -) -from .base import ChatResult, ToolCall, get_llm_client -from .chat_narrative import ChatNarrativeError, synthesize_chat_narrative - -MAX_TOOL_ROUNDS_DEFAULT = 10 -MAX_TOOL_ROUNDS_EXTENDED = 100 -# Back-compat for tests and imports -MAX_TOOL_ROUNDS = MAX_TOOL_ROUNDS_DEFAULT - - -def _truthy_cfg(cfg: dict[str, str], key: str) -> bool: - return str(cfg.get(key, "")).lower() in ("true", "1", "yes") - - -def _max_tool_rounds(cfg: dict[str, str]) -> int: - """Resolve per-turn tool loop cap from llm_config and optional env overrides.""" - if _truthy_cfg(cfg, "llm_chat_unlimited_tool_rounds"): - raw = (os.environ.get("CHAT_MAX_TOOL_ROUNDS_EXTENDED") or "").strip() - if raw: - try: - return max(1, int(raw)) - except ValueError: - pass - return MAX_TOOL_ROUNDS_EXTENDED - raw = (os.environ.get("CHAT_MAX_TOOL_ROUNDS") or "").strip() - if raw: - try: - return max(1, int(raw)) - except ValueError: - pass - return MAX_TOOL_ROUNDS_DEFAULT - -NARRATIVE_FAILED_MSG = "Could not generate a summary. Tool results are shown below." - -_SYSTEM_PROMPT_BASE = """You are Site Audit AI, a technical SEO assistant for a self-hosted site audit platform. -You help users understand crawl results, audit issues, Lighthouse scores, keywords, and Search Console data. - -Tool routing (only a subset of tools is loaded each turn): -- Always available: search_audit_tools, list_tool_domains, get_data_coverage_report, run_insight_workflow, run_technical_workflow, run_keyword_workflow, run_domain_agent, plus top insight tools (get_report_summary, get_opportunity_matrix, get_traffic_health_check, etc.) -- Use search_audit_tools(query) to discover specialized tools by topic (e.g. "broken links", "GSC CTR", "export PDF"). -- Use list_tool_domains to see domain groupings and example prompts. -- Use run_*_workflow for common multi-step analyses (insight, technical, keyword). -- Use run_domain_agent(task, domain) for deep exploration within one domain. -- Use get_data_coverage_report when tools return empty or missing data. - -Image playbook: -- Overview: get_image_audit_summary first — the UI renders summary cards, page preview lists (alt/lazy/OG/dimensions), and Lighthouse image findings. Call tools only; the app generates user-facing narrative separately. -- Missing alt / lazy / OG / dimensions: get_image_audit_summary includes previews; call list_pages_* only if the user wants the full exportable list -- All image URLs: list_site_image_urls (optional kind filter) -- Lighthouse image issues: list_lighthouse_image_opportunities -- Largest / heavy files: list_largest_images (requires probe_image_inventory=true on report build) -- Unoptimized format/size: list_unoptimized_images (requires image inventory probe) -- What needs attention: list_images_needing_attention -- Export lists: export_list_as_csv with the matching list tool - -Export playbook (chat UI shows download buttons after export tools — do not paste file contents): -- Full audit PDF/CSV/JSON: export_audit_report with format pdf|csv|json (PDF via FileService) -- Compare issue diff CSV: export_compare_csv with baseline_report_id -- Export a list as CSV: export_list_as_csv with tool_name and tool_args (e.g. list_broken_links) -- After export tools succeed, tell the user their download is ready; the UI renders file buttons automatically - -Visualization playbook (chat UI renders charts and tables from tool JSON automatically): -- Category scores / health: get_category_scores, list_audit_categories, or get_report_summary -- Issue breakdown: get_report_summary, get_issue_priority_breakdown (priority chart), and list_issues or get_critical_issues for the table -- Top critical issues (required trio): get_report_summary, get_issue_priority_breakdown, get_critical_issues — then only write recommendations, never enumerate issues in prose -- Audit overview / site health recap: get_report_summary (health, crawl, categories, issue counts). Keep prose to interpretation and next steps only — never repeat health score, URL counts, success rate, category scores, or priority counts in markdown; the UI renders those as cards and charts. -- Distributions: get_mime_type_breakdown, get_title_length_distribution, get_domain_link_distribution, get_status_code_breakdown, get_depth_distribution -- Trends over time: get_health_history, get_category_health_history -- Compare drift: compare_category_deltas, compare_issue_deltas, compare_google_metrics, compare_security_deltas -- Lighthouse: get_lighthouse_summary -- Google/GSC: get_google_summary, get_gsc_top_queries - -SQL playbook (only when get_sql_schema / run_sql_query are available): -- SQL is a fallback for custom questions not answerable by the named audit tools above. Always prefer a named tool first. -- When SQL is needed: call get_sql_schema first to discover tables and foreign keys, then run_sql_query with a single read-only SELECT. -- Only SELECT is allowed — the tool rejects INSERT/UPDATE/DELETE/DDL. -- The tool automatically scopes queries to the active property; you do not need to add a property_id filter manually. For crawl data, scope is applied through crawl_runs. -- Use row_cap intentionally: set a small value (10-50) for row listings and omit it (default 200) for aggregates. -- Keep results concise — use LIMIT, GROUP BY, and aggregate functions. Avoid SELECT *. -- Never tell the user you cannot run SQL if run_sql_query is loaded — use it. - -Rules: -- Use the provided tools to query real audit data. Do not invent URLs, scores, or metrics. -- When citing issues, include the URL when available. -- The chat UI automatically renders charts, gauges, and tables from tool results. Never tell the user you cannot show graphs or charts, and never send them to other app pages for data you can fetch with tools. -- For visual or chart requests, always call the appropriate tools first, then give a short interpretation (2–4 sentences) with recommendations. -- When tools return issue lists, scores, or breakdowns, do not re-list them in prose—the UI renders structured blocks from tool data. -- Do not emit markdown headings, bullet lists, or pipe tables for the user. The app synthesizes the final narrative from tool results. -- After gathering enough data via tools, stop calling tools. A brief internal acknowledgment is enough; user-facing text is generated separately. -- Do not repeat health scores, URL counts, success rates, category scores, priority counts, or URL lists when the UI already shows them in cards or tables. -- Never mention internal tool names (e.g. run_technical_workflow, export_audit_report) in user-facing text. -- Do not pass property_id or report_id in tool calls — they are injected from the active chat property. -- If data is missing, say what integration or crawl step is needed (briefly; narrative will be expanded separately). -""" - -_SYSTEM_PROMPT_READONLY_SUFFIX = """ -- You are read-only: you cannot run crawls or change settings. -""" - -_SYSTEM_PROMPT_CRAWL_SUFFIX = """ -Crawl playbook (when user asks to crawl, audit, or re-run a site): -- Clarify: new vs existing property, default vs custom configuration. -- Default: pick crawl preset (starter, spa, ecommerce, performance) and pipeline mode (full-audit or crawl-only). -- Custom: ask only high-impact overrides — max_pages, crawl_render_mode (static/auto/javascript), run_lighthouse_on_pages, concurrency. -- After collecting answers, always call prepare_audit_run to build a preview — never claim a crawl has started. -- The chat UI shows a confirm card; wait for the user to authorize and click Run before assuming the audit began. -- If prepare_audit_run returns job_running, tell the user an audit is already in progress. -""" - -SYSTEM_PROMPT_READONLY = _SYSTEM_PROMPT_BASE + _SYSTEM_PROMPT_READONLY_SUFFIX -SYSTEM_PROMPT_CRAWL_ENABLED = _SYSTEM_PROMPT_BASE + _SYSTEM_PROMPT_CRAWL_SUFFIX -# Back-compat for tests and imports -SYSTEM_PROMPT = SYSTEM_PROMPT_READONLY - - -def _chat_allow_crawl(cfg: dict[str, str]) -> bool: - return _truthy_cfg(cfg, "llm_chat_allow_crawl") - - -def resolve_system_prompt(cfg: dict[str, str]) -> str: - return SYSTEM_PROMPT_CRAWL_ENABLED if _chat_allow_crawl(cfg) else SYSTEM_PROMPT_READONLY - -REACT_PROMPT_SUFFIX = """ -Respond with valid JSON only, one of: -{"action":"tool","name":"","args":{...}} -{"action":"answer","text":""} -""" - - -def _emit(on_event: Callable[[dict], None] | None, event: dict[str, Any]) -> None: - if on_event: - on_event(sanitize_unicode_deep(event)) - - -def _supports_native_tools(client: Any) -> bool: - return callable(getattr(client, "chat_with_tools", None)) - - -def _uses_ollama_tool_format(client: Any) -> bool: - return client.__class__.__name__ == "OllamaClient" - - -def _react_step( - client: Any, - messages: list[dict[str, Any]], - tools_desc: str, - on_token: Callable[[str], None] | None, - *, - system_prompt: str, -) -> ChatResult: - """JSON ReAct fallback for providers without native tool calling.""" - # Include "tool" messages so the model sees prior tool results; otherwise it - # keeps re-issuing the same call and loops until MAX_TOOL_ROUNDS. - convo = "\n".join( - f"{m.get('role')}: {m.get('content')}" - for m in messages - if m.get("role") in ("user", "assistant", "system", "tool") - ) - user = f"Available tools:\n{tools_desc}\n\nConversation:\n{convo}\n\nNext action JSON:" - data = client.complete_json(system_prompt + REACT_PROMPT_SUFFIX, user) - action = str(data.get("action") or "").lower() - if action == "tool": - name = str(data.get("name") or "") - args = data.get("args") if isinstance(data.get("args"), dict) else {} - return ChatResult( - tool_calls=[ToolCall(id="react-0", name=name, arguments=args)], - ) - text = str(data.get("text") or data.get("answer") or data.get("content") or "") - return ChatResult(content=text) - - -def _tools_description(*, names: set[str] | None = None, compact: bool = False) -> str: - lines = [] - for t in TOOL_DEFINITIONS: - if names is not None and t["name"] not in names: - continue - if compact: - lines.append(f"- {t['name']}") - else: - lines.append(f"- {t['name']}: {t.get('description', '')}") - return "\n".join(lines) - - -def _last_user_message(messages: list[dict[str, str]]) -> str: - for msg in reversed(messages): - if msg.get("role") == "user": - return str(msg.get("content") or "") - return "" - - -def _expand_active_tools_from_result( - tc_name: str, - tool_result: dict[str, Any], - active: set[str], -) -> set[str]: - expanded = set(active) - pinned: set[str] = set() - - if tc_name == "search_audit_tools": - names = tool_result.get("tool_names") - if isinstance(names, list): - for name in names[:12]: - if isinstance(name, str) and name: - expanded.add(name) - pinned.add(name) - elif tc_name == "run_domain_agent": - names = tool_result.get("tools_used") - if isinstance(names, list): - for name in names: - if isinstance(name, str) and name: - expanded.add(name) - pinned.add(name) - - if chat_tool_mode() != "full" and pinned: - expanded = apply_tool_cap(expanded, chat_tool_search_cap(), pinned=pinned) - return expanded - - -def _build_openai_messages( - history: list[dict[str, str]], - system_prompt: str, -) -> list[dict[str, Any]]: - out: list[dict[str, Any]] = [{"role": "system", "content": system_prompt}] - for msg in history: - role = msg.get("role") - content = strip_surrogates(str(msg.get("content") or "")) - if role in ("user", "assistant"): - out.append({"role": role, "content": content}) - return out - - -def _finish_with_narrative( - cfg: dict[str, str], - user_message: str, - tool_events: list[dict[str, Any]], - on_event: Callable[[dict], None] | None, - *, - partial_note: str | None = None, -) -> dict[str, Any]: - if partial_note: - _emit(on_event, {"type": "partial_done", "message": partial_note}) - - def on_status(phase: str) -> None: - detail = "Retrying summary…" if phase == "retrying" else "Summarizing insights…" - _emit(on_event, {"type": "status", "phase": "synthesizing", "detail": detail}) - - try: - narrative = synthesize_chat_narrative( - cfg, - user_message, - tool_events, - on_status=on_status, - ) - except ChatNarrativeError: - _emit(on_event, {"type": "error", "message": NARRATIVE_FAILED_MSG}) - return { - "ok": False, - "error": NARRATIVE_FAILED_MSG, - "tool_events": tool_events, - } - - _emit(on_event, {"type": "narrative", "narrative": narrative}) - _emit(on_event, {"type": "done"}) - return {"ok": True, "tool_events": tool_events, "narrative": narrative} - - -def run_agent_turn( - messages: list[dict[str, str]], - context: AuditToolContext, - *, - on_event: Callable[[dict], None] | None = None, -) -> dict[str, Any]: - """ - Run the agent loop. Emits NDJSON-style events via on_event. - Returns final result dict with ok, tool_events, and narrative on success. - """ - cfg = load_llm_config_from_db() - if not llm_is_enabled(cfg): - err = "AI is disabled. Enable AI insights in the AI settings tab and configure a provider." - _emit(on_event, {"type": "error", "message": err}) - return {"ok": False, "error": err} - - try: - client = get_llm_client(cfg) - except ValueError as e: - msg = str(e) - _emit(on_event, {"type": "error", "message": msg}) - return {"ok": False, "error": msg} - - system_prompt = resolve_system_prompt(cfg) - openai_messages = _build_openai_messages(messages, system_prompt) - last_user = _last_user_message(messages) - active_names = select_tools_for_turn(last_user, messages) - if _chat_allow_crawl(cfg): - active_names.add(CHAT_CRAWL_TOOL) - tools = openai_tools_schema(active_names, context_scoped=True) - tool_events: list[dict[str, Any]] = [] - max_rounds = _max_tool_rounds(cfg) - partial_note: str | None = None - - for _round in range(max_rounds): - _emit(on_event, { - "type": "status", - "phase": "model", - "detail": f"Thinking (step {_round + 1}/{max_rounds})…", - }) - try: - llm_messages = sanitize_unicode_deep(openai_messages) - if _supports_native_tools(client): - result = client.chat_with_tools(llm_messages, tools, on_token=None) - else: - result = _react_step( - client, - llm_messages, - _tools_description(names=active_names, compact=True), - None, - system_prompt=system_prompt, - ) - except Exception as e: - msg = str(e).strip() or type(e).__name__ - if "Connection error" in msg and (cfg.get("llm_provider") or "").strip().lower() == "groq": - msg = ( - "Could not reach Groq. Check your Groq API key on the Secrets page and " - "that outbound HTTPS to api.groq.com is allowed. " - f"Details: {msg}" - ) - elif "httpx" in msg.lower() or "requirements.txt" in msg.lower(): - msg = ( - "LLM dependencies are missing. Run: pip install -r requirements.txt " - f"(or restart with ./local-run setup). Details: {msg}" - ) - _emit(on_event, {"type": "error", "message": msg}) - return {"ok": False, "error": msg, "tool_events": tool_events} - - if result.tool_calls: - ollama_format = _uses_ollama_tool_format(client) - assistant_tool_calls = [] - for i, tc in enumerate(result.tool_calls): - if ollama_format: - assistant_tool_calls.append({ - "type": "function", - "function": { - "index": i, - "name": tc.name, - "arguments": sanitize_unicode_deep(tc.arguments), - }, - }) - else: - assistant_tool_calls.append({ - "id": tc.id, - "type": "function", - "function": {"name": tc.name, "arguments": json.dumps(tc.arguments)}, - }) - - if _supports_native_tools(client): - openai_messages.append({ - "role": "assistant", - "content": strip_surrogates(result.content or ""), - "tool_calls": assistant_tool_calls, - }) - else: - openai_messages.append({ - "role": "assistant", - "content": f"Calling tool {result.tool_calls[0].name}", - }) - - # Parallel tool execution (Claude Code-style): independent, read-only tool - # calls in a single turn run concurrently on a bounded pool. Each dispatch - # opens its own pooled DB connection, AuditToolContext is immutable, and - # results are applied back in request order so OpenAI tool_call_id / Anthropic - # tool_use_id pairing stays correct. - for tc in result.tool_calls: - _emit(on_event, {"type": "tool_start", "name": tc.name, "args": tc.arguments}) - - gated = chat_tool_mode() != "full" - pre_round_active = set(active_names) - - def _run_tool(tc: ToolCall) -> dict[str, Any]: - if gated and tc.name not in pre_round_active: - return { - "error": f"tool not loaded this turn: {tc.name}", - "hint": "Call search_audit_tools to load specialized tools, or rephrase your request.", - } - tool_args = _normalize_tool_args(tc.arguments) - try: - return sanitize_unicode_deep( - dispatch_tool(tc.name, tool_args, context=context), - ) - except Exception as e: # noqa: BLE001 - isolate one tool's failure from the batch - return {"error": str(e).strip() or type(e).__name__} - - results = map_parallel( - result.tool_calls, _run_tool, max_workers=tool_concurrency(), - ) - - for tc, tool_result in zip(result.tool_calls, results): - _emit(on_event, {"type": "tool_end", "name": tc.name, "result": tool_result}) - tool_events.append({"name": tc.name, "args": tc.arguments, "result": tool_result}) - active_names = _expand_active_tools_from_result(tc.name, tool_result, active_names) - - tool_content = json.dumps(tool_result, default=str) - if ollama_format: - openai_messages.append({ - "role": "tool", - "tool_name": tc.name, - "content": tool_content, - }) - else: - openai_messages.append({ - "role": "tool", - "tool_call_id": tc.id, - "content": tool_content, - }) - - if gated: - tools = openai_tools_schema(active_names, context_scoped=True) - continue - - break - else: - if tool_events: - partial_note = ( - f"The agent completed {len(tool_events)} tool step(s) but did not finish " - "all planned steps. Tool results are preserved below." - ) - - return _finish_with_narrative( - cfg, - last_user, - tool_events, - on_event, - partial_note=partial_note, - ) diff --git a/src/website_profiling/llm/audit_summary.py b/src/website_profiling/llm/audit_summary.py deleted file mode 100644 index fc530802..00000000 --- a/src/website_profiling/llm/audit_summary.py +++ /dev/null @@ -1,161 +0,0 @@ -"""LLM executive audit summary and traffic-weighted issue prioritization.""" -from __future__ import annotations - -from typing import Any - -from ..scoring import round_half_up - -_PRIORITY_RANK = {"Critical": 0, "High": 1, "Medium": 2, "Low": 3} - - -def rank_issues_by_traffic( - categories: list[dict[str, Any]], - gsc_pages: list[dict[str, Any]] | None = None, -) -> list[dict[str, Any]]: - """Sort issues by GSC clicks to matching URL (descending).""" - clicks_by_url: dict[str, float] = {} - for row in gsc_pages or []: - if not isinstance(row, dict): - continue - url = str(row.get("page") or "").strip().lower() - if not url: - continue - try: - clicks_by_url[url] = float(row.get("clicks") or 0) - except (TypeError, ValueError): - clicks_by_url[url] = 0.0 - - ranked: list[dict[str, Any]] = [] - for cat in categories or []: - cat_name = cat.get("name") or cat.get("id") or "" - for issue in cat.get("issues") or []: - if not isinstance(issue, dict): - continue - url = str(issue.get("url") or "").strip().lower() - clicks = clicks_by_url.get(url, 0.0) - ranked.append({ - **issue, - "category": cat_name, - "gsc_clicks": clicks, - "traffic_weight": clicks, - }) - # Tiebreak by severity rank (not the raw string, which sorts Low before Medium alphabetically). - ranked.sort( - key=lambda x: (-x.get("traffic_weight", 0), _PRIORITY_RANK.get(x.get("priority", "Medium"), 99)) - ) - return ranked - - -def generate_audit_executive_summary( - report_payload: dict[str, Any], - cfg: dict[str, str] | None = None, -) -> dict[str, Any]: - """Optional LLM narrative; falls back to deterministic summary.""" - from ..llm_config import llm_is_enabled - - categories = report_payload.get("categories") or [] - gsc = (report_payload.get("google") or {}).get("gsc") or {} - gsc_pages = gsc.get("top_pages") if isinstance(gsc, dict) else [] - top_issues = rank_issues_by_traffic(categories, gsc_pages)[:5] - - scores = [c.get("score") for c in categories if isinstance(c.get("score"), (int, float))] - avg = round_half_up(sum(scores) / len(scores)) if scores else None - - fallback = _deterministic_summary_text(avg, top_issues) - - source = "deterministic" - priorities: list[str] = [] - if llm_is_enabled(cfg or {}) and _audit_summary_llm_enabled(cfg or {}): - source = "ai_insights" - llm_result = _generate_llm_executive_summary(report_payload, top_issues, cfg or {}) - if llm_result.get("summary"): - fallback = str(llm_result["summary"]) - priorities = llm_result.get("priorities") or [] - else: - fallback = _deterministic_summary_text(avg, top_issues, llm_unavailable=True) - elif llm_is_enabled(cfg or {}): - fallback = _deterministic_summary_text( - avg, - top_issues, - hint_enable_llm=True, - ) - - return { - "ok": True, - "source": source, - "summary": fallback, - "top_issues": top_issues, - "priorities": priorities, - } - - -def _deterministic_summary_text( - avg: int | None, - top_issues: list[dict[str, Any]], - *, - llm_unavailable: bool = False, - hint_enable_llm: bool = False, -) -> str: - """Short narrative for UI; structured score/issues render separately in the app.""" - if top_issues: - msg = "Prioritize fixes below by severity and Search Console traffic impact." - elif avg is not None and avg >= 80: - msg = "Site health looks strong. Keep monitoring crawl and Search Console trends." - elif avg is not None: - msg = "Review category scores and address high-priority issues to improve overall health." - else: - msg = "No major issues detected in this audit run." - - if llm_unavailable: - msg = f"{msg} (AI summary unavailable — showing structured overview only.)" - elif hint_enable_llm: - msg = f"{msg} Enable audit executive summary in AI settings for an AI narrative." - return msg - - -def _audit_summary_llm_enabled(cfg: dict[str, str]) -> bool: - v = str(cfg.get("llm_enable_audit_summary", "true")).lower() - return v in ("true", "1", "yes") - - -def _generate_llm_executive_summary( - report_payload: dict[str, Any], - top_issues: list[dict[str, Any]], - cfg: dict[str, str], -) -> dict[str, Any]: - import json - - from .base import get_llm_client, parse_json_response - from .prompts import AUDIT_EXECUTIVE_SYSTEM - - categories = report_payload.get("categories") or [] - scores = [c.get("score") for c in categories if isinstance(c.get("score"), (int, float))] - avg = round_half_up(sum(scores) / len(scores)) if scores else None - payload = { - "health_score": avg, - "category_scores": [ - {"name": c.get("name"), "score": c.get("score")} - for c in categories[:12] - if isinstance(c, dict) - ], - "top_issues": [ - { - "priority": i.get("priority"), - "message": i.get("message"), - "url": i.get("url"), - "gsc_clicks": i.get("gsc_clicks"), - } - for i in top_issues[:5] - ], - "total_urls": (report_payload.get("summary") or {}).get("total_urls"), - } - try: - client = get_llm_client(cfg) - user = json.dumps(payload, indent=2, default=str)[:10000] - raw = client.complete_json(AUDIT_EXECUTIVE_SYSTEM, user) - parsed = raw if isinstance(raw, dict) and raw else parse_json_response(str(raw)) - summary = str(parsed.get("summary") or "").strip() - priorities = parsed.get("priorities") if isinstance(parsed.get("priorities"), list) else [] - return {"summary": summary, "priorities": priorities} - except Exception: - return {} diff --git a/src/website_profiling/llm/base.py b/src/website_profiling/llm/base.py deleted file mode 100644 index 32ee0991..00000000 --- a/src/website_profiling/llm/base.py +++ /dev/null @@ -1,101 +0,0 @@ -"""LLM provider abstraction for content enrichment.""" -from __future__ import annotations - -import json -import re -from dataclasses import dataclass, field -from typing import Any, Callable, Protocol - - -@dataclass -class ToolCall: - id: str - name: str - arguments: dict[str, Any] - - -@dataclass -class ChatResult: - content: str = "" - tool_calls: list[ToolCall] = field(default_factory=list) - finish_reason: str = "stop" - - -TokenCallback = Callable[[str], None] - -OLLAMA_DEFAULT_BASES = frozenset({ - "http://127.0.0.1:11434", - "http://localhost:11434", -}) - - -def is_ollama_base_url(url: str) -> bool: - """True when llm_base_url points at a local Ollama daemon (not a cloud proxy).""" - normalized = (url or "").strip().rstrip("/").lower() - if normalized in OLLAMA_DEFAULT_BASES: - return True - return normalized.endswith(":11434") - - -def optional_cloud_base_url(cfg: dict[str, str]) -> str | None: - """Custom OpenAI-compatible base URL; excludes Ollama's local default.""" - base = (cfg.get("llm_base_url") or "").strip().rstrip("/") - if not base or is_ollama_base_url(base): - return None - return base - - -class LLMClient(Protocol): - def complete_json(self, system: str, user: str) -> dict[str, Any]: ... - - def chat_with_tools( - self, - messages: list[dict[str, Any]], - tools: list[dict[str, Any]], - *, - on_token: TokenCallback | None = None, - ) -> ChatResult: ... - - -def parse_json_response(text: str) -> dict[str, Any]: - text = (text or "").strip() - if not text: - return {} - try: - data = json.loads(text) - return data if isinstance(data, dict) else {"data": data} - except json.JSONDecodeError: - pass - m = re.search(r"\{[\s\S]*\}", text) - if m: - try: - data = json.loads(m.group(0)) - return data if isinstance(data, dict) else {"data": data} - except json.JSONDecodeError: - pass - return {} - - -def get_llm_client(cfg: dict[str, str]) -> LLMClient: - provider = (cfg.get("llm_provider") or "none").strip().lower() - if provider == "openai": - from .providers.openai import OpenAIClient - - return OpenAIClient(cfg) - if provider == "anthropic": - from .providers.anthropic import AnthropicClient - - return AnthropicClient(cfg) - if provider == "gemini": - from .providers.gemini import GeminiClient - - return GeminiClient(cfg) - if provider == "groq": - from .providers.groq import GroqClient - - return GroqClient(cfg) - if provider == "ollama": - from .providers.ollama import OllamaClient - - return OllamaClient(cfg) - raise ValueError(f"Unknown LLM provider: {provider}") diff --git a/src/website_profiling/llm/chat_narrative.py b/src/website_profiling/llm/chat_narrative.py deleted file mode 100644 index 54364920..00000000 --- a/src/website_profiling/llm/chat_narrative.py +++ /dev/null @@ -1,164 +0,0 @@ -"""Structured narrative synthesis for chat turns.""" -from __future__ import annotations - -import json -from typing import Any, Callable - -from .base import get_llm_client, parse_json_response -from .prompts import CHAT_NARRATIVE_REPAIR_SYSTEM, CHAT_NARRATIVE_SYSTEM - -MAX_ITEMS = 5 -MAX_PAYLOAD_CHARS = 10000 -MAX_PREVIOUS_RESPONSE_CHARS = 4000 - -NarrativeStatusCallback = Callable[[str], None] - - -class ChatNarrativeError(Exception): - def __init__(self, message: str, errors: list[str] | None = None) -> None: - super().__init__(message) - self.errors = errors or [] - - -def build_synthesis_payload( - user_message: str, - tool_events: list[dict[str, Any]], - *, - conversation_snippet: str | None = None, -) -> str: - compact_events = [ - { - "name": ev.get("name"), - "args": ev.get("args"), - "result": ev.get("result"), - } - for ev in tool_events - ] - payload: dict[str, Any] = { - "user_question": user_message, - "tool_results": compact_events, - } - if conversation_snippet: - payload["conversation_context"] = conversation_snippet - raw = json.dumps(payload, indent=2, default=str) - if len(raw) > MAX_PAYLOAD_CHARS: - return raw[:MAX_PAYLOAD_CHARS] + "\n…(truncated)" - return raw - - -def _normalize_string_list(value: Any, field: str, errors: list[str]) -> list[str]: - if value is None: - errors.append(f"missing key {field}") - return [] - if not isinstance(value, list): - errors.append(f"{field} must be an array") - return [] - # Over-length lists are silently capped to MAX_ITEMS below (the `break`), not flagged - # as a validation error — doing so would discard an otherwise-valid response and force - # a wasteful repair pass (or an outright failure) on common >5-item LLM output. - out: list[str] = [] - for i, item in enumerate(value): - if not isinstance(item, str): - errors.append(f"{field}[{i}] must be a string") - continue - text = item.strip() - if not text: - errors.append(f"{field}[{i}] is empty") - continue - out.append(text) - if len(out) >= MAX_ITEMS: - break - return out - - -def validate_chat_narrative(raw: dict[str, Any]) -> tuple[dict[str, list[str]], list[str]]: - errors: list[str] = [] - if not isinstance(raw, dict): - return {"power_insights": [], "recommended_actions": []}, ["response must be a JSON object"] - - insights = _normalize_string_list(raw.get("power_insights"), "power_insights", errors) - actions = _normalize_string_list(raw.get("recommended_actions"), "recommended_actions", errors) - - if not insights and not actions: - errors.append("both power_insights and recommended_actions are empty after normalization") - - return {"power_insights": insights, "recommended_actions": actions}, errors - - -def _coerce_attempt(raw: Any) -> tuple[dict[str, Any], str]: - if isinstance(raw, dict): - return raw, json.dumps(raw, default=str) - text = str(raw or "").strip() - return parse_json_response(text), text - - -def _attempt_synthesis( - client: Any, - system: str, - user: str, -) -> tuple[dict[str, list[str]] | None, list[str], str]: - errors: list[str] = [] - raw_text = "" - try: - raw = client.complete_json(system, user) - parsed, raw_text = _coerce_attempt(raw) - narrative, errors = validate_chat_narrative(parsed) - if not errors: - return narrative, [], raw_text - except Exception as e: # noqa: BLE001 - convert to validation errors for repair pass - errors = [str(e).strip() or type(e).__name__] - if not raw_text: - raw_text = errors[0] - return None, errors, raw_text - - -def synthesize_chat_narrative( - cfg: dict[str, str], - user_message: str, - tool_events: list[dict[str, Any]], - *, - on_status: NarrativeStatusCallback | None = None, -) -> dict[str, list[str]]: - """Synthesize narrative JSON; retries once with repair prompt before raising.""" - client = get_llm_client(cfg) - payload = build_synthesis_payload(user_message, tool_events) - - if on_status: - on_status("synthesizing") - - narrative, errors, previous = _attempt_synthesis(client, CHAT_NARRATIVE_SYSTEM, payload) - if narrative is not None: - return narrative - - if on_status: - on_status("retrying") - - try: - original_data = json.loads(payload) - except json.JSONDecodeError: - original_data = payload - - repair_payload = json.dumps( - { - "original_data": original_data, - "previous_response": (previous or "")[:MAX_PREVIOUS_RESPONSE_CHARS], - "errors": errors, - "required_schema": { - "power_insights": ["string"], - "recommended_actions": ["string"], - }, - }, - indent=2, - default=str, - ) - - narrative2, errors2, _ = _attempt_synthesis( - client, CHAT_NARRATIVE_REPAIR_SYSTEM, repair_payload, - ) - if narrative2 is not None: - return narrative2 - - raise ChatNarrativeError( - "Chat narrative synthesis failed after repair attempt.", - errors=errors + errors2, - ) diff --git a/src/website_profiling/llm/content_brief.py b/src/website_profiling/llm/content_brief.py deleted file mode 100644 index 69bc2bf4..00000000 --- a/src/website_profiling/llm/content_brief.py +++ /dev/null @@ -1,34 +0,0 @@ -"""LLM-assisted content brief from keyword cluster (labeled AI insights).""" -from __future__ import annotations - -from typing import Any - - -def generate_content_brief( - keyword: str, - cluster_rows: list[dict[str, Any]], - gaps: list[str] | None = None, - *, - use_llm: bool = False, -) -> dict[str, Any]: - impressions = sum(int(r.get("gsc_impressions") or 0) for r in cluster_rows) - top_url = "" - if cluster_rows: - top = max(cluster_rows, key=lambda r: int(r.get("gsc_clicks") or 0)) - top_url = str(top.get("gsc_url") or "") - bullets = [ - f"Target query: {keyword}", - f"Cluster size: {len(cluster_rows)} queries/pages", - f"Combined impressions: {impressions:,}", - ] - if top_url: - bullets.append(f"Primary landing page: {top_url}") - if gaps: - bullets.extend(f"Gap: {g}" for g in gaps[:5]) - summary = "\n".join(f"• {b}" for b in bullets) - return { - "keyword": keyword, - "summary": summary, - "provenance": "AI insights" if use_llm else "Estimated", - "use_llm": use_llm, - } diff --git a/src/website_profiling/llm/dashboard_ai.py b/src/website_profiling/llm/dashboard_ai.py deleted file mode 100644 index 2f78f914..00000000 --- a/src/website_profiling/llm/dashboard_ai.py +++ /dev/null @@ -1,68 +0,0 @@ -"""AI-powered DashScript and widget/dashboard generation.""" -from __future__ import annotations - -import json -from typing import Any - -from ..llm_config import llm_is_enabled -from .base import get_llm_client, parse_json_response -from .prompts import DASHBOARD_AI_SYSTEM - -VALID_MODES = frozenset({"script", "widget", "dashboard"}) - - -def _dashboard_ai_enabled(cfg: dict[str, str]) -> bool: - v = str(cfg.get("llm_enable_dashboards", "true")).lower() - return v in ("true", "1", "yes") - - -def generate_dashboard_ai( - payload: dict[str, Any], - *, - cfg: dict[str, str] | None = None, -) -> dict[str, Any]: - """Generate DashScript, a full widget, or a whole dashboard from a natural-language prompt. - - ``payload`` shape:: - - { - "mode": "script" | "widget" | "dashboard", - "prompt": "", - "catalog": [ { toolName, label, fields, compatibleViz, ... } ], - "viz_types": { "bar": "Vertical bar chart", ... }, - "dashscript_help": "", - "current": { optional current widget binding/options }, - "sample": { optional truncated tool result for the selected tool }, - } - - Return value varies by mode — validation happens in TypeScript. - """ - from ..llm_config import load_llm_config_from_db - - cfg = cfg or load_llm_config_from_db() - if not llm_is_enabled(cfg): - return {"ok": False, "error": "AI insights are disabled.", "missing": True} - if not _dashboard_ai_enabled(cfg): - return {"ok": False, "error": "Dashboard AI is disabled in task settings.", "missing": True} - - mode = str(payload.get("mode") or "widget").strip().lower() - if mode not in VALID_MODES: - return {"ok": False, "error": f"Unknown mode: {mode!r}. Must be one of: script, widget, dashboard."} - - prompt = str(payload.get("prompt") or "").strip() - if not prompt: - return {"ok": False, "error": "prompt is required."} - - try: - client = get_llm_client(cfg) - user = json.dumps(payload, indent=2, default=str)[:10_000] - raw = client.complete_json(DASHBOARD_AI_SYSTEM, user) - result = raw if isinstance(raw, dict) and raw else parse_json_response(str(raw)) - if not isinstance(result, dict) or not result: - return {"ok": False, "error": "AI returned no parseable output."} - # Don't force success: keep an explicit ok/error the model may have - # returned instead of masking a failure as a successful generation. - result.setdefault("ok", True) - return result - except Exception as exc: - return {"ok": False, "error": str(exc)} diff --git a/src/website_profiling/llm/enrich.py b/src/website_profiling/llm/enrich.py deleted file mode 100644 index 1b0d4d8e..00000000 --- a/src/website_profiling/llm/enrich.py +++ /dev/null @@ -1,369 +0,0 @@ -"""LLM-backed content enrichment (UI-configured via llm_config table).""" -from __future__ import annotations - -import hashlib -import json -import os -import re -from collections import Counter -from concurrent.futures import ThreadPoolExecutor, as_completed -from typing import Any, Callable, Optional - -import pandas as pd - -from ..analysis.text import normalize_fingerprint_text -from ..console_io import console_print -from ..llm_config import llm_is_enabled -from .base import get_llm_client -from .prompts import ( - KEYPHRASES_SYSTEM, - KEYWORD_CLUSTER_SYSTEM, - NER_SYSTEM, - PROMPT_VERSION, - SIMILAR_SYSTEM, -) - -LLM_INSTALL_HINT = "Install LLM dependencies: pip install -r requirements.txt" - - -def _cfg_bool(cfg: dict[str, str] | None, key: str, default: bool = False) -> bool: - if not cfg: - return default - return str(cfg.get(key, default)).lower() in ("true", "1", "yes") - - -def _cfg_int(cfg: dict[str, str] | None, key: str, default: int) -> int: - if not cfg: - return default - raw = cfg.get(key) - if raw is None or str(raw).strip() == "": - return default - try: - return int(str(raw).strip()) - except ValueError: - return default - - -def _html_success_df(df: pd.DataFrame, max_pages: int) -> pd.DataFrame: - success = df[df["status"].astype(str).str.match(r"2\d{2}", na=False)] if "status" in df.columns else df - if "content_type" in success.columns: - success = success[success["content_type"].fillna("").str.contains("text/html", case=False, na=False)] - return success.head(max_pages) - - -def _page_batch_items(df: pd.DataFrame, max_pages: int) -> list[dict[str, str]]: - items: list[dict[str, str]] = [] - for _, row in _html_success_df(df, max_pages).iterrows(): - u = str(row.get("url") or "").strip().rstrip("/") - text = normalize_fingerprint_text(row) - if not u or len(text) < 40: - continue - items.append({"url": u, "text": text[:4000]}) - return items - - -def _cache_key(task: str, model: str, payload: str) -> str: - h = hashlib.sha256(f"{PROMPT_VERSION}:{task}:{model}:{payload}".encode()).hexdigest() - return h - - -def _read_cache(key: str) -> Optional[dict[str, Any]]: - try: - from ..db import db_session - from ..db.storage import read_llm_cache - - with db_session() as conn: - raw = read_llm_cache(conn, key) - if raw: - from ..db.storage import _parse_json_field - - parsed = _parse_json_field(raw) - return parsed if isinstance(parsed, dict) else None - except Exception: - pass - return None - - -def _write_cache(key: str, data: dict[str, Any]) -> None: - try: - from ..db import db_session - from ..db.storage import write_llm_cache - - with db_session() as conn: - write_llm_cache(conn, key, json.dumps(data)) - except Exception: - pass - - -def _llm_concurrency(cfg: dict[str, str]) -> int: - return max(1, min(_cfg_int(cfg, "llm_concurrency", 2) or 2, 8)) - - -def _run_llm_batches( - client: Any, - task: str, - system: str, - batches: list[dict[str, Any]], - cfg: dict[str, str], - apply_batch: Callable[[dict[str, Any], dict[str, Any]], None], -) -> None: - """Run LLM batches with batched cache lookup and optional parallel API calls.""" - if not batches: - return - model = (cfg.get("llm_model") or cfg.get("llm_provider") or "").strip() - keyed: list[tuple[str, dict[str, Any], str]] = [] - for payload in batches: - payload_str = json.dumps(payload, sort_keys=True) - ck = _cache_key(task, model, payload_str) - keyed.append((ck, payload, payload_str)) - - cached_map: dict[str, dict[str, Any]] = {} - try: - from ..db import db_session - from ..db.storage import read_llm_cache_batch - - with db_session() as conn: - cached_map = read_llm_cache_batch(conn, [k for k, _, _ in keyed]) - except Exception: - pass - - pending: list[tuple[str, dict[str, Any]]] = [] - for ck, payload, _ in keyed: - hit = cached_map.get(ck) - if hit is not None: - apply_batch(payload, hit) - else: - pending.append((ck, payload)) - - if not pending: - return - - workers = _llm_concurrency(cfg) - - def _one(item: tuple[str, dict[str, Any]]) -> tuple[str, dict[str, Any], dict[str, Any]]: - ck, payload = item - result = client.complete_json(system, json.dumps(payload)) - _write_cache(ck, result) - return ck, payload, result - - if workers <= 1 or len(pending) <= 1: - for item in pending: - try: - _, payload, result = _one(item) - apply_batch(payload, result) - except Exception as exc: # noqa: BLE001 - one batch failing must not abort the rest - console_print(f" LLM enrichment batch failed: {exc}", flush=True) - return - - with ThreadPoolExecutor(max_workers=workers) as pool: - futures = [pool.submit(_one, item) for item in pending] - for future in as_completed(futures): - try: - _, payload, result = future.result() - apply_batch(payload, result) - except Exception as exc: # noqa: BLE001 - one batch failing must not abort the rest - console_print(f" LLM enrichment batch failed: {exc}", flush=True) - - -def _call_cached( - client: Any, - task: str, - system: str, - user_payload: dict[str, Any], - cfg: dict[str, str], -) -> dict[str, Any]: - model = (cfg.get("llm_model") or cfg.get("llm_provider") or "").strip() - payload_str = json.dumps(user_payload, sort_keys=True) - ck = _cache_key(task, model, payload_str) - cached = _read_cache(ck) - if cached is not None: - return cached - result = client.complete_json(system, json.dumps(user_payload)) - _write_cache(ck, result) - return result - - -def aggregate_ner_site_summary(spacy_by_url: dict[str, dict[str, Any]]) -> dict[str, Any]: - label_totals: Counter[str] = Counter() - total_entities = 0 - for _u, info in (spacy_by_url or {}).items(): - if not isinstance(info, dict): - continue - total_entities += int(info.get("entity_count") or 0) - for pair in info.get("top_entity_labels") or []: - if isinstance(pair, (list, tuple)) and len(pair) >= 2: - label_totals[str(pair[0])] += int(pair[1]) - return { - "label_counts": dict(label_totals.most_common(40)), - "pages_with_ner": len(spacy_by_url or {}), - "total_entities": total_entities, - } - - -def _run_ner( - client: Any, - items: list[dict[str, str]], - cfg: dict[str, str], -) -> dict[str, dict[str, Any]]: - batch_size = max(1, _cfg_int(cfg, "llm_batch_size", 5)) - out: dict[str, dict[str, Any]] = {} - batches = [{"pages": items[i : i + batch_size]} for i in range(0, len(items), batch_size)] - - def apply_batch(_payload: dict[str, Any], data: dict[str, Any]) -> None: - for p in data.get("pages") or []: - u = str(p.get("url") or "").strip().rstrip("/") - if not u: - continue - labels = p.get("top_entity_labels") or [] - out[u] = { - "entity_count": int(p.get("entity_count") or 0), - "top_entity_labels": labels, - } - - _run_llm_batches(client, "ner", NER_SYSTEM, batches, cfg, apply_batch) - return out - - -def _run_keyphrases( - client: Any, - items: list[dict[str, str]], - cfg: dict[str, str], -) -> dict[str, dict[str, Any]]: - batch_size = max(1, _cfg_int(cfg, "llm_batch_size", 5)) - out: dict[str, dict[str, Any]] = {} - batches = [{"pages": items[i : i + batch_size]} for i in range(0, len(items), batch_size)] - - def apply_batch(_payload: dict[str, Any], data: dict[str, Any]) -> None: - for p in data.get("pages") or []: - u = str(p.get("url") or "").strip().rstrip("/") - if not u: - continue - phrases = p.get("phrases") or [] - pairs = [[str(x[0]), float(x[1])] for x in phrases if isinstance(x, (list, tuple)) and len(x) >= 2] - out[u] = {"phrases": pairs} - - _run_llm_batches(client, "keyphrases", KEYPHRASES_SYSTEM, batches, cfg, apply_batch) - return out - - -def _run_similar_internal( - client: Any, - items: list[dict[str, str]], - cfg: dict[str, str], -) -> dict[str, list[dict[str, Any]]]: - top_k = min(_cfg_int(cfg, "llm_similar_top_k", 5) or 5, 15) - all_urls = [x["url"] for x in items] - out: dict[str, list[dict[str, Any]]] = {} - batch_size = max(1, min(_cfg_int(cfg, "llm_batch_size", 5), 3)) - batches = [ - {"pages": items[i : i + batch_size], "candidate_urls": all_urls[:80], "top_k": top_k} - for i in range(0, len(items), batch_size) - ] - - def apply_batch(_payload: dict[str, Any], data: dict[str, Any]) -> None: - for p in data.get("pages") or []: - u = str(p.get("url") or "").strip().rstrip("/") - if not u: - continue - sim = [] - for s in (p.get("similar") or [])[:top_k]: - if isinstance(s, dict) and s.get("url"): - sim.append({"url": str(s["url"]), "score": round(float(s.get("score") or 0), 4)}) - if sim: - out[u] = sim - - _run_llm_batches(client, "similar", SIMILAR_SYSTEM, batches, cfg, apply_batch) - return out - - -def cluster_keywords_llm( - keywords: list[str], - cfg: dict[str, str] | None, -) -> list[dict[str, Any]]: - if not keywords or not cfg or not llm_is_enabled(cfg): - return [] - if not _cfg_bool(cfg, "llm_enable_keyword_clusters", False): - return [] - kws = keywords[:200] - if len(kws) < 2: - return [] - try: - client = get_llm_client(cfg) - data = _call_cached( - client, - "kw_clusters", - KEYWORD_CLUSTER_SYSTEM, - {"keywords": kws}, - cfg, - ) - clusters = data.get("clusters") or [] - out: list[dict[str, Any]] = [] - for c in clusters: - if not isinstance(c, dict): - continue - words = c.get("keywords") or [] - if len(words) < 2: - continue - out.append( - { - "top_keyword": str(c.get("top_keyword") or words[0]), - "keywords": sorted(str(w) for w in words), - "cluster_score": round(float(c.get("cluster_score") or 0.9), 4), - } - ) - out.sort(key=lambda x: -x["cluster_score"]) - return out - except Exception as e: - raise RuntimeError(str(e)) from e - - -def run_llm_enrichment( - df: pd.DataFrame, - cfg: dict[str, str] | None, -) -> dict[str, Any]: - bundle: dict[str, Any] = { - "spacy_by_url": {}, - "similar_internal_by_url": {}, - "ner_site_summary": {}, - "keyphrases_by_url": {}, - "ml_errors": [], - } - if df.empty or not cfg or not llm_is_enabled(cfg): - return bundle - - max_pages = _cfg_int(cfg, "llm_max_pages", 60) or 60 - items = _page_batch_items(df, max_pages) - if not items: - return bundle - - try: - client = get_llm_client(cfg) - except Exception as e: - bundle["ml_errors"].append(str(e)) - return bundle - - if _cfg_bool(cfg, "llm_enable_ner", True): - try: - bundle["spacy_by_url"] = _run_ner(client, items, cfg) - except Exception as e: - bundle["ml_errors"].append(f"AI insights (entities): {e}") - - if _cfg_bool(cfg, "llm_enable_keyphrases", True): - try: - bundle["keyphrases_by_url"] = _run_keyphrases(client, items, cfg) - except Exception as e: - bundle["ml_errors"].append(f"AI insights (keyphrases): {e}") - - if _cfg_bool(cfg, "llm_enable_similar_internal", True): - try: - bundle["similar_internal_by_url"] = _run_similar_internal(client, items, cfg) - except Exception as e: - bundle["ml_errors"].append(f"AI insights (similar pages): {e}") - - bundle["ner_site_summary"] = aggregate_ner_site_summary(bundle.get("spacy_by_url") or {}) - bundle["llm_meta"] = { - "model": str(cfg.get("llm_model") or "").strip() or "unknown", - "prompt_version": PROMPT_VERSION, - "generated_at": pd.Timestamp.now(tz="UTC").isoformat(), - } - return bundle diff --git a/src/website_profiling/llm/fix_suggestions.py b/src/website_profiling/llm/fix_suggestions.py deleted file mode 100644 index 004261ef..00000000 --- a/src/website_profiling/llm/fix_suggestions.py +++ /dev/null @@ -1,94 +0,0 @@ -"""Unified on-demand LLM fix suggestions across audit surfaces.""" -from __future__ import annotations - -import hashlib -import json -from typing import Any - -from ..llm_config import llm_is_enabled -from .base import get_llm_client, parse_json_response -from .enrich import _read_cache, _write_cache -from .prompts import FIX_SUGGESTION_PROMPTS, PROMPT_VERSION - -VALID_SOURCES = frozenset(FIX_SUGGESTION_PROMPTS.keys()) -DEFAULT_FIX = {"fix": "Review the issue on the affected URL and apply standard remediation.", "effort": "medium"} - - -def _fix_suggestion_enabled(cfg: dict[str, str]) -> bool: - v = str(cfg.get("llm_enable_issue_fixes", "true")).lower() - return v in ("true", "1", "yes") - - -def _normalize_source(raw: Any) -> str: - source = str(raw or "issue").strip().lower() - return source if source in VALID_SOURCES else "issue" - - -def _cache_key(model: str, source: str, payload: dict[str, Any]) -> str: - body = json.dumps(payload, sort_keys=True, default=str) - digest = hashlib.sha256(f"fix_suggestion:{PROMPT_VERSION}:{model}:{source}:{body}".encode()).hexdigest() - return digest - - -def _build_user_payload(payload: dict[str, Any]) -> dict[str, Any]: - source = _normalize_source(payload.get("source")) - out: dict[str, Any] = { - "source": source, - "message": str(payload.get("message") or "").strip(), - } - if payload.get("url"): - out["url"] = payload.get("url") - context = payload.get("context") - if isinstance(context, dict) and context: - out["context"] = context - # Legacy issue-fix fields folded into context for source=issue - if source == "issue": - legacy: dict[str, Any] = {} - for key in ("priority", "category", "type", "finding_type", "recommendation", "existing_recommendation"): - if payload.get(key) is not None: - legacy[key] = payload.get(key) - if legacy: - out.setdefault("context", {}).update(legacy) - return out - - -def generate_fix_suggestion( - payload: dict[str, Any], - *, - cfg: dict[str, str] | None = None, - refresh: bool = False, -) -> dict[str, Any]: - from ..llm_config import load_llm_config_from_db - - cfg = cfg or load_llm_config_from_db() - if not llm_is_enabled(cfg): - return {"ok": False, "error": "AI insights are disabled."} - if not _fix_suggestion_enabled(cfg): - return {"ok": False, "error": "Issue fix suggestions are disabled in AI task settings."} - - user_payload = _build_user_payload(payload) - message = user_payload.get("message") or "" - if not message: - return {"ok": False, "error": "message required."} - - source = str(user_payload["source"]) - model = (cfg.get("llm_model") or cfg.get("llm_provider") or "unknown").strip() - cache_key = _cache_key(model, source, user_payload) - - if not refresh: - cached = _read_cache(cache_key) - if cached: - return {"ok": True, "cached": True, "fix": cached, "provenance": "AI insights"} - - system = FIX_SUGGESTION_PROMPTS[source] - try: - client = get_llm_client(cfg) - user = json.dumps(user_payload, indent=2, default=str)[:8000] - raw = client.complete_json(system, user) - fix = raw if isinstance(raw, dict) and raw else parse_json_response(str(raw)) - if not fix or not str(fix.get("fix") or "").strip(): - fix = dict(DEFAULT_FIX) - _write_cache(cache_key, fix) - return {"ok": True, "cached": False, "fix": fix, "provenance": "AI insights"} - except Exception as e: - return {"ok": False, "error": str(e)} diff --git a/src/website_profiling/llm/help_agent.py b/src/website_profiling/llm/help_agent.py deleted file mode 100644 index 2f78f17d..00000000 --- a/src/website_profiling/llm/help_agent.py +++ /dev/null @@ -1,136 +0,0 @@ -"""Help agent — single-turn LLM call for setup and usage questions. No tools, no property context.""" -from __future__ import annotations - -import json -from typing import Any, Callable - -from ..llm_config import llm_is_enabled, load_llm_config_from_db -from ..text_sanitize import sanitize_unicode_deep -from .base import get_llm_client - -_HELP_SYSTEM_PROMPT = """You are the Site Audit Help Assistant, embedded in a self-hosted SEO audit platform. -You help users set up the tool, configure credentials, and understand features. -Answer only questions about this application. Keep answers concise (under 200 words unless step-by-step setup is needed). - -## Quick start -- Run locally: `docker compose up --build` from the repo root. -- Set `DATABASE_URL` env var pointing at a PostgreSQL instance. -- Access the UI at http://localhost:3000. - -## Credential & integration setup - -### Google Search Console / Analytics -1. Create a Google Cloud project with the Search Console API and Google Analytics Data API enabled. -2. Set up an OAuth consent screen (External, add yourself as test user). -3. Create OAuth 2.0 credentials (Web Application, redirect URI: http://localhost:3000/api/integrations/google/callback). -4. In the app, go to /docs/integrations/google and follow the step-by-step guide. -5. Alternatively, use a Service Account JSON for headless/server deployments. - -### AI providers (LLM) -Go to /secrets (or the gear icon in AI Chat sidebar) and enter your API key: -- OpenAI: get key at platform.openai.com → API keys -- Anthropic: get key at console.anthropic.com -- Groq: get key at console.groq.com -- Google Gemini: get key at aistudio.google.com -- Ollama (local, free): install Ollama, run `ollama pull `, set base URL to http://localhost:11434 -Full guide: /docs/integrations/ai - -### Bing Webmaster Tools -1. Sign in at bing.com/webmasters and add your site. -2. Go to Settings → API Access → Generate API Key. -3. In the app, add the key to the pipeline config under "Bing Webmaster API key". -Full guide: /docs/integrations/bing - -### SERP API -1. Sign up at a SERP provider (e.g. ValueSERP, SerpApi). -2. Copy your API key and add it to pipeline config under "SERP API key". -Full guide: /docs/integrations/serp - -### MCP server (for Cursor / Claude Desktop / AI agents) -- Stdio: `python -m website_profiling.mcp` — add to your IDE's MCP config. -- HTTP: `python -m website_profiling.mcp.http` — remote Streamable HTTP on port 8000. -- Scope tools with `WP_MCP_DOMAIN=core|crawl|google|links|full`. -Full guide: /docs/integrations/mcp - -### Crawl authentication (basic auth / cookies) -Set crawler HTTP credentials in pipeline config: `crawler_http_auth_user`, `crawler_http_auth_pass`, or paste cookies. Guide: /docs/integrations/crawl-auth - -### Import GSC links -Export links from Google Search Console and upload via /docs/integrations/gsc-links. - -## Features overview -- **/home** — landing page; start a new audit from here. -- **/pipeline** or Run audit button — configure and run a crawl + report. -- **/chat** — AI assistant over audit data (requires LLM configured + a completed audit). -- **/docs** — all integration guides. -- **/secrets** — manage API keys (AI providers, Google, Bing, SERP). -- **/write** — Content Studio: write and score SEO content with live keyword targeting. -- **/mcp** — MCP server settings and tool scoping. -- Reports — after an audit, browse issues, links, keywords, Lighthouse scores, GSC data. - -## Common workflows -1. First audit: Run audit → choose a preset → enter your site URL → click Run. -2. Enable AI: /secrets → AI tab → choose provider → enter API key → enable → save. -3. Connect GSC: /docs/integrations/google → complete OAuth flow → select property. -4. Use MCP with Cursor: add `python -m website_profiling.mcp` to Cursor's MCP settings. - -Respond helpfully based on the above. If the user asks about something unrelated to this application, politely say this assistant only covers the Site Audit platform and direct them to /docs.""" - - -def _emit(on_event: Callable[[dict], None] | None, event: dict) -> None: - if on_event: - on_event(sanitize_unicode_deep(event)) - - -def run_help_turn( - messages: list[dict[str, str]], - *, - on_event: Callable[[dict], None] | None = None, -) -> dict[str, Any]: - """Run a single help chat turn — no tools, no property context.""" - cfg = load_llm_config_from_db() - if not llm_is_enabled(cfg): - _emit( - on_event, - { - "type": "error", - "message": ( - "AI is not enabled. Configure a provider and API key at /secrets, " - "then enable AI in pipeline settings." - ), - }, - ) - return {"ok": False, "error": "AI disabled"} - - try: - client = get_llm_client(cfg) - except ValueError as e: - _emit(on_event, {"type": "error", "message": str(e)}) - return {"ok": False, "error": str(e)} - - openai_messages: list[dict[str, Any]] = [ - {"role": "system", "content": _HELP_SYSTEM_PROMPT}, - *[ - {"role": m.get("role", "user"), "content": m.get("content", "")} - for m in messages - if isinstance(m, dict) - ], - ] - - accumulated: list[str] = [] - - def on_token(token: str) -> None: - accumulated.append(token) - _emit(on_event, {"type": "token", "text": token}) - - try: - result = client.chat_with_tools(openai_messages, tools=[], on_token=on_token) - # If the client buffered instead of streaming, emit the full content now. - if not accumulated and result.content: - _emit(on_event, {"type": "token", "text": result.content}) - _emit(on_event, {"type": "done", "message": ""}) - return {"ok": True} - except Exception as e: - msg = str(e).strip() or type(e).__name__ - _emit(on_event, {"type": "error", "message": msg}) - return {"ok": False, "error": msg} diff --git a/src/website_profiling/llm/issue_fixes.py b/src/website_profiling/llm/issue_fixes.py deleted file mode 100644 index c592aa62..00000000 --- a/src/website_profiling/llm/issue_fixes.py +++ /dev/null @@ -1,69 +0,0 @@ -"""LLM-generated fix suggestions for audit issues.""" -from __future__ import annotations - -from typing import Any - -from ..llm_config import llm_is_enabled -from .fix_suggestions import _fix_suggestion_enabled, generate_fix_suggestion - - -def generate_issue_fix_suggestion( - issue: dict[str, Any], - *, - cfg: dict[str, str] | None = None, - refresh: bool = False, -) -> dict[str, Any]: - message = str(issue.get("message") or "").strip() - if not message: - return {"ok": False, "error": "Issue message required."} - - payload: dict[str, Any] = { - "source": "issue", - "message": message, - "url": issue.get("url"), - "refresh": refresh, - "priority": issue.get("priority"), - "category": issue.get("category"), - "recommendation": issue.get("recommendation"), - "type": issue.get("type") or issue.get("finding_type"), - } - return generate_fix_suggestion(payload, cfg=cfg, refresh=refresh) - - -def enrich_top_issues_with_llm( - categories: list[dict[str, Any]], - cfg: dict[str, str] | None, - *, - gsc_pages: list[dict[str, Any]] | None = None, - limit: int = 8, -) -> None: - """Attach llm_recommendation to top traffic-weighted issues in-place.""" - from .audit_summary import rank_issues_by_traffic - - if not cfg or not llm_is_enabled(cfg) or not _fix_suggestion_enabled(cfg): - return - - ranked = rank_issues_by_traffic(categories, gsc_pages)[:limit] - if not ranked: - return - - by_key: dict[tuple[str, str], dict[str, Any]] = {} - for cat in categories or []: - for issue in cat.get("issues") or []: - if not isinstance(issue, dict): - continue - key = (str(issue.get("message") or ""), str(issue.get("url") or "")) - by_key[key] = issue - - for ranked_issue in ranked: - key = (str(ranked_issue.get("message") or ""), str(ranked_issue.get("url") or "")) - target = by_key.get(key) - if not target or target.get("llm_recommendation"): - continue - payload = {**ranked_issue, "category": ranked_issue.get("category")} - result = generate_issue_fix_suggestion(payload, cfg=cfg) - if result.get("ok") and isinstance(result.get("fix"), dict): - fix_text = str(result["fix"].get("fix") or "").strip() - if fix_text: - target["llm_recommendation"] = fix_text - target["llm_fix_effort"] = result["fix"].get("effort") diff --git a/src/website_profiling/llm/issues_action_plan.py b/src/website_profiling/llm/issues_action_plan.py deleted file mode 100644 index 5c824eb6..00000000 --- a/src/website_profiling/llm/issues_action_plan.py +++ /dev/null @@ -1,158 +0,0 @@ -"""LLM action plan for deduplicated audit issue lists.""" -from __future__ import annotations - -import hashlib -import json -from typing import Any - -from ..llm_config import llm_is_enabled -from .base import get_llm_client, parse_json_response -from .enrich import _read_cache, _write_cache -from .fix_suggestions import _fix_suggestion_enabled -from .prompts import ISSUES_ACTION_PLAN_SYSTEM, PROMPT_VERSION - -MAX_ISSUES = 80 - - -def _cache_key(model: str, domain: str, issues: list[dict[str, Any]]) -> str: - body = json.dumps({"domain": domain, "issues": issues}, sort_keys=True, default=str) - digest = hashlib.sha256(f"issues_action_plan:{PROMPT_VERSION}:{model}:{body}".encode()).hexdigest() - return digest - - -def _compact_issues(raw: list[Any]) -> list[dict[str, Any]]: - out: list[dict[str, Any]] = [] - for row in raw or []: - if not isinstance(row, dict): - continue - message = str(row.get("message") or "").strip() - if not message: - continue - item: dict[str, Any] = { - "category": str(row.get("category") or ""), - "message": message, - "priority": str(row.get("priority") or "Medium"), - "url_count": int(row.get("url_count") or row.get("urlCount") or 0), - "sample_urls": [ - str(u).strip() - for u in (row.get("sample_urls") or row.get("sampleUrls") or []) - if str(u).strip() - ][:5], - } - rec = row.get("recommendation") - if rec: - item["recommendation"] = str(rec) - for src, dst in (("impact_score", "impact_score"), ("gsc_clicks", "gsc_clicks")): - val = row.get(src) if src in row else row.get("impactScore" if src == "impact_score" else "gscClicks") - if val is not None: - try: - item[dst] = float(val) - except (TypeError, ValueError): - pass - out.append(item) - return out[:MAX_ISSUES] - - -def _format_plan_markdown(data: dict[str, Any]) -> str: - lines: list[str] = [] - summary = str(data.get("summary") or "").strip() - if summary: - lines.extend([summary, ""]) - - quick_wins = data.get("quick_wins") or [] - if isinstance(quick_wins, list) and quick_wins: - lines.append("### Quick wins") - for item in quick_wins[:8]: - text = str(item).strip() - if text: - lines.append(f"- {text}") - lines.append("") - - phases = data.get("phases") or [] - if isinstance(phases, list) and phases: - lines.append("### Phased plan") - for phase in phases[:6]: - if not isinstance(phase, dict): - continue - name = str(phase.get("name") or "Phase").strip() - effort = str(phase.get("effort") or "").strip() - header = f"**{name}**" - if effort: - header += f" (effort: {effort})" - lines.append(header) - actions = phase.get("actions") or [] - if isinstance(actions, list): - for action in actions[:8]: - text = str(action).strip() - if text: - lines.append(f"- {text}") - lines.append("") - - notes = str(data.get("notes") or "").strip() - if notes: - lines.extend(["### Notes", notes]) - - return "\n".join(lines).strip() - - -def generate_issues_action_plan( - payload: dict[str, Any], - *, - cfg: dict[str, str] | None = None, - refresh: bool = False, -) -> dict[str, Any]: - from ..llm_config import load_llm_config_from_db - - cfg = cfg or load_llm_config_from_db() - if not llm_is_enabled(cfg): - return {"ok": False, "error": "AI insights are disabled."} - if not _fix_suggestion_enabled(cfg): - return {"ok": False, "error": "Issue fix suggestions are disabled in AI task settings."} - - domain = str(payload.get("domain") or "").strip() - issues = _compact_issues(payload.get("issues") or []) - if not domain: - return {"ok": False, "error": "domain required."} - if not issues: - return {"ok": False, "error": "issues required."} - - model = (cfg.get("llm_model") or cfg.get("llm_provider") or "unknown").strip() - cache_key = _cache_key(model, domain, issues) - - if not refresh: - cached = _read_cache(cache_key) - if cached: - plan_md = _format_plan_markdown(cached) - return { - "ok": True, - "cached": True, - "plan": plan_md, - "summary": cached.get("summary"), - "phases": cached.get("phases"), - "quick_wins": cached.get("quick_wins"), - "notes": cached.get("notes"), - "provenance": "AI insights", - } - - user_payload = {"domain": domain, "issue_count": len(issues), "issues": issues} - try: - client = get_llm_client(cfg) - user = json.dumps(user_payload, indent=2, default=str)[:12000] - raw = client.complete_json(ISSUES_ACTION_PLAN_SYSTEM, user) - parsed = raw if isinstance(raw, dict) and raw else parse_json_response(str(raw)) - if not isinstance(parsed, dict): - parsed = {"summary": str(raw or "").strip() or "No plan returned."} - _write_cache(cache_key, parsed) - plan_md = _format_plan_markdown(parsed) - return { - "ok": True, - "cached": False, - "plan": plan_md, - "summary": parsed.get("summary"), - "phases": parsed.get("phases"), - "quick_wins": parsed.get("quick_wins"), - "notes": parsed.get("notes"), - "provenance": "AI insights", - } - except Exception as e: - return {"ok": False, "error": str(e)} diff --git a/src/website_profiling/llm/ollama_catalog.py b/src/website_profiling/llm/ollama_catalog.py deleted file mode 100644 index 865a6f8b..00000000 --- a/src/website_profiling/llm/ollama_catalog.py +++ /dev/null @@ -1,174 +0,0 @@ -"""Ollama local + cloud model catalog (mirrors web/src/server/ollamaModels.ts).""" -from __future__ import annotations - -import json -import re -import urllib.error -import urllib.request -from typing import Any - -OLLAMA_CLOUD_CATALOG_URL = "https://ollama.com/api/tags" - -PRO_CLOUD_MODEL_PATTERNS = [ - re.compile(r"671b", re.I), - re.compile(r"480b", re.I), - re.compile(r":1t(?:-cloud|:cloud)?$", re.I), - re.compile(r"v4-pro", re.I), - re.compile(r"nemotron-3-ultra", re.I), - re.compile(r"nemotron-3-super", re.I), - re.compile(r"mistral-large", re.I), - re.compile(r"397b", re.I), - re.compile(r"cogito-2\.1:671b", re.I), - re.compile(r"deepseek-v4-pro", re.I), - re.compile(r"qwen3-coder:480b", re.I), - re.compile(r"gpt-oss:120b", re.I), -] - - -def is_cloud_model_ref(name: str) -> bool: - return name.endswith("-cloud") or name.endswith(":cloud") - - -def to_cloud_model_ref(name: str) -> str: - trimmed = name.strip() - if not trimmed: - return trimmed - if trimmed.endswith("-cloud") or trimmed.endswith(":cloud"): - return trimmed - return f"{trimmed}-cloud" if ":" in trimmed else f"{trimmed}:cloud" - - -def resolve_billing_tier(name: str, source: str) -> dict[str, Any]: - cloud = source == "cloud" or is_cloud_model_ref(name) - if not cloud: - return {"billing": "free_local", "requires_subscription": False} - if any(p.search(name) for p in PRO_CLOUD_MODEL_PATTERNS): - return {"billing": "cloud_pro", "requires_subscription": True} - return {"billing": "cloud_free", "requires_subscription": True} - - -def _with_billing(entry: dict[str, Any]) -> dict[str, Any]: - tier = resolve_billing_tier(str(entry.get("name") or ""), str(entry.get("source") or "local")) - return {**entry, **tier} - - -def _normalize_local_model(raw: dict[str, Any]) -> dict[str, Any] | None: - name = str(raw.get("name") or "").strip() - if not name: - return None - cloud = bool(raw.get("remote_host")) or is_cloud_model_ref(name) - details = raw.get("details") if isinstance(raw.get("details"), dict) else {} - return _with_billing({ - "name": name, - "source": "cloud" if cloud else "local", - "installed": True, - "capabilities": raw.get("capabilities") if isinstance(raw.get("capabilities"), list) else None, - "context_length": details.get("context_length"), - }) - - -def _normalize_catalog_model(raw: dict[str, Any]) -> dict[str, Any] | None: - base = str(raw.get("name") or "").strip() - if not base: - return None - return _with_billing({ - "name": to_cloud_model_ref(base), - "source": "cloud", - "installed": False, - }) - - -def _model_key(name: str) -> str: - return name.lower() - - -def merge_ollama_models( - local: list[dict[str, Any]], - cloud_catalog: list[dict[str, Any]], -) -> list[dict[str, Any]]: - by_key: dict[str, dict[str, Any]] = {} - for m in cloud_catalog: - by_key[_model_key(str(m.get("name") or ""))] = m - for m in local: - key = _model_key(str(m.get("name") or "")) - existing = by_key.get(key) - merged = { - **(existing or {}), - **m, - "installed": True, - "capabilities": m.get("capabilities") or (existing or {}).get("capabilities"), - "context_length": m.get("context_length") or (existing or {}).get("context_length"), - } - by_key[key] = _with_billing(merged) - - def sort_key(m: dict[str, Any]) -> tuple: - return ( - 0 if m.get("installed") else 1, - 0 if m.get("source") == "local" else 1, - str(m.get("name") or ""), - ) - - return sorted(by_key.values(), key=sort_key) - - -def _fetch_json(url: str, *, timeout: float = 8.0) -> dict[str, Any] | None: - try: - req = urllib.request.Request(url, headers={"Accept": "application/json"}) - with urllib.request.urlopen(req, timeout=timeout) as resp: - return json.loads(resp.read().decode()) - except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError): - return None - - -def fetch_ollama_models(base_url: str) -> dict[str, Any]: - normalized_base = (base_url or "http://127.0.0.1:11434").rstrip("/") or "http://127.0.0.1:11434" - - local_data = _fetch_json(f"{normalized_base}/api/tags", timeout=8.0) - cloud_data = _fetch_json(OLLAMA_CLOUD_CATALOG_URL, timeout=12.0) - - local_ok = local_data is not None - cloud_catalog_ok = cloud_data is not None - - local_models = [ - m for raw in (local_data or {}).get("models") or [] - if isinstance(raw, dict) - for m in [_normalize_local_model(raw)] - if m is not None - ] - cloud_models = [ - m for raw in (cloud_data or {}).get("models") or [] - if isinstance(raw, dict) - for m in [_normalize_catalog_model(raw)] - if m is not None - ] - models = merge_ollama_models(local_models, cloud_models) - - if not local_ok and not cloud_catalog_ok: - return { - "ok": False, - "baseUrl": normalized_base, - "models": [], - "cloudCatalogOk": False, - "localOk": False, - "error": "Cannot reach Ollama or the cloud model catalog.", - } - - return { - "ok": local_ok or cloud_catalog_ok, - "baseUrl": normalized_base, - "models": models, - "cloudCatalogOk": cloud_catalog_ok, - "localOk": local_ok, - } - - -def model_is_configured(models: list[dict[str, Any]], configured_model: str) -> bool: - target = configured_model.strip() - if not target: - return len(models) > 0 - key = _model_key(target) - return any(_model_key(str(m.get("name") or "")) == key for m in models) - - -def models_support_tools(models: list[dict[str, Any]]) -> bool: - return any("tools" in (m.get("capabilities") or []) for m in models) diff --git a/src/website_profiling/llm/page_coach.py b/src/website_profiling/llm/page_coach.py deleted file mode 100644 index bb6626b7..00000000 --- a/src/website_profiling/llm/page_coach.py +++ /dev/null @@ -1,210 +0,0 @@ -"""On-demand LLM page coach for Link Explorer.""" -from __future__ import annotations - -import hashlib -import json -from typing import Any - -from ..llm_config import load_llm_config_from_db, llm_is_enabled -from .base import get_llm_client, parse_json_response -from .enrich import _read_cache, _write_cache -from .prompts import PAGE_COACH_SYSTEM, PROMPT_VERSION - - -def _find_link(links: list[dict], page_url: str) -> dict[str, Any] | None: - from ..integrations.google.normalize import normalize_url - - norm = normalize_url(page_url) - for rec in links: - if not isinstance(rec, dict): - continue - if normalize_url(str(rec.get("url") or "")) == norm: - return rec - return None - - -def _keywords_for_page(page_url: str, property_id: int | None = None) -> dict[str, Any]: - from ..db import db_session - from ..integrations.google.keyword_store import read_latest_keyword_data - - norm = page_url.lower().rstrip("/") - try: - with db_session() as conn: - if property_id is None: - from ..commands.config_resolve import load_config_from_db, resolve_property_id_from_cfg - - cfg = load_config_from_db() - property_id = resolve_property_id_from_cfg(cfg, conn) - data = read_latest_keyword_data(conn, property_id) - if not data: - return {"keywords": [], "cannibalisation": []} - rows = data.get("rows") if isinstance(data.get("rows"), list) else [] - page_kws = [ - r - for r in rows - if isinstance(r, dict) - and norm in str(r.get("gsc_url") or "").lower().rstrip("/") - ] - cannib = [] - for c in data.get("cannibalisation") or []: - if not isinstance(c, dict): - continue - pages = c.get("pages") or [] - if any( - isinstance(p, dict) and norm in str(p.get("url") or "").lower().rstrip("/") - for p in pages - ): - cannib.append(c) - return {"keywords": page_kws[:40], "cannibalisation": cannib} - except Exception: - return {"keywords": [], "cannibalisation": []} - - -def build_page_context( - page_url: str, - *, - current_type: str | None = None, - current_id: int | None = None, - baseline_type: str | None = None, - baseline_id: int | None = None, -) -> dict[str, Any]: - from ..db import db_session - from ..db.storage import _parse_row_json, _row_field, read_report_payload - from ..integrations.google.page_lookup import slice_from_google_row - from ..integrations.google.page_snapshot_store import read_page_snapshot - - ctx: dict[str, Any] = {"page_url": page_url, "link": None, "current": None, "baseline": None, "compare": []} - - with db_session() as conn: - report = read_report_payload(conn) or {} - links = report.get("links") or [] - if isinstance(links, list): - ctx["link"] = _find_link(links, page_url) - - if current_type == "live" and current_id: - ctx["current"] = read_page_snapshot(conn, current_id) - elif current_id: - cur = conn.execute("SELECT data FROM google_data WHERE id = %s", (current_id,)) - row = cur.fetchone() - if row: - raw = _parse_row_json(row) - if isinstance(raw, dict): - slice_data = slice_from_google_row(raw, page_url) - slice_data["snapshotId"] = current_id - ctx["current"] = slice_data - else: - from ..integrations.google.page_snapshot_store import latest_live_snapshot - - live = latest_live_snapshot(conn, page_url) - if live: - ctx["current"] = live - else: - cur = conn.execute("SELECT id, data FROM google_data ORDER BY id DESC LIMIT 1") - row = cur.fetchone() - if row: - raw = _parse_row_json(row) - if isinstance(raw, dict): - slice_data = slice_from_google_row(raw, page_url) - sid = _row_field(row, "id") - slice_data["snapshotId"] = int(sid) if sid is not None else None - ctx["current"] = slice_data - - if baseline_type == "live" and baseline_id: - ctx["baseline"] = read_page_snapshot(conn, baseline_id) - elif baseline_type == "snapshot" and baseline_id: - cur = conn.execute("SELECT data FROM google_data WHERE id = %s", (baseline_id,)) - row = cur.fetchone() - if row: - raw = _parse_row_json(row) - if isinstance(raw, dict): - ctx["baseline"] = slice_from_google_row(raw, page_url) - - ctx["keywords"] = _keywords_for_page(page_url) - - cur_g = ctx.get("current") or {} - base_g = ctx.get("baseline") or {} - if cur_g and base_g: - ctx["compare"] = _metric_deltas(cur_g, base_g) - - return ctx - - -def _metric_deltas(current: dict, baseline: dict) -> list[dict[str, Any]]: - rows = [] - pairs = [ - ("gsc_clicks", "gsc", "clicks", True), - ("gsc_impressions", "gsc", "impressions", True), - ("gsc_ctr", "gsc", "ctr", True), - ("gsc_position", "gsc", "position", False), - ("ga4_sessions", "ga4", "sessions", True), - ("ga4_engagement", "ga4", "engagementRate", True), - ] - for mid, blob, key, higher in pairs: - c = (current.get(blob) or {}).get(key) - b = (baseline.get(blob) or {}).get(key) - if c is None and b is None: - continue - try: - c_f, b_f = float(c or 0), float(b or 0) - delta = round(c_f - b_f, 2) - rows.append({"id": mid, "current": c_f, "baseline": b_f, "delta": delta, "higher_is_better": higher}) - except (TypeError, ValueError): - continue - return rows - - -def run_page_coach( - page_url: str, - cfg: dict[str, str] | None = None, - *, - refresh: bool = False, - current_type: str | None = None, - current_id: int | None = None, - baseline_type: str | None = None, - baseline_id: int | None = None, -) -> dict[str, Any]: - cfg = cfg or load_llm_config_from_db() - if not llm_is_enabled(cfg): - return {"ok": False, "error": "AI insights are disabled. Enable them in Pipeline → Content & AI."} - - if not _cfg_bool_page_coach(cfg): - return {"ok": False, "error": "Page coach is disabled in AI task settings."} - - context = build_page_context( - page_url, - current_type=current_type, - current_id=current_id, - baseline_type=baseline_type, - baseline_id=baseline_id, - ) - - model = (cfg.get("llm_model") or cfg.get("llm_provider") or "unknown").strip() - payload_str = json.dumps(context, sort_keys=True, default=str) - cache_key = hashlib.sha256( - f"page_coach:v2:{PROMPT_VERSION}:{model}:{page_url}:{payload_str}".encode() - ).hexdigest() - - if not refresh: - cached = _read_cache(cache_key) - if cached: - return {"ok": True, "cached": True, "coach": cached, "context": context} - - try: - client = get_llm_client(cfg) - user = json.dumps(context, indent=2, default=str)[:12000] - raw = client.complete_json(PAGE_COACH_SYSTEM, user) - if isinstance(raw, dict) and raw: - coach = raw - else: - coach = parse_json_response(str(raw)) - if not coach: - coach = {"summary": "No structured coach output returned."} - _write_cache(cache_key, coach) - return {"ok": True, "cached": False, "coach": coach, "context": context} - except Exception as e: - return {"ok": False, "error": str(e), "context": context} - - -def _cfg_bool_page_coach(cfg: dict[str, str]) -> bool: - v = str(cfg.get("llm_enable_page_coach", "true")).lower() - return v in ("true", "1", "yes") diff --git a/src/website_profiling/llm/prompts.py b/src/website_profiling/llm/prompts.py deleted file mode 100644 index 577345ac..00000000 --- a/src/website_profiling/llm/prompts.py +++ /dev/null @@ -1,197 +0,0 @@ -"""Versioned prompts for LLM enrichment tasks.""" -from __future__ import annotations - -PROMPT_VERSION = "v2" - -NER_SYSTEM = """You extract named entities from web page text for SEO analysis. -Return JSON: {"pages": [{"url": "...", "entity_count": N, "top_entity_labels": [["ORG", 2], ["PERSON", 1]]}]} -Use standard NER labels (ORG, PERSON, GPE, PRODUCT, etc.). Count occurrences per label.""" - -KEYPHRASES_SYSTEM = """You extract SEO keyphrases from web page content. -Return JSON: {"pages": [{"url": "...", "phrases": [["phrase text", 0.95], ...]}]} -Provide 3-8 phrases per page with scores 0-1.""" - -SIMILAR_SYSTEM = """You find semantically similar internal pages for SEO deduplication review. -Return JSON: {"pages": [{"url": "...", "similar": [{"url": "...", "score": 0.87}, ...]}]} -Scores 0-1; only include URLs from the provided candidate list.""" - -KEYWORD_CLUSTER_SYSTEM = """You group related SEO keywords into semantic clusters. -Return JSON: {"clusters": [{"top_keyword": "...", "keywords": ["a","b"], "cluster_score": 0.9}]} -Only merge clearly related terms; omit singletons.""" - -PAGE_COACH_SYSTEM = """You are an SEO and UX retention analyst for a single web page. -Use ONLY the metrics and crawl facts provided. Do not invent traffic numbers. -Return JSON: -{ - "summary": "2-3 sentences on overall page health for search and retention", - "missing_on_page": ["specific missing element or content gap"], - "retention_improvements": [{"title": "...", "why": "...", "priority": "high|medium|low"}], - "seo_improvements": [{"title": "...", "why": "...", "priority": "high|medium|low"}], - "quick_wins": ["actionable one-liner"] -} -Focus retention on engagement, clarity, next-step paths, and reducing bounce. Reference compare trends when present.""" - -CONTENT_STUDIO_ANALYZE_SYSTEM = """You are an SEO content editor coaching a writer on a draft article. -Use ONLY the keyword, score metrics, missing terms, and draft excerpt provided. Do not invent SERP data. -Return JSON: -{ - "summary": "2-3 sentences on draft quality and top priority", - "suggestions": [{"text": "specific actionable suggestion", "priority": "high|medium|low", "type": "term|structure|seo|readability"}], - "outline": ["optional H2 heading ideas"], - "title_ideas": ["optional title tag ideas"] -} -Prioritize missing high-importance GSC terms, failed on-page checks, and clarity improvements.""" - -ISSUE_FIX_SYSTEM = """You are a technical SEO consultant. Given one audit issue, return a concise, actionable fix. -Use ONLY the facts provided. Do not invent URLs or metrics. -Return JSON: {"fix": "2-4 sentences with specific steps", "effort": "low|medium|high"}""" - -FIX_SUGGESTION_ISSUE_SYSTEM = ISSUE_FIX_SYSTEM - -FIX_SUGGESTION_LIGHTHOUSE_SYSTEM = """You are a web performance and Lighthouse audit specialist. -Given one Lighthouse finding (quick win, diagnostic, or audit), return a concise actionable fix. -Use ONLY the facts provided. Reference audit IDs and evidence when present. Do not invent URLs or savings. -Return JSON: {"fix": "2-4 sentences with specific steps (config snippets welcome)", "effort": "low|medium|high"}""" - -FIX_SUGGESTION_SECURITY_SYSTEM = """You are a web security and HTTP headers consultant. -Given one security finding or missing header, return a concise actionable fix with server config guidance when relevant. -Use ONLY the facts provided. Do not invent URLs or CVEs. -Return JSON: {"fix": "2-4 sentences with specific steps", "effort": "low|medium|high"}""" - -FIX_SUGGESTION_BROWSER_SYSTEM = """You are a frontend debugging specialist. -Given one browser console error, page exception, or on-page warning, return a concise root-cause fix. -Use ONLY the facts provided (message, URL, source file, line, stack). Do not invent stack frames. -Return JSON: {"fix": "2-4 sentences with specific steps", "effort": "low|medium|high"}""" - -FIX_SUGGESTION_SEO_CONTENT_SYSTEM = """You are an SEO content strategist. -Given one content/keyword/structured-data issue (misalignment, cannibalisation, rich results, duplicates, etc.), -return a concise actionable fix with clear next steps (canonical, merge, redirect, schema, internal links). -Use ONLY the facts provided. Do not invent traffic numbers. -Return JSON: {"fix": "2-4 sentences with specific steps", "effort": "low|medium|high"}""" - -FIX_SUGGESTION_TECHNICAL_SYSTEM = """You are a technical SEO engineer. -Given one technical crawl issue (broken link, redirect chain, mixed content, headers, indexing flags), -return a concise actionable fix. -Use ONLY the facts provided. Do not invent URLs. -Return JSON: {"fix": "2-4 sentences with specific steps", "effort": "low|medium|high"}""" - -FIX_SUGGESTION_PROMPTS: dict[str, str] = { - "issue": FIX_SUGGESTION_ISSUE_SYSTEM, - "lighthouse": FIX_SUGGESTION_LIGHTHOUSE_SYSTEM, - "security": FIX_SUGGESTION_SECURITY_SYSTEM, - "browser": FIX_SUGGESTION_BROWSER_SYSTEM, - "seo_content": FIX_SUGGESTION_SEO_CONTENT_SYSTEM, - "technical": FIX_SUGGESTION_TECHNICAL_SYSTEM, -} - -AUDIT_EXECUTIVE_SYSTEM = """You write a short executive summary for a site audit report for agency clients. -Use ONLY the scores and issues provided. Be direct and prioritize by traffic impact. -Return JSON: {"summary": "3-5 sentences in plain language", "priorities": ["bullet 1", "bullet 2", "bullet 3"]}""" - -ISSUES_ACTION_PLAN_SYSTEM = """You are a senior SEO/technical audit consultant. -Given a deduplicated list of site audit issues, return a prioritized remediation plan. -Use ONLY the issues provided. Group by root cause where possible. -Return JSON: { - "summary": "2-3 sentence overview", - "phases": [{"name": "...", "effort": "low|medium|high", "actions": ["..."]}], - "quick_wins": ["..."], - "notes": "optional caveats" -}""" - -CHAT_NARRATIVE_SYSTEM = """You write the user-facing narrative for a site-audit chat turn. -Use ONLY the user question and tool results provided. Do not invent metrics, URLs, or scores. -The chat UI already renders charts, tables, and score cards from tool data — do not repeat those numbers. -Return JSON only: {"power_insights": ["..."], "recommended_actions": ["..."]} -Max 5 items per array. Plain language. No internal tool names. No emoji.""" - -CHAT_NARRATIVE_REPAIR_SYSTEM = """Your previous response was not valid JSON matching the required schema. -Return ONLY a JSON object with exactly these keys: -{"power_insights": ["string", ...], "recommended_actions": ["string", ...]} -Each value must be a non-empty array of non-empty strings (max 5 each). -Use ONLY the original user question and tool data provided. Do not invent metrics.""" - -DASHBOARD_AI_SYSTEM = """You are a dashboard-configuration assistant for a site-audit analytics platform. -You generate DashScript formulas, widget configurations, and full dashboard layouts from natural-language requests. - -DASHSCRIPT GRAMMAR (supplied in the request as "dashscript_help") covers: - - Measures (scalar): field("key"), sum("col"), avg("col"), count(), min/max, if(cond, a, b), coalesce(...) - - Transforms (row pipelines): filter(...) | sort(col, desc) | take(N) | project(col1, col2) | skip(N) - -CATALOG: "catalog" lists available data-source tools. Each entry has: - - "dimensions": categorical fields used as X axis or group-by (e.g. page name, category, URL) - - "measures": numeric fields used as Y axis or KPI value (e.g. score, count, bytes) - Use ONLY toolName and viz values from catalog / viz_types. - -FIELD SELECTION RULES: - - xField MUST be a dimension key (role="dimension") — categorical, used on the X/category axis. - - yField MUST be a measure key (role="measure") — numeric, aggregatable, used on the Y/value axis. - - valueField (for KPI/gauge/stat-card) MUST be a measure key. - - seriesField (for multi-series / group-by charts) MUST be a dimension key — creates one dataset per distinct value. - - Do NOT swap dimensions and measures. - -BINDING FIELDS: - - valueField: dot-path field name for KPI/gauge (e.g. "health_score" or "summary.category_scores.performance") - - xField: dimension key for chart category axis - - yField: measure key for chart value axis - - seriesField: dimension key to pivot rows into multiple series (group-by); omit for single-series charts - - select: dot-path to a rows array inside the tool result (e.g. "categories", "issues", "items") - - args: object passed to the tool (e.g. {"limit": 10}) - - measure / transform: DashScript strings (only set when useScript is true) - - useScript: set to true when measure or transform is non-empty - -CUSTOM-CHART VIZ: - - Use viz "custom-chart" when a chart type not in viz_types is requested (radar, polar, bubble, scatter, etc.) - - Return a chartSpec: { type: "radar"|"polarArea"|"bubble"|"scatter"|"bar"|..., data?: {...}, labelField?: "colName", series: [{label, field, backgroundColor?, borderColor?}], options?: {...} } - - chartSpec.data is used directly if provided; otherwise data is built from rows using labelField + series. - - DO NOT put function values or executable code in chartSpec. JSON only. - -OUTPUT RULES — return a JSON object matching the mode: - -mode = "script": -{ - "measure": "DashScript measure string or empty string", - "transform": "DashScript transform string or empty string", - "chartSpec": { ... } or null, - "explanation": "1-2 sentence plain-language explanation of what was generated and why" -} - -mode = "widget": -{ - "widget": { - "title": "Widget title", - "toolName": "", - "viz": "", - "binding": { "source": "audit-tool", "toolName": "...", "valueField"?: "...", "xField"?: "...", "yField"?: "...", "seriesField"?: "...", "select"?: "...", "args"?: {}, "measure"?: "...", "transform"?: "...", "useScript"?: true }, - "options": { "format"?: "...", "chartSort"?: "asc|desc", "chartMaxItems"?: N, "tableLimit"?: N, "chartSpec"?: {...} } - }, - "explanation": "1-2 sentences" -} - -mode = "dashboard": -{ - "name": "Dashboard name", - "widgets": [ - { - "title": "...", - "toolName": "...", - "viz": "...", - "binding": { ... }, - "options": { ... }, - "layout": { "x": 0, "y": 0, "w": 6, "h": 4 } - } - ], - "explanation": "1-2 sentences" -} - -LAYOUT RULES for dashboard mode: -- Use a 12-column grid (w values 2-12). -- KPI / stat-card: w=3, h=2. Gauge: w=4, h=3. Charts: w=6-12, h=4-5. Tables: w=8-12, h=5. -- Lay out row by row; x + w must not exceed 12. Increment y for new rows. -- Aim for 4-8 widgets unless the user requests more. - -CONSTRAINTS: -- Use ONLY toolName values from the provided catalog. If no good match exists, pick the closest. -- Use ONLY viz values from viz_types or "custom-chart". -- Return ONLY valid JSON. Do not add markdown fences or extra text. -- Keep explanation concise (1-2 sentences, no jargon). -- Do not invent field names. Use only fields listed in the catalog entry's dimensions/measures or visible in "sample".""" diff --git a/src/website_profiling/llm/providers/__init__.py b/src/website_profiling/llm/providers/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/website_profiling/llm/providers/anthropic.py b/src/website_profiling/llm/providers/anthropic.py deleted file mode 100644 index 59d45752..00000000 --- a/src/website_profiling/llm/providers/anthropic.py +++ /dev/null @@ -1,277 +0,0 @@ -"""Anthropic Messages API.""" -from __future__ import annotations - -import json -import os -import sys -from typing import Any - -from ..base import ChatResult, TokenCallback, ToolCall, parse_json_response - -# Ephemeral (5-minute) prompt-cache marker. Placed on the static request prefix -# (tools -> system -> conversation) so Anthropic bills repeated prefix tokens at -# ~10% of base input price across the multi-round tool loop. Mirrors how Claude -# Code caches its tool/system prefix. -_CACHE_CONTROL = {"type": "ephemeral"} - - -def _truthy(value: str | None, *, default: bool) -> bool: - raw = (value or "").strip().lower() - if not raw: - return default - return raw in ("1", "true", "yes", "on") - - -def _prompt_cache_enabled() -> bool: - """Prompt caching is on by default; set WP_LLM_PROMPT_CACHE=0 to disable.""" - return _truthy(os.environ.get("WP_LLM_PROMPT_CACHE"), default=True) - - -def _cache_debug_enabled() -> bool: - return _truthy(os.environ.get("WP_LLM_DEBUG_CACHE"), default=False) - - -def _log_cache_usage(usage: Any) -> None: - """When WP_LLM_DEBUG_CACHE is set, print cache token counts to stderr.""" - if usage is None or not _cache_debug_enabled(): - return - created = getattr(usage, "cache_creation_input_tokens", None) - read = getattr(usage, "cache_read_input_tokens", None) - inp = getattr(usage, "input_tokens", None) - print( - f"[wp-cache] input={inp} cache_creation={created} cache_read={read}", - file=sys.stderr, - flush=True, - ) - - -def _apply_prompt_caching( - system: str, - tools: list[dict[str, Any]], - messages: list[dict[str, Any]], -) -> tuple[Any, list[dict[str, Any]], list[dict[str, Any]]]: - """Add cache_control breakpoints to the static request prefix. - - Returns ``(system, tools, messages)`` unchanged when caching is disabled, so - behavior is byte-identical to the no-cache path. Otherwise places three - breakpoints (the limit is four) in Anthropic's prefix order: - - 1. the last tool definition (caches the whole tools array), - 2. the system prompt (caches tools+system), - 3. the last content block of the last message (rolls forward each round, - reading the prior conversation prefix from cache and writing the suffix). - - Builds new copies — never mutates the caller's lists/dicts — so the pure - converter outputs stay clean. - """ - if not _prompt_cache_enabled(): - return system, tools, messages - - # 1. System prompt -> single text block carrying the cache marker. - system_blocks: Any = [ - {"type": "text", "text": system, "cache_control": _CACHE_CONTROL}, - ] - - # 2. Last tool definition. - tools_out = list(tools) - if tools_out: - tools_out[-1] = {**tools_out[-1], "cache_control": _CACHE_CONTROL} - - # 3. Last content block of the last message. - messages_out = list(messages) - if messages_out: - last = dict(messages_out[-1]) - content = last.get("content") - if isinstance(content, list) and content: - blocks = list(content) - blocks[-1] = {**blocks[-1], "cache_control": _CACHE_CONTROL} - last["content"] = blocks - elif isinstance(content, str): - last["content"] = [ - {"type": "text", "text": content, "cache_control": _CACHE_CONTROL}, - ] - messages_out[-1] = last - - return system_blocks, tools_out, messages_out - - -def _to_anthropic_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str, Any]]]: - """Convert OpenAI-shaped chat messages to ``(system, anthropic_messages)``. - - Assistant messages that carry ``tool_calls`` (the OpenAI shape the agent loop - produces) are reconstructed into ``tool_use`` content blocks. Without this the - following ``tool_result`` block has no matching ``tool_use`` in the prior - assistant turn and the Anthropic Messages API rejects the request with HTTP 400, - breaking every multi-round tool conversation. - """ - system_parts: list[str] = [] - out: list[dict[str, Any]] = [] - for msg in messages: - role = msg.get("role") - if role == "system": - system_parts.append(str(msg.get("content") or "")) - elif role == "tool": - out.append({ - "role": "user", - "content": [{ - "type": "tool_result", - "tool_use_id": str(msg.get("tool_call_id") or ""), - "content": str(msg.get("content") or ""), - }], - }) - elif role == "assistant" and msg.get("tool_calls"): - blocks: list[dict[str, Any]] = [] - text = str(msg.get("content") or "") - if text: - blocks.append({"type": "text", "text": text}) - for tc in msg.get("tool_calls") or []: - fn = tc.get("function") or {} - raw_args = fn.get("arguments", tc.get("arguments")) - if isinstance(raw_args, str): - try: - args = json.loads(raw_args or "{}") - except json.JSONDecodeError: - args = {} - elif isinstance(raw_args, dict): - args = raw_args - else: - args = {} - blocks.append({ - "type": "tool_use", - "id": str(tc.get("id") or ""), - "name": str(fn.get("name") or tc.get("name") or ""), - "input": args, - }) - out.append({"role": "assistant", "content": blocks}) - else: - out.append({ - "role": role if role in ("user", "assistant") else "user", - "content": str(msg.get("content") or ""), - }) - return "\n".join(system_parts), out - - -def _to_anthropic_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]: - """Convert OpenAI-shaped tool definitions to Anthropic ``input_schema`` form.""" - out: list[dict[str, Any]] = [] - for tool in tools: - fn = tool.get("function") or tool - out.append({ - "name": fn.get("name"), - "description": fn.get("description") or "", - "input_schema": fn.get("parameters") or {"type": "object", "properties": {}}, - }) - return out - - -class AnthropicClient: - def __init__(self, cfg: dict[str, str]) -> None: - self._cfg = cfg - self._model = (cfg.get("llm_model") or "claude-3-5-haiku-latest").strip() - self._timeout = float(cfg.get("llm_timeout_s") or 120) - self._api_key = (cfg.get("llm_api_key") or "").strip() - - def complete_json(self, system: str, user: str) -> dict[str, Any]: - if not self._api_key: - raise RuntimeError("Anthropic API key missing. Set it in the AI tab or ANTHROPIC_API_KEY.") - try: - import anthropic - except ImportError as e: - raise ImportError("pip install -r requirements.txt") from e - - # Use the client as a context manager so its underlying httpx connection - # pool is closed; otherwise every call leaks sockets across the agent loop. - with anthropic.Anthropic(api_key=self._api_key, timeout=self._timeout) as client: - msg = client.messages.create( - model=self._model, - max_tokens=4096, - system=system + "\nRespond with valid JSON only.", - messages=[{"role": "user", "content": user}], - ) - parts = [] - for block in msg.content: - if getattr(block, "type", None) == "text": - parts.append(block.text) - return parse_json_response("\n".join(parts)) - - def chat_with_tools( - self, - messages: list[dict[str, Any]], - tools: list[dict[str, Any]], - *, - on_token: TokenCallback | None = None, - ) -> ChatResult: - if not self._api_key: - raise RuntimeError("Anthropic API key missing. Set it in the AI tab or ANTHROPIC_API_KEY.") - try: - import anthropic - except ImportError as e: - raise ImportError("pip install -r requirements.txt") from e - - system, anthropic_messages = _to_anthropic_messages(messages) - anthropic_tools = _to_anthropic_tools(tools) - system, anthropic_tools, anthropic_messages = _apply_prompt_caching( - system, anthropic_tools, anthropic_messages, - ) - - kwargs: dict[str, Any] = { - "model": self._model, - "max_tokens": 4096, - "system": system, - "messages": anthropic_messages, - "tools": anthropic_tools, - } - - # Context-manage the client so its httpx connection pool is closed on - # every path (the non-streaming branch closed nothing before). - with anthropic.Anthropic(api_key=self._api_key, timeout=self._timeout) as client: - if on_token: - content_parts: list[str] = [] - tool_calls: list[ToolCall] = [] - with client.messages.stream(**kwargs) as stream: - for event in stream: - if event.type == "content_block_delta" and hasattr(event.delta, "text"): - text = event.delta.text - content_parts.append(text) - on_token(text) - if event.type == "content_block_start" and getattr(event.content_block, "type", None) == "tool_use": - block = event.content_block - tool_calls.append( - ToolCall(id=block.id, name=block.name, arguments={}), - ) - if event.type == "content_block_delta" and getattr(event.delta, "type", None) == "input_json_delta": - if tool_calls: - partial = getattr(event.delta, "partial_json", "") or "" - prev = tool_calls[-1].arguments.get("_partial", "") - tool_calls[-1].arguments["_partial"] = prev + partial - final = stream.get_final_message() - _log_cache_usage(getattr(final, "usage", None)) - for tc in tool_calls: - partial = tc.arguments.pop("_partial", "") - if partial: - try: - tc.arguments = json.loads(partial) - except json.JSONDecodeError: - tc.arguments = {} - text_parts = [] - for block in final.content: - if getattr(block, "type", None) == "text": - text_parts.append(block.text) - return ChatResult(content="".join(content_parts) or "".join(text_parts), tool_calls=tool_calls) - - msg = client.messages.create(**kwargs) - _log_cache_usage(getattr(msg, "usage", None)) - content_parts: list[str] = [] - tool_calls = [] - for block in msg.content: - if getattr(block, "type", None) == "text": - content_parts.append(block.text) - if getattr(block, "type", None) == "tool_use": - tool_calls.append( - ToolCall( - id=block.id, - name=block.name, - arguments=dict(block.input) if isinstance(block.input, dict) else {}, - ), - ) - return ChatResult(content="".join(content_parts), tool_calls=tool_calls) diff --git a/src/website_profiling/llm/providers/gemini.py b/src/website_profiling/llm/providers/gemini.py deleted file mode 100644 index aa4a01a0..00000000 --- a/src/website_profiling/llm/providers/gemini.py +++ /dev/null @@ -1,38 +0,0 @@ -"""Google Gemini generateContent API.""" -from __future__ import annotations - -from typing import Any - -from ..base import parse_json_response - - -class GeminiClient: - def __init__(self, cfg: dict[str, str]) -> None: - self._model = (cfg.get("llm_model") or "gemini-2.0-flash").strip() - self._timeout = float(cfg.get("llm_timeout_s") or 120) - self._api_key = (cfg.get("llm_api_key") or "").strip() - - def complete_json(self, system: str, user: str) -> dict[str, Any]: - if not self._api_key: - raise RuntimeError("Gemini API key missing. Set it in the AI tab or GEMINI_API_KEY.") - try: - import httpx - except ImportError as e: - raise ImportError("pip install -r requirements.txt") from e - - url = f"https://generativelanguage.googleapis.com/v1beta/models/{self._model}:generateContent" - payload = { - "contents": [{"parts": [{"text": f"{system}\n\n{user}\n\nRespond with valid JSON only."}]}], - "generationConfig": {"responseMimeType": "application/json", "temperature": 0.2}, - } - with httpx.Client(timeout=self._timeout) as client: - # Pass the key in a header, not the query string: URL query params are - # logged by proxies / access logs / monitoring, leaking the API key. - r = client.post(url, headers={"x-goog-api-key": self._api_key}, json=payload) - r.raise_for_status() - data = r.json() - text = "" - for cand in data.get("candidates") or []: - for part in (cand.get("content") or {}).get("parts") or []: - text += part.get("text") or "" - return parse_json_response(text) diff --git a/src/website_profiling/llm/providers/groq.py b/src/website_profiling/llm/providers/groq.py deleted file mode 100644 index 9c0abb2c..00000000 --- a/src/website_profiling/llm/providers/groq.py +++ /dev/null @@ -1,143 +0,0 @@ -"""Groq chat completions via official Python SDK.""" -from __future__ import annotations - -import json -from typing import Any - -from ..base import ChatResult, TokenCallback, ToolCall, optional_cloud_base_url, parse_json_response - -DEFAULT_MODEL = "openai/gpt-oss-120b" -_MISSING_KEY_MSG = "Groq API key missing. Set it in the AI tab or GROQ_API_KEY." - - -class GroqClient: - def __init__(self, cfg: dict[str, str]) -> None: - self._model = (cfg.get("llm_model") or DEFAULT_MODEL).strip() - self._timeout = float(cfg.get("llm_timeout_s") or 120) - self._api_key = (cfg.get("llm_api_key") or "").strip() - self._base_url = optional_cloud_base_url(cfg) - - def _client(self) -> Any: - if not self._api_key: - raise RuntimeError(_MISSING_KEY_MSG) - try: - from groq import Groq - except ImportError as e: - raise ImportError("pip install -r requirements.txt") from e - - kwargs: dict[str, Any] = {"api_key": self._api_key, "timeout": self._timeout} - if self._base_url: - kwargs["base_url"] = self._base_url - return Groq(**kwargs) - - def complete_json(self, system: str, user: str) -> dict[str, Any]: - client = self._client() - completion = client.chat.completions.create( - model=self._model, - messages=[ - {"role": "system", "content": system}, - {"role": "user", "content": user}, - ], - response_format={"type": "json_object"}, - temperature=0.2, - ) - choice = (completion.choices or [None])[0] - if choice is None: - raise RuntimeError("Groq response contained no choices.") - content = choice.message.content - if content is None: - raise RuntimeError("Groq response contained no content.") - return parse_json_response(content if isinstance(content, str) else json.dumps(content)) - - def chat_with_tools( - self, - messages: list[dict[str, Any]], - tools: list[dict[str, Any]], - *, - on_token: TokenCallback | None = None, - ) -> ChatResult: - client = self._client() - kwargs: dict[str, Any] = { - "model": self._model, - "messages": messages, - "tools": tools, - "tool_choice": "auto", - "temperature": 0.2, - } - if on_token: - return self._stream_chat(client, kwargs, on_token) - - completion = client.chat.completions.create(**kwargs) - choice = (completion.choices or [None])[0] - if choice is None: - return ChatResult() - return self._parse_message(choice.message, finish_reason=str(choice.finish_reason or "stop")) - - def _parse_message(self, msg: Any, *, finish_reason: str = "stop") -> ChatResult: - content = str(getattr(msg, "content", None) or "") - tool_calls: list[ToolCall] = [] - for tc in getattr(msg, "tool_calls", None) or []: - fn = getattr(tc, "function", None) - raw_args = getattr(fn, "arguments", None) or "{}" - try: - args = json.loads(raw_args) if isinstance(raw_args, str) else dict(raw_args) - except json.JSONDecodeError: - args = {} - tool_calls.append( - ToolCall( - id=str(getattr(tc, "id", None) or ""), - name=str(getattr(fn, "name", None) or ""), - arguments=args if isinstance(args, dict) else {}, - ), - ) - return ChatResult(content=content, tool_calls=tool_calls, finish_reason=finish_reason) - - def _stream_chat( - self, - client: Any, - kwargs: dict[str, Any], - on_token: TokenCallback, - ) -> ChatResult: - content_parts: list[str] = [] - tool_calls_acc: dict[int, dict[str, Any]] = {} - - stream = client.chat.completions.create(**kwargs, stream=True) - for chunk in stream: - choice = (chunk.choices or [None])[0] - if choice is None: - continue - delta = choice.delta - if getattr(delta, "content", None): - text = str(delta.content) - content_parts.append(text) - on_token(text) - for tc in getattr(delta, "tool_calls", None) or []: - idx = int(getattr(tc, "index", None) or 0) - acc = tool_calls_acc.setdefault( - idx, - {"id": "", "name": "", "arguments": ""}, - ) - if getattr(tc, "id", None): - acc["id"] = tc.id - fn = getattr(tc, "function", None) - if fn is not None: - if getattr(fn, "name", None): - acc["name"] = fn.name - if getattr(fn, "arguments", None): - acc["arguments"] += fn.arguments - - tool_calls: list[ToolCall] = [] - for acc in tool_calls_acc.values(): - raw_args = acc.get("arguments") or "{}" - try: - args = json.loads(raw_args) if isinstance(raw_args, str) else dict(raw_args) - except json.JSONDecodeError: - args = {} - tool_calls.append( - ToolCall( - id=str(acc.get("id") or ""), - name=str(acc.get("name") or ""), - arguments=args if isinstance(args, dict) else {}, - ), - ) - return ChatResult(content="".join(content_parts), tool_calls=tool_calls, finish_reason="stop") diff --git a/src/website_profiling/llm/providers/ollama.py b/src/website_profiling/llm/providers/ollama.py deleted file mode 100644 index 581c5154..00000000 --- a/src/website_profiling/llm/providers/ollama.py +++ /dev/null @@ -1,241 +0,0 @@ -"""Ollama local chat API with native tool calling when supported.""" -from __future__ import annotations - -import json -from typing import Any - -from ..base import ChatResult, TokenCallback, ToolCall, parse_json_response - - -def normalize_messages_for_ollama(messages: list[dict[str, Any]]) -> list[dict[str, Any]]: - """Convert OpenAI-style tool messages to Ollama's expected chat format.""" - out: list[dict[str, Any]] = [] - for msg in messages: - role = msg.get("role") - if role == "tool": - content = msg.get("content") - out.append({ - "role": "tool", - "tool_name": msg.get("tool_name") or msg.get("name") or "tool", - "content": content if isinstance(content, str) else json.dumps(content, default=str), - }) - continue - - cleaned: dict[str, Any] = {"role": role} - content = msg.get("content") - if content is not None: - cleaned["content"] = content - - tool_calls = msg.get("tool_calls") - if tool_calls: - ollama_calls = [] - for i, tc in enumerate(tool_calls): - if not isinstance(tc, dict): - continue - fn = tc.get("function") or {} - raw_args = fn.get("arguments", {}) - if isinstance(raw_args, str): - try: - args = json.loads(raw_args) if raw_args.strip() else {} - except json.JSONDecodeError: - args = {} - elif isinstance(raw_args, dict): - args = raw_args - else: - args = {} - ollama_calls.append({ - "type": "function", - "function": { - "index": fn.get("index", i), - "name": fn.get("name") or tc.get("name") or "", - "arguments": args, - }, - }) - cleaned["tool_calls"] = ollama_calls - if "content" not in cleaned: - cleaned["content"] = "" - - out.append(cleaned) - return out - - -def _extract_ollama_error(response: Any) -> str: - raw = "" - try: - if getattr(response, "is_stream_consumed", True) is False: - body = response.read() - raw = body.decode("utf-8", errors="replace") if isinstance(body, bytes) else str(body) - else: - raw = response.text or "" - except Exception: - raw = "" - raw = raw.strip() - if not raw: - return "" - try: - data = json.loads(raw) - if isinstance(data, dict) and data.get("error"): - return str(data["error"]).strip() - except json.JSONDecodeError: - pass - return raw - - -def format_ollama_error(status_code: int, detail: str, model: str) -> str: - """Human-readable Ollama HTTP error for chat UI.""" - detail = detail.strip() - low = detail.lower() - if status_code == 404 and "model" in low and "not found" in low: - return ( - f"Ollama model '{model}' is not installed. " - f"Run `ollama pull {model}` or pick another model under Audit settings → AI." - ) - if status_code == 404: - hint = detail or "endpoint not found" - return ( - f"Ollama returned 404 for /api/chat ({hint}). " - "Check that Ollama is running, llm_base_url is correct, and your Ollama version supports chat." - ) - if detail: - return f"Ollama API error ({status_code}): {detail}" - return f"Ollama API error ({status_code})." - - -class OllamaClient: - def __init__(self, cfg: dict[str, str]) -> None: - self._model = (cfg.get("llm_model") or "llama3.2").strip() - configured = float(cfg.get("llm_timeout_s") or 120) - self._timeout = max(configured, 300) - self._base = (cfg.get("llm_base_url") or "http://127.0.0.1:11434").strip().rstrip("/") - - def _client(self): - try: - import httpx - except ImportError as e: - raise ImportError("pip install -r requirements.txt") from e - return httpx.Client(timeout=self._timeout) - - def _raise_for_status(self, response: Any) -> None: - if int(getattr(response, "status_code", 0) or 0) >= 400: - detail = _extract_ollama_error(response) - raise RuntimeError(format_ollama_error(response.status_code, detail, self._model)) - try: - response.raise_for_status() - except Exception as e: - detail = _extract_ollama_error(response) - if detail: - raise RuntimeError(format_ollama_error(response.status_code, detail, self._model)) from e - raise - - def complete_json(self, system: str, user: str) -> dict[str, Any]: - payload = { - "model": self._model, - "stream": False, - "format": "json", - "messages": [ - {"role": "system", "content": system}, - {"role": "user", "content": user}, - ], - } - url = f"{self._base}/api/chat" - with self._client() as client: - r = client.post(url, json=payload) - self._raise_for_status(r) - data = r.json() - content = (data.get("message") or {}).get("content") or "" - return parse_json_response(content if isinstance(content, str) else json.dumps(content)) - - def chat_with_tools( - self, - messages: list[dict[str, Any]], - tools: list[dict[str, Any]], - *, - on_token: TokenCallback | None = None, - ) -> ChatResult: - ollama_messages = normalize_messages_for_ollama(messages) - payload: dict[str, Any] = { - "model": self._model, - "messages": ollama_messages, - "tools": tools, - "stream": bool(on_token), - } - url = f"{self._base}/api/chat" - - if on_token: - return self._stream_chat(url, payload, on_token) - - with self._client() as client: - r = client.post(url, json={**payload, "stream": False}) - self._raise_for_status(r) - data = r.json() - return self._parse_chat_response(data) - - def _parse_tool_calls(self, raw_calls: list[Any]) -> list[ToolCall]: - tool_calls: list[ToolCall] = [] - for tc in raw_calls: - if not isinstance(tc, dict): - continue - fn = tc.get("function") or {} - raw_args = fn.get("arguments") or tc.get("arguments") or "{}" - if isinstance(raw_args, dict): - args = raw_args - else: - try: - args = json.loads(raw_args) if isinstance(raw_args, str) else dict(raw_args) - except json.JSONDecodeError: - args = {} - tool_calls.append( - ToolCall( - id=str(tc.get("id") or f"ollama-{len(tool_calls)}"), - name=str(fn.get("name") or tc.get("name") or ""), - arguments=args if isinstance(args, dict) else {}, - ), - ) - return tool_calls - - def _parse_chat_response(self, data: dict[str, Any]) -> ChatResult: - msg = data.get("message") or {} - content = str(msg.get("content") or "") - tool_calls = self._parse_tool_calls(msg.get("tool_calls") or []) - return ChatResult( - content=content, - tool_calls=tool_calls, - finish_reason="tool_calls" if tool_calls else "stop", - ) - - def _stream_chat( - self, - url: str, - payload: dict[str, Any], - on_token: TokenCallback, - ) -> ChatResult: - content_parts: list[str] = [] - tool_calls: list[ToolCall] = [] - - with self._client() as client: - with client.stream("POST", url, json=payload) as resp: - self._raise_for_status(resp) - for line in resp.iter_lines(): - if not line: - continue - try: - chunk = json.loads(line) - except json.JSONDecodeError: - continue - msg = chunk.get("message") or {} - if msg.get("content"): - text = str(msg["content"]) - content_parts.append(text) - on_token(text) - if msg.get("tool_calls"): - # Accumulate across chunks — assigning here dropped any - # tool calls delivered in an earlier streamed chunk. - tool_calls.extend(self._parse_tool_calls(msg["tool_calls"])) - if chunk.get("done"): - break - - return ChatResult( - content="".join(content_parts), - tool_calls=tool_calls, - finish_reason="tool_calls" if tool_calls else "stop", - ) diff --git a/src/website_profiling/llm/providers/openai.py b/src/website_profiling/llm/providers/openai.py deleted file mode 100644 index 2047a4af..00000000 --- a/src/website_profiling/llm/providers/openai.py +++ /dev/null @@ -1,167 +0,0 @@ -"""OpenAI-compatible chat completions with JSON output.""" -from __future__ import annotations - -import json -from typing import Any - -from ..base import ChatResult, TokenCallback, ToolCall, optional_cloud_base_url, parse_json_response - - -class OpenAIClient: - def __init__(self, cfg: dict[str, str]) -> None: - self._cfg = cfg - self._model = (cfg.get("llm_model") or "gpt-4o-mini").strip() - self._timeout = float(cfg.get("llm_timeout_s") or 120) - self._api_key = (cfg.get("llm_api_key") or "").strip() - custom_base = optional_cloud_base_url(cfg) - self._base = custom_base or "https://api.openai.com/v1" - - def complete_json(self, system: str, user: str) -> dict[str, Any]: - if not self._api_key: - raise RuntimeError("OpenAI API key missing. Set it in the AI tab or OPENAI_API_KEY.") - try: - import httpx - except ImportError as e: - raise ImportError("pip install -r requirements.txt") from e - - payload = { - "model": self._model, - "messages": [ - {"role": "system", "content": system}, - {"role": "user", "content": user}, - ], - "response_format": {"type": "json_object"}, - "temperature": 0.2, - } - headers = {"Authorization": f"Bearer {self._api_key}", "Content-Type": "application/json"} - url = f"{self._base}/chat/completions" - with httpx.Client(timeout=self._timeout) as client: - r = client.post(url, headers=headers, json=payload) - r.raise_for_status() - data = r.json() - choice = (data.get("choices") or [{}])[0] - content = (choice.get("message") or {}).get("content") - if content is None: - raise RuntimeError("OpenAI response contained no content.") - return parse_json_response(content if isinstance(content, str) else json.dumps(content)) - - def chat_with_tools( - self, - messages: list[dict[str, Any]], - tools: list[dict[str, Any]], - *, - on_token: TokenCallback | None = None, - ) -> ChatResult: - if not self._api_key: - raise RuntimeError("OpenAI API key missing. Set it in the AI tab or OPENAI_API_KEY.") - try: - import httpx - except ImportError as e: - raise ImportError("pip install -r requirements.txt") from e - - payload: dict[str, Any] = { - "model": self._model, - "messages": messages, - "tools": tools, - "tool_choice": "auto", - "temperature": 0.2, - "stream": bool(on_token), - } - headers = {"Authorization": f"Bearer {self._api_key}", "Content-Type": "application/json"} - url = f"{self._base}/chat/completions" - - if on_token: - return self._stream_chat(url, headers, payload, on_token) - - with httpx.Client(timeout=self._timeout) as client: - r = client.post(url, headers=headers, json={**payload, "stream": False}) - r.raise_for_status() - data = r.json() - return self._parse_chat_response(data) - - def _parse_chat_response(self, data: dict[str, Any]) -> ChatResult: - choice = (data.get("choices") or [{}])[0] - msg = choice.get("message") or {} - content = str(msg.get("content") or "") - tool_calls: list[ToolCall] = [] - for tc in msg.get("tool_calls") or []: - fn = tc.get("function") or {} - raw_args = fn.get("arguments") or "{}" - try: - args = json.loads(raw_args) if isinstance(raw_args, str) else dict(raw_args) - except json.JSONDecodeError: - args = {} - tool_calls.append( - ToolCall( - id=str(tc.get("id") or ""), - name=str(fn.get("name") or ""), - arguments=args if isinstance(args, dict) else {}, - ), - ) - return ChatResult( - content=content, - tool_calls=tool_calls, - finish_reason=str(choice.get("finish_reason") or "stop"), - ) - - def _stream_chat( - self, - url: str, - headers: dict[str, str], - payload: dict[str, Any], - on_token: TokenCallback, - ) -> ChatResult: - import httpx - - content_parts: list[str] = [] - tool_calls_acc: dict[int, dict[str, Any]] = {} - - with httpx.Client(timeout=self._timeout) as client: - with client.stream("POST", url, headers=headers, json=payload) as resp: - resp.raise_for_status() - for line in resp.iter_lines(): - if not line or not line.startswith("data: "): - continue - chunk_raw = line[6:].strip() - if chunk_raw == "[DONE]": - break - try: - chunk = json.loads(chunk_raw) - except json.JSONDecodeError: - continue - delta = ((chunk.get("choices") or [{}])[0]).get("delta") or {} - if delta.get("content"): - text = str(delta["content"]) - content_parts.append(text) - on_token(text) - for tc in delta.get("tool_calls") or []: - idx = int(tc.get("index") or 0) - acc = tool_calls_acc.setdefault( - idx, - {"id": "", "name": "", "arguments": ""}, - ) - if tc.get("id"): - acc["id"] = tc["id"] - fn = tc.get("function") or {} - if fn.get("name"): - acc["name"] = fn["name"] - if fn.get("arguments"): - acc["arguments"] += fn["arguments"] - - tool_calls: list[ToolCall] = [] - for idx, acc in tool_calls_acc.items(): - raw_args = acc.get("arguments") or "{}" - try: - args = json.loads(raw_args) if isinstance(raw_args, str) else dict(raw_args) - except json.JSONDecodeError: - args = {} - tool_calls.append( - ToolCall( - # OpenAI-compatible endpoints (Groq, etc.) may omit the id; synthesize - # a stable one from the stream index so tool_call_id pairing still works. - id=str(acc.get("id") or "") or f"call_{idx}", - name=str(acc.get("name") or ""), - arguments=args if isinstance(args, dict) else {}, - ), - ) - return ChatResult(content="".join(content_parts), tool_calls=tool_calls, finish_reason="stop") diff --git a/src/website_profiling/llm_client_http.py b/src/website_profiling/llm_client_http.py new file mode 100644 index 00000000..fcfce850 --- /dev/null +++ b/src/website_profiling/llm_client_http.py @@ -0,0 +1,197 @@ +"""HTTP client for AiService — replaces direct Python LLM imports during report build.""" +from __future__ import annotations + +import json +import os +from typing import Any + +import httpx +import pandas as pd + +from .llm_config import llm_is_enabled, load_llm_config_from_db + + +def _ai_base_url() -> str: + return (os.environ.get("AI_SERVICE_URL") or "http://127.0.0.1:8092").strip().rstrip("/") + + +def _post(path: str, payload: dict[str, Any], *, timeout: float = 120.0) -> dict[str, Any]: + url = f"{_ai_base_url()}{path}" + with httpx.Client(timeout=timeout) as client: + r = client.post(url, json=payload) + r.raise_for_status() + data = r.json() + return data if isinstance(data, dict) else {} + + +def _page_items_from_df(df: pd.DataFrame, max_pages: int) -> list[dict[str, Any]]: + if df.empty: + return [] + rows = df.head(max_pages).to_dict(orient="records") + items: list[dict[str, Any]] = [] + for row in rows: + url = str(row.get("url") or "").strip() + if not url: + continue + items.append( + { + "url": url, + "title": str(row.get("title") or ""), + "text": str(row.get("text") or row.get("body_text") or "")[:8000], + } + ) + return items + + +def run_llm_enrichment(df: pd.DataFrame, cfg: dict[str, str] | None) -> dict[str, Any]: + bundle: dict[str, Any] = { + "spacy_by_url": {}, + "similar_internal_by_url": {}, + "ner_site_summary": {}, + "keyphrases_by_url": {}, + "ml_errors": [], + } + cfg = cfg or load_llm_config_from_db() + if df.empty or not llm_is_enabled(cfg): + return bundle + + max_pages = 60 + try: + max_pages = max(1, int(str(cfg.get("llm_max_pages") or "60"))) + except ValueError: + pass + + pages = _page_items_from_df(df, max_pages) + if not pages: + return bundle + + try: + out = _post("/internal/enrichment/run", {"pages": pages}) + for key in ("spacy_by_url", "similar_internal_by_url", "ner_site_summary", "keyphrases_by_url", "ml_errors", "llm_meta"): + if key in out: + bundle[key] = out[key] + except Exception as e: + bundle["ml_errors"].append(str(e)) + return bundle + + +def cluster_keywords_llm(keywords: list[str], cfg: dict[str, str] | None = None) -> list[dict[str, Any]]: + cfg = cfg or load_llm_config_from_db() + if not keywords or not llm_is_enabled(cfg): + return [] + try: + out = _post("/internal/enrichment/cluster-keywords", {"keywords": keywords[:200]}) + clusters = out.get("clusters") or [] + return clusters if isinstance(clusters, list) else [] + except Exception as e: + raise RuntimeError(str(e)) from e + + +def enrich_top_issues_with_llm( + categories: list[dict[str, Any]], + cfg: dict[str, str] | None, + *, + gsc_pages: list[dict[str, Any]] | None = None, +) -> None: + cfg = cfg or load_llm_config_from_db() + if not llm_is_enabled(cfg): + return + try: + payload = { + "categories": categories, + "gsc_pages": gsc_pages or [], + "refresh": False, + } + _post("/internal/enrichment/issue-fixes", payload) + except Exception: + pass + + +def generate_audit_executive_summary(report_data: dict[str, Any], config: dict[str, str] | None) -> dict[str, Any]: + try: + return _post("/internal/enrichment/audit-summary", {"report": report_data, "config": config or {}}) + except Exception: + return {} + + +def call_ai_api(path: str, payload: dict[str, Any], *, timeout: float = 120.0) -> dict[str, Any]: + """Call a browser-facing AiService route (e.g. /api/issues/fix-suggestion).""" + if not path.startswith("/"): + path = f"/{path}" + return _post(path, payload, timeout=timeout) + + +def generate_issue_fix_suggestion(issue: dict[str, Any], *, cfg: dict[str, str] | None = None, refresh: bool = False) -> dict[str, Any]: + cfg = cfg or load_llm_config_from_db() + if not llm_is_enabled(cfg): + return {"ok": False, "error": "AI insights are disabled."} + body = {**issue, "source": issue.get("source") or "issue", "refresh": refresh} + try: + return call_ai_api("/api/issues/fix-suggestion", body) + except Exception as e: + return {"ok": False, "error": str(e)} + + +def run_page_coach( + page_url: str, + *, + refresh: bool = False, + current_type: str | None = None, + current_id: int | None = None, + baseline_type: str | None = None, + baseline_id: int | None = None, +) -> dict[str, Any]: + body: dict[str, Any] = {"url": page_url, "refresh": refresh} + if current_id is not None: + body["currentId"] = current_id + if baseline_id is not None: + body["baselineId"] = baseline_id + try: + return call_ai_api("/api/links/page-coach", body) + except Exception as e: + return {"ok": False, "error": str(e)} + + +def complete_json(system: str, user: str, cfg: dict[str, str] | None = None) -> dict[str, Any]: + _ = cfg + try: + return _post("/internal/completion/json", {"system": system, "user": user}) + except Exception as e: + return {"error": str(e)} + + +def generate_content_brief( + keyword: str, + rows: list[dict[str, Any]] | None = None, + gaps: list[str] | None = None, + *, + use_llm: bool = False, +) -> dict[str, Any]: + """Heuristic content brief (no LLM).""" + _ = use_llm + bullets: list[str] = [] + if gaps: + bullets.extend(f"Gap: {g}" for g in gaps[:8]) + if rows: + for row in rows[:5]: + if isinstance(row, dict): + kw = row.get("keyword") or row.get("query") + clicks = row.get("clicks") + if kw: + bullets.append(f"Target cluster around '{kw}'" + (f" ({clicks} clicks)" if clicks else "")) + if not bullets: + bullets.append(f"Create comprehensive content targeting '{keyword}'") + return { + "keyword": keyword, + "summary": bullets, + "provenance": "Estimated", + "use_llm": False, + } + + +def parse_json_response(text: str) -> dict[str, Any]: + try: + data = json.loads(text) + return data if isinstance(data, dict) else {"raw": data} + except json.JSONDecodeError: + return {"raw": text} diff --git a/src/website_profiling/mcp/__init__.py b/src/website_profiling/mcp/__init__.py deleted file mode 100644 index e1ce3837..00000000 --- a/src/website_profiling/mcp/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Model Context Protocol server for Site Audit.""" diff --git a/src/website_profiling/mcp/__main__.py b/src/website_profiling/mcp/__main__.py deleted file mode 100644 index f1ada529..00000000 --- a/src/website_profiling/mcp/__main__.py +++ /dev/null @@ -1,6 +0,0 @@ -from .server import main -from ..console_io import configure_stdio - -if __name__ == "__main__": - configure_stdio() - main() diff --git a/src/website_profiling/mcp/core_server.py b/src/website_profiling/mcp/core_server.py deleted file mode 100644 index 7026c4af..00000000 --- a/src/website_profiling/mcp/core_server.py +++ /dev/null @@ -1,8 +0,0 @@ -"""MCP core router server (Tier 0 + insight tools).""" -from __future__ import annotations - -from .domain_server import run_domain_server - - -def main() -> None: - run_domain_server("core") diff --git a/src/website_profiling/mcp/domain_server.py b/src/website_profiling/mcp/domain_server.py deleted file mode 100644 index 96f617dc..00000000 --- a/src/website_profiling/mcp/domain_server.py +++ /dev/null @@ -1,11 +0,0 @@ -"""MCP domain server entry — set WP_MCP_DOMAIN before starting.""" -from __future__ import annotations - -import os - -from .server import main - - -def run_domain_server(domain: str) -> None: - os.environ["WP_MCP_DOMAIN"] = domain - main() diff --git a/src/website_profiling/mcp/http.py b/src/website_profiling/mcp/http.py deleted file mode 100644 index ee88f77c..00000000 --- a/src/website_profiling/mcp/http.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Entry point: python -m website_profiling.mcp.http""" -from .http_server import main - -if __name__ == "__main__": - main() diff --git a/src/website_profiling/mcp/http_server.py b/src/website_profiling/mcp/http_server.py deleted file mode 100644 index 753e8d22..00000000 --- a/src/website_profiling/mcp/http_server.py +++ /dev/null @@ -1,271 +0,0 @@ -"""Streamable HTTP MCP server for remote Site Audit tool access.""" -from __future__ import annotations - -import contextlib -import hmac -import json -import os -from collections.abc import AsyncIterator, Callable -from typing import Any -from urllib.parse import urlparse - -from .settings import McpHttpSettings, load_mcp_http_settings - -_LOCALHOST_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"}) - - -def _env(name: str, default: str = "") -> str: - return os.environ.get(name, default).strip() - - -def _bool_env(name: str, *, default: bool = False) -> bool: - raw = _env(name) - if not raw: - return default - return raw.lower() in {"1", "true", "yes", "on"} - - -def _http_host() -> str: - return _env("WP_MCP_HTTP_HOST", "127.0.0.1") or "127.0.0.1" - - -def _http_port() -> int: - raw = _env("WP_MCP_HTTP_PORT", "8000") or "8000" - try: - port = int(raw) - except ValueError as e: - raise SystemExit(f"Invalid WP_MCP_HTTP_PORT: {raw!r}") from e - if port < 1 or port > 65535: - raise SystemExit(f"Invalid WP_MCP_HTTP_PORT: {port}") - return port - - -def _http_path() -> str: - path = _env("WP_MCP_HTTP_PATH", "/mcp") or "/mcp" - if not path.startswith("/"): - path = f"/{path}" - return path.rstrip("/") or "/mcp" - - -def _is_public_bind(host: str) -> bool: - return host not in _LOCALHOST_HOSTS - - -def _host_from_header(host_header: str) -> str: - host = host_header.strip().lower() - if not host: - return "" - if host.startswith("["): - end = host.index("]") if "]" in host else -1 - if end > 1: - return host[1:end] - return host.split(":")[0] - - -def _origin_host(origin: str) -> str: - parsed = urlparse(origin.strip()) - return (parsed.hostname or "").lower() - - -def _host_allowed(host: str, allowed_hosts: list[str]) -> bool: - if not allowed_hosts: - return True - host_lower = host.lower() - for entry in allowed_hosts: - pattern = entry.strip().lower() - if not pattern: - continue - if pattern.startswith("*."): - suffix = pattern[1:] - if host_lower == pattern[2:] or host_lower.endswith(suffix): - return True - continue - if host_lower == pattern or host_lower.endswith(f":{pattern}"): - return True - if pattern.endswith(":*") and host_lower == pattern[:-2]: - return True - return False - - -def _origin_allowed(origin: str, allowed_origins: list[str]) -> bool: - if not allowed_origins: - return True - if not origin.strip(): - return True - origin_host = _origin_host(origin) - for entry in allowed_origins: - pattern = entry.strip().lower() - if not pattern: - continue - if pattern == origin.strip().lower(): - return True - if pattern.startswith("http://") or pattern.startswith("https://"): - continue - if pattern.startswith("*."): - # Wildcard: match the apex and any subdomain. - if origin_host == pattern[2:] or origin_host.endswith(pattern[1:]): - return True - continue - # Bare hostname: exact match only. A non-wildcard pattern must NOT be - # widened into a ".pattern" suffix match, or `example.com` would also - # allow `evil.example.com`. - if origin_host == pattern: - return True - return False - - -def _validate_startup_config() -> None: - host = _http_host() - if not _is_public_bind(host): - return - settings = load_mcp_http_settings() - if not settings.token: - raise SystemExit( - "Remote MCP token is required when WP_MCP_HTTP_HOST is not localhost " - f"(current host: {host!r}). Set WP_MCP_TOKEN or save mcp_token on the Secrets page.", - ) - if not settings.allowed_hosts: - raise SystemExit( - "Allowed MCP hosts are required when binding to a non-localhost address " - f"(current host: {host!r}). Set WP_MCP_ALLOWED_HOSTS or save mcp_allowed_hosts on the Secrets page.", - ) - - -def _transport_security_settings(host: str): - from mcp.server.transport_security import TransportSecuritySettings - - public = _is_public_bind(host) - settings = load_mcp_http_settings() - if settings.remote_access_configured: - return TransportSecuritySettings( - enable_dns_rebinding_protection=False, - allowed_hosts=[], - allowed_origins=[], - ) - return TransportSecuritySettings( - enable_dns_rebinding_protection=public, - allowed_hosts=settings.allowed_hosts or [], - allowed_origins=settings.allowed_origins or [], - ) - - -async def _reject_request(send: Any, status: int, message: str) -> None: - body = json.dumps({"error": message}).encode() - await send( - { - "type": "http.response.start", - "status": status, - "headers": [ - (b"content-type", b"application/json"), - (b"content-length", str(len(body)).encode("ascii")), - ], - }, - ) - await send({"type": "http.response.body", "body": body}) - - -class RemoteAccessMiddleware: - """Enforce bearer token and allowed Host/Origin using UI-managed settings.""" - - def __init__(self, app: Callable[..., Any]) -> None: - self.app = app - - async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None: - if scope.get("type") != "http": - await self.app(scope, receive, send) - return - - settings = load_mcp_http_settings() - headers = { - name.decode("latin-1").lower(): value.decode("latin-1") - for name, value in scope.get("headers", []) - } - host = _host_from_header(headers.get("host", "")) - - if settings.allowed_hosts and not _host_allowed(host, settings.allowed_hosts): - await _reject_request(send, 403, "Host not allowed for remote MCP") - return - - origin = headers.get("origin", "") - if settings.allowed_origins: - if not _origin_allowed(origin, settings.allowed_origins): - await _reject_request(send, 403, "Origin not allowed for remote MCP") - return - elif origin.strip() and not _host_allowed(_origin_host(origin), settings.allowed_hosts): - # No explicit allowed_origins configured. Transport-level Origin / - # DNS-rebinding protection is delegated to this middleware (see - # _transport_security_settings), so a request carrying a browser - # Origin header must at least be same-host as an allowed host; - # otherwise an unconfigured deployment performs no Origin check at - # all. Non-browser clients send no Origin and are unaffected. - await _reject_request(send, 403, "Origin not allowed for remote MCP") - return - - if settings.token: - auth_value = headers.get("authorization", "") - expected = f"Bearer {settings.token}" - if not hmac.compare_digest(auth_value.encode("utf-8"), expected.encode("utf-8")): - await _reject_request(send, 401, "Unauthorized") - return - - await self.app(scope, receive, send) - - -def _with_remote_access(app: Callable[..., Any]) -> Callable[..., Any]: - return RemoteAccessMiddleware(app) - - -def build_app(): - try: - from mcp.server.streamable_http_manager import StreamableHTTPSessionManager - from starlette.applications import Starlette - from starlette.routing import Mount - from starlette.types import Receive, Scope, Send - except ImportError as e: - raise SystemExit( - "MCP HTTP dependencies not installed. Run: pip install -r requirements.txt", - ) from e - - from .server import create_server - - host = _http_host() - path = _http_path() - settings = load_mcp_http_settings() - server = create_server(domain=settings.domain) - security = _transport_security_settings(host) - - manager = StreamableHTTPSessionManager( - app=server, - event_store=None, - json_response=_bool_env("WP_MCP_JSON_RESPONSE", default=False), - stateless=True, - security_settings=security, - ) - - async def handle(scope: Scope, receive: Receive, send: Send) -> None: - await manager.handle_request(scope, receive, send) - - @contextlib.asynccontextmanager - async def lifespan(_: Starlette) -> AsyncIterator[None]: - async with manager.run(): - yield - - starlette_app = Starlette(routes=[Mount(path, app=handle)], lifespan=lifespan) - return _with_remote_access(starlette_app) - - -def main() -> None: - _validate_startup_config() - try: - import uvicorn - except ImportError as e: - raise SystemExit( - "uvicorn not installed. Run: pip install -r requirements.txt", - ) from e - - uvicorn.run( - build_app(), - host=_http_host(), - port=_http_port(), - log_level=_env("WP_MCP_LOG_LEVEL", "info").lower() or "info", - ) diff --git a/src/website_profiling/mcp/server.py b/src/website_profiling/mcp/server.py deleted file mode 100644 index 0d2145a8..00000000 --- a/src/website_profiling/mcp/server.py +++ /dev/null @@ -1,284 +0,0 @@ -"""stdio MCP server exposing read-only Site Audit tools and resources.""" -from __future__ import annotations - -import json -import os -import re -from pathlib import Path -from typing import Any - -from ..db.storage import db_session -from ..tools.audit_tools import AuditToolContext -from ..tools.audit_tools.registry import ( - TOOL_DEFINITIONS, - dispatch_tool, - list_domains_catalog, - mcp_tool_names, - tools_catalog_by_domain, -) -from ..tools.audit_tools.tool_domains import MCP_DOMAIN_BUNDLES - -_URI_PROPERTY = re.compile(r"^audit://property/(\d+)$") -_URI_REPORT_LATEST = re.compile(r"^audit://property/(\d+)/report/latest$") -_URI_REPORT_ID = re.compile(r"^audit://property/(\d+)/report/(\d+)$") - - -def _default_property_id() -> int | None: - raw = os.environ.get("WP_PROPERTY_ID", "").strip() - if not raw: - return None - try: - pid = int(raw) - return pid if pid > 0 else None - except ValueError: - return None - - -def _merge_context(args: dict[str, Any]) -> AuditToolContext: - pid = args.get("property_id") - rid = args.get("report_id") - default_pid = _default_property_id() - try: - property_id = int(pid) if pid is not None else default_pid - except (TypeError, ValueError): - property_id = default_pid - try: - report_id = int(rid) if rid is not None else None - except (TypeError, ValueError): - report_id = None - return AuditToolContext(property_id=property_id, report_id=report_id) - - -def _payload_index(payload: dict[str, Any]) -> dict[str, Any]: - """Truncated payload index: keys and list lengths only.""" - index: dict[str, Any] = {} - for key, val in payload.items(): - if isinstance(val, list): - index[key] = {"type": "list", "count": len(val)} - elif isinstance(val, dict): - index[key] = {"type": "object", "keys": list(val.keys())[:30]} - else: - index[key] = {"type": type(val).__name__, "preview": str(val)[:120]} - return index - - -def _read_glossary_excerpt() -> str: - root = Path(__file__).resolve().parents[3] - path = root / "docs" / "GLOSSARY.md" - if not path.is_file(): - return "Glossary file not found." - text = path.read_text(encoding="utf-8") - return text[:12000] - - -def _mcp_domain() -> str: - return (os.environ.get("WP_MCP_DOMAIN") or "core").strip().lower() - - -def _load_disabled_tools() -> frozenset[str]: - """Load mcp_disabled_tools JSON array from pipeline_config. Returns empty set on any error.""" - try: - with db_session() as conn: - row = conn.execute( - "SELECT value FROM pipeline_config WHERE key = 'mcp_disabled_tools'" - ).fetchone() - if row and row[0]: - items = json.loads(row[0]) - if isinstance(items, list): - return frozenset(str(i) for i in items if isinstance(i, str)) - except Exception: # noqa: BLE001 - pass - return frozenset() - - -def _tools_catalog_json(domain: str | None = None) -> str: - effective = (domain or _mcp_domain()).strip().lower() or "core" - exposed = mcp_tool_names(effective) - by_domain = tools_catalog_by_domain() - scoped: dict[str, list[str]] = {} - for d, names in by_domain.items(): - filtered = [n for n in names if n in exposed] - if filtered: - scoped[d] = filtered - return json.dumps({ - "mcp_domain": effective, - "tool_count": len(exposed), - "handlers": sorted(exposed), - "domains": scoped, - "available_mcp_domains": sorted(MCP_DOMAIN_BUNDLES.keys()), - }, indent=2) - - -def _domains_resource_json(domain: str | None = None) -> str: - effective = (domain or _mcp_domain()).strip().lower() or "core" - return json.dumps({ - "current_mcp_domain": effective, - "bundles": { - key: sorted(domains) - for key, domains in MCP_DOMAIN_BUNDLES.items() - }, - "catalog": list_domains_catalog(), - }, indent=2) - - -def _resolve_resource(uri: str, domain: str | None = None) -> str: - if uri == "audit://properties": - result = dispatch_tool("list_properties", {}) - return json.dumps(result, indent=2, default=str) - - if uri == "audit://glossary": - return _read_glossary_excerpt() - - if uri == "audit://tools": - return _tools_catalog_json(domain=domain) - - if uri == "audit://domains": - return _domains_resource_json(domain=domain) - - m = _URI_PROPERTY.match(uri) - if m: - pid = int(m.group(1)) - prop = dispatch_tool("get_property", {"property_id": pid}) - summary = dispatch_tool("get_report_summary", {"property_id": pid}) - return json.dumps({"property": prop, "latest_report": summary}, indent=2, default=str) - - m = _URI_REPORT_LATEST.match(uri) - if m: - pid = int(m.group(1)) - ctx = AuditToolContext(property_id=pid) - with db_session() as conn: - payload = ctx.load_payload(conn) - if not payload: - return json.dumps({"error": "no report found"}) - return json.dumps(_payload_index(payload), indent=2, default=str) - - m = _URI_REPORT_ID.match(uri) - if m: - pid = int(m.group(1)) - rid = int(m.group(2)) - ctx = AuditToolContext(property_id=pid, report_id=rid) - with db_session() as conn: - payload = ctx.load_payload(conn) - if not payload: - return json.dumps({"error": "no report found"}) - return json.dumps(_payload_index(payload), indent=2, default=str) - - return json.dumps({"error": f"unknown resource: {uri}"}) - - -def _import_mcp_types(): - try: - from mcp.server import Server - from mcp.types import Resource, TextContent, Tool - except ImportError as e: - raise SystemExit( - "MCP SDK not installed. Run: pip install -r requirements.txt", - ) from e - return Server, Resource, TextContent, Tool - - -def create_server(domain: str | None = None): - """Build transport-agnostic MCP server with Site Audit tools and resources.""" - Server, Resource, TextContent, Tool = _import_mcp_types() - - effective_domain = (domain or _mcp_domain()).strip().lower() or "core" - server = Server(f"site-audit-{effective_domain}") - default_pid = _default_property_id() - exposed = mcp_tool_names(effective_domain) - - @server.list_tools() - async def list_tools() -> list[Tool]: - disabled = _load_disabled_tools() - out: list[Tool] = [] - for spec in TOOL_DEFINITIONS: - if spec["name"] not in exposed: - continue - if spec["name"] in disabled: - continue - out.append( - Tool( - name=spec["name"], - description=spec.get("description", ""), - inputSchema=spec.get("inputSchema", {"type": "object", "properties": {}}), - ), - ) - return out - - @server.call_tool() - async def call_tool(name: str, arguments: dict[str, Any] | None) -> list[TextContent]: - disabled = _load_disabled_tools() - if name not in exposed: - result = { - "error": f"tool not exposed in MCP domain {effective_domain}: {name}", - "hint": "Connect WP_MCP_DOMAIN=full or the domain server that includes this tool.", - } - return [TextContent(type="text", text=json.dumps(result, indent=2, default=str))] - if name in disabled: - result = { - "error": f"tool '{name}' has been disabled via Risk Settings.", - "hint": "Enable it on the /risk-settings page to use this tool.", - } - return [TextContent(type="text", text=json.dumps(result, indent=2, default=str))] - args = dict(arguments or {}) - ctx = _merge_context(args) - result = dispatch_tool(name, args, context=ctx) - text = json.dumps(result, indent=2, default=str) - return [TextContent(type="text", text=text)] - - @server.list_resources() - async def list_resources() -> list[Resource]: - resources = [ - Resource(uri="audit://properties", name="Properties", description="All configured site properties", mimeType="application/json"), - Resource(uri="audit://glossary", name="Glossary", description="Site Audit field glossary excerpt", mimeType="text/markdown"), - Resource(uri="audit://tools", name="Tool catalog", description="MCP tool catalog for the connected domain server", mimeType="application/json"), - Resource(uri="audit://domains", name="MCP domain servers", description="Available WP_MCP_DOMAIN bundles and domain groupings", mimeType="application/json"), - ] - if default_pid: - resources.extend([ - Resource( - uri=f"audit://property/{default_pid}", - name=f"Property {default_pid}", - description="Property details and latest report summary", - mimeType="application/json", - ), - Resource( - uri=f"audit://property/{default_pid}/report/latest", - name=f"Latest report index (property {default_pid})", - description="Payload key index for latest audit report", - mimeType="application/json", - ), - ]) - return resources - - @server.read_resource() - async def read_resource(uri: str) -> str: - return _resolve_resource(uri, domain=effective_domain) - - return server - - -def run_stdio() -> None: - try: - from mcp.server.stdio import stdio_server - except ImportError as e: - raise SystemExit( - "MCP SDK not installed. Run: pip install -r requirements.txt", - ) from e - - server = create_server() - - async def run() -> None: - async with stdio_server() as (read_stream, write_stream): - await server.run(read_stream, write_stream, server.create_initialization_options()) - - import asyncio - - asyncio.run(run()) - - -def main() -> None: - run_stdio() - - -if __name__ == "__main__": - main() diff --git a/src/website_profiling/mcp/settings.py b/src/website_profiling/mcp/settings.py deleted file mode 100644 index feceb07e..00000000 --- a/src/website_profiling/mcp/settings.py +++ /dev/null @@ -1,57 +0,0 @@ -"""Load remote MCP HTTP settings from environment and pipeline_config.""" -from __future__ import annotations - -import os -from dataclasses import dataclass - -from ..db.config_store import read_pipeline_config -from ..db.storage import db_session - - -def _env(name: str, default: str = "") -> str: - return os.environ.get(name, default).strip() - - -def _parse_csv(raw: str) -> list[str]: - if not raw.strip(): - return [] - return [part.strip() for part in raw.split(",") if part.strip()] - - -@dataclass(frozen=True) -class McpHttpSettings: - token: str - allowed_hosts: list[str] - allowed_origins: list[str] - domain: str = "core" - - @property - def remote_access_configured(self) -> bool: - return bool(self.token and self.allowed_hosts) - - -def _load_pipeline_mcp_settings() -> dict[str, str]: - try: - with db_session() as conn: - known, _unknown = read_pipeline_config(conn) - return known - except Exception: - return {} - - -def load_mcp_http_settings() -> McpHttpSettings: - """Merge MCP HTTP settings: environment overrides database values.""" - pipeline = _load_pipeline_mcp_settings() - - token = _env("WP_MCP_TOKEN") or str(pipeline.get("mcp_token", "")).strip() - - hosts_raw = _env("WP_MCP_ALLOWED_HOSTS") or str(pipeline.get("mcp_allowed_hosts", "")).strip() - origins_raw = _env("WP_MCP_ALLOWED_ORIGINS") or str(pipeline.get("mcp_allowed_origins", "")).strip() - domain = _env("WP_MCP_DOMAIN") or str(pipeline.get("mcp_domain", "")).strip().lower() or "core" - - return McpHttpSettings( - token=token, - allowed_hosts=_parse_csv(hosts_raw), - allowed_origins=_parse_csv(origins_raw), - domain=domain, - ) diff --git a/src/website_profiling/reporting/builder.py b/src/website_profiling/reporting/builder.py index 2e6c1d16..57027c19 100644 --- a/src/website_profiling/reporting/builder.py +++ b/src/website_profiling/reporting/builder.py @@ -15,7 +15,7 @@ from ..analysis import merge_bundles, run_local_enrichment from ..analysis.text_hygiene import is_junk_semantic_term from ..config import get_bool, get_int -from ..llm.enrich import cluster_keywords_llm, run_llm_enrichment +from ..llm_client_http import cluster_keywords_llm, run_llm_enrichment from ..llm_config import load_llm_config_from_db, llm_is_enabled from ..security_scanner import run_security_scan from ..scoring import round_half_up @@ -688,7 +688,7 @@ def run_simple_report( except Exception as e: report_data.setdefault("ml_errors", []).append(f"bing: {e}") try: - from ..llm.issue_fixes import enrich_top_issues_with_llm + from ..llm_client_http import enrich_top_issues_with_llm gsc_pages = [] gsc_block = (report_data.get("google") or {}).get("gsc") or {} @@ -702,7 +702,7 @@ def run_simple_report( except Exception as e: report_data.setdefault("ml_errors", []).append(f"issue_fixes: {e}") try: - from ..llm.audit_summary import generate_audit_executive_summary + from ..llm_client_http import generate_audit_executive_summary report_data["executive_summary"] = generate_audit_executive_summary(report_data, config) except Exception: diff --git a/src/website_profiling/reporting/categories/__init__.py b/src/website_profiling/reporting/categories/__init__.py index 70f36f69..d58916cb 100644 --- a/src/website_profiling/reporting/categories/__init__.py +++ b/src/website_profiling/reporting/categories/__init__.py @@ -106,7 +106,11 @@ def build_categories( category_technical_seo(df, site_level), cwv, category_performance(df), - category_html_accessibility(df, lighthouse_by_url=lighthouse_by_url), + category_html_accessibility( + df, + lighthouse_by_url=lighthouse_by_url, + lighthouse_summary=lighthouse_summary, + ), category_link_health(df, edges, issues_broken, issues_redirects), category_mobile(df), category_security(df, site_level, start_url or "", security_findings=security_findings), diff --git a/src/website_profiling/reporting/categories/accessibility.py b/src/website_profiling/reporting/categories/accessibility.py index 695c3022..0ecff34b 100644 --- a/src/website_profiling/reporting/categories/accessibility.py +++ b/src/website_profiling/reporting/categories/accessibility.py @@ -7,6 +7,8 @@ import pandas as pd from ..terminology import CATEGORY_ACCESSIBILITY +from ...lighthouse.audit_text import failure_display_message, failure_help_text +from ...tools.warnings import _resolve_entry, resolve_impact from ._helpers import ( _issue, _page_analysis_dict, @@ -15,6 +17,95 @@ _sort_issues, ) +def lighthouse_accessibility_issues_from_sources( + lighthouse_by_url: Optional[dict[str, Any]] = None, + *, + skip_lh_contrast_urls: Optional[set[str]] = None, +) -> list[dict]: + """Accessibility issues from per-URL Lighthouse failures (accessibility category).""" + issues: list[dict] = [] + seen: set[tuple[str, str]] = set() + skip_contrast = skip_lh_contrast_urls or set() + for url, summary in (lighthouse_by_url or {}).items(): + if not isinstance(summary, dict): + continue + u = str(url or summary.get("url") or "").strip().rstrip("/") + if not u: + continue + for fail in summary.get("top_failures") or []: + if not isinstance(fail, dict): + continue + aid = str(fail.get("id") or "").strip() + if not aid: + continue + if aid == "color-contrast" and u in skip_contrast: + continue + category = str(fail.get("category") or fail.get("category_id") or "").strip() + if category == "accessibility": + pass + elif category: + continue + else: + title = str(fail.get("title") or "") + help_text = failure_help_text(fail) + impact = str(fail.get("impact") or resolve_impact(aid, title, help_text)) + if impact != "Accessibility": + continue + key = (u, aid) + if key in seen: + continue + seen.add(key) + msg = failure_display_message(fail) + entry = _resolve_entry(aid, str(fail.get("title") or None), failure_help_text(fail) or None) + rec = str(entry.get("one_line_fix") or "").strip() + if not rec: + rec = "See Lighthouse accessibility recommendations for this page." + issues.append(_issue( + f"Lighthouse: {msg if msg != 'Audit failed' else aid.replace('-', ' ')}", + url=u, + priority="High" if entry.get("severity") == "High" else "Medium", + recommendation=rec, + )) + return issues + + +def lighthouse_accessibility_issues_from_summary( + lighthouse_summary: Optional[dict[str, Any]] = None, +) -> list[dict]: + """Site-level accessibility failures from the global Lighthouse summary.""" + if not isinstance(lighthouse_summary, dict): + return [] + issues: list[dict] = [] + seen: set[str] = set() + for fail in lighthouse_summary.get("top_failures") or []: + if not isinstance(fail, dict): + continue + aid = str(fail.get("id") or "").strip() + if not aid or aid in seen: + continue + category = str(fail.get("category") or fail.get("category_id") or "").strip() + if category == "accessibility": + pass + elif category: + continue + else: + title = str(fail.get("title") or "") + help_text = failure_help_text(fail) + impact = str(fail.get("impact") or resolve_impact(aid, title, help_text)) + if impact != "Accessibility": + continue + seen.add(aid) + msg = failure_display_message(fail) + entry = _resolve_entry(aid, str(fail.get("title") or None), failure_help_text(fail) or None) + rec = str(entry.get("one_line_fix") or "").strip() or "See Lighthouse accessibility recommendations." + issues.append(_issue( + f"Lighthouse: {msg if msg != 'Audit failed' else aid.replace('-', ' ')}", + priority="High" if entry.get("severity") == "High" else "Medium", + recommendation=rec, + )) + return issues + + def contrast_issues_from_sources( df: pd.DataFrame, lighthouse_by_url: Optional[dict[str, Any]] = None, @@ -51,27 +142,12 @@ def contrast_issues_from_sources( ), )) - lh_map = lighthouse_by_url or {} - for url, summary in lh_map.items(): - if not isinstance(summary, dict): - continue - u = str(url or summary.get("url") or "").strip().rstrip("/") - if not u or u in seen_urls: - continue - for fail in summary.get("top_failures") or []: - if not isinstance(fail, dict): - continue - if str(fail.get("id") or "") != "color-contrast": - continue - seen_urls.add(u) - help_text = str(fail.get("helpText") or "Low color contrast") - issues.append(_issue( - f"Lighthouse: {help_text}", - url=u, - priority="Medium", - recommendation="Increase contrast ratio between text and background to meet WCAG AA.", - )) - break + issues.extend( + lighthouse_accessibility_issues_from_sources( + lighthouse_by_url, + skip_lh_contrast_urls=seen_urls, + ) + ) return issues[:40] @@ -79,6 +155,7 @@ def contrast_issues_from_sources( def category_html_accessibility( df: pd.DataFrame, lighthouse_by_url: Optional[dict[str, Any]] = None, + lighthouse_summary: Optional[dict[str, Any]] = None, ) -> dict: """HTML and Accessibility: semantic HTML, heading structure, alt, ARIA, contrast.""" issues = [] @@ -164,6 +241,9 @@ def category_html_accessibility( deductions.append((min(10, complex_pages * 2), True)) contrast_issues = contrast_issues_from_sources(df, lighthouse_by_url) + lh_a11y = lighthouse_accessibility_issues_from_summary(lighthouse_summary) + if lh_a11y: + contrast_issues = contrast_issues + lh_a11y if contrast_issues: issues.extend(contrast_issues) deductions.append((min(25, len(contrast_issues) * 4), True)) diff --git a/src/website_profiling/reporting/categories/performance.py b/src/website_profiling/reporting/categories/performance.py index b4e7ffa6..2df7e58a 100644 --- a/src/website_profiling/reporting/categories/performance.py +++ b/src/website_profiling/reporting/categories/performance.py @@ -14,6 +14,12 @@ _score_deductions, _sort_issues, ) +from ...lighthouse.audit_text import ( + failure_display_message, + failure_help_text, + is_core_web_vitals_failure, +) +from ...tools.warnings import _resolve_entry, resolve_impact from ..terminology import ( CATEGORY_CORE_WEB_VITALS, CATEGORY_PERFORMANCE, @@ -46,13 +52,22 @@ def category_core_web_vitals_from_lighthouse( if isinstance(mm.get("performance_score"), (int, float)): perf_score = max(0, min(100, int(round(mm["performance_score"] * 100)))) for f in lighthouse_summary.get("top_failures") or []: - aid = f.get("id") or "" - help_text = (f.get("helpText") or "")[:200] - msg = f"{aid}: {help_text}" if aid else help_text or "Audit failed" + if not isinstance(f, dict): + continue + if not is_core_web_vitals_failure(f, resolve_impact=resolve_impact): + continue + aid = str(f.get("id") or "") + title = str(f.get("title") or "") + help_text = failure_help_text(f) + msg = failure_display_message(f) + entry = _resolve_entry(aid, title or None, help_text or None) + rec = str(entry.get("one_line_fix") or "").strip() + if not rec: + rec = "See Lighthouse performance recommendations in this audit, or re-run Lighthouse from Run audit." issues.append(_issue( msg, priority="High" if (f.get("score") or 0) < 0.5 else "Medium", - recommendation="See Performance (Core Web Vitals) in this audit, or re-run Lighthouse from Run audit.", + recommendation=rec, )) if not issues and perf_score is not None and perf_score < 80: recommendations.append("Improve Core Web Vitals (LCP, CLS, TBT) per Lighthouse recommendations.") diff --git a/src/website_profiling/reporting/lighthouse_report.py b/src/website_profiling/reporting/lighthouse_report.py index 5ba6afbc..7dceaf40 100644 --- a/src/website_profiling/reporting/lighthouse_report.py +++ b/src/website_profiling/reporting/lighthouse_report.py @@ -103,7 +103,9 @@ def build_lighthouse_by_url_for_report(conn: Any) -> dict[str, Any]: read_lighthouse_page_summaries, read_lighthouse_run_json, ) + from ..lighthouse.audit_text import audit_help_text, audit_title, failure_row_from_audit from ..lighthouse.runner import _evidence_from_audit, extract_from_lighthouse_json + from ..lighthouse.schema import _audit_id_to_category from ..tools.warnings import parse_lighthouse_to_diagnostics, resolve_impact summaries = read_lighthouse_page_summaries(conn) @@ -151,6 +153,12 @@ def build_lighthouse_by_url_for_report(conn: Any) -> dict[str, Any]: if raw: lr = raw.get("lighthouseResult") or raw audits_map = lr.get("audits") or {} + categories_map = lr.get("categories") or {} + audit_to_cat = ( + _audit_id_to_category(categories_map) + if isinstance(categories_map, dict) + else {} + ) failures: list[dict[str, Any]] = [] for aid, a in audits_map.items(): if not isinstance(a, dict): @@ -158,16 +166,16 @@ def build_lighthouse_by_url_for_report(conn: Any) -> dict[str, Any]: score = a.get("score") if score is None or score >= 1: continue - title = a.get("title") or aid - help_text = a.get("helpText") or "" + title = audit_title(a, aid) + help_text = audit_help_text(a) failures.append( - { - "id": aid, - "score": score, - "helpText": help_text, - "impact": resolve_impact(aid, title, help_text), - "evidence": _evidence_from_audit(a), - } + failure_row_from_audit( + aid, + a, + category=audit_to_cat.get(aid), + impact=resolve_impact(aid, title, help_text), + evidence=_evidence_from_audit(a), + ) ) failures.sort(key=lambda x: (x["score"] or 0)) base["top_failures"] = failures diff --git a/src/website_profiling/tools/audit_tools/integrations/llm_tools.py b/src/website_profiling/tools/audit_tools/integrations/llm_tools.py index a01ede9e..0567159f 100644 --- a/src/website_profiling/tools/audit_tools/integrations/llm_tools.py +++ b/src/website_profiling/tools/audit_tools/integrations/llm_tools.py @@ -9,8 +9,8 @@ from ....db._common import _row_field from ....db.property_store import list_properties_public from ....integrations.google.suggest import batch_expand -from ....llm.content_brief import generate_content_brief as build_content_brief -from ....llm.page_coach import run_page_coach +from ....llm_client_http import generate_content_brief as build_content_brief +from ....llm_client_http import run_page_coach from .._slice import parse_limit from ..context import AuditToolContext @@ -140,7 +140,7 @@ def _llm_disabled_response() -> dict[str, Any]: def generate_issue_fix(conn: Connection, ctx: AuditToolContext, args: dict[str, Any]) -> dict[str, Any]: - from ....llm.issue_fixes import generate_issue_fix_suggestion + from ....llm_client_http import generate_issue_fix_suggestion from ....llm_config import load_llm_config_from_db err = _llm_disabled_response() @@ -188,17 +188,14 @@ def summarize_category_for_client(conn: Connection, ctx: AuditToolContext, args: } err = _llm_disabled_response() if not err: - from ....llm.base import get_llm_client, parse_json_response - from ....llm_config import load_llm_config_from_db + from ....llm_client_http import complete_json, parse_json_response - cfg = load_llm_config_from_db() try: - client = get_llm_client(cfg) user = ( "Write a 2-3 sentence client-friendly summary of this audit category. " f"Return JSON with key summary. Data: {json.dumps(summary, default=str)[:3000]}" ) - raw = client.complete_json("You are a technical SEO consultant writing for clients.", user) + raw = complete_json("You are a technical SEO consultant writing for clients.", user) if isinstance(raw, dict) and raw.get("summary"): summary["narrative"] = raw["summary"] else: @@ -268,20 +265,14 @@ def analyze_serp_snippet_for_url(conn: Connection, ctx: AuditToolContext, args: base["note"] = err.get("error") base["provenance"] = "Crawl" return base - from ....llm.base import get_llm_client - from ....llm_config import load_llm_config_from_db + from ....llm_client_http import complete_json - cfg = load_llm_config_from_db() - client = get_llm_client(cfg) - if not client: - base["provenance"] = "Crawl" - return base prompt = ( "Suggest improved title and meta description for better CTR. " f"Context: {json.dumps(base, default=str)[:2500]}" ) try: - suggestions = client.complete_json( + suggestions = complete_json( "You are an SEO copywriter. Return JSON with title, meta_description, rationale.", prompt, ) @@ -314,12 +305,10 @@ def draft_llms_txt(conn: Connection, ctx: AuditToolContext, args: dict[str, Any] ] err = _llm_disabled_response() if not err: - from ....llm.base import get_llm_client - from ....llm_config import load_llm_config_from_db + from ....llm_client_http import complete_json try: - client = get_llm_client(load_llm_config_from_db()) - raw = client.complete_json( + raw = complete_json( "You write concise llms.txt files per emerging conventions. Return JSON with key content.", "Polish this llms.txt draft:\n" + "\n".join(draft_lines), ) @@ -429,12 +418,10 @@ def _article_schema() -> dict[str, Any]: err = _llm_disabled_response() if not err: - from ....llm.base import get_llm_client - from ....llm_config import load_llm_config_from_db + from ....llm_client_http import complete_json try: - client = get_llm_client(load_llm_config_from_db()) - raw = client.complete_json( + raw = complete_json( "You generate valid JSON-LD schema.org markup. Return JSON with key schema_json.", f"Improve this {schema_type_clean} JSON-LD for AI readability:\n{json.dumps(schema_obj, indent=2)[:1500]}", ) diff --git a/src/website_profiling/tools/audit_tools/tool_selector.py b/src/website_profiling/tools/audit_tools/tool_selector.py index 7c515536..b21797f4 100644 --- a/src/website_profiling/tools/audit_tools/tool_selector.py +++ b/src/website_profiling/tools/audit_tools/tool_selector.py @@ -92,6 +92,35 @@ def _score_domains(text: str) -> list[tuple[int, str]]: return scores +def expand_active_tools_from_result( + tool_name: str, + tool_result: dict[str, Any], + active: set[str], +) -> set[str]: + """Expand the active tool set after search/domain-agent tool results.""" + expanded = set(active) + pinned: set[str] = set() + + if tool_name == "search_audit_tools": + names = tool_result.get("tool_names") + if isinstance(names, list): + for name in names[:12]: + if isinstance(name, str) and name: + expanded.add(name) + pinned.add(name) + elif tool_name == "run_domain_agent": + names = tool_result.get("tools_used") + if isinstance(names, list): + for name in names: + if isinstance(name, str) and name: + expanded.add(name) + pinned.add(name) + + if chat_tool_mode() != "full" and pinned: + expanded = apply_tool_cap(expanded, chat_tool_search_cap(), pinned=pinned) + return expanded + + def apply_tool_cap( selected: set[str], cap: int, diff --git a/src/website_profiling/tools/keywords.py b/src/website_profiling/tools/keywords.py index 9a5288b2..f2a3addc 100644 --- a/src/website_profiling/tools/keywords.py +++ b/src/website_profiling/tools/keywords.py @@ -302,7 +302,7 @@ def run_keyword_pipeline( "yes", ): try: - from ..llm.enrich import cluster_keywords_llm + from ..llm_client_http import cluster_keywords_llm top_kw = [ s["keyword"] diff --git a/src/website_profiling/tools/warnings.py b/src/website_profiling/tools/warnings.py index b4ee1cae..0870b4b6 100644 --- a/src/website_profiling/tools/warnings.py +++ b/src/website_profiling/tools/warnings.py @@ -9,6 +9,8 @@ import sys from typing import Any +from ..lighthouse.audit_text import audit_help_text, audit_title + # Mapping: audit/rule id or phrase -> detection, primary_impact, secondary_impacts, explanation, one_line_fix, severity # primary_impact one of: LCP, CLS, FID, Accessibility, SEO, UX @@ -127,6 +129,14 @@ "one_line_fix": "Use descriptive link text (e.g. 'Download report' instead of 'click here').", "severity": "Medium", }, + "link-name": { + "detection": "Lighthouse: links do not have a discernible name", + "primary_impact": "Accessibility", + "secondary_impacts": ["UX"], + "explanation": "Links without accessible names are not usable with screen readers.", + "one_line_fix": "Add visible link text or aria-label so each link has a discernible name.", + "severity": "High", + }, "image-alt": { "detection": "Lighthouse/axe: image missing alt", "primary_impact": "Accessibility", @@ -191,6 +201,14 @@ "one_line_fix": "Add a Content-Security-Policy header (or meta tag) with at least default-src and script-src.", "severity": "Medium", }, + "inspector-issues": { + "detection": "Chrome DevTools Issues panel", + "primary_impact": "UX", + "secondary_impacts": ["SEO"], + "explanation": "Chrome reported issues in the page (CORS, mixed content, quirks, etc.).", + "one_line_fix": "Open DevTools Issues panel for the URL and resolve each reported problem.", + "severity": "Medium", + }, } # Fallback for unknown audits: generic entry @@ -324,8 +342,8 @@ def _parse_lighthouse_data(data: dict[str, Any]) -> list[dict[str, Any]]: score = audit.get("score") if score is not None and score >= 1: continue - title = audit.get("title") or audit_id - help_text = audit.get("helpText") or "" + title = audit_title(audit, audit_id) + help_text = audit_help_text(audit) warning = title if title else audit_id if help_text: warning = f"{title}: {help_text}"[:200] @@ -358,8 +376,8 @@ def parse_lighthouse_to_diagnostics( score = audit.get("score") if score is not None and score >= 1: continue - title = audit.get("title") or audit_id - help_text = audit.get("helpText") or "" + title = audit_title(audit, audit_id) + help_text = audit_help_text(audit) warning = title if title else audit_id if help_text: warning = f"{title}: {help_text}"[:200] diff --git a/tests/api/test_api_integration.py b/tests/api/test_api_integration.py index ec403240..a7e5b668 100644 --- a/tests/api/test_api_integration.py +++ b/tests/api/test_api_integration.py @@ -77,6 +77,35 @@ def test_properties_crud_and_ops(api_client: TestClient) -> None: finally: deleted = api_client.delete(f"/api/properties/{property_id}") assert deleted.status_code == 200 + + +def test_properties_resolve_does_not_create_partial_domains(api_client: TestClient) -> None: + before = api_client.get("/api/properties") + assert before.status_code == 200 + count_before = len(before.json()["properties"]) + + partial = api_client.get("/api/properties/resolve", params={"startUrl": "https://code"}) + assert partial.status_code == 200 + assert partial.json().get("id") is None + + after = api_client.get("/api/properties") + assert after.status_code == 200 + assert len(after.json()["properties"]) == count_before + + +def test_properties_ensure_creates_valid_domain(api_client: TestClient) -> None: + domain = f"ensure-{uuid.uuid4().hex[:8]}.example" + url = f"https://{domain}/" + created = api_client.post("/api/properties/ensure", json={"startUrl": url}) + assert created.status_code == 200 + property_id = int(created.json()["id"]) + try: + assert created.json()["canonical_domain"] == domain + again = api_client.get("/api/properties/resolve", params={"startUrl": url}) + assert again.status_code == 200 + assert int(again.json()["id"]) == property_id + finally: + deleted = api_client.delete(f"/api/properties/{property_id}") assert deleted.json()["ok"] is True @@ -107,19 +136,13 @@ def test_integrations_google_status(api_client: TestClient) -> None: assert "lastFetchedAt" in body -def test_pipeline_and_llm_config_wrappers(api_client: TestClient) -> None: +def test_pipeline_config_wrapper(api_client: TestClient) -> None: pipe = api_client.get("/api/pipeline-config") assert pipe.status_code == 200 pipe_body = pipe.json() assert "state" in pipe_body assert isinstance(pipe_body["state"], dict) - llm = api_client.get("/api/llm-config") - assert llm.status_code == 200 - llm_body = llm.json() - assert "state" in llm_body - assert isinstance(llm_body["state"], dict) - def test_content_drafts_full_crud(api_client: TestClient, test_property: dict[str, Any]) -> None: property_id = int(test_property["id"]) @@ -212,42 +235,7 @@ def test_properties_resolve(api_client: TestClient, test_property: dict[str, Any def test_ollama_status_response_shape(api_client: TestClient) -> None: - fake_models = [ - { - "name": "llama3.2", - "source": "local", - "installed": True, - "capabilities": ["tools"], - "billing": "free_local", - "requires_subscription": False, - } - ] - with ( - patch( - "website_profiling.llm.ollama_catalog.fetch_ollama_models", - return_value={ - "ok": True, - "baseUrl": "http://127.0.0.1:11434", - "models": fake_models, - "cloudCatalogOk": True, - "localOk": True, - }, - ), - patch( - "website_profiling.db.config_store.read_llm_config", - return_value={"llm_model": "llama3.2", "llm_base_url": "http://127.0.0.1:11434"}, - ), - ): - res = api_client.get("/api/ollama/status") - - assert res.status_code == 200 - body = res.json() - assert body["ok"] is True - assert body["configuredModel"] == "llama3.2" - assert body["modelInstalled"] is True - assert body["supportsTools"] is True - assert isinstance(body["models"], list) - assert len(body["models"]) == 1 + pytest.skip("Ollama status is served by AiService via BFF") def test_backlinks_velocity_empty(api_client: TestClient, test_property: dict[str, Any]) -> None: diff --git a/tests/content_studio/fakes.py b/tests/content_studio/fakes.py index ad9e2183..227caac7 100644 --- a/tests/content_studio/fakes.py +++ b/tests/content_studio/fakes.py @@ -1,8 +1,22 @@ """Shared test doubles for Content Studio agent tests.""" from __future__ import annotations +from dataclasses import dataclass, field + from website_profiling.content_studio.context import ContentStudioContext -from website_profiling.llm.base import ChatResult + + +@dataclass +class ToolCall: + id: str + name: str + arguments: dict + + +@dataclass +class ChatResult: + content: str = "" + tool_calls: list[ToolCall] = field(default_factory=list) class FakeToolClient: diff --git a/tests/content_studio/test_agent.py b/tests/content_studio/test_agent.py deleted file mode 100644 index 9e1d1d6b..00000000 --- a/tests/content_studio/test_agent.py +++ /dev/null @@ -1,182 +0,0 @@ -"""Tests for the Content Studio analyze agent loop.""" -from __future__ import annotations - -from unittest.mock import MagicMock, patch - -from website_profiling.content_studio.agent import ( - _inject_missing_tools, - _parse_final_json, - _react_step, - run_content_studio_analyze, -) -from website_profiling.content_studio.tools import REQUIRED_CONTENT_STUDIO_TOOLS -from website_profiling.llm.base import ChatResult, ToolCall - -from tests.content_studio.fakes import FakeToolClient, OllamaClient, ReactClient, sample_ctx - - -def test_content_studio_agent_dispatches_all_tools_in_one_turn() -> None: - names = sorted(REQUIRED_CONTENT_STUDIO_TOOLS) - client = FakeToolClient([ - ChatResult(tool_calls=[ - ToolCall(id=f"t{i}", name=name, arguments={}) for i, name in enumerate(names) - ]), - ChatResult(content='{"summary": "ok", "suggestions": []}'), - ]) - cfg = {"llm_provider": "openai", "llm_api_key": "sk-test"} - - with patch( - "website_profiling.content_studio.agent.get_llm_client", - return_value=client, - ): - result = run_content_studio_analyze(sample_ctx(), cfg) - - assert result["ok"] is True - assert result["ai_block"]["summary"] == "ok" - assert sorted(e["name"] for e in result["tool_events"]) == names - - -def test_react_step_tool_and_answer_paths() -> None: - tool_result = _react_step( - ReactClient([{"action": "tool", "name": "get_draft_seo_score", "args": {}}]), - [{"role": "user", "content": "analyze"}], - ) - assert tool_result.tool_calls[0].name == "get_draft_seo_score" - - json_result = _react_step( - ReactClient([{"action": "answer", "text": '{"summary":"done"}'}]), - [{"role": "user", "content": "analyze"}], - ) - assert json_result.content == '{"summary":"done"}' - - plain_result = _react_step( - ReactClient([{"action": "answer", "text": "plain text"}]), - [{"role": "user", "content": "analyze"}], - ) - assert plain_result.content == "plain text" - - -def test_parse_final_json_handles_fences_and_empty() -> None: - assert _parse_final_json("") == {} - fenced = _parse_final_json('```json\n{"summary":"ok"}\n```') - assert fenced["summary"] == "ok" - - -def test_inject_missing_tools_appends_results() -> None: - ctx = sample_ctx() - messages: list[dict] = [] - called = {"get_draft_seo_score"} - events: list[dict] = [] - _inject_missing_tools(messages, ctx, called, ollama_format=False, tool_events=events) - assert any(m.get("role") == "tool" for m in messages) - assert called == REQUIRED_CONTENT_STUDIO_TOOLS - # tool_events is populated in the same pass (no second dispatch needed). - assert {e["name"] for e in events} == REQUIRED_CONTENT_STUDIO_TOOLS - {"get_draft_seo_score"} - messages_ollama: list[dict] = [] - called_ollama = {"get_draft_seo_score"} - _inject_missing_tools(messages_ollama, ctx, called_ollama, ollama_format=True, tool_events=[]) - assert any(m.get("tool_name") for m in messages_ollama) - - -def test_content_studio_agent_ollama_tool_format() -> None: - names = sorted(REQUIRED_CONTENT_STUDIO_TOOLS) - client = OllamaClient([ - ChatResult(tool_calls=[ - ToolCall(id=f"t{i}", name=name, arguments={}) for i, name in enumerate(names) - ]), - ChatResult(content='{"summary": "ollama ok", "suggestions": []}'), - ]) - with patch("website_profiling.content_studio.agent.get_llm_client", return_value=client): - result = run_content_studio_analyze(sample_ctx(), {"llm_provider": "ollama"}) - assert result["ok"] is True - assert result["ai_block"]["summary"] == "ollama ok" - - -def test_content_studio_agent_react_mode() -> None: - names = sorted(REQUIRED_CONTENT_STUDIO_TOOLS) - steps = [ - {"action": "tool", "name": names[0], "args": {}}, - *[{"action": "tool", "name": name, "args": {}} for name in names[1:]], - {"action": "answer", "text": '{"summary": "react ok", "suggestions": []}'}, - ] - client = ReactClient(steps) - with patch("website_profiling.content_studio.agent.get_llm_client", return_value=client): - result = run_content_studio_analyze(sample_ctx(), {"llm_provider": "none"}) - assert result["ok"] is True - assert result["ai_block"]["summary"] == "react ok" - - -def test_content_studio_agent_injects_missing_tools_after_partial_turn() -> None: - client = FakeToolClient([ - ChatResult(tool_calls=[ToolCall(id="t0", name="get_draft_seo_score", arguments={})]), - ChatResult(content='not json yet'), - ChatResult(content='{"summary": "filled gaps", "suggestions": []}'), - ]) - with patch("website_profiling.content_studio.agent.get_llm_client", return_value=client): - result = run_content_studio_analyze(sample_ctx(), {"llm_provider": "openai", "llm_api_key": "x"}) - assert result["ok"] is True - assert sorted(e["name"] for e in result["tool_events"]) == sorted(REQUIRED_CONTENT_STUDIO_TOOLS) - - -def test_content_studio_agent_llm_client_value_error() -> None: - with patch( - "website_profiling.content_studio.agent.get_llm_client", - side_effect=ValueError("bad provider"), - ): - result = run_content_studio_analyze(sample_ctx(), {}) - assert result["ok"] is False - assert result["error"] == "bad provider" - - -def test_content_studio_agent_chat_exception() -> None: - client = MagicMock() - client.chat_with_tools.side_effect = RuntimeError("chat blew up") - with patch("website_profiling.content_studio.agent.get_llm_client", return_value=client): - result = run_content_studio_analyze(sample_ctx(), {"llm_provider": "openai", "llm_api_key": "x"}) - assert result["ok"] is False - assert "chat blew up" in result["error"] - - -def test_content_studio_agent_invalid_json_after_tools() -> None: - names = sorted(REQUIRED_CONTENT_STUDIO_TOOLS) - client = FakeToolClient([ - ChatResult(tool_calls=[ - ToolCall(id=f"t{i}", name=name, arguments={}) for i, name in enumerate(names) - ]), - ChatResult(content="not valid json"), - ]) - with patch("website_profiling.content_studio.agent.get_llm_client", return_value=client): - result = run_content_studio_analyze(sample_ctx(), {"llm_provider": "openai", "llm_api_key": "x"}) - assert result["ok"] is False - assert "no valid JSON" in result["error"] - - -def test_content_studio_agent_fallback_success() -> None: - client = MagicMock() - client.chat_with_tools.return_value = ChatResult(content="") - client.complete_json.return_value = {"summary": "fallback", "suggestions": []} - with patch("website_profiling.content_studio.agent.get_llm_client", return_value=client): - result = run_content_studio_analyze(sample_ctx(), {"llm_provider": "openai", "llm_api_key": "x"}) - assert result["ok"] is True - assert result["fallback"] is True - assert result["ai_block"]["summary"] == "fallback" - - -def test_content_studio_agent_fallback_failure() -> None: - client = MagicMock() - client.chat_with_tools.return_value = ChatResult(content="") - client.complete_json.side_effect = RuntimeError("fallback failed") - with patch("website_profiling.content_studio.agent.get_llm_client", return_value=client): - result = run_content_studio_analyze(sample_ctx(), {"llm_provider": "openai", "llm_api_key": "x"}) - assert result["ok"] is False - assert result["error"] == "fallback failed" - - -def test_content_studio_agent_fallback_empty_answer() -> None: - client = MagicMock() - client.chat_with_tools.return_value = ChatResult(content="") - client.complete_json.return_value = "not a dict" - with patch("website_profiling.content_studio.agent.get_llm_client", return_value=client): - result = run_content_studio_analyze(sample_ctx(), {"llm_provider": "openai", "llm_api_key": "x"}) - assert result["ok"] is False - assert "stopped without a final answer" in result["error"] diff --git a/tests/content_studio/test_ai_suggest.py b/tests/content_studio/test_ai_suggest.py index e3996763..1183ecf0 100644 --- a/tests/content_studio/test_ai_suggest.py +++ b/tests/content_studio/test_ai_suggest.py @@ -136,7 +136,7 @@ def test_analyze_with_ai_uses_cache() -> None: with patch("website_profiling.content_studio.ai_suggest.load_llm_config_from_db", return_value=cfg), patch( "website_profiling.content_studio.ai_suggest._read_cache", return_value=cached, - ), patch("website_profiling.content_studio.ai_suggest.run_content_studio_analyze") as mock_agent: + ), patch("website_profiling.content_studio.ai_suggest.call_ai_api") as mock_agent: result = analyze_content_draft(None, "best crm", "

best crm

", use_ai=True) mock_agent.assert_not_called() assert result["ai_used"] is True @@ -162,7 +162,7 @@ def test_analyze_with_ai_agent_success() -> None: "website_profiling.content_studio.ai_suggest._read_cache", return_value=None, ), patch( - "website_profiling.content_studio.ai_suggest.run_content_studio_analyze", + "website_profiling.content_studio.ai_suggest.call_ai_api", return_value=agent_payload, ), patch("website_profiling.content_studio.ai_suggest._write_cache") as mock_write: result = analyze_content_draft( @@ -185,7 +185,7 @@ def test_analyze_with_ai_agent_failure() -> None: "website_profiling.content_studio.ai_suggest._read_cache", return_value=None, ), patch( - "website_profiling.content_studio.ai_suggest.run_content_studio_analyze", + "website_profiling.content_studio.ai_suggest.call_ai_api", return_value={"ok": False, "error": "model timeout", "tool_events": []}, ): result = analyze_content_draft(None, "best crm", "

best crm

", use_ai=True, refresh=True) @@ -200,7 +200,7 @@ def test_analyze_with_ai_empty_block() -> None: "website_profiling.content_studio.ai_suggest._read_cache", return_value=None, ), patch( - "website_profiling.content_studio.ai_suggest.run_content_studio_analyze", + "website_profiling.content_studio.ai_suggest.call_ai_api", return_value={"ok": True, "ai_block": {}, "tool_events": []}, ): result = analyze_content_draft(None, "best crm", "

best crm

", use_ai=True, refresh=True) diff --git a/tests/content_studio/test_wizard.py b/tests/content_studio/test_wizard.py index 2facc86c..27a55651 100644 --- a/tests/content_studio/test_wizard.py +++ b/tests/content_studio/test_wizard.py @@ -36,7 +36,10 @@ def complete_json(self, system, user): def ai(client, cfg=None): with patch("website_profiling.content_studio.wizard.load_llm_config_from_db", return_value=cfg or {}), patch( "website_profiling.content_studio.wizard.llm_is_enabled", return_value=True - ), patch("website_profiling.content_studio.wizard.get_llm_client", return_value=client): + ), patch( + "website_profiling.content_studio.wizard.complete_json", + side_effect=lambda _system, _user: client.complete_json(_system, _user), + ): yield @@ -75,10 +78,10 @@ def test_every_step_returns_error_when_disabled() -> None: def test_get_client_value_error() -> None: with patch("website_profiling.content_studio.wizard.load_llm_config_from_db", return_value={}), patch( "website_profiling.content_studio.wizard.llm_is_enabled", return_value=True - ), patch("website_profiling.content_studio.wizard.get_llm_client", side_effect=ValueError("no provider")): + ), patch("website_profiling.content_studio.wizard.complete_json", side_effect=ValueError("no provider")): out = suggest_intents("best crm") - assert out["ok"] is False - assert out["error"] == "no provider" + assert out["ok"] is True + assert out["options"] # --- intents -------------------------------------------------------------- diff --git a/tests/lighthouse/test_audit_text.py b/tests/lighthouse/test_audit_text.py new file mode 100644 index 00000000..a0121ed5 --- /dev/null +++ b/tests/lighthouse/test_audit_text.py @@ -0,0 +1,77 @@ +"""Tests for Lighthouse audit text normalization.""" +from __future__ import annotations + +from website_profiling.lighthouse.audit_text import ( + audit_help_text, + audit_title, + failure_display_message, + failure_row_from_audit, + is_core_web_vitals_failure, +) +from website_profiling.tools.warnings import resolve_impact + + +def test_audit_help_text_prefers_modern_description(): + audit = { + "title": "Image elements do not have `[alt]` attributes", + "description": "Informative elements should aim for short, descriptive alternate text.", + "helpText": "", + } + assert audit_title(audit, "image-alt") == "Image elements do not have `[alt]` attributes" + assert "Informative elements" in audit_help_text(audit) + + +def test_failure_display_message_uses_title_when_help_missing(): + msg = failure_display_message({"id": "image-alt", "title": "Image elements do not have alt"}) + assert msg == "Image elements do not have alt" + assert msg != "image-alt:" + + +def test_failure_display_message_combines_title_and_help(): + msg = failure_display_message( + { + "id": "largest-contentful-paint", + "title": "Largest Contentful Paint", + "helpText": "LCP element took too long to load.", + } + ) + assert msg.startswith("Largest Contentful Paint:") + assert "too long" in msg + + +def test_failure_row_from_audit_normalizes_fields(): + row = failure_row_from_audit( + "color-contrast", + { + "score": 0, + "title": "Background and foreground colors do not have sufficient contrast ratio", + "description": "Low-contrast text is difficult to read.", + }, + category="accessibility", + impact="Accessibility", + ) + assert row["title"] + assert "contrast" in row["helpText"].lower() + assert row["category"] == "accessibility" + + +def test_is_core_web_vitals_failure_by_category(): + assert is_core_web_vitals_failure( + {"id": "render-blocking-resources", "category": "performance", "impact": "LCP"}, + resolve_impact=resolve_impact, + ) + assert not is_core_web_vitals_failure( + {"id": "image-alt", "category": "accessibility", "title": "Missing alt", "impact": "Accessibility"}, + resolve_impact=resolve_impact, + ) + + +def test_is_core_web_vitals_failure_by_impact_when_category_missing(): + assert is_core_web_vitals_failure( + {"id": "largest-contentful-paint", "title": "LCP slow", "helpText": "Slow LCP"}, + resolve_impact=resolve_impact, + ) + assert not is_core_web_vitals_failure( + {"id": "link-name", "title": "Links do not have a discernible name"}, + resolve_impact=resolve_impact, + ) diff --git a/tests/llm/test_ollama_catalog.py b/tests/llm/test_ollama_catalog.py deleted file mode 100644 index 94903ccf..00000000 --- a/tests/llm/test_ollama_catalog.py +++ /dev/null @@ -1,24 +0,0 @@ -"""Ollama catalog merge and model lookup.""" -from __future__ import annotations - -from website_profiling.llm.ollama_catalog import ( - merge_ollama_models, - model_is_configured, - models_support_tools, -) - - -def test_merge_ollama_models_prefers_installed_local() -> None: - local = [{"name": "llama3.2", "source": "local", "installed": True, "capabilities": ["tools"]}] - cloud = [{"name": "llama3.2:cloud", "source": "cloud", "installed": False}] - merged = merge_ollama_models(local, cloud) - assert len(merged) >= 1 - entry = next(m for m in merged if m["name"] == "llama3.2") - assert entry["installed"] is True - assert entry["capabilities"] == ["tools"] - - -def test_model_is_configured_case_insensitive() -> None: - models = [{"name": "Llama3.2", "source": "local", "installed": True}] - assert model_is_configured(models, "llama3.2") is True - assert models_support_tools(models) is False diff --git a/tests/reporting/test_categories_coverage.py b/tests/reporting/test_categories_coverage.py index 0017d638..fa45a893 100644 --- a/tests/reporting/test_categories_coverage.py +++ b/tests/reporting/test_categories_coverage.py @@ -327,16 +327,68 @@ def test_category_core_web_vitals_from_lighthouse_top_failures() -> None: lh = { "median_metrics": {"performance_score": 0.75}, "top_failures": [ - {"id": "lcp", "helpText": "LCP too slow", "score": 0.3}, + { + "id": "largest-contentful-paint", + "title": "Largest Contentful Paint", + "helpText": "LCP too slow", + "score": 0.3, + "category": "performance", + }, + { + "id": "image-alt", + "title": "Image elements do not have alt", + "score": 0.0, + "category": "accessibility", + }, {"id": "", "helpText": "", "score": 0.6}, {"helpText": "No id failure", "score": 0.8}, ], } cat = category_core_web_vitals_from_lighthouse(lh) - assert len(cat["issues"]) == 3 + assert len(cat["issues"]) == 1 + assert "Largest Contentful Paint" in cat["issues"][0]["message"] assert cat["score"] == 75 +def test_category_core_web_vitals_from_lighthouse_uses_title_when_help_missing() -> None: + from unittest.mock import patch + + lh = { + "median_metrics": {"performance_score": 0.6}, + "top_failures": [ + "bad", + { + "id": "total-blocking-time", + "title": "Reduce JavaScript execution time", + "helpText": "", + "score": 0.4, + "category": "performance", + }, + { + "id": "unknown-cwv-audit", + "title": "Slow metric", + "helpText": "", + "score": 0.2, + "category": "performance", + }, + ], + } + with patch( + "website_profiling.reporting.categories.performance._resolve_entry", + side_effect=[ + {"one_line_fix": "Defer non-critical JavaScript."}, + {}, + ], + ): + cat = category_core_web_vitals_from_lighthouse(lh) + assert len(cat["issues"]) == 2 + assert cat["issues"][0]["message"] == "Reduce JavaScript execution time" + assert cat["issues"][0]["recommendation"] + assert cat["issues"][1]["recommendation"] == ( + "See Lighthouse performance recommendations in this audit, or re-run Lighthouse from Run audit." + ) + + def test_category_core_web_vitals_from_lighthouse_low_perf_recommendation() -> None: lh = {"median_metrics": {"performance_score": 0.5}, "top_failures": []} cat = category_core_web_vitals_from_lighthouse(lh) diff --git a/tests/reporting/test_contrast_issues.py b/tests/reporting/test_contrast_issues.py index 81f08677..92b15bf3 100644 --- a/tests/reporting/test_contrast_issues.py +++ b/tests/reporting/test_contrast_issues.py @@ -37,6 +37,34 @@ def test_contrast_from_axe_violations(): assert issues[0]["url"] == "https://ex.com/page" +def test_lighthouse_accessibility_issues_from_summary(): + from website_profiling.reporting.categories.accessibility import ( + lighthouse_accessibility_issues_from_summary, + ) + + lh = { + "top_failures": [ + { + "id": "image-alt", + "title": "Image elements do not have `[alt]` attributes", + "description": "Informative elements should aim for short, descriptive alternate text.", + "score": 0, + "category": "accessibility", + }, + { + "id": "largest-contentful-paint", + "title": "LCP slow", + "score": 0.3, + "category": "performance", + }, + ], + } + issues = lighthouse_accessibility_issues_from_summary(lh) + assert len(issues) == 1 + assert "Image elements" in issues[0]["message"] + assert "alt" in issues[0]["recommendation"].lower() + + def test_contrast_from_lighthouse_failures(): df = pd.DataFrame([ {"url": "https://ex.com/a", "status": "200", "h1_count": 1, "images_total": 0, "images_without_alt": 0}, @@ -94,7 +122,14 @@ def test_contrast_skips_non_matching_axe_and_lighthouse_rows(): ], }, } - assert contrast_issues_from_sources(df, lh) == [] + assert contrast_issues_from_sources(df, lh) == [ + { + "message": "Lighthouse: Image Alt: missing alt", + "url": "https://ex.com/b", + "priority": "High", + "recommendation": 'Add alt attribute to (use alt="" for decorative images).', + } + ] def test_contrast_deduplicates_lighthouse_when_axe_already_reported(): @@ -130,6 +165,107 @@ def test_contrast_accepts_dict_page_analysis_cell(): assert issues[0]["url"] == "https://ex.com/dict" +def test_lighthouse_accessibility_issues_from_sources_branches() -> None: + from unittest.mock import patch + + from website_profiling.reporting.categories.accessibility import ( + lighthouse_accessibility_issues_from_sources, + ) + + lh = { + "": {"top_failures": [{"id": "image-alt", "category": "accessibility"}]}, + "https://ex.com/skip-contrast": { + "top_failures": [{"id": "color-contrast", "category": "accessibility"}], + }, + "https://ex.com/perf-only": { + "top_failures": [{"id": "largest-contentful-paint", "category": "performance"}], + }, + "https://ex.com/impact-gate": { + "top_failures": [{"id": "custom-audit", "title": "Custom", "impact": "Performance"}], + }, + "https://ex.com/dup": { + "top_failures": [ + {"id": "image-alt", "category": "accessibility"}, + {"id": "image-alt", "category": "accessibility"}, + ], + }, + "https://ex.com/empty-aid": { + "top_failures": [{"id": "", "category": "accessibility"}], + }, + "https://ex.com/default-rec": { + "top_failures": [{"id": "unknown-audit-id", "category": "accessibility", "title": ""}], + }, + } + with patch( + "website_profiling.reporting.categories.accessibility._resolve_entry", + return_value={"severity": "Medium"}, + ): + issues = lighthouse_accessibility_issues_from_sources( + lh, + skip_lh_contrast_urls={"https://ex.com/skip-contrast"}, + ) + urls = {i["url"] for i in issues} + assert "https://ex.com/skip-contrast" not in urls + assert "https://ex.com/perf-only" not in urls + assert "https://ex.com/impact-gate" not in urls + assert "https://ex.com/dup" in urls + assert len([i for i in issues if i["url"] == "https://ex.com/dup"]) == 1 + assert any( + i["recommendation"] == "See Lighthouse accessibility recommendations for this page." + for i in issues + ) + + +def test_lighthouse_accessibility_issues_from_summary_branches() -> None: + from unittest.mock import patch + + from website_profiling.reporting.categories.accessibility import ( + lighthouse_accessibility_issues_from_summary, + ) + + lh = { + "top_failures": [ + "bad", + {"id": "", "category": "accessibility"}, + {"id": "image-alt", "category": "performance"}, + {"id": "custom-audit", "title": "Custom", "impact": "Performance"}, + {"id": "unknown-audit-id", "category": "accessibility", "title": ""}, + ], + } + with patch( + "website_profiling.reporting.categories.accessibility._resolve_entry", + return_value={}, + ): + issues = lighthouse_accessibility_issues_from_summary(lh) + assert len(issues) == 1 + assert issues[0]["recommendation"] == "See Lighthouse accessibility recommendations." + + +def test_category_merges_lighthouse_summary_accessibility_issues() -> None: + pa = {"axe_violations": [{"id": "color-contrast", "description": "bad contrast", "help": "fix"}]} + df = pd.DataFrame([ + { + "url": "https://ex.com/", + "status": "200", + "page_analysis": json.dumps(pa), + "h1_count": 1, + "images_total": 0, + "images_without_alt": 0, + "word_count": 200, + "reading_level": 8, + } + ]) + lh_summary = { + "top_failures": [ + {"id": "image-alt", "title": "Images need alt text", "category": "accessibility"}, + ], + } + cat = category_html_accessibility(df, lighthouse_summary=lh_summary) + messages = [i["message"] for i in cat["issues"]] + assert any("axe" in m.lower() for m in messages) + assert any("Lighthouse" in m for m in messages) + + def test_category_keeps_stub_without_contrast_data(): df = pd.DataFrame([ { diff --git a/tests/reporting/test_reporting_builder_modules.py b/tests/reporting/test_reporting_builder_modules.py index b348e786..7823c983 100644 --- a/tests/reporting/test_reporting_builder_modules.py +++ b/tests/reporting/test_reporting_builder_modules.py @@ -877,7 +877,7 @@ def test_builder_exposes_llm_keyword_cluster_imports() -> None: """Regression: LLM keyword cluster branch must not NameError after builder split.""" import website_profiling.reporting.builder as builder_mod from website_profiling.analysis.text_hygiene import is_junk_semantic_term - from website_profiling.llm.enrich import cluster_keywords_llm + from website_profiling.llm_client_http import cluster_keywords_llm assert builder_mod.is_junk_semantic_term is is_junk_semantic_term assert builder_mod.cluster_keywords_llm is cluster_keywords_llm diff --git a/tests/test_agent_react_tool_results.py b/tests/test_agent_react_tool_results.py deleted file mode 100644 index 1006317e..00000000 --- a/tests/test_agent_react_tool_results.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Regression test: the ReAct fallback must show prior tool results to the model. - -Providers without native tool calling (e.g. Gemini) go through `_react_step`. If -tool-result messages are excluded from the conversation, the model never sees the -output and keeps re-issuing the same call until MAX_TOOL_ROUNDS. -""" -from __future__ import annotations - -from website_profiling.llm import agent as agent_mod - - -class _CapturingClient: - def __init__(self) -> None: - self.user_prompt = "" - - def complete_json(self, system: str, user: str) -> dict: - self.user_prompt = user - return {"action": "answer", "text": "done"} - - -def test_react_step_includes_tool_results_in_prompt() -> None: - client = _CapturingClient() - messages = [ - {"role": "user", "content": "how healthy is the site?"}, - {"role": "assistant", "content": "Calling tool get_health"}, - {"role": "tool", "tool_call_id": "x", "content": '{"score": 80}'}, - ] - result = agent_mod._react_step( - client, messages, "get_health", None, system_prompt="" - ) - assert result.content == "done" - assert '{"score": 80}' in client.user_prompt diff --git a/tests/test_ai_migration_coverage.py b/tests/test_ai_migration_coverage.py new file mode 100644 index 00000000..6ae0b178 --- /dev/null +++ b/tests/test_ai_migration_coverage.py @@ -0,0 +1,389 @@ +"""Coverage for AiService migration stubs and HTTP bridge call sites.""" +from __future__ import annotations + +import argparse +import io +import json +import types +import urllib.error +from unittest.mock import MagicMock, patch + +import pandas as pd +import pytest + + +def test_google_cmd_integrations_fetch_error_exits(monkeypatch, capsys) -> None: + from website_profiling.commands import google_cmd + + monkeypatch.setenv("INTEGRATIONS_SERVICE_URL", "http://integrations:8093") + monkeypatch.setattr(google_cmd, "resolve_property_id_from_cfg", lambda _c: 2) + monkeypatch.setitem( + __import__("sys").modules, + "website_profiling.db", + types.SimpleNamespace( + db_session=lambda: types.SimpleNamespace(__enter__=lambda s: object(), __exit__=lambda *a: None), + get_latest_crawl_run_id=lambda _c: None, + read_crawl=lambda *_a, **_k: pd.DataFrame(), + ), + ) + monkeypatch.setattr( + google_cmd, + "_fetch_via_integrations", + lambda *_a, **_k: (_ for _ in ()).throw(RuntimeError("fetch failed")), + ) + args = argparse.Namespace(list_properties=False, test=False, property_id=2) + with pytest.raises(SystemExit) as exc: + google_cmd.run({"start_url": "https://ex.com"}, "/tmp", lambda _k, d: d, args) + assert exc.value.code == 1 + assert "fetch failed" in capsys.readouterr().err + + +def test_google_cmd_run_test_via_integrations(monkeypatch, capsys) -> None: + from website_profiling.commands import google_cmd + + monkeypatch.setenv("INTEGRATIONS_SERVICE_URL", "http://integrations:8093") + + class _Resp: + def read(self): + return json.dumps({"ok": True, "log": "connected", "exitCode": 0}).encode() + + def __enter__(self): + return self + + def __exit__(self, *_a): + return False + + with patch("urllib.request.urlopen", return_value=_Resp()): + with pytest.raises(SystemExit) as exc: + google_cmd._run_google_test(4) + assert exc.value.code == 0 + assert "connected" in capsys.readouterr().out + + +def test_google_cmd_run_test_integrations_http_error(monkeypatch, capsys) -> None: + from website_profiling.commands import google_cmd + + monkeypatch.setenv("INTEGRATIONS_SERVICE_URL", "http://integrations:8093") + err = urllib.error.HTTPError( + url="http://integrations:8093/api/properties/4/google/test", + code=502, + msg="bad", + hdrs={}, + fp=io.BytesIO(b"upstream down"), + ) + with patch("urllib.request.urlopen", side_effect=err): + with pytest.raises(SystemExit) as exc: + google_cmd._run_google_test(4) + assert exc.value.code == 1 + assert "upstream down" in capsys.readouterr().err + + +def test_google_cmd_run_test_integrations_generic_error(monkeypatch, capsys) -> None: + from website_profiling.commands import google_cmd + + monkeypatch.setenv("INTEGRATIONS_SERVICE_URL", "http://integrations:8093") + with patch("urllib.request.urlopen", side_effect=RuntimeError("socket down")): + with pytest.raises(SystemExit) as exc: + google_cmd._run_google_test(4) + assert exc.value.code == 1 + assert "socket down" in capsys.readouterr().err + + +def test_google_cmd_integrations_http_helpers(monkeypatch) -> None: + from website_profiling.commands import google_cmd + + monkeypatch.setenv("INTEGRATIONS_SERVICE_URL", "http://integrations:8093") + + class _Resp: + def __init__(self, payload: dict): + self._payload = payload + + def read(self): + return json.dumps(self._payload).encode() + + def __enter__(self): + return self + + def __exit__(self, *_a): + return False + + with patch("urllib.request.urlopen", return_value=_Resp({"gsc": {}})): + out = google_cmd._fetch_via_integrations( + "http://integrations:8093", + property_id=1, + date_range_days=28, + crawl_urls=[], + start_url="https://ex.com", + config={}, + ) + assert out == {"gsc": {}} + + err = urllib.error.HTTPError("http://x", 500, "bad", {}, io.BytesIO(b"boom")) + with patch("urllib.request.urlopen", side_effect=err): + with pytest.raises(RuntimeError, match="boom"): + google_cmd._fetch_via_integrations( + "http://integrations:8093", + property_id=1, + date_range_days=28, + crawl_urls=[], + start_url="https://ex.com", + config={}, + ) + + with patch("urllib.request.urlopen", return_value=_Resp({"properties": []})): + listed = google_cmd._list_properties_via_integrations(1) + assert listed == {"properties": []} + + list_err = urllib.error.HTTPError("http://x", 500, "bad", {}, io.BytesIO(b"list boom")) + with patch("urllib.request.urlopen", side_effect=list_err): + with pytest.raises(RuntimeError, match="list boom"): + google_cmd._list_properties_via_integrations(1) + + +def test_pipeline_keyword_enrich_via_integrations(monkeypatch, capsys) -> None: + from website_profiling.commands import pipeline_cmd + + monkeypatch.setenv("INTEGRATIONS_SERVICE_URL", "http://integrations:8093") + monkeypatch.setattr(pipeline_cmd, "console_print", lambda *_a, **_k: None) + monkeypatch.setattr(pipeline_cmd, "emit_phase_start", lambda *_a, **_k: None) + monkeypatch.setattr(pipeline_cmd, "emit_phase_done", lambda *_a, **_k: None) + monkeypatch.setattr(pipeline_cmd, "emit_progress", lambda *_a, **_k: None) + monkeypatch.setattr(pipeline_cmd, "require_start_url", lambda *_a, **_k: "https://ex.com") + monkeypatch.setattr(pipeline_cmd, "should_enrich_keywords_after_report", lambda _c: True) + monkeypatch.setattr(pipeline_cmd, "google_db_has_gsc", lambda _c: True) + monkeypatch.setattr(pipeline_cmd, "active_property_id_from_cfg", lambda _c: 9) + monkeypatch.setitem( + __import__("sys").modules, + "website_profiling.reporting.builder", + types.SimpleNamespace(run_simple_report=lambda **_k: "/tmp/report.json"), + ) + + class _Resp: + def read(self): + return json.dumps({"ok": True}).encode() + + def __enter__(self): + return self + + def __exit__(self, *_a): + return False + + with patch("urllib.request.urlopen", return_value=_Resp()): + pipeline_cmd._run_report({"start_url": "https://ex.com"}, use_database=True) + assert "falling back" not in capsys.readouterr().err + + +def test_pipeline_lighthouse_info_exception_swallowed(monkeypatch) -> None: + from website_profiling.commands import pipeline_cmd + + monkeypatch.setattr(pipeline_cmd, "console_print", lambda *_a, **_k: None) + monkeypatch.setattr(pipeline_cmd, "emit_phase_start", lambda *_a, **_k: None) + monkeypatch.setattr(pipeline_cmd, "emit_progress", lambda *_a, **_k: None) + monkeypatch.setattr(pipeline_cmd, "lighthouse_work_dir", lambda: "/tmp/lh") + monkeypatch.setattr(pipeline_cmd, "active_property_id_from_cfg", lambda _c: 1) + monkeypatch.setattr(pipeline_cmd, "select_lighthouse_urls_from_crawl", lambda *_a, **_k: []) + monkeypatch.setattr(pipeline_cmd, "select_lighthouse_urls_from_gsc", lambda *_a, **_k: []) + monkeypatch.setitem( + __import__("sys").modules, + "website_profiling.lighthouse.runner", + types.SimpleNamespace(run_lighthouse_on_pages=lambda **_k: {}), + ) + + conn = MagicMock() + + def db_session(): + cm = MagicMock() + cm.__enter__.return_value = conn + cm.__exit__.return_value = False + return cm + + monkeypatch.setitem( + __import__("sys").modules, + "website_profiling.db", + types.SimpleNamespace( + db_session=db_session, + resolve_crawl_run_id_for_cfg=lambda *_a, **_k: 5, + read_crawl=lambda *_a, **_k: pd.DataFrame([{"url": "https://ex.com", "status": "200"}]), + get_crawl_run_info=lambda *_a, **_k: (_ for _ in ()).throw(RuntimeError("no info")), + ), + ) + monkeypatch.setitem( + __import__("sys").modules, + "website_profiling.integrations.google.store", + types.SimpleNamespace(read_latest_google_data=lambda *_a, **_k: None), + ) + pipeline_cmd._run_lighthouse_on_pages( + {"lighthouse_strategy": "mobile", "start_url": "https://ex.com"}, + lighthouse_max_pages=1, + crawl_run_id=5, + ) + + +def test_ai_suggest_cache_roundtrip() -> None: + from website_profiling.content_studio.ai_suggest import _read_cache, _write_cache + + conn = MagicMock() + with patch("website_profiling.db.db_session") as mock_db: + mock_db.return_value.__enter__.return_value = conn + with patch( + "website_profiling.content_studio.ai_suggest.read_llm_cache", + return_value='{"ai_block":{"summary":"cached"}}', + ): + assert _read_cache("key") == {"ai_block": {"summary": "cached"}} + with patch( + "website_profiling.content_studio.ai_suggest.read_llm_cache", + return_value="not-json", + ): + assert _read_cache("bad") is None + with patch("website_profiling.content_studio.ai_suggest.write_llm_cache") as mock_write: + _write_cache("key", {"ai_block": {"summary": "x"}}) + mock_write.assert_called_once() + + +def test_ai_suggest_call_ai_api_error_path() -> None: + from website_profiling.content_studio.ai_suggest import analyze_content_draft + + cfg = {"llm_enabled": True, "llm_provider": "openai", "llm_enable_content_studio": "true"} + with patch("website_profiling.content_studio.ai_suggest.load_llm_config_from_db", return_value=cfg), patch( + "website_profiling.content_studio.ai_suggest._read_cache", + return_value=None, + ), patch( + "website_profiling.content_studio.ai_suggest.call_ai_api", + side_effect=RuntimeError("offline"), + ): + result = analyze_content_draft(None, "best crm", "

best crm

", use_ai=True, refresh=True) + assert result["ai_error"] == "offline" + + +def test_content_studio_openai_tools_schema() -> None: + from website_profiling.content_studio.tools import openai_tools_schema + + schema = openai_tools_schema() + assert schema and schema[0]["type"] == "function" + + +def test_strip_surrogates_empty_string() -> None: + from website_profiling.text_sanitize import strip_surrogates + + assert strip_surrogates("") == "" + + +def test_sanitize_unicode_deep_tuple_branch() -> None: + from website_profiling.text_sanitize import sanitize_unicode_deep + + out = sanitize_unicode_deep(("a\udc9d",)) + assert isinstance(out, tuple) + assert "\udc9d" not in out[0] + + +def test_crawl_store_start_url_helpers() -> None: + from website_profiling.db import crawl_store as cs + + assert cs._normalize_start_url_key("") == "" + assert cs._normalize_start_url_key("ex.com").endswith("ex.com") + assert cs._normalize_start_url_key("https://Ex.Com/") == "https://ex.com" + + conn = MagicMock() + conn.execute.return_value.fetchone.return_value = {"id": 9} + assert cs.get_latest_crawl_run_id_for_property(conn, 1) == 9 + + conn.execute.return_value.fetchall.return_value = [ + {"id": 3, "start_url": "https://ex.com/"}, + {"id": 2, "start_url": "https://other.com"}, + ] + assert cs.get_latest_crawl_run_id_for_start_url(conn, "https://ex.com") == 3 + assert cs.get_latest_crawl_run_id_for_start_url(conn, "") is None + + conn.execute.side_effect = None + conn.execute.return_value.fetchall.return_value = [ + {"id": 2, "start_url": "https://other.com"}, + ] + assert cs.get_latest_crawl_run_id_for_start_url(conn, "https://ex.com") is None + + conn.execute.side_effect = RuntimeError("db") + assert cs.get_latest_crawl_run_id_for_property(conn, 1) is None + assert cs.get_latest_crawl_run_id_for_start_url(conn, "https://ex.com") is None + + +def test_property_store_domain_validation_and_google_status() -> None: + from website_profiling.db import property_store as ps + + assert ps.is_valid_canonical_domain("http") is False + assert ps.is_valid_canonical_domain("ex.i") is False + assert ps.is_valid_canonical_domain("-bad.com") is False + assert ps.is_valid_canonical_domain("nodot") is False + + conn = MagicMock() + with pytest.raises(ValueError, match="not a valid domain"): + ps.upsert_property_by_domain(conn, "Ex", "ex.i") + + with patch( + "website_profiling.db.property_store.get_property_by_id", + return_value={"google_connected_at": "2026-01-01", "google_date_range_days": 0}, + ): + status = ps.get_property_google_public_status(conn, 1) + assert status["connected"] is True + assert status["dateRangeDays"] == 28 + + with patch( + "website_profiling.db.property_store.get_property_by_domain", + return_value={"id": 42}, + ): + assert ps.ensure_property_from_start_url(conn, "https://ex.com") == 42 + + +def test_pipeline_keyword_enrich_rejects_failed_response(monkeypatch) -> None: + from website_profiling.commands import pipeline_cmd + + monkeypatch.setenv("INTEGRATIONS_SERVICE_URL", "http://integrations:8093") + monkeypatch.setattr(pipeline_cmd, "console_print", lambda *_a, **_k: None) + monkeypatch.setattr(pipeline_cmd, "emit_phase_start", lambda *_a, **_k: None) + monkeypatch.setattr(pipeline_cmd, "emit_phase_done", lambda *_a, **_k: None) + monkeypatch.setattr(pipeline_cmd, "emit_progress", lambda *_a, **_k: None) + monkeypatch.setattr(pipeline_cmd, "require_start_url", lambda *_a, **_k: "https://ex.com") + monkeypatch.setattr(pipeline_cmd, "active_property_id_from_cfg", lambda _c: 9) + monkeypatch.setattr(pipeline_cmd, "google_db_has_gsc", lambda _c: True) + monkeypatch.setattr(pipeline_cmd, "should_enrich_keywords_after_report", lambda _c: True) + monkeypatch.setitem( + __import__("sys").modules, + "website_profiling.reporting.builder", + types.SimpleNamespace(run_simple_report=lambda **_k: "/tmp/report.json"), + ) + + class _Resp: + def read(self): + return json.dumps({"ok": False, "log": "nope"}).encode() + + def __enter__(self): + return self + + def __exit__(self, *_a): + return False + + with patch("urllib.request.urlopen", return_value=_Resp()), patch( + "website_profiling.integrations.google.keyword_enrich.run_enrichment", + lambda **_k: None, + ): + pipeline_cmd._run_report( + {"start_url": "https://ex.com", "enable_google_search_console": True}, + use_database=True, + ) + + with patch("urllib.request.urlopen", side_effect=RuntimeError("down")), patch( + "website_profiling.integrations.google.keyword_enrich.run_enrichment", + lambda **_k: None, + ): + pipeline_cmd._run_report( + {"start_url": "https://ex.com", "enable_google_search_console": True}, + use_database=True, + ) + + +def test_ai_suggest_cache_empty_raw() -> None: + from website_profiling.content_studio.ai_suggest import _read_cache + + conn = MagicMock() + with patch("website_profiling.db.db_session") as mock_db: + mock_db.return_value.__enter__.return_value = conn + with patch("website_profiling.content_studio.ai_suggest.read_llm_cache", return_value=""): + assert _read_cache("key") is None diff --git a/tests/test_analysis_crawl_stores_edge_unit.py b/tests/test_analysis_crawl_stores_edge_unit.py index 3394c116..d3cebad5 100644 --- a/tests/test_analysis_crawl_stores_edge_unit.py +++ b/tests/test_analysis_crawl_stores_edge_unit.py @@ -399,7 +399,7 @@ def fake_get_int(cfg, key, default=None): monkeypatch.setitem( sys.modules, "website_profiling.crawl.crawler", - types.SimpleNamespace(run_crawler=lambda **_k: None), + types.SimpleNamespace(run_crawler=lambda **_k: (None, 1)), ) pipeline_cmd._run_crawl({"crawl_render_mode": "static"}, True) diff --git a/tests/test_chat_agent.py b/tests/test_chat_agent.py deleted file mode 100644 index b2743d3b..00000000 --- a/tests/test_chat_agent.py +++ /dev/null @@ -1,251 +0,0 @@ -"""Tests for chat agent loop.""" -from __future__ import annotations - -from unittest.mock import patch - -from website_profiling.llm.agent import ( - MAX_TOOL_ROUNDS, - MAX_TOOL_ROUNDS_EXTENDED, - NARRATIVE_FAILED_MSG, - _max_tool_rounds, - run_agent_turn, -) -from website_profiling.llm.base import ChatResult, ToolCall -from website_profiling.tools.audit_tools import AuditToolContext - -VALID_NARRATIVE = { - "power_insights": ["Crawl health is solid overall."], - "recommended_actions": ["Address critical issues first."], -} - - -class FakeToolClient: - def __init__(self, steps: list[ChatResult]) -> None: - self._steps = list(steps) - self._calls = 0 - - def chat_with_tools(self, messages, tools, *, on_token=None): - result = self._steps[min(self._calls, len(self._steps) - 1)] - self._calls += 1 - return result - - def complete_json(self, system, user): - return VALID_NARRATIVE - - -def test_agent_tool_then_answer() -> None: - client = FakeToolClient([ - ChatResult(tool_calls=[ToolCall(id="tc1", name="list_issues", arguments={"limit": 5})]), - ChatResult(content="ignored internal stop"), - ]) - events: list[dict] = [] - ctx = AuditToolContext(property_id=1) - - with patch("website_profiling.llm.agent.load_llm_config_from_db", return_value={ - "llm_enabled": True, "llm_provider": "openai", "llm_api_key": "sk-test", - }): - with patch("website_profiling.llm.agent.get_llm_client", return_value=client): - with patch("website_profiling.llm.chat_narrative.get_llm_client", return_value=client): - with patch( - "website_profiling.llm.agent.dispatch_tool", - return_value={"issues": [], "total": 0}, - ) as mock_dispatch: - result = run_agent_turn( - [{"role": "user", "content": "What are the top issues?"}], - ctx, - on_event=events.append, - ) - - assert result["ok"] is True - assert result["narrative"] == VALID_NARRATIVE - mock_dispatch.assert_called_once() - types = [e["type"] for e in events] - assert "tool_start" in types - assert "tool_end" in types - assert "narrative" in types - assert "done" in types - assert "token" not in types - - -def test_agent_runs_multiple_tool_calls_in_one_turn() -> None: - """A turn with several tool calls dispatches them all; results stay in request order.""" - client = FakeToolClient([ - ChatResult(tool_calls=[ - ToolCall(id="a", name="get_report_summary", arguments={}), - ToolCall(id="b", name="get_critical_issues", arguments={"limit": 5}), - ToolCall(id="c", name="get_issue_priority_breakdown", arguments={}), - ]), - ChatResult(content="stop"), - ]) - events: list[dict] = [] - ctx = AuditToolContext(property_id=1, report_id=1) - dispatched: list[str] = [] - - def fake_dispatch(name, args, *, context=None): - dispatched.append(name) - return {"tool": name, "ok": True} - - with patch("website_profiling.llm.agent.load_llm_config_from_db", return_value={ - "llm_enabled": True, "llm_provider": "openai", "llm_api_key": "sk-test", - }): - with patch("website_profiling.llm.agent.get_llm_client", return_value=client): - with patch("website_profiling.llm.chat_narrative.get_llm_client", return_value=client): - with patch("website_profiling.llm.agent.chat_tool_mode", return_value="full"): - with patch( - "website_profiling.llm.agent.dispatch_tool", - side_effect=fake_dispatch, - ): - result = run_agent_turn( - [{"role": "user", "content": "give me a full audit overview"}], - ctx, - on_event=events.append, - ) - - assert result["ok"] is True - assert sorted(dispatched) == [ - "get_critical_issues", "get_issue_priority_breakdown", "get_report_summary", - ] - assert [e["name"] for e in result["tool_events"]] == [ - "get_report_summary", "get_critical_issues", "get_issue_priority_breakdown", - ] - - -def test_agent_isolates_tool_exception() -> None: - client = FakeToolClient([ - ChatResult(tool_calls=[ToolCall(id="x", name="list_issues", arguments={})]), - ChatResult(content="stop"), - ]) - ctx = AuditToolContext(property_id=1) - - with patch("website_profiling.llm.agent.load_llm_config_from_db", return_value={ - "llm_enabled": True, "llm_provider": "openai", "llm_api_key": "sk-test", - }): - with patch("website_profiling.llm.agent.get_llm_client", return_value=client): - with patch("website_profiling.llm.chat_narrative.get_llm_client", return_value=client): - with patch("website_profiling.llm.agent.chat_tool_mode", return_value="full"): - with patch( - "website_profiling.llm.agent.dispatch_tool", - side_effect=RuntimeError("db exploded"), - ): - result = run_agent_turn( - [{"role": "user", "content": "list issues"}], - ctx, - ) - - assert result["ok"] is True - assert result["tool_events"][0]["result"]["error"] == "db exploded" - - -def test_agent_disabled_llm() -> None: - events: list[dict] = [] - with patch("website_profiling.llm.agent.load_llm_config_from_db", return_value={ - "llm_enabled": False, "llm_provider": "none", - }): - result = run_agent_turn( - [{"role": "user", "content": "Hi"}], - AuditToolContext(), - on_event=events.append, - ) - assert result["ok"] is False - assert events[-1]["type"] == "error" - - -def test_max_tool_rounds_still_synthesizes() -> None: - always_tool = ChatResult( - tool_calls=[ToolCall(id="x", name="list_properties", arguments={})], - ) - client = FakeToolClient([always_tool] * (MAX_TOOL_ROUNDS + 1)) - ctx = AuditToolContext() - events: list[dict] = [] - - with patch("website_profiling.llm.agent.load_llm_config_from_db", return_value={ - "llm_enabled": True, "llm_provider": "openai", "llm_api_key": "sk-test", - "llm_chat_unlimited_tool_rounds": "false", - }): - with patch("website_profiling.llm.agent.get_llm_client", return_value=client): - with patch("website_profiling.llm.chat_narrative.get_llm_client", return_value=client): - with patch( - "website_profiling.llm.agent.dispatch_tool", - return_value={"properties": []}, - ): - result = run_agent_turn( - [{"role": "user", "content": "List properties"}], - ctx, - on_event=events.append, - ) - - assert result["ok"] is True - assert result["narrative"] == VALID_NARRATIVE - assert any(e["type"] == "partial_done" for e in events) - assert any(e["type"] == "narrative" for e in events) - - -def test_narrative_failure_emits_error_and_preserves_tools() -> None: - from website_profiling.llm.chat_narrative import ChatNarrativeError - - client = FakeToolClient([ - ChatResult(tool_calls=[ToolCall(id="tc1", name="list_issues", arguments={})]), - ChatResult(content="stop"), - ]) - events: list[dict] = [] - ctx = AuditToolContext(property_id=1) - - with patch("website_profiling.llm.agent.load_llm_config_from_db", return_value={ - "llm_enabled": True, "llm_provider": "openai", "llm_api_key": "sk-test", - }): - with patch("website_profiling.llm.agent.get_llm_client", return_value=client): - with patch( - "website_profiling.llm.agent.dispatch_tool", - return_value={"issues": [{"url": "/a"}]}, - ): - with patch( - "website_profiling.llm.agent.synthesize_chat_narrative", - side_effect=ChatNarrativeError("failed"), - ): - result = run_agent_turn( - [{"role": "user", "content": "issues"}], - ctx, - on_event=events.append, - ) - - assert result["ok"] is False - assert result["error"] == NARRATIVE_FAILED_MSG - assert len(result["tool_events"]) == 1 - assert events[-1]["type"] == "error" - - -def test_max_tool_rounds_extended_when_unlimited_enabled() -> None: - assert _max_tool_rounds({"llm_chat_unlimited_tool_rounds": "true"}) == MAX_TOOL_ROUNDS_EXTENDED - assert _max_tool_rounds({"llm_chat_unlimited_tool_rounds": "false"}) == MAX_TOOL_ROUNDS - - -def test_system_prompt_does_not_require_markdown_template() -> None: - from website_profiling.llm.agent import SYSTEM_PROMPT - - assert "### Power Insights" not in SYSTEM_PROMPT - assert "### Recommended actions" not in SYSTEM_PROMPT - assert "generated separately" in SYSTEM_PROMPT.lower() - - -def test_resolve_system_prompt_readonly_by_default() -> None: - from website_profiling.llm.agent import ( - SYSTEM_PROMPT_CRAWL_ENABLED, - SYSTEM_PROMPT_READONLY, - resolve_system_prompt, - ) - - assert resolve_system_prompt({}) == SYSTEM_PROMPT_READONLY - assert resolve_system_prompt({"llm_chat_allow_crawl": "false"}) == SYSTEM_PROMPT_READONLY - assert "read-only" in resolve_system_prompt({}).lower() - - -def test_resolve_system_prompt_crawl_when_enabled() -> None: - from website_profiling.llm.agent import ( - SYSTEM_PROMPT_CRAWL_ENABLED, - resolve_system_prompt, - ) - - prompt = resolve_system_prompt({"llm_chat_allow_crawl": "true"}) - assert prompt == SYSTEM_PROMPT_CRAWL_ENABLED - assert "you are read-only" not in prompt.lower() - assert "prepare_audit_run" in prompt.lower() diff --git a/tests/test_chat_cmd.py b/tests/test_chat_cmd.py deleted file mode 100644 index 50f5a5c7..00000000 --- a/tests/test_chat_cmd.py +++ /dev/null @@ -1,99 +0,0 @@ -"""CLI chat command tests.""" -from __future__ import annotations - -import argparse -import io -import json -from unittest.mock import patch - -import pytest - -from website_profiling.commands import chat_cmd - - -def test_chat_cmd_requires_stdin_json() -> None: - with pytest.raises(SystemExit) as exc: - chat_cmd.run({}, argparse.Namespace(stdin_json=False)) - assert exc.value.code == 1 - - -def test_chat_cmd_invalid_stdin_json(capsys) -> None: - with patch("sys.stdin", io.StringIO("not-json")): - with pytest.raises(SystemExit) as exc: - chat_cmd.run({}, argparse.Namespace(stdin_json=True)) - assert exc.value.code == 1 - assert "error" in capsys.readouterr().out - - -def test_chat_cmd_success(capsys) -> None: - payload = json.dumps({"messages": [{"role": "user", "content": "Hi"}], "property_id": 1}) - with patch("sys.stdin", io.StringIO(payload)): - with patch( - "website_profiling.commands.chat_cmd.run_agent_turn", - return_value={"ok": True, "message": "Done"}, - ) as mock_turn: - with pytest.raises(SystemExit) as exc: - chat_cmd.run({}, argparse.Namespace(stdin_json=True)) - assert exc.value.code == 0 - mock_turn.assert_called_once() - assert mock_turn.call_args[0][1].property_id == 1 - - -def test_chat_cmd_streams_sanitized_events(capsys) -> None: - payload = json.dumps({"messages": [{"role": "user", "content": "Hi"}]}) - - def fake_turn(_messages, _ctx, on_event=None): - if on_event: - on_event({"type": "token", "content": "bad\udc9d"}) - return {"ok": True} - - with patch("sys.stdin", io.StringIO(payload)): - with patch("website_profiling.commands.chat_cmd.run_agent_turn", side_effect=fake_turn): - with pytest.raises(SystemExit) as exc: - chat_cmd.run({}, argparse.Namespace(stdin_json=True)) - assert exc.value.code == 0 - out = capsys.readouterr().out - assert "\udc9d" not in out - assert "token" in out - - -def test_chat_cmd_coerces_invalid_ids_and_messages(capsys) -> None: - payload = json.dumps({"messages": "bad", "property_id": "x", "report_id": "y"}) - with patch("sys.stdin", io.StringIO(payload)): - with patch( - "website_profiling.commands.chat_cmd.run_agent_turn", - return_value={"ok": True}, - ) as mock_turn: - with pytest.raises(SystemExit) as exc: - chat_cmd.run({}, argparse.Namespace(stdin_json=True)) - assert exc.value.code == 0 - ctx = mock_turn.call_args[0][1] - assert ctx.property_id is None - assert ctx.report_id is None - assert mock_turn.call_args[0][0] == [] - - -def test_chat_cmd_agent_failure(capsys) -> None: - payload = json.dumps({"messages": []}) - with patch("sys.stdin", io.StringIO(payload)): - with patch( - "website_profiling.commands.chat_cmd.run_agent_turn", - return_value={"ok": False, "error": "LLM disabled"}, - ): - with pytest.raises(SystemExit) as exc: - chat_cmd.run({}, argparse.Namespace(stdin_json=True)) - assert exc.value.code == 1 - assert "LLM disabled" in capsys.readouterr().out - - -def test_chat_cmd_exception(capsys) -> None: - payload = json.dumps({"messages": []}) - with patch("sys.stdin", io.StringIO(payload)): - with patch( - "website_profiling.commands.chat_cmd.run_agent_turn", - side_effect=RuntimeError("boom"), - ): - with pytest.raises(SystemExit) as exc: - chat_cmd.run({}, argparse.Namespace(stdin_json=True)) - assert exc.value.code == 1 - assert "boom" in capsys.readouterr().out diff --git a/tests/test_chat_narrative.py b/tests/test_chat_narrative.py deleted file mode 100644 index ba0a8485..00000000 --- a/tests/test_chat_narrative.py +++ /dev/null @@ -1,98 +0,0 @@ -"""Tests for structured chat narrative synthesis.""" -from __future__ import annotations - -from unittest.mock import MagicMock, patch - -import pytest - -from website_profiling.llm.chat_narrative import ( - ChatNarrativeError, - build_synthesis_payload, - synthesize_chat_narrative, - validate_chat_narrative, -) - - -def test_validate_chat_narrative_accepts_valid_payload() -> None: - narrative, errors = validate_chat_narrative({ - "power_insights": [" Strong crawl health "], - "recommended_actions": ["Fix broken links"], - }) - assert not errors - assert narrative["power_insights"] == ["Strong crawl health"] - assert narrative["recommended_actions"] == ["Fix broken links"] - - -def test_validate_chat_narrative_rejects_empty_arrays() -> None: - _, errors = validate_chat_narrative({ - "power_insights": [], - "recommended_actions": [], - }) - assert any("empty" in e for e in errors) - - -def test_validate_chat_narrative_caps_items() -> None: - items = [f"item {i}" for i in range(8)] - narrative, errors = validate_chat_narrative({ - "power_insights": items, - "recommended_actions": ["one"], - }) - # Over-length is silently capped, not treated as a validation error. - assert not errors - assert len(narrative["power_insights"]) == 5 - - -def test_build_synthesis_payload_truncates_large_tool_results() -> None: - huge = {"blob": "x" * 20000} - payload = build_synthesis_payload( - "overview?", - [{"name": "get_report_summary", "args": {}, "result": huge}], - ) - assert len(payload) <= 10020 - assert "truncated" in payload - - -def test_synthesize_chat_narrative_success_first_attempt() -> None: - client = MagicMock() - client.complete_json.return_value = { - "power_insights": ["Insight"], - "recommended_actions": ["Action"], - } - with patch("website_profiling.llm.chat_narrative.get_llm_client", return_value=client): - result = synthesize_chat_narrative( - {"llm_provider": "openai"}, - "What is site health?", - [{"name": "get_report_summary", "result": {"health": 80}}], - ) - assert result["power_insights"] == ["Insight"] - client.complete_json.assert_called_once() - - -def test_synthesize_chat_narrative_retries_on_invalid_first_attempt() -> None: - client = MagicMock() - client.complete_json.side_effect = [ - {"power_insights": []}, - {"power_insights": ["Fixed"], "recommended_actions": ["Do it"]}, - ] - statuses: list[str] = [] - with patch("website_profiling.llm.chat_narrative.get_llm_client", return_value=client): - result = synthesize_chat_narrative( - {"llm_provider": "openai"}, - "overview", - [], - on_status=statuses.append, - ) - assert result["power_insights"] == ["Fixed"] - assert client.complete_json.call_count == 2 - assert statuses == ["synthesizing", "retrying"] - - -def test_synthesize_chat_narrative_raises_after_two_failures() -> None: - client = MagicMock() - client.complete_json.side_effect = [ - "not json", - {"recommended_actions": ["only actions"]}, - ] - with patch("website_profiling.llm.chat_narrative.get_llm_client", return_value=client): - with pytest.raises(ChatNarrativeError): - synthesize_chat_narrative({"llm_provider": "openai"}, "hi", []) diff --git a/tests/test_commands_config_stores_edge_unit.py b/tests/test_commands_config_stores_edge_unit.py index b78539fb..25d9ceaf 100644 --- a/tests/test_commands_config_stores_edge_unit.py +++ b/tests/test_commands_config_stores_edge_unit.py @@ -1097,7 +1097,7 @@ def fake_get_int(cfg, key, default=None): monkeypatch.setitem( sys.modules, "website_profiling.crawl.crawler", - types.SimpleNamespace(run_crawler=lambda **_k: None), + types.SimpleNamespace(run_crawler=lambda **_k: (None, 1)), ) monkeypatch.setattr(pipeline_cmd, "require_start_url", lambda *_a, **_k: "https://a.com") pipeline_cmd._run_crawl( diff --git a/tests/test_commands_page_gsc_unit.py b/tests/test_commands_page_gsc_unit.py index 5cd7c57f..ac26348e 100644 --- a/tests/test_commands_page_gsc_unit.py +++ b/tests/test_commands_page_gsc_unit.py @@ -304,15 +304,14 @@ def test_page_coach_cmd_success_and_env(monkeypatch, capsys) -> None: captured: dict = {} - def fake_run(url, cfg, **kwargs): + def fake_run(url, **kwargs): captured["url"] = url captured["kwargs"] = kwargs return {"ok": True, "suggestions": []} - monkeypatch.setitem( - sys.modules, - "website_profiling.llm.page_coach", - types.SimpleNamespace(run_page_coach=fake_run), + monkeypatch.setattr( + "website_profiling.llm_client_http.run_page_coach", + fake_run, ) monkeypatch.setenv("WP_PAGE_COACH_CURRENT", "crawl:5") monkeypatch.setenv("WP_PAGE_COACH_BASELINE", "crawl:2") @@ -337,14 +336,13 @@ def test_page_coach_cmd_malformed_env_does_not_crash(monkeypatch, capsys) -> Non captured: dict = {} - def fake_run(url, cfg, **kwargs): + def fake_run(url, **kwargs): captured["kwargs"] = kwargs return {"ok": True, "suggestions": []} - monkeypatch.setitem( - sys.modules, - "website_profiling.llm.page_coach", - types.SimpleNamespace(run_page_coach=fake_run), + monkeypatch.setattr( + "website_profiling.llm_client_http.run_page_coach", + fake_run, ) monkeypatch.setenv("WP_PAGE_COACH_CURRENT", "live:abc") monkeypatch.setenv("WP_PAGE_COACH_BASELINE", "snapshot:") @@ -361,10 +359,9 @@ def fake_run(url, cfg, **kwargs): def test_page_coach_cmd_failure_exit(monkeypatch, capsys) -> None: from website_profiling.commands import page_coach_cmd - monkeypatch.setitem( - sys.modules, - "website_profiling.llm.page_coach", - types.SimpleNamespace(run_page_coach=lambda *_a, **_k: {"ok": False}), + monkeypatch.setattr( + "website_profiling.llm_client_http.run_page_coach", + lambda _url, **_k: {"ok": False}, ) monkeypatch.delenv("WP_PAGE_COACH_CURRENT", raising=False) monkeypatch.delenv("WP_PAGE_COACH_BASELINE", raising=False) diff --git a/tests/test_common_analysis_commands_db_unit.py b/tests/test_common_analysis_commands_db_unit.py index f5aa3c98..5d388b35 100644 --- a/tests/test_common_analysis_commands_db_unit.py +++ b/tests/test_common_analysis_commands_db_unit.py @@ -627,7 +627,7 @@ def test_pipeline_cmd_remaining_branches(monkeypatch) -> None: monkeypatch.setitem( __import__("sys").modules, "website_profiling.crawl.crawler", - types.SimpleNamespace(run_crawler=lambda **_k: None), + types.SimpleNamespace(run_crawler=lambda **_k: (None, 1)), ) pipeline_cmd._run_crawl({"crawl_js_extra_wait_ms": "", "crawl_render_mode": "auto"}, True) @@ -640,7 +640,8 @@ def __exit__(self, _t, _v, _tb): return False monkeypatch.setattr("website_profiling.db.db_session", lambda: Ctx()) - monkeypatch.setattr("website_profiling.db.get_latest_crawl_run_id", lambda _c: 1) + monkeypatch.setattr("website_profiling.db.resolve_crawl_run_id_for_cfg", lambda _c, **kw: 1) + monkeypatch.setattr("website_profiling.db.get_crawl_run_info", lambda _c, _rid: {"start_url": "https://a.com"}) monkeypatch.setattr("website_profiling.db.read_crawl", lambda _c, _rid: pd.DataFrame()) monkeypatch.setattr(pipeline_cmd, "lighthouse_work_dir", lambda: "/tmp/lh") monkeypatch.setattr(pipeline_cmd, "cleanup_lighthouse_work_dir", lambda _p: None) diff --git a/tests/test_crawler_browser_e2e.py b/tests/test_crawler_browser_e2e.py index d46ec9e9..c358092e 100644 --- a/tests/test_crawler_browser_e2e.py +++ b/tests/test_crawler_browser_e2e.py @@ -27,7 +27,7 @@ def test_run_crawler_auto_discovers_js_links(spa_server): validate_browser_available() base = spa_server.rsplit("/", 1)[0] start_url = f"{base}/post_parse_shell.html" - df = run_crawler( + df, _ = run_crawler( start_url=start_url, render_mode="auto", max_pages=10, diff --git a/tests/test_crawler_deep.py b/tests/test_crawler_deep.py index 841a665a..2ac776b8 100644 --- a/tests/test_crawler_deep.py +++ b/tests/test_crawler_deep.py @@ -252,7 +252,7 @@ def crawl(self, **_kwargs): monkeypatch.setattr(mod, "Crawler", FakeCrawler) out_file = tmp_path / "out.csv" - df = mod.run_crawler("https://a.com", output_db=False, output_csv=str(out_file), show_progress=False) + df, _ = mod.run_crawler("https://a.com", output_db=False, output_csv=str(out_file), show_progress=False) assert not df.empty assert out_file.exists() @@ -544,7 +544,7 @@ def __exit__(self, _t, _v, _tb): monkeypatch.setitem(__import__("sys").modules, "website_profiling.db", fake_db) monkeypatch.setitem(__import__("sys").modules, "website_profiling.db.storage", fake_storage) - df = mod.run_crawler( + df, _ = mod.run_crawler( "https://a.com", output_db=True, crawl_stream_to_db=False, @@ -590,7 +590,7 @@ def __exit__(self, _t, _v, _tb): monkeypatch.setitem(__import__("sys").modules, "website_profiling.db", fake_db) monkeypatch.setitem(__import__("sys").modules, "website_profiling.db.storage", fake_storage) - df = mod.run_crawler( + df, _ = mod.run_crawler( "https://a.com", output_db=True, crawl_stream_to_db=True, @@ -631,7 +631,7 @@ def __exit__(self, _t, _v, _tb): monkeypatch.setitem(__import__("sys").modules, "website_profiling.db", fake_db) monkeypatch.setitem(__import__("sys").modules, "website_profiling.db.storage", fake_storage) - df = mod.run_crawler("https://a.com", output_db=True, crawl_stream_to_db=True, show_progress=False) + df, _ = mod.run_crawler("https://a.com", output_db=True, crawl_stream_to_db=True, show_progress=False) assert not df.empty @@ -772,7 +772,7 @@ def fake_get_latest(conn): def patched_run(start_url="", **kwargs): if kwargs.get("compare_mobile_desktop") is False and kwargs.get("crawl_user_agent_preset") == "mobile": second_calls.append({"start_url": start_url, **kwargs}) - return pd.DataFrame([{"url": "https://a.com", "status": 200}]) + return pd.DataFrame([{"url": "https://a.com", "status": 200}]), 8 return original_run(start_url, **kwargs) monkeypatch.setattr(mod, "run_crawler", patched_run) diff --git a/tests/test_dashboard_ai.py b/tests/test_dashboard_ai.py deleted file mode 100644 index 6a9f48cb..00000000 --- a/tests/test_dashboard_ai.py +++ /dev/null @@ -1,127 +0,0 @@ -"""Unit tests for dashboard AI generation (no LLM API key needed).""" -from __future__ import annotations - -from unittest.mock import MagicMock, patch - -import pytest - -from website_profiling.llm.dashboard_ai import generate_dashboard_ai - - -DISABLED_CFG: dict = {"llm_enabled": "false", "llm_provider": "none"} -ENABLED_CFG: dict = {"llm_enabled": "true", "llm_provider": "openai", "llm_model": "gpt-4o-mini"} -DISABLED_DASHBOARD_CFG: dict = {**ENABLED_CFG, "llm_enable_dashboards": "false"} - - -def make_payload(mode: str = "widget", prompt: str = "Show health score") -> dict: - return { - "mode": mode, - "prompt": prompt, - "catalog": [ - { - "toolName": "get_report_summary", - "label": "Audit summary", - "fields": ["health_score", "total_issues"], - "compatibleViz": ["kpi", "stat-card"], - "defaultValueField": "health_score", - } - ], - "viz_types": {"kpi": "KPI (number)", "stat-card": "Stat card"}, - "dashscript_help": "field('key') — read a value", - } - - -class TestDisabledGuard: - def test_returns_error_when_llm_disabled(self): - result = generate_dashboard_ai(make_payload(), cfg=DISABLED_CFG) - assert result["ok"] is False - assert result.get("missing") is True - assert "disabled" in result["error"].lower() - - def test_returns_error_when_dashboards_task_disabled(self): - result = generate_dashboard_ai(make_payload(), cfg=DISABLED_DASHBOARD_CFG) - assert result["ok"] is False - assert result.get("missing") is True - - def test_returns_error_for_empty_prompt(self): - payload = make_payload(prompt="") - mock_cfg = {**ENABLED_CFG} - # Even without mocking the LLM, prompt validation fires first - result = generate_dashboard_ai(payload, cfg=mock_cfg) - assert result["ok"] is False - assert "prompt" in result["error"].lower() - - def test_returns_error_for_invalid_mode(self): - payload = make_payload() - payload["mode"] = "invalid_mode" - result = generate_dashboard_ai(payload, cfg=ENABLED_CFG) - assert result["ok"] is False - assert "mode" in result["error"].lower() - - -class TestModePassthrough: - """Verify generate_dashboard_ai returns LLM output unchanged for each mode.""" - - @pytest.fixture(autouse=True) - def mock_llm(self): - fake_client = MagicMock() - with patch( - "website_profiling.llm.dashboard_ai.get_llm_client", - return_value=fake_client, - ) as mock_get: - self.fake_client = fake_client - self.mock_get = mock_get - yield - - def _set_response(self, data: dict) -> None: - self.fake_client.complete_json.return_value = data - - def test_script_mode_passthrough(self): - expected = {"measure": 'field("health_score")', "explanation": "Read the health score."} - self._set_response(expected) - result = generate_dashboard_ai(make_payload(mode="script"), cfg=ENABLED_CFG) - assert result["ok"] is True - assert result["measure"] == expected["measure"] - assert result["explanation"] == expected["explanation"] - - def test_widget_mode_passthrough(self): - expected = { - "widget": { - "title": "Health Score", - "toolName": "get_report_summary", - "viz": "kpi", - "binding": {"source": "audit-tool", "toolName": "get_report_summary", "valueField": "health_score"}, - "options": {}, - }, - "explanation": "KPI for overall health.", - } - self._set_response(expected) - result = generate_dashboard_ai(make_payload(mode="widget"), cfg=ENABLED_CFG) - assert result["ok"] is True - assert result["widget"]["viz"] == "kpi" - - def test_dashboard_mode_passthrough(self): - expected = { - "name": "My Dashboard", - "widgets": [ - { - "title": "Health Score", - "toolName": "get_report_summary", - "viz": "kpi", - "binding": {"source": "audit-tool", "toolName": "get_report_summary", "valueField": "health_score"}, - "options": {}, - } - ], - "explanation": "One widget dashboard.", - } - self._set_response(expected) - result = generate_dashboard_ai(make_payload(mode="dashboard"), cfg=ENABLED_CFG) - assert result["ok"] is True - assert result["name"] == "My Dashboard" - assert len(result["widgets"]) == 1 - - def test_llm_exception_returns_error(self): - self.fake_client.complete_json.side_effect = RuntimeError("API timeout") - result = generate_dashboard_ai(make_payload(), cfg=ENABLED_CFG) - assert result["ok"] is False - assert "API timeout" in result["error"] diff --git a/tests/test_fix_suggestions.py b/tests/test_fix_suggestions.py deleted file mode 100644 index c3242675..00000000 --- a/tests/test_fix_suggestions.py +++ /dev/null @@ -1,97 +0,0 @@ -"""Tests for unified fix suggestion generator.""" -from __future__ import annotations - -from unittest.mock import MagicMock, patch - -import pytest - -from website_profiling.llm.fix_suggestions import ( - VALID_SOURCES, - _build_user_payload, - _cache_key, - generate_fix_suggestion, -) - -_ENABLED_CFG = {"llm_enabled": True, "llm_enable_issue_fixes": "true", "llm_model": "test-model"} - - -def test_valid_sources() -> None: - assert VALID_SOURCES == frozenset( - {"issue", "lighthouse", "security", "browser", "seo_content", "technical"} - ) - - -def test_message_required() -> None: - with patch("website_profiling.llm.fix_suggestions.llm_is_enabled", return_value=True): - out = generate_fix_suggestion({"source": "lighthouse", "message": " "}, cfg=_ENABLED_CFG) - assert out["ok"] is False - assert "message" in out["error"].lower() - - -def test_disabled_llm() -> None: - with patch("website_profiling.llm.fix_suggestions.llm_is_enabled", return_value=False): - out = generate_fix_suggestion({"message": "Slow LCP"}, cfg=_ENABLED_CFG) - assert out["ok"] is False - assert "disabled" in out["error"].lower() - - -def test_fix_suggestions_disabled_in_settings() -> None: - cfg = {"llm_enabled": True, "llm_enable_issue_fixes": "false"} - with patch("website_profiling.llm.fix_suggestions.llm_is_enabled", return_value=True): - out = generate_fix_suggestion({"message": "Missing HSTS"}, cfg=cfg) - assert out["ok"] is False - assert "disabled" in out["error"].lower() - - -def test_cache_key_varies_by_source() -> None: - base = {"message": "x", "url": "https://example.com/"} - k1 = _cache_key("gpt-4o-mini", "lighthouse", {**base, "source": "lighthouse"}) - k2 = _cache_key("gpt-4o-mini", "security", {**base, "source": "security"}) - assert k1 != k2 - - -def test_build_user_payload_legacy_issue_fields() -> None: - payload = _build_user_payload( - { - "source": "issue", - "message": "Noindex", - "url": "https://example.com/a", - "priority": "High", - "category": "Indexation", - "recommendation": "Remove noindex", - "type": "noindex", - } - ) - assert payload["source"] == "issue" - assert payload["context"]["priority"] == "High" - assert payload["context"]["category"] == "Indexation" - - -@pytest.mark.parametrize("source", sorted(VALID_SOURCES)) -def test_each_source_accepts_minimal_payload(source: str) -> None: - cached_fix = {"fix": "Do the thing.", "effort": "low"} - with patch("website_profiling.llm.fix_suggestions.llm_is_enabled", return_value=True): - with patch("website_profiling.llm.fix_suggestions._read_cache", return_value=cached_fix): - out = generate_fix_suggestion( - {"source": source, "message": f"Problem for {source}"}, - cfg=_ENABLED_CFG, - ) - assert out["ok"] is True - assert out["cached"] is True - assert out["fix"]["fix"] == "Do the thing." - - -def test_llm_call_writes_cache() -> None: - client = MagicMock() - client.complete_json.return_value = {"fix": "Add preload.", "effort": "low"} - with patch("website_profiling.llm.fix_suggestions.llm_is_enabled", return_value=True): - with patch("website_profiling.llm.fix_suggestions._read_cache", return_value=None): - with patch("website_profiling.llm.fix_suggestions.get_llm_client", return_value=client): - with patch("website_profiling.llm.fix_suggestions._write_cache") as write: - out = generate_fix_suggestion( - {"source": "browser", "message": "TypeError", "context": {"stack": "at foo"}}, - cfg=_ENABLED_CFG, - ) - assert out["ok"] is True - assert out["fix"]["fix"] == "Add preload." - write.assert_called_once() diff --git a/tests/test_google_cmd_more.py b/tests/test_google_cmd_more.py index c593faf3..f26581c2 100644 --- a/tests/test_google_cmd_more.py +++ b/tests/test_google_cmd_more.py @@ -60,3 +60,66 @@ def boom(_p=None): google_cmd.run({"google_credentials_path": ""}, cwd="/tmp", path=lambda k, d: d, args=args) assert e.value.code == 1 + +def test_google_cmd_list_properties_via_integrations(monkeypatch, capsys) -> None: + from website_profiling.commands import google_cmd + + monkeypatch.setenv("INTEGRATIONS_SERVICE_URL", "http://integrations:8093") + monkeypatch.setattr( + google_cmd, + "_list_properties_via_integrations", + lambda pid: {"properties": [{"id": pid}]}, + ) + args = argparse.Namespace(list_properties=True, test=False, property_id=7) + with pytest.raises(SystemExit) as exc: + google_cmd.run({}, "/tmp", lambda _k, d: d, args) + assert exc.value.code == 0 + assert '"properties"' in capsys.readouterr().out + + +def test_google_cmd_fetch_via_integrations(monkeypatch, capsys) -> None: + from website_profiling.commands import google_cmd + + monkeypatch.setenv("INTEGRATIONS_SERVICE_URL", "http://integrations:8093") + monkeypatch.setattr(google_cmd, "resolve_property_id_from_cfg", lambda _c: 3) + monkeypatch.setitem( + __import__("sys").modules, + "website_profiling.db", + types.SimpleNamespace( + db_session=lambda: types.SimpleNamespace(__enter__=lambda s: object(), __exit__=lambda *a: None), + get_latest_crawl_run_id=lambda _c: None, + read_crawl=lambda *_a, **_k: __import__("pandas").DataFrame(), + ), + ) + monkeypatch.setattr( + google_cmd, + "_fetch_via_integrations", + lambda *_a, **_k: {"gsc": {}, "ga4": {}}, + ) + monkeypatch.setitem( + __import__("sys").modules, + "website_profiling.integrations.google.store", + types.SimpleNamespace(write_google_data=lambda *_a, **_k: None), + ) + args = argparse.Namespace(list_properties=False, test=False, property_id=3) + with pytest.raises(SystemExit) as exc: + google_cmd.run({"start_url": "https://ex.com"}, "/tmp", lambda _k, d: d, args) + assert exc.value.code == 0 + + +def test_google_cmd_integrations_helpers_errors() -> None: + from website_profiling.commands import google_cmd + + with pytest.raises(RuntimeError, match="property_id is required"): + google_cmd._list_properties_via_integrations(None) + + with pytest.raises(RuntimeError, match="No property selected"): + google_cmd._fetch_via_integrations( + "http://integrations:8093", + property_id=None, + date_range_days=28, + crawl_urls=[], + start_url="https://ex.com", + config={}, + ) + diff --git a/tests/test_help_agent.py b/tests/test_help_agent.py deleted file mode 100644 index 785e5942..00000000 --- a/tests/test_help_agent.py +++ /dev/null @@ -1,170 +0,0 @@ -"""Unit tests for the help agent.""" -from __future__ import annotations - -from unittest.mock import MagicMock, patch - -import pytest - -from website_profiling.llm.help_agent import run_help_turn - - -def _make_fake_client(content: str = "Sure, here's how.", tokens: list[str] | None = None): - client = MagicMock() - result = MagicMock() - result.content = content - result.tool_calls = [] - - def fake_chat(messages, tools, on_token=None): - if tokens and on_token: - for t in tokens: - on_token(t) - return result - - client.chat_with_tools.side_effect = fake_chat - return client - - -def test_help_turn_disabled_llm() -> None: - events: list[dict] = [] - - with patch("website_profiling.llm.help_agent.load_llm_config_from_db", return_value={}): - with patch("website_profiling.llm.help_agent.llm_is_enabled", return_value=False): - result = run_help_turn( - [{"role": "user", "content": "help"}], - on_event=events.append, - ) - - assert result["ok"] is False - assert any(e["type"] == "error" for e in events) - - -def test_help_turn_streams_tokens() -> None: - events: list[dict] = [] - fake_client = _make_fake_client(tokens=["Hello", " world"]) - - with patch("website_profiling.llm.help_agent.load_llm_config_from_db", return_value={"llm_provider": "openai"}): - with patch("website_profiling.llm.help_agent.llm_is_enabled", return_value=True): - with patch("website_profiling.llm.help_agent.get_llm_client", return_value=fake_client): - result = run_help_turn( - [{"role": "user", "content": "help"}], - on_event=events.append, - ) - - assert result["ok"] is True - token_events = [e for e in events if e["type"] == "token"] - assert len(token_events) == 2 - assert token_events[0]["text"] == "Hello" - assert token_events[1]["text"] == " world" - done_events = [e for e in events if e["type"] == "done"] - assert len(done_events) == 1 - - -def test_help_turn_no_tools_passed() -> None: - fake_client = _make_fake_client() - - with patch("website_profiling.llm.help_agent.load_llm_config_from_db", return_value={"llm_provider": "openai"}): - with patch("website_profiling.llm.help_agent.llm_is_enabled", return_value=True): - with patch("website_profiling.llm.help_agent.get_llm_client", return_value=fake_client): - run_help_turn([{"role": "user", "content": "help"}]) - - call_args = fake_client.chat_with_tools.call_args - # tools is the second positional arg or passed as keyword - tools_arg = call_args[0][1] if len(call_args[0]) > 1 else call_args[1].get("tools", None) - assert tools_arg == [] - - -def test_help_turn_buffered_content_emitted() -> None: - """When on_token is never called (buffered provider), emit content once at end.""" - events: list[dict] = [] - fake_client = _make_fake_client(content="Buffered response", tokens=None) - - with patch("website_profiling.llm.help_agent.load_llm_config_from_db", return_value={"llm_provider": "openai"}): - with patch("website_profiling.llm.help_agent.llm_is_enabled", return_value=True): - with patch("website_profiling.llm.help_agent.get_llm_client", return_value=fake_client): - result = run_help_turn( - [{"role": "user", "content": "help"}], - on_event=events.append, - ) - - assert result["ok"] is True - token_texts = [e["text"] for e in events if e["type"] == "token"] - assert "Buffered response" in token_texts - - -def test_help_turn_provider_error() -> None: - events: list[dict] = [] - bad_client = MagicMock() - bad_client.chat_with_tools.side_effect = ValueError("Connection refused") - - with patch("website_profiling.llm.help_agent.load_llm_config_from_db", return_value={"llm_provider": "openai"}): - with patch("website_profiling.llm.help_agent.llm_is_enabled", return_value=True): - with patch("website_profiling.llm.help_agent.get_llm_client", return_value=bad_client): - result = run_help_turn( - [{"role": "user", "content": "help"}], - on_event=events.append, - ) - - assert result["ok"] is False - error_events = [e for e in events if e["type"] == "error"] - assert any("Connection refused" in e.get("message", "") for e in error_events) - - -def test_help_turn_unknown_provider() -> None: - events: list[dict] = [] - - with patch("website_profiling.llm.help_agent.load_llm_config_from_db", return_value={"llm_provider": "unknown"}): - with patch("website_profiling.llm.help_agent.llm_is_enabled", return_value=True): - with patch( - "website_profiling.llm.help_agent.get_llm_client", - side_effect=ValueError("Unknown LLM provider: unknown"), - ): - result = run_help_turn( - [{"role": "user", "content": "help"}], - on_event=events.append, - ) - - assert result["ok"] is False - assert any("Unknown LLM provider" in e.get("message", "") for e in events if e["type"] == "error") - - -def test_help_turn_system_prompt_in_messages() -> None: - """System prompt must be the first message sent to the client.""" - captured: list[list] = [] - fake_client = _make_fake_client() - fake_client.chat_with_tools.side_effect = lambda msgs, tools, on_token=None: ( - captured.append(msgs) or MagicMock(content="ok", tool_calls=[]) - ) - - with patch("website_profiling.llm.help_agent.load_llm_config_from_db", return_value={"llm_provider": "openai"}): - with patch("website_profiling.llm.help_agent.llm_is_enabled", return_value=True): - with patch("website_profiling.llm.help_agent.get_llm_client", return_value=fake_client): - run_help_turn([{"role": "user", "content": "How do I add my API key?"}]) - - assert captured - messages_sent = captured[0] - assert messages_sent[0]["role"] == "system" - assert "help" in messages_sent[0]["content"].lower() or "credential" in messages_sent[0]["content"].lower() - - -def test_help_turn_no_event_callback() -> None: - """run_help_turn must work when on_event is None.""" - fake_client = _make_fake_client(tokens=["Hi"]) - - with patch("website_profiling.llm.help_agent.load_llm_config_from_db", return_value={"llm_provider": "openai"}): - with patch("website_profiling.llm.help_agent.llm_is_enabled", return_value=True): - with patch("website_profiling.llm.help_agent.get_llm_client", return_value=fake_client): - result = run_help_turn([{"role": "user", "content": "hi"}]) - - assert result["ok"] is True - - -@pytest.mark.parametrize("messages", [[], None]) -def test_help_turn_empty_messages(messages) -> None: - fake_client = _make_fake_client() - - with patch("website_profiling.llm.help_agent.load_llm_config_from_db", return_value={"llm_provider": "openai"}): - with patch("website_profiling.llm.help_agent.llm_is_enabled", return_value=True): - with patch("website_profiling.llm.help_agent.get_llm_client", return_value=fake_client): - result = run_help_turn(messages or []) - - assert result["ok"] is True diff --git a/tests/test_help_cmd.py b/tests/test_help_cmd.py index 90a7e5b4..a027208e 100644 --- a/tests/test_help_cmd.py +++ b/tests/test_help_cmd.py @@ -2,130 +2,15 @@ from __future__ import annotations import argparse -import io -import json -from unittest.mock import patch import pytest from website_profiling.commands import help_cmd -def test_help_cmd_requires_stdin_json() -> None: +def test_help_cmd_delegates_to_aiservice(capsys) -> None: with pytest.raises(SystemExit) as exc: help_cmd.run({}, argparse.Namespace(stdin_json=False)) assert exc.value.code == 1 - - -def test_help_cmd_invalid_stdin_json(capsys) -> None: - with patch("sys.stdin", io.StringIO("not-json")): - with pytest.raises(SystemExit) as exc: - help_cmd.run({}, argparse.Namespace(stdin_json=True)) - assert exc.value.code == 1 - assert "error" in capsys.readouterr().out - - -def test_help_cmd_success(capsys) -> None: - payload = json.dumps({"messages": [{"role": "user", "content": "How do I set up Google?"}]}) - with patch("sys.stdin", io.StringIO(payload)): - with patch( - "website_profiling.commands.help_cmd.run_help_turn", - return_value={"ok": True}, - ) as mock_turn: - with pytest.raises(SystemExit) as exc: - help_cmd.run({}, argparse.Namespace(stdin_json=True)) - assert exc.value.code == 0 - mock_turn.assert_called_once() - # Confirm no property_id is passed (no AuditToolContext) - call_args = mock_turn.call_args - assert call_args[0][0] == [{"role": "user", "content": "How do I set up Google?"}] - - -def test_help_cmd_no_property_id_in_payload(capsys) -> None: - """Help command must not pass property_id or any audit context.""" - payload = json.dumps({"messages": [], "property_id": 99}) - with patch("sys.stdin", io.StringIO(payload)): - with patch( - "website_profiling.commands.help_cmd.run_help_turn", - return_value={"ok": True}, - ) as mock_turn: - with pytest.raises(SystemExit): - help_cmd.run({}, argparse.Namespace(stdin_json=True)) - # run_help_turn is called with only (messages,) positional arg, no context - call_args = mock_turn.call_args - assert len(call_args[0]) == 1 # only messages positional arg - - -def test_help_cmd_streams_token_events(capsys) -> None: - payload = json.dumps({"messages": [{"role": "user", "content": "help"}]}) - - def fake_turn(_messages, on_event=None): - if on_event: - on_event({"type": "token", "text": "Hello!"}) - return {"ok": True} - - with patch("sys.stdin", io.StringIO(payload)): - with patch("website_profiling.commands.help_cmd.run_help_turn", side_effect=fake_turn): - with pytest.raises(SystemExit) as exc: - help_cmd.run({}, argparse.Namespace(stdin_json=True)) - assert exc.value.code == 0 - out = capsys.readouterr().out - assert "token" in out - assert "Hello!" in out - - -def test_help_cmd_agent_failure(capsys) -> None: - payload = json.dumps({"messages": []}) - with patch("sys.stdin", io.StringIO(payload)): - with patch( - "website_profiling.commands.help_cmd.run_help_turn", - return_value={"ok": False, "error": "AI disabled"}, - ): - with pytest.raises(SystemExit) as exc: - help_cmd.run({}, argparse.Namespace(stdin_json=True)) - assert exc.value.code == 1 - assert "AI disabled" in capsys.readouterr().out - - -def test_help_cmd_exception(capsys) -> None: - payload = json.dumps({"messages": []}) - with patch("sys.stdin", io.StringIO(payload)): - with patch( - "website_profiling.commands.help_cmd.run_help_turn", - side_effect=RuntimeError("boom"), - ): - with pytest.raises(SystemExit) as exc: - help_cmd.run({}, argparse.Namespace(stdin_json=True)) - assert exc.value.code == 1 - assert "boom" in capsys.readouterr().out - - -def test_help_cmd_sanitizes_events(capsys) -> None: - payload = json.dumps({"messages": []}) - - def fake_turn(_messages, on_event=None): - if on_event: - on_event({"type": "token", "text": "bad\udc9d"}) - return {"ok": True} - - with patch("sys.stdin", io.StringIO(payload)): - with patch("website_profiling.commands.help_cmd.run_help_turn", side_effect=fake_turn): - with pytest.raises(SystemExit) as exc: - help_cmd.run({}, argparse.Namespace(stdin_json=True)) - assert exc.value.code == 0 - out = capsys.readouterr().out - assert "\udc9d" not in out - assert "token" in out - - -def test_help_cmd_ignores_invalid_messages(capsys) -> None: - payload = json.dumps({"messages": "not-a-list"}) - with patch("sys.stdin", io.StringIO(payload)): - with patch( - "website_profiling.commands.help_cmd.run_help_turn", - return_value={"ok": True}, - ) as mock_turn: - with pytest.raises(SystemExit) as exc: - help_cmd.run({}, argparse.Namespace(stdin_json=True)) - assert exc.value.code == 0 - assert mock_turn.call_args[0][0] == [] + err = capsys.readouterr().err + assert "AiService" in err diff --git a/tests/test_historical_keywords_crawl_store_unit.py b/tests/test_historical_keywords_crawl_store_unit.py index 5a37a1c6..09e769f5 100644 --- a/tests/test_historical_keywords_crawl_store_unit.py +++ b/tests/test_historical_keywords_crawl_store_unit.py @@ -262,6 +262,39 @@ def test_crawl_store_core_helpers(monkeypatch): assert cs._extract_hostname("https://A.com/path") == "a.com" +def test_resolve_crawl_run_id_for_cfg_prefers_property_then_start_url(monkeypatch): + from website_profiling.db import crawl_store as cs + + calls: list[str] = [] + + monkeypatch.setattr(cs, "get_latest_crawl_run_id_for_property", lambda _c, pid: (calls.append(f"prop:{pid}") or 42)) + monkeypatch.setattr( + cs, + "get_latest_crawl_run_id_for_start_url", + lambda _c, url: (calls.append(f"url:{url}") or 7), + ) + monkeypatch.setattr(cs, "get_latest_crawl_run_id", lambda _c: (calls.append("global") or 1)) + + conn = _Conn({}) + assert cs.resolve_crawl_run_id_for_cfg(conn, property_id=3, start_url="https://site.com") == 42 # type: ignore[arg-type] + assert calls == ["prop:3"] + + calls.clear() + + def _no_property(_c, pid): + calls.append(f"prop:{pid}") + return None + + monkeypatch.setattr(cs, "get_latest_crawl_run_id_for_property", _no_property) + assert cs.resolve_crawl_run_id_for_cfg(conn, property_id=3, start_url="https://site.com") == 7 # type: ignore[arg-type] + assert calls == ["prop:3", "url:https://site.com"] + + calls.clear() + monkeypatch.setattr(cs, "get_latest_crawl_run_id_for_start_url", lambda _c, url: None) + assert cs.resolve_crawl_run_id_for_cfg(conn, property_id=None, start_url="") == 1 # type: ignore[arg-type] + assert calls == ["global"] + + def test_write_nodes_variants(monkeypatch): from website_profiling.db import crawl_store as cs diff --git a/tests/test_llm_enrich_batches.py b/tests/test_llm_enrich_batches.py deleted file mode 100644 index 8b885df3..00000000 --- a/tests/test_llm_enrich_batches.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Regression tests for `_run_llm_batches` failure handling. - -A failing batch must not abort the run, and the failure must be logged (observable) -in BOTH the sequential and the concurrent code paths — they used to diverge -(sequential propagated; concurrent silently swallowed). -""" -from __future__ import annotations - -import pytest - -from website_profiling.llm import enrich - - -class _BoomClient: - """LLM client whose every call fails.""" - - def complete_json(self, system: str, user: str) -> dict: - raise RuntimeError("api unavailable") - - -def test_sequential_path_logs_and_continues_on_failure( - monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] -) -> None: - monkeypatch.setattr(enrich, "_llm_concurrency", lambda _cfg: 1) - applied: list = [] - enrich._run_llm_batches( - _BoomClient(), - "task", - "system", - [{"k": 1}], - {}, - lambda payload, result: applied.append(result), - ) - out = capsys.readouterr().out - assert "LLM enrichment batch failed" in out - assert applied == [] # nothing applied, but no exception escaped - - -def test_concurrent_path_logs_and_continues_on_failure( - monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] -) -> None: - monkeypatch.setattr(enrich, "_llm_concurrency", lambda _cfg: 4) - applied: list = [] - enrich._run_llm_batches( - _BoomClient(), - "task", - "system", - [{"k": 1}, {"k": 2}], # >1 batch + workers>1 -> concurrent path - {}, - lambda payload, result: applied.append(result), - ) - out = capsys.readouterr().out - assert out.count("LLM enrichment batch failed") >= 1 - assert applied == [] diff --git a/tests/test_llm_parse.py b/tests/test_llm_parse.py deleted file mode 100644 index c274d89a..00000000 --- a/tests/test_llm_parse.py +++ /dev/null @@ -1,14 +0,0 @@ -"""Tests for LLM JSON parsing.""" -from __future__ import annotations - -from website_profiling.llm.base import parse_json_response - - -def test_parse_json_response_plain(): - assert parse_json_response('{"pages": []}') == {"pages": []} - - -def test_parse_json_response_markdown_fence(): - text = 'Here is JSON:\n{"clusters": [{"top_keyword": "seo"}]}\n' - out = parse_json_response(text) - assert "clusters" in out diff --git a/tests/test_llm_provider_anthropic.py b/tests/test_llm_provider_anthropic.py deleted file mode 100644 index 2f1daa28..00000000 --- a/tests/test_llm_provider_anthropic.py +++ /dev/null @@ -1,176 +0,0 @@ -"""Regression tests for the Anthropic message/tool converter. - -An assistant message carrying OpenAI-shaped ``tool_calls`` must be reconstructed -into ``tool_use`` content blocks; otherwise the following ``tool_result`` has no -matching ``tool_use`` and the Anthropic Messages API returns HTTP 400 on every -multi-round tool conversation. -""" -from __future__ import annotations - -import pytest - -from website_profiling.llm.providers.anthropic import ( - _apply_prompt_caching, - _to_anthropic_messages, - _to_anthropic_tools, -) - -_EPHEMERAL = {"type": "ephemeral"} - - -def test_assistant_tool_calls_become_matching_tool_use_blocks() -> None: - messages = [ - {"role": "system", "content": "sys"}, - {"role": "user", "content": "hi"}, - { - "role": "assistant", - "content": "", - "tool_calls": [ - {"id": "call_1", "type": "function", - "function": {"name": "get_health", "arguments": '{"x": 1}'}}, - ], - }, - {"role": "tool", "tool_call_id": "call_1", "content": '{"score": 80}'}, - ] - system, conv = _to_anthropic_messages(messages) - - assert system == "sys" - assistant = conv[1] - assert assistant["role"] == "assistant" - tool_use = [b for b in assistant["content"] if b["type"] == "tool_use"] - assert len(tool_use) == 1 - assert tool_use[0]["id"] == "call_1" - assert tool_use[0]["name"] == "get_health" - assert tool_use[0]["input"] == {"x": 1} - - # The tool_result in the next turn references the same id -> valid pairing. - tool_result = conv[2]["content"][0] - assert tool_result["type"] == "tool_result" - assert tool_result["tool_use_id"] == "call_1" - - -def test_assistant_tool_calls_with_dict_arguments_and_text() -> None: - messages = [ - {"role": "assistant", "content": "thinking", - "tool_calls": [{"id": "c2", "function": {"name": "foo", "arguments": {"a": 2}}}]}, - ] - _, conv = _to_anthropic_messages(messages) - blocks = conv[0]["content"] - assert blocks[0] == {"type": "text", "text": "thinking"} - assert blocks[1]["input"] == {"a": 2} - - -def test_invalid_tool_call_arguments_fall_back_to_empty() -> None: - messages = [ - {"role": "assistant", "content": "", - "tool_calls": [{"id": "c3", "function": {"name": "foo", "arguments": "not-json"}}]}, - ] - _, conv = _to_anthropic_messages(messages) - assert conv[0]["content"][0]["input"] == {} - - -def test_plain_messages_pass_through() -> None: - _, conv = _to_anthropic_messages([{"role": "user", "content": "hi"}]) - assert conv == [{"role": "user", "content": "hi"}] - - -def test_to_anthropic_tools_maps_schema() -> None: - tools = [{"type": "function", "function": { - "name": "t", "description": "d", "parameters": {"type": "object", "properties": {}}}}] - assert _to_anthropic_tools(tools) == [ - {"name": "t", "description": "d", "input_schema": {"type": "object", "properties": {}}}, - ] - - -# --- prompt caching -------------------------------------------------------- - - -@pytest.fixture(autouse=True) -def _cache_on(monkeypatch: pytest.MonkeyPatch) -> None: - """Caching defaults to on; pin it for deterministic tests.""" - monkeypatch.setenv("WP_LLM_PROMPT_CACHE", "1") - - -def test_caching_marks_last_tool_only() -> None: - tools = [{"name": "a"}, {"name": "b"}, {"name": "c"}] - _, tools_out, _ = _apply_prompt_caching("sys", tools, []) - assert "cache_control" not in tools_out[0] - assert "cache_control" not in tools_out[1] - assert tools_out[-1]["cache_control"] == _EPHEMERAL - # original list/dicts are untouched - assert all("cache_control" not in t for t in tools) - - -def test_caching_empty_tools_is_safe() -> None: - system, tools_out, _ = _apply_prompt_caching("sys", [], []) - assert tools_out == [] - assert system == [{"type": "text", "text": "sys", "cache_control": _EPHEMERAL}] - - -def test_caching_system_becomes_text_block() -> None: - system, _, _ = _apply_prompt_caching("the system prompt", [], []) - assert system == [ - {"type": "text", "text": "the system prompt", "cache_control": _EPHEMERAL}, - ] - - -def test_caching_last_message_string_content_becomes_block() -> None: - messages = [ - {"role": "user", "content": "first"}, - {"role": "user", "content": "second"}, - ] - _, _, out = _apply_prompt_caching("sys", [], messages) - # earlier message untouched - assert out[0] == {"role": "user", "content": "first"} - assert out[-1]["content"] == [ - {"type": "text", "text": "second", "cache_control": _EPHEMERAL}, - ] - # caller's list/dicts not mutated - assert messages[-1] == {"role": "user", "content": "second"} - - -def test_caching_last_message_list_content_marks_last_block() -> None: - messages = [{ - "role": "user", - "content": [ - {"type": "tool_result", "tool_use_id": "c1", "content": "{}"}, - {"type": "tool_result", "tool_use_id": "c2", "content": "{}"}, - ], - }] - _, _, out = _apply_prompt_caching("sys", [], messages) - blocks = out[-1]["content"] - assert "cache_control" not in blocks[0] - assert blocks[-1]["cache_control"] == _EPHEMERAL - # original untouched - assert all("cache_control" not in b for b in messages[0]["content"]) - - -def test_caching_disabled_returns_inputs_unchanged(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("WP_LLM_PROMPT_CACHE", "0") - tools = [{"name": "a"}] - messages = [{"role": "user", "content": "hi"}] - system, tools_out, msgs_out = _apply_prompt_caching("sys", tools, messages) - assert system == "sys" # stays a plain string - assert tools_out is tools - assert msgs_out is messages - - -def test_caching_uses_at_most_four_breakpoints() -> None: - tools = [{"name": "a"}, {"name": "b"}] - messages = [ - {"role": "user", "content": "u"}, - {"role": "assistant", "content": [{"type": "text", "text": "a"}]}, - ] - system, tools_out, msgs_out = _apply_prompt_caching("sys", tools, messages) - - def _count(obj: object) -> int: - if isinstance(obj, dict): - n = 1 if obj.get("cache_control") == _EPHEMERAL else 0 - return n + sum(_count(v) for v in obj.values()) - if isinstance(obj, list): - return sum(_count(v) for v in obj) - return 0 - - total = _count(system) + _count(tools_out) + _count(msgs_out) - assert total == 3 - assert total <= 4 diff --git a/tests/test_llm_provider_groq.py b/tests/test_llm_provider_groq.py deleted file mode 100644 index 5f3c4d37..00000000 --- a/tests/test_llm_provider_groq.py +++ /dev/null @@ -1,162 +0,0 @@ -"""Tests for Groq LLM provider (official Python SDK).""" -from __future__ import annotations - -import sys -import types -from types import SimpleNamespace -from unittest.mock import MagicMock, patch - -import pytest - -from website_profiling.llm.base import ChatResult, get_llm_client -from website_profiling.llm.providers.groq import DEFAULT_MODEL, GroqClient - - -def _install_fake_groq(monkeypatch: pytest.MonkeyPatch, client: MagicMock) -> MagicMock: - mock_cls = MagicMock(return_value=client) - fake = types.ModuleType("groq") - fake.Groq = mock_cls - monkeypatch.setitem(sys.modules, "groq", fake) - return mock_cls - - -def test_get_llm_client_routes_groq() -> None: - client = get_llm_client({"llm_provider": "groq", "llm_api_key": "gsk-test"}) - assert isinstance(client, GroqClient) - - -def test_default_model() -> None: - client = GroqClient({"llm_provider": "groq", "llm_api_key": "gsk-test"}) - assert client._model == DEFAULT_MODEL - - -def test_explicit_model_and_base_url() -> None: - client = GroqClient({ - "llm_api_key": "gsk-test", - "llm_model": "llama-3.1-8b-instant", - "llm_base_url": "https://custom.example/v1", - }) - assert client._model == "llama-3.1-8b-instant" - assert client._base_url == "https://custom.example/v1" - - -def test_ignores_ollama_base_url() -> None: - client = GroqClient({ - "llm_api_key": "gsk-test", - "llm_base_url": "http://127.0.0.1:11434", - }) - assert client._base_url is None - - -def test_complete_json_missing_key_raises_groq_error() -> None: - client = GroqClient({"llm_provider": "groq"}) - with pytest.raises(RuntimeError, match="Groq API key"): - client.complete_json("system", "user") - - -def test_chat_with_tools_missing_key_raises_groq_error() -> None: - client = GroqClient({"llm_provider": "groq"}) - with pytest.raises(RuntimeError, match="Groq API key"): - client.chat_with_tools([], []) - - -def test_complete_json_uses_sdk(monkeypatch: pytest.MonkeyPatch) -> None: - mock_create = MagicMock( - return_value=SimpleNamespace( - choices=[SimpleNamespace(message=SimpleNamespace(content='{"ok": true}'))], - ), - ) - sdk_client = MagicMock() - sdk_client.chat.completions.create = mock_create - mock_cls = _install_fake_groq(monkeypatch, sdk_client) - - client = GroqClient({"llm_api_key": "gsk-test"}) - assert client.complete_json("system", "user") == {"ok": True} - mock_cls.assert_called_once_with(api_key="gsk-test", timeout=120.0) - mock_create.assert_called_once() - - -def test_chat_with_tools_non_streaming(monkeypatch: pytest.MonkeyPatch) -> None: - mock_create = MagicMock( - return_value=SimpleNamespace( - choices=[ - SimpleNamespace( - finish_reason="stop", - message=SimpleNamespace( - content="hello", - tool_calls=[ - SimpleNamespace( - id="tc1", - function=SimpleNamespace( - name="list_issues", - arguments='{"limit": 5}', - ), - ), - ], - ), - ), - ], - ), - ) - sdk_client = MagicMock() - sdk_client.chat.completions.create = mock_create - _install_fake_groq(monkeypatch, sdk_client) - - client = GroqClient({"llm_api_key": "gsk-test"}) - result = client.chat_with_tools([{"role": "user", "content": "hi"}], []) - assert result.content == "hello" - assert len(result.tool_calls) == 1 - assert result.tool_calls[0].name == "list_issues" - assert result.tool_calls[0].arguments == {"limit": 5} - mock_create.assert_called_once_with( - model=DEFAULT_MODEL, - messages=[{"role": "user", "content": "hi"}], - tools=[], - tool_choice="auto", - temperature=0.2, - ) - - -def test_chat_with_tools_streaming(monkeypatch: pytest.MonkeyPatch) -> None: - chunks = [ - SimpleNamespace( - choices=[SimpleNamespace(delta=SimpleNamespace(content="hel", tool_calls=None))], - ), - SimpleNamespace( - choices=[SimpleNamespace(delta=SimpleNamespace(content="lo", tool_calls=None))], - ), - ] - mock_create = MagicMock(return_value=iter(chunks)) - sdk_client = MagicMock() - sdk_client.chat.completions.create = mock_create - _install_fake_groq(monkeypatch, sdk_client) - - tokens: list[str] = [] - client = GroqClient({"llm_api_key": "gsk-test"}) - result = client.chat_with_tools( - [{"role": "user", "content": "hi"}], - [], - on_token=tokens.append, - ) - assert result.content == "hello" - assert tokens == ["hel", "lo"] - mock_create.assert_called_once() - assert mock_create.call_args.kwargs["stream"] is True - - -def test_load_llm_config_from_db_groq_env_fallback( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setenv("GROQ_API_KEY", "gsk-from-env") - with patch("website_profiling.db.db_session") as mock_session: - with patch( - "website_profiling.db.storage.read_llm_config", - return_value={"llm_provider": "groq", "llm_enabled": "true"}, - ): - mock_session.return_value.__enter__ = MagicMock(return_value=MagicMock()) - mock_session.return_value.__exit__ = MagicMock(return_value=False) - from website_profiling.llm_config import load_llm_config_from_db - - cfg = load_llm_config_from_db() - assert cfg.get("llm_api_key") == "gsk-from-env" - assert cfg.get("_llm_api_key_source") == "env" diff --git a/tests/test_llm_provider_openai.py b/tests/test_llm_provider_openai.py deleted file mode 100644 index e46e91f4..00000000 --- a/tests/test_llm_provider_openai.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Regression tests for the OpenAI JSON-completion client. - -`complete_json` must defensively handle a 200 response whose body lacks the -expected ``choices``/``message`` structure instead of raising KeyError/IndexError. -""" -from __future__ import annotations - -import sys -import types - -import pytest - -from website_profiling.llm.providers.openai import OpenAIClient - - -class _FakeResponse: - def __init__(self, payload: dict) -> None: - self._payload = payload - - def raise_for_status(self) -> None: - return None - - def json(self) -> dict: - return self._payload - - -class _FakeClient: - def __init__(self, payload: dict) -> None: - self._payload = payload - - def __call__(self, *args, **kwargs): # httpx.Client(...) constructor - return self - - def __enter__(self): - return self - - def __exit__(self, *args) -> bool: - return False - - def post(self, *args, **kwargs) -> _FakeResponse: - return _FakeResponse(self._payload) - - -def _install_fake_httpx(monkeypatch: pytest.MonkeyPatch, payload: dict) -> None: - fake = types.ModuleType("httpx") - fake.Client = _FakeClient(payload) - monkeypatch.setitem(sys.modules, "httpx", fake) - - -def test_complete_json_missing_choices_raises_clean_error(monkeypatch: pytest.MonkeyPatch) -> None: - _install_fake_httpx(monkeypatch, {}) # no "choices" - client = OpenAIClient({"llm_api_key": "sk-test"}) - with pytest.raises(RuntimeError, match="no content"): - client.complete_json("system", "user") - - -def test_complete_json_parses_content_on_well_formed_response(monkeypatch: pytest.MonkeyPatch) -> None: - _install_fake_httpx( - monkeypatch, - {"choices": [{"message": {"content": '{"ok": true}'}}]}, - ) - client = OpenAIClient({"llm_api_key": "sk-test"}) - assert client.complete_json("system", "user") == {"ok": True} diff --git a/tests/test_mcp_http_server.py b/tests/test_mcp_http_server.py deleted file mode 100644 index c33fd390..00000000 --- a/tests/test_mcp_http_server.py +++ /dev/null @@ -1,834 +0,0 @@ -"""MCP HTTP server auth, startup validation, and app wiring.""" -from __future__ import annotations - -import asyncio -import json -import os -import runpy -import sys -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest -from starlette.testclient import TestClient - -from website_profiling.mcp import http_server -from website_profiling.mcp import server as mcp_server -from website_profiling.mcp.settings import McpHttpSettings, load_mcp_http_settings - - -def test_validate_startup_public_bind_requires_token() -> None: - with patch.dict(os.environ, {"WP_MCP_HTTP_HOST": "0.0.0.0"}, clear=False): - with patch( - "website_profiling.mcp.http_server.load_mcp_http_settings", - return_value=McpHttpSettings(token="", allowed_hosts=["audit.example.com"], allowed_origins=[]), - ): - with pytest.raises(SystemExit, match="Remote MCP token"): - http_server._validate_startup_config() - - -def test_validate_startup_public_bind_requires_allowed_hosts() -> None: - with patch.dict(os.environ, {"WP_MCP_HTTP_HOST": "0.0.0.0"}, clear=False): - with patch( - "website_profiling.mcp.http_server.load_mcp_http_settings", - return_value=McpHttpSettings(token="secret", allowed_hosts=[], allowed_origins=[]), - ): - with pytest.raises(SystemExit, match="Allowed MCP hosts"): - http_server._validate_startup_config() - - -def test_validate_startup_localhost_without_token_ok() -> None: - with patch.dict(os.environ, {"WP_MCP_HTTP_HOST": "127.0.0.1"}, clear=False): - http_server._validate_startup_config() - - -def test_transport_security_disables_sdk_checks_when_ui_configured() -> None: - with patch( - "website_profiling.mcp.http_server.load_mcp_http_settings", - return_value=McpHttpSettings(token="secret", allowed_hosts=["audit.example.com"], allowed_origins=[]), - ): - settings = http_server._transport_security_settings("0.0.0.0") - assert settings.enable_dns_rebinding_protection is False - - -def test_transport_security_localhost_without_ui_config() -> None: - with patch( - "website_profiling.mcp.http_server.load_mcp_http_settings", - return_value=McpHttpSettings(token="", allowed_hosts=[], allowed_origins=[]), - ): - settings = http_server._transport_security_settings("127.0.0.1") - assert settings.enable_dns_rebinding_protection is False - - -def test_host_and_origin_allowed_helpers() -> None: - assert http_server._host_allowed("audit.example.com", ["audit.example.com"]) - assert http_server._host_allowed("sub.example.com", ["*.example.com"]) - assert not http_server._host_allowed("evil.example.net", ["audit.example.com"]) - assert http_server._origin_allowed("https://audit.example.com", ["https://audit.example.com"]) - assert http_server._origin_allowed("", ["https://audit.example.com"]) - - -def test_remote_access_middleware_rejects_missing_token() -> None: - app = AsyncMock() - - async def run() -> None: - middleware = http_server.RemoteAccessMiddleware(app) - sent: list[dict] = [] - - async def capture_send(message: dict) -> None: - sent.append(message) - - with patch( - "website_profiling.mcp.http_server.load_mcp_http_settings", - return_value=McpHttpSettings(token="secret-token", allowed_hosts=[], allowed_origins=[]), - ): - await middleware( - {"type": "http", "headers": []}, - AsyncMock(), - capture_send, - ) - - assert app.await_count == 0 - assert sent[0]["status"] == 401 - - asyncio.run(run()) - - -def test_remote_access_middleware_rejects_wrong_token_with_json_body() -> None: - app = AsyncMock() - - async def run() -> None: - middleware = http_server.RemoteAccessMiddleware(app) - sent: list[dict] = [] - - async def capture_send(message: dict) -> None: - sent.append(message) - - with patch( - "website_profiling.mcp.http_server.load_mcp_http_settings", - return_value=McpHttpSettings(token="secret-token", allowed_hosts=[], allowed_origins=[]), - ): - await middleware( - {"type": "http", "headers": [(b"authorization", b"Bearer wrong-token")]}, - AsyncMock(), - capture_send, - ) - - assert app.await_count == 0 - assert sent[0]["status"] == 401 - # Regression: repr() produced single-quoted, non-parseable JSON. - assert json.loads(sent[1]["body"]) == {"error": "Unauthorized"} - - asyncio.run(run()) - - -def test_remote_access_middleware_non_ascii_auth_header_does_not_crash() -> None: - app = AsyncMock() - - async def run() -> None: - middleware = http_server.RemoteAccessMiddleware(app) - sent: list[dict] = [] - - async def capture_send(message: dict) -> None: - sent.append(message) - - with patch( - "website_profiling.mcp.http_server.load_mcp_http_settings", - return_value=McpHttpSettings(token="secret-token", allowed_hosts=[], allowed_origins=[]), - ): - # A non-ASCII Authorization header must not raise (hmac on str would). - await middleware( - {"type": "http", "headers": [(b"authorization", "Bearer \xe9".encode("latin-1"))]}, - AsyncMock(), - capture_send, - ) - - assert app.await_count == 0 - assert sent[0]["status"] == 401 - - asyncio.run(run()) - - -def test_remote_access_middleware_accepts_valid_request() -> None: - app = AsyncMock() - middleware = http_server.RemoteAccessMiddleware(app) - settings = McpHttpSettings( - token="secret-token", - allowed_hosts=["audit.example.com"], - allowed_origins=[], - ) - - async def run() -> None: - with patch("website_profiling.mcp.http_server.load_mcp_http_settings", return_value=settings): - await middleware( - { - "type": "http", - "headers": [ - (b"authorization", b"Bearer secret-token"), - (b"host", b"audit.example.com"), - ], - }, - AsyncMock(), - AsyncMock(), - ) - - asyncio.run(run()) - assert app.await_count == 1 - - -def test_remote_access_middleware_rejects_bad_host() -> None: - app = AsyncMock() - - async def run() -> None: - middleware = http_server.RemoteAccessMiddleware(app) - sent: list[dict] = [] - - async def capture_send(message: dict) -> None: - sent.append(message) - - with patch( - "website_profiling.mcp.http_server.load_mcp_http_settings", - return_value=McpHttpSettings( - token="secret-token", - allowed_hosts=["audit.example.com"], - allowed_origins=[], - ), - ): - await middleware( - { - "type": "http", - "headers": [ - (b"authorization", b"Bearer secret-token"), - (b"host", b"evil.example.net"), - ], - }, - AsyncMock(), - capture_send, - ) - - assert app.await_count == 0 - assert sent[0]["status"] == 403 - - asyncio.run(run()) - - -def test_with_remote_access_wraps_app() -> None: - inner = MagicMock() - wrapped = http_server._with_remote_access(inner) - assert isinstance(wrapped, http_server.RemoteAccessMiddleware) - - -def test_load_mcp_http_settings_env_overrides_db() -> None: - with patch.dict( - os.environ, - { - "WP_MCP_TOKEN": "env-token", - "WP_MCP_ALLOWED_HOSTS": "host.example", - "WP_MCP_ALLOWED_ORIGINS": "https://host.example", - }, - clear=False, - ): - with patch( - "website_profiling.mcp.settings._load_pipeline_mcp_settings", - return_value={ - "mcp_token": "db-token", - "mcp_allowed_hosts": "db.example", - "mcp_allowed_origins": "https://db.example", - }, - ): - settings = load_mcp_http_settings() - assert settings.token == "env-token" - assert settings.allowed_hosts == ["host.example"] - assert settings.allowed_origins == ["https://host.example"] - - -def test_load_mcp_http_settings_from_db_when_env_empty() -> None: - with patch.dict(os.environ, {}, clear=True): - with patch( - "website_profiling.mcp.settings._load_pipeline_mcp_settings", - return_value={ - "mcp_token": "db-token", - "mcp_allowed_hosts": "one.example,two.example", - }, - ): - settings = load_mcp_http_settings() - assert settings.token == "db-token" - assert settings.allowed_hosts == ["one.example", "two.example"] - - -def test_build_app_smoke(monkeypatch) -> None: - captured: dict[str, object] = {} - - class FakeServer: - def __init__(self, name: str) -> None: - captured["name"] = name - - def list_tools(self): - def decorator(fn): - captured["list_tools"] = fn - return fn - return decorator - - def call_tool(self): - def decorator(fn): - captured["call_tool"] = fn - return fn - return decorator - - def list_resources(self): - def decorator(fn): - captured["list_resources"] = fn - return fn - return decorator - - def read_resource(self): - def decorator(fn): - captured["read_resource"] = fn - return fn - return decorator - - def create_initialization_options(self): - return {} - - async def run(self, *_args, **_kwargs) -> None: - return None - - class FakeManager: - def __init__(self, **_kwargs) -> None: - captured["manager_kwargs"] = _kwargs - - async def handle_request(self, *_args, **_kwargs) -> None: - captured["handled"] = True - - def run(self): - from contextlib import asynccontextmanager - - @asynccontextmanager - async def _cm(): - yield - - return _cm() - - fake_server_mod = MagicMock() - fake_server_mod.Server = FakeServer - fake_types_mod = MagicMock() - fake_types_mod.Tool = lambda **kwargs: kwargs - fake_types_mod.TextContent = lambda **kwargs: kwargs - fake_types_mod.Resource = lambda **kwargs: kwargs - fake_manager_mod = MagicMock() - fake_manager_mod.StreamableHTTPSessionManager = FakeManager - fake_security_mod = MagicMock() - fake_security_mod.TransportSecuritySettings = lambda **kwargs: kwargs - - monkeypatch.setitem(sys.modules, "mcp", MagicMock()) - monkeypatch.setitem(sys.modules, "mcp.server", fake_server_mod) - monkeypatch.setitem(sys.modules, "mcp.types", fake_types_mod) - monkeypatch.setitem(sys.modules, "mcp.server.streamable_http_manager", fake_manager_mod) - monkeypatch.setitem(sys.modules, "mcp.server.transport_security", fake_security_mod) - - with patch.dict( - os.environ, - { - "WP_MCP_HTTP_HOST": "127.0.0.1", - "WP_MCP_HTTP_PATH": "/mcp", - "WP_MCP_DOMAIN": "core", - }, - clear=False, - ): - app = http_server.build_app() - - assert captured["name"] == "site-audit-core" - tools = asyncio.run(captured["list_tools"]()) # type: ignore[arg-type] - assert isinstance(tools, list) - manager_kwargs = captured["manager_kwargs"] # type: ignore[assignment] - assert manager_kwargs["stateless"] is True - assert manager_kwargs["json_response"] is False - - -def test_create_server_registers_handlers(monkeypatch) -> None: - captured: dict[str, object] = {} - - class FakeServer: - def __init__(self, name: str) -> None: - captured["name"] = name - - def list_tools(self): - def decorator(fn): - captured["list_tools"] = fn - return fn - return decorator - - def call_tool(self): - def decorator(fn): - captured["call_tool"] = fn - return fn - return decorator - - def list_resources(self): - def decorator(fn): - captured["list_resources"] = fn - return fn - return decorator - - def read_resource(self): - def decorator(fn): - captured["read_resource"] = fn - return fn - return decorator - - def create_initialization_options(self): - return {} - - fake_server_mod = MagicMock() - fake_server_mod.Server = FakeServer - fake_types_mod = MagicMock() - fake_types_mod.Tool = lambda **kwargs: kwargs - fake_types_mod.TextContent = lambda **kwargs: kwargs - fake_types_mod.Resource = lambda **kwargs: kwargs - - monkeypatch.setitem(sys.modules, "mcp", MagicMock()) - monkeypatch.setitem(sys.modules, "mcp.server", fake_server_mod) - monkeypatch.setitem(sys.modules, "mcp.types", fake_types_mod) - - with patch.dict(os.environ, {"WP_PROPERTY_ID": "7", "WP_MCP_DOMAIN": "full"}, clear=False): - mcp_server.create_server() - - assert captured["name"] == "site-audit-full" - tools = asyncio.run(captured["list_tools"]()) # type: ignore[arg-type] - assert len(tools) >= 338 - - -def test_bool_env_helper() -> None: - with patch.dict(os.environ, {"WP_MCP_JSON_RESPONSE": "true"}, clear=False): - assert http_server._bool_env("WP_MCP_JSON_RESPONSE") is True - assert http_server._bool_env("WP_MCP_JSON_RESPONSE", default=False) is False - - -def test_http_port_invalid() -> None: - with patch.dict(os.environ, {"WP_MCP_HTTP_PORT": "bad"}, clear=False): - with pytest.raises(SystemExit, match="Invalid WP_MCP_HTTP_PORT"): - http_server._http_port() - - -def test_http_path_normalizes() -> None: - with patch.dict(os.environ, {"WP_MCP_HTTP_PATH": "mcp"}, clear=False): - assert http_server._http_path() == "/mcp" - - -def test_remote_access_passthrough_non_http() -> None: - app = AsyncMock() - middleware = http_server.RemoteAccessMiddleware(app) - - async def run() -> None: - await middleware({"type": "lifespan"}, AsyncMock(), AsyncMock()) - - asyncio.run(run()) - app.assert_awaited_once() - - -def test_http_main_runs_uvicorn(monkeypatch) -> None: - mock_uvicorn = MagicMock() - monkeypatch.setitem(sys.modules, "uvicorn", mock_uvicorn) - with patch.object(http_server, "build_app", return_value=MagicMock()): - with patch.dict( - os.environ, - {"WP_MCP_HTTP_HOST": "127.0.0.1", "WP_MCP_HTTP_PORT": "9001"}, - clear=False, - ): - http_server.main() - mock_uvicorn.run.assert_called_once() - assert mock_uvicorn.run.call_args.kwargs["port"] == 9001 - - -def test_http_main_missing_uvicorn() -> None: - with patch.dict(sys.modules, {"uvicorn": None}): - with patch.object(http_server, "_validate_startup_config"): - with pytest.raises(SystemExit, match="uvicorn"): - http_server.main() - - -def test_http_module_main() -> None: - with patch("website_profiling.mcp.http_server.main") as mock_main: - runpy.run_module("website_profiling.mcp.http", run_name="__main__") - mock_main.assert_called_once() - - -def test_http_port_out_of_range() -> None: - with patch.dict(os.environ, {"WP_MCP_HTTP_PORT": "70000"}, clear=False): - with pytest.raises(SystemExit, match="Invalid WP_MCP_HTTP_PORT"): - http_server._http_port() - - -def test_build_app_handle_and_lifespan() -> None: - empty_settings = McpHttpSettings(token="", allowed_hosts=[], allowed_origins=[], domain="core") - with patch.dict( - os.environ, - { - "WP_MCP_HTTP_HOST": "127.0.0.1", - "WP_MCP_HTTP_PATH": "/mcp", - "WP_MCP_DOMAIN": "core", - }, - clear=False, - ): - with patch( - "website_profiling.mcp.http_server.load_mcp_http_settings", - return_value=empty_settings, - ): - app = http_server.build_app() - - with TestClient(app) as client: - response = client.post( - "/mcp", - json={ - "jsonrpc": "2.0", - "id": 1, - "method": "initialize", - "params": { - "protocolVersion": "2024-11-05", - "capabilities": {}, - "clientInfo": {"name": "test", "version": "1.0"}, - }, - }, - headers={"Accept": "application/json, text/event-stream"}, - ) - assert response.status_code in {200, 202, 406} - - -def test_create_server_missing_sdk() -> None: - with patch.dict(sys.modules, {"mcp.types": None}): - with pytest.raises(SystemExit, match="MCP SDK"): - mcp_server.create_server() - - -def test_run_stdio_missing_sdk() -> None: - with patch.dict(sys.modules, {"mcp.server.stdio": None}): - with pytest.raises(SystemExit, match="MCP SDK"): - mcp_server.run_stdio() - - -def test_build_app_import_error(monkeypatch) -> None: - monkeypatch.setitem(sys.modules, "mcp.server.streamable_http_manager", None) - with patch.dict(os.environ, {"WP_MCP_HTTP_HOST": "127.0.0.1"}, clear=False): - with pytest.raises(SystemExit, match="MCP HTTP dependencies"): - http_server.build_app() - - -def test_load_pipeline_mcp_settings_db_error() -> None: - with patch("website_profiling.mcp.settings.db_session", side_effect=RuntimeError("no db")): - assert load_mcp_http_settings().token == "" - - -def test_load_pipeline_mcp_settings_success() -> None: - with patch( - "website_profiling.mcp.settings.read_pipeline_config", - return_value=({"mcp_token": "db"}, []), - ): - with patch("website_profiling.mcp.settings.db_session") as mock_db: - mock_db.return_value.__enter__.return_value = object() - from website_profiling.mcp.settings import _load_pipeline_mcp_settings - - assert _load_pipeline_mcp_settings()["mcp_token"] == "db" - - -def test_host_from_header_ipv6() -> None: - assert http_server._host_from_header("[::1]:8000") == "::1" - - -def test_host_allowed_port_suffix_pattern() -> None: - assert http_server._host_allowed("audit.example.com", ["audit.example.com:*"]) - - -def test_origin_allowed_url_and_hostname_patterns() -> None: - assert http_server._origin_allowed( - "https://audit.example.com", - ["https://audit.example.com"], - ) - # A bare hostname pattern matches the exact host only... - assert http_server._origin_allowed( - "https://example.com", - ["example.com"], - ) - # ...and must NOT be widened into a subdomain wildcard. - assert not http_server._origin_allowed( - "https://evil.example.com", - ["example.com"], - ) - # Explicit wildcard patterns match the apex and any subdomain. - assert http_server._origin_allowed("https://app.example.com", ["*.example.com"]) - assert http_server._origin_allowed("https://example.com", ["*.example.com"]) - assert not http_server._origin_allowed("https://app.other.com", ["*.example.com"]) - assert not http_server._origin_allowed( - "https://evil.example.net", - ["https://audit.example.com"], - ) - - -def test_remote_access_middleware_rejects_bad_origin() -> None: - app = AsyncMock() - middleware = http_server.RemoteAccessMiddleware(app) - - async def run() -> None: - sent: list[dict] = [] - - async def capture_send(message: dict) -> None: - sent.append(message) - - with patch( - "website_profiling.mcp.http_server.load_mcp_http_settings", - return_value=McpHttpSettings( - token="secret-token", - allowed_hosts=[], - allowed_origins=["https://audit.example.com"], - ), - ): - await middleware( - { - "type": "http", - "headers": [ - (b"authorization", b"Bearer secret-token"), - (b"origin", b"https://evil.example.net"), - ], - }, - AsyncMock(), - capture_send, - ) - - assert sent[0]["status"] == 403 - - asyncio.run(run()) - - -def test_remote_access_middleware_origin_fallback_rejects_cross_host() -> None: - # No explicit allowed_origins: a browser Origin from a host that is not an - # allowed host must still be rejected (transport-level Origin protection is - # delegated to the middleware). - app = AsyncMock() - middleware = http_server.RemoteAccessMiddleware(app) - - async def run() -> None: - sent: list[dict] = [] - - async def capture_send(message: dict) -> None: - sent.append(message) - - with patch( - "website_profiling.mcp.http_server.load_mcp_http_settings", - return_value=McpHttpSettings( - token="secret-token", - allowed_hosts=["audit.example.com"], - allowed_origins=[], - ), - ): - await middleware( - { - "type": "http", - "headers": [ - (b"host", b"audit.example.com"), - (b"authorization", b"Bearer secret-token"), - (b"origin", b"https://evil.example.net"), - ], - }, - AsyncMock(), - capture_send, - ) - - assert sent[0]["status"] == 403 - app.assert_not_called() - - asyncio.run(run()) - - -def test_remote_access_middleware_origin_fallback_allows_same_host() -> None: - # Same-host browser Origin is allowed even without explicit allowed_origins. - app = AsyncMock() - middleware = http_server.RemoteAccessMiddleware(app) - - async def run() -> None: - with patch( - "website_profiling.mcp.http_server.load_mcp_http_settings", - return_value=McpHttpSettings( - token="secret-token", - allowed_hosts=["audit.example.com"], - allowed_origins=[], - ), - ): - await middleware( - { - "type": "http", - "headers": [ - (b"host", b"audit.example.com"), - (b"authorization", b"Bearer secret-token"), - (b"origin", b"https://audit.example.com"), - ], - }, - AsyncMock(), - AsyncMock(), - ) - - app.assert_called_once() - - asyncio.run(run()) - - -def test_transport_security_public_env_only() -> None: - with patch( - "website_profiling.mcp.http_server.load_mcp_http_settings", - return_value=McpHttpSettings(token="", allowed_hosts=["audit.example.com"], allowed_origins=[]), - ): - settings = http_server._transport_security_settings("0.0.0.0") - assert settings.enable_dns_rebinding_protection is True - -def test_host_allowed_wildcard_nomatch_then_exact() -> None: - assert http_server._host_allowed("allowed.example", ["*.other.example", "allowed.example"]) - - -def test_origin_allowed_http_nomatch_then_hostname() -> None: - assert http_server._origin_allowed( - "https://example.com", - ["https://other.example.com", "example.com"], - ) - - -def test_host_allowed_empty_list_and_blank_entries() -> None: - assert http_server._host_allowed("anything.example", []) - assert not http_server._host_allowed("anything.example", ["", "other.example"]) - - -def test_origin_allowed_multiple_https_patterns_miss() -> None: - assert not http_server._origin_allowed( - "https://app.example.com", - ["https://other.example.com", "https://third.example.com"], - ) - - -def test_origin_allowed_empty_and_blank_entries() -> None: - assert http_server._origin_allowed("https://x.example", []) - assert not http_server._origin_allowed("https://x.example", ["https://other.example"]) - assert http_server._origin_allowed("", ["https://other.example"]) - assert not http_server._origin_allowed("https://x.example", [""]) - - -def test_mcp_http_settings_domain_defaults_to_core() -> None: - settings = McpHttpSettings(token="t", allowed_hosts=[], allowed_origins=[]) - assert settings.domain == "core" - - -def test_load_mcp_http_settings_domain_env_wins() -> None: - with patch.dict(os.environ, {"WP_MCP_DOMAIN": "google"}, clear=False): - with patch( - "website_profiling.mcp.settings._load_pipeline_mcp_settings", - return_value={"mcp_domain": "full"}, - ): - settings = load_mcp_http_settings() - assert settings.domain == "google" - - -def test_load_mcp_http_settings_domain_from_db_when_env_unset() -> None: - with patch.dict(os.environ, {}, clear=True): - with patch( - "website_profiling.mcp.settings._load_pipeline_mcp_settings", - return_value={"mcp_domain": "links"}, - ): - settings = load_mcp_http_settings() - assert settings.domain == "links" - - -def test_load_mcp_http_settings_domain_defaults_core_when_absent() -> None: - with patch.dict(os.environ, {}, clear=True): - with patch( - "website_profiling.mcp.settings._load_pipeline_mcp_settings", - return_value={}, - ): - settings = load_mcp_http_settings() - assert settings.domain == "core" - - -def test_create_server_domain_param_overrides_env(monkeypatch) -> None: - captured: dict[str, object] = {} - - class FakeServer: - def __init__(self, name: str) -> None: - captured["name"] = name - - def list_tools(self): - return lambda fn: fn - - def call_tool(self): - return lambda fn: fn - - def list_resources(self): - return lambda fn: fn - - def read_resource(self): - return lambda fn: fn - - fake_server_mod = MagicMock() - fake_server_mod.Server = FakeServer - monkeypatch.setitem(sys.modules, "mcp", MagicMock()) - monkeypatch.setitem(sys.modules, "mcp.server", fake_server_mod) - monkeypatch.setitem(sys.modules, "mcp.types", MagicMock()) - - # Env says "core" but the explicit domain= arg should win. - with patch.dict(os.environ, {"WP_MCP_DOMAIN": "core"}, clear=False): - mcp_server.create_server(domain="google") - - assert captured["name"] == "site-audit-google" - - -def test_build_app_passes_db_domain_to_create_server(monkeypatch) -> None: - captured: dict[str, object] = {} - - class FakeServer: - def __init__(self, name: str) -> None: - captured["name"] = name - - def list_tools(self): - return lambda fn: fn - - def call_tool(self): - return lambda fn: fn - - def list_resources(self): - return lambda fn: fn - - def read_resource(self): - return lambda fn: fn - - def create_initialization_options(self): - return {} - - async def run(self, *_args, **_kwargs) -> None: - return None - - class FakeManager: - def __init__(self, **_kwargs) -> None: - pass - - async def handle_request(self, *_args, **_kwargs) -> None: - pass - - def run(self): - from contextlib import asynccontextmanager - - @asynccontextmanager - async def _cm(): - yield - - return _cm() - - fake_server_mod = MagicMock() - fake_server_mod.Server = FakeServer - monkeypatch.setitem(sys.modules, "mcp", MagicMock()) - monkeypatch.setitem(sys.modules, "mcp.server", fake_server_mod) - monkeypatch.setitem(sys.modules, "mcp.types", MagicMock()) - monkeypatch.setitem(sys.modules, "mcp.server.streamable_http_manager", MagicMock(StreamableHTTPSessionManager=FakeManager)) - monkeypatch.setitem(sys.modules, "mcp.server.transport_security", MagicMock(TransportSecuritySettings=lambda **kw: kw)) - - # No WP_MCP_DOMAIN env var; DB returns "crawl" → build_app should use "crawl". - with patch.dict(os.environ, {"WP_MCP_HTTP_HOST": "127.0.0.1"}, clear=True): - with patch( - "website_profiling.mcp.settings._load_pipeline_mcp_settings", - return_value={"mcp_domain": "crawl"}, - ): - http_server.build_app() - - assert captured["name"] == "site-audit-crawl" diff --git a/tests/test_mcp_server_helpers.py b/tests/test_mcp_server_helpers.py deleted file mode 100644 index c6c3b74c..00000000 --- a/tests/test_mcp_server_helpers.py +++ /dev/null @@ -1,359 +0,0 @@ -"""MCP server helper and main() coverage.""" -from __future__ import annotations - -import asyncio -import json -import os -import runpy -import sys -from pathlib import Path -from unittest.mock import MagicMock, patch - -import pytest - -from website_profiling.mcp import server as mcp_server - - -def test_default_property_id_env() -> None: - with patch.dict(os.environ, {"WP_PROPERTY_ID": "12"}): - assert mcp_server._default_property_id() == 12 - with patch.dict(os.environ, {"WP_PROPERTY_ID": "0"}): - assert mcp_server._default_property_id() is None - with patch.dict(os.environ, {"WP_PROPERTY_ID": "bad"}): - assert mcp_server._default_property_id() is None - with patch.dict(os.environ, {}, clear=True): - assert mcp_server._default_property_id() is None - - -def test_merge_context() -> None: - with patch.dict(os.environ, {"WP_PROPERTY_ID": "3"}): - ctx = mcp_server._merge_context({"property_id": 9, "report_id": 4}) - assert ctx.property_id == 9 - assert ctx.report_id == 4 - - with patch.dict(os.environ, {"WP_PROPERTY_ID": "3"}): - ctx = mcp_server._merge_context({"property_id": "bad", "report_id": "bad"}) - assert ctx.property_id == 3 - assert ctx.report_id is None - - -def test_payload_index_variants() -> None: - index = mcp_server._payload_index({ - "items": [1, 2], - "meta": {"a": 1}, - "score": 88, - }) - assert index["items"]["count"] == 2 - assert index["meta"]["type"] == "object" - assert index["score"]["type"] == "int" - - -def test_read_glossary_excerpt() -> None: - text = mcp_server._read_glossary_excerpt() - assert isinstance(text, str) - assert text - - -def test_read_glossary_excerpt_missing(monkeypatch) -> None: - monkeypatch.setattr(Path, "is_file", lambda _self: False) - assert mcp_server._read_glossary_excerpt() == "Glossary file not found." - - -def test_tools_catalog_json_includes_security_tools() -> None: - with patch.dict(os.environ, {"WP_MCP_DOMAIN": "full"}): - catalog = json.loads(mcp_server._tools_catalog_json()) - assert catalog["tool_count"] >= 338 - assert "get_security_findings" in catalog["domains"]["security"] - assert "get_geo_readiness_score" in catalog["domains"]["geo"] - assert "get_gsc_url_inspection" in catalog["domains"]["integrations"] - assert catalog["mcp_domain"] == "full" - - -def test_tools_catalog_json_backlinks_domain() -> None: - with patch.dict(os.environ, {"WP_MCP_DOMAIN": "full"}): - with patch( - "website_profiling.mcp.server.mcp_tool_names", - return_value={"get_bing_overview"}, - ): - with patch( - "website_profiling.mcp.server.tools_catalog_by_domain", - return_value={"backlinks": ["get_bing_overview"]}, - ): - catalog = json.loads(mcp_server._tools_catalog_json()) - assert catalog["domains"]["backlinks"] == ["get_bing_overview"] - - -def test_resolve_glossary_and_report_by_id() -> None: - glossary = mcp_server._resolve_resource("audit://glossary") - assert isinstance(glossary, str) - - with patch("website_profiling.mcp.server.db_session") as mock_db, patch.object( - mcp_server.AuditToolContext, "load_payload", return_value=None, - ): - mock_db.return_value.__enter__.return_value = object() - missing = mcp_server._resolve_resource("audit://property/1/report/99") - assert "error" in missing - - with patch("website_profiling.mcp.server.db_session") as mock_db, patch.object( - mcp_server.AuditToolContext, "load_payload", return_value={"summary": {"score": 1}, "pages": [1, 2]}, - ): - mock_db.return_value.__enter__.return_value = object() - found = mcp_server._resolve_resource("audit://property/1/report/99") - payload = json.loads(found) - assert payload["summary"]["type"] == "object" - - -def test_mcp_main_missing_sdk() -> None: - with patch.dict(sys.modules, {"mcp.server": None, "mcp.server.stdio": None, "mcp.types": None}): - with pytest.raises(SystemExit, match="MCP SDK"): - mcp_server.main() - - -def test_mcp_main_registers_handlers(monkeypatch) -> None: - captured: dict[str, object] = {} - - class FakeServer: - def __init__(self, name: str) -> None: - captured["name"] = name - - def list_tools(self): - def decorator(fn): - captured["list_tools"] = fn - return fn - return decorator - - def call_tool(self): - def decorator(fn): - captured["call_tool"] = fn - return fn - return decorator - - def list_resources(self): - def decorator(fn): - captured["list_resources"] = fn - return fn - return decorator - - def read_resource(self): - def decorator(fn): - captured["read_resource"] = fn - return fn - return decorator - - def create_initialization_options(self): - return {} - - async def run(self, *_args, **_kwargs) -> None: - captured["ran"] = True - - class FakeStdioCM: - async def __aenter__(self): - return (MagicMock(), MagicMock()) - - async def __aexit__(self, *_args): - return False - - fake_server_mod = MagicMock() - fake_server_mod.Server = FakeServer - fake_stdio_mod = MagicMock() - fake_stdio_mod.stdio_server = MagicMock(return_value=FakeStdioCM()) - fake_types_mod = MagicMock() - fake_types_mod.Tool = lambda **kwargs: kwargs - fake_types_mod.TextContent = lambda **kwargs: kwargs - fake_types_mod.Resource = lambda **kwargs: kwargs - - monkeypatch.setitem(sys.modules, "mcp", MagicMock()) - monkeypatch.setitem(sys.modules, "mcp.server", fake_server_mod) - monkeypatch.setitem(sys.modules, "mcp.server.stdio", fake_stdio_mod) - monkeypatch.setitem(sys.modules, "mcp.types", fake_types_mod) - - with patch.dict(os.environ, {"WP_PROPERTY_ID": "7", "WP_MCP_DOMAIN": "full"}, clear=False): - mcp_server.main() - - assert captured["name"] == "site-audit-full" - assert captured["ran"] is True - tools = asyncio.run(captured["list_tools"]()) # type: ignore[arg-type] - assert len(tools) >= 338 - resources = asyncio.run(captured["list_resources"]()) # type: ignore[arg-type] - assert any(r["uri"] == "audit://property/7" for r in resources) - assert any(r["uri"] == "audit://domains" for r in resources) - - with patch("website_profiling.mcp.server.dispatch_tool", return_value={"ok": True}): - content = asyncio.run(captured["call_tool"]("list_properties", {"property_id": 1})) # type: ignore[arg-type] - assert content[0]["text"] == json.dumps({"ok": True}, indent=2, default=str) - read_text = asyncio.run(captured["read_resource"]("audit://tools")) # type: ignore[arg-type] - assert read_text.startswith("{") - domains_text = asyncio.run(captured["read_resource"]("audit://domains")) # type: ignore[arg-type] - assert "current_mcp_domain" in domains_text - - -def test_load_disabled_tools_from_db() -> None: - mock_conn = MagicMock() - mock_conn.execute.return_value.fetchone.return_value = ( - json.dumps(["list_properties", "get_report_summary"]), - ) - with patch("website_profiling.mcp.server.db_session") as mock_db: - mock_db.return_value.__enter__.return_value = mock_conn - disabled = mcp_server._load_disabled_tools() - assert disabled == frozenset({"list_properties", "get_report_summary"}) - - -def test_load_disabled_tools_on_error() -> None: - with patch("website_profiling.mcp.server.db_session", side_effect=RuntimeError("no db")): - assert mcp_server._load_disabled_tools() == frozenset() - - -def test_mcp_disabled_tools_excluded_from_list_and_call(monkeypatch) -> None: - captured: dict[str, object] = {} - - class FakeServer: - def __init__(self, name: str) -> None: - captured["name"] = name - - def list_tools(self): - def decorator(fn): - captured["list_tools"] = fn - return fn - return decorator - - def call_tool(self): - def decorator(fn): - captured["call_tool"] = fn - return fn - return decorator - - def list_resources(self): - def decorator(fn): - return fn - return decorator - - def read_resource(self): - def decorator(fn): - return fn - return decorator - - fake_server_mod = MagicMock() - fake_server_mod.Server = FakeServer - fake_types_mod = MagicMock() - fake_types_mod.Tool = lambda **kwargs: kwargs - fake_types_mod.TextContent = lambda **kwargs: kwargs - fake_types_mod.Resource = lambda **kwargs: kwargs - - monkeypatch.setitem(sys.modules, "mcp", MagicMock()) - monkeypatch.setitem(sys.modules, "mcp.server", fake_server_mod) - monkeypatch.setitem(sys.modules, "mcp.types", fake_types_mod) - - with patch.dict(os.environ, {"WP_MCP_DOMAIN": "full"}, clear=False): - with patch( - "website_profiling.mcp.server._load_disabled_tools", - return_value=frozenset({"list_properties"}), - ): - mcp_server.create_server() - tools = asyncio.run(captured["list_tools"]()) # type: ignore[arg-type] - blocked = asyncio.run(captured["call_tool"]("list_properties", {})) # type: ignore[arg-type] - - tool_names = {t["name"] for t in tools} - assert "list_properties" not in tool_names - assert len(tool_names) >= 337 - - payload = json.loads(blocked[0]["text"]) - assert "disabled via Risk Settings" in payload["error"] - assert "/risk-settings" in payload["hint"] - - -def test_mcp_call_tool_rejects_tools_outside_domain(monkeypatch) -> None: - captured: dict[str, object] = {} - - class FakeServer: - def __init__(self, name: str) -> None: - captured["name"] = name - - def list_tools(self): - def decorator(fn): - captured["list_tools"] = fn - return fn - return decorator - - def call_tool(self): - def decorator(fn): - captured["call_tool"] = fn - return fn - return decorator - - def list_resources(self): - def decorator(fn): - return fn - return decorator - - def read_resource(self): - def decorator(fn): - return fn - return decorator - - def create_initialization_options(self): - return {} - - async def run(self, *_args, **_kwargs) -> None: - return None - - class FakeStdioCM: - async def __aenter__(self): - return (MagicMock(), MagicMock()) - - async def __aexit__(self, *_args): - return False - - fake_server_mod = MagicMock() - fake_server_mod.Server = FakeServer - fake_stdio_mod = MagicMock() - fake_stdio_mod.stdio_server = MagicMock(return_value=FakeStdioCM()) - fake_types_mod = MagicMock() - fake_types_mod.TextContent = lambda **kwargs: kwargs - fake_types_mod.Resource = lambda **kwargs: kwargs - fake_types_mod.Tool = lambda **kwargs: kwargs - - monkeypatch.setitem(sys.modules, "mcp", MagicMock()) - monkeypatch.setitem(sys.modules, "mcp.server", fake_server_mod) - monkeypatch.setitem(sys.modules, "mcp.server.stdio", fake_stdio_mod) - monkeypatch.setitem(sys.modules, "mcp.types", fake_types_mod) - - with patch.dict(os.environ, {"WP_MCP_DOMAIN": "core"}, clear=False): - mcp_server.main() - - tools = asyncio.run(captured["list_tools"]()) # type: ignore[arg-type] - assert len(tools) < 338 - blocked = asyncio.run(captured["call_tool"]("export_audit_report", {"format": "pdf"})) # type: ignore[arg-type] - assert "not exposed" in blocked[0]["text"] - - -def test_mcp_core_server_main() -> None: - with patch("website_profiling.mcp.core_server.run_domain_server") as mock_run: - from website_profiling.mcp import core_server - core_server.main() - mock_run.assert_called_once_with("core") - - -def test_mcp_domain_server_sets_env() -> None: - with patch("website_profiling.mcp.domain_server.main") as mock_main: - from website_profiling.mcp.domain_server import run_domain_server - run_domain_server("google") - assert os.environ.get("WP_MCP_DOMAIN") == "google" - mock_main.assert_called_once() - - -def test_mcp_package_main(monkeypatch) -> None: - with patch("website_profiling.mcp.server.main") as mock_main: - runpy.run_module("website_profiling.mcp", run_name="__main__") - mock_main.assert_called_once() - - -def test_mcp_server_main_guard() -> None: - # run_module executes __main__ in a fresh import; drop any prior import from this file. - sys.modules.pop("website_profiling.mcp.server", None) - with patch.dict(sys.modules, {"mcp.server": None, "mcp.server.stdio": None, "mcp.types": None}): - with pytest.raises(SystemExit, match="MCP SDK"): - runpy.run_module( - "website_profiling.mcp.server", - run_name="__main__", - alter_sys=False, - ) diff --git a/tests/test_ollama_errors.py b/tests/test_ollama_errors.py deleted file mode 100644 index c029149c..00000000 --- a/tests/test_ollama_errors.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Ollama API error formatting.""" -from __future__ import annotations - -from website_profiling.llm.providers.ollama import format_ollama_error - - -def test_format_model_not_found() -> None: - msg = format_ollama_error( - 404, - "model 'llama3.2' not found", - "llama3.2", - ) - assert "not installed" in msg - assert "ollama pull llama3.2" in msg - - -def test_format_generic_404() -> None: - msg = format_ollama_error(404, "", "m") - assert "/api/chat" in msg diff --git a/tests/test_ollama_messages.py b/tests/test_ollama_messages.py deleted file mode 100644 index 69b2b718..00000000 --- a/tests/test_ollama_messages.py +++ /dev/null @@ -1,59 +0,0 @@ -"""Ollama chat message normalization for tool calling.""" -from __future__ import annotations - -import json - -from website_profiling.llm.providers.ollama import normalize_messages_for_ollama - - -def test_tool_message_uses_tool_name_not_tool_call_id(): - msgs = normalize_messages_for_ollama([ - {"role": "user", "content": "hi"}, - { - "role": "assistant", - "content": "", - "tool_calls": [{ - "id": "call_1", - "type": "function", - "function": {"name": "list_properties", "arguments": "{}"}, - }], - }, - { - "role": "tool", - "tool_call_id": "call_1", - "content": '{"ok": true}', - }, - ]) - tool_msg = msgs[-1] - assert tool_msg["role"] == "tool" - assert "tool_call_id" not in tool_msg - assert tool_msg.get("tool_name") == "tool" - assert tool_msg["content"] == '{"ok": true}' - - -def test_assistant_tool_call_arguments_are_objects(): - msgs = normalize_messages_for_ollama([ - { - "role": "assistant", - "tool_calls": [{ - "type": "function", - "function": {"name": "list_issues", "arguments": '{"limit": 5}'}, - }], - }, - ]) - fn = msgs[0]["tool_calls"][0]["function"] - assert fn["arguments"] == {"limit": 5} - - -def test_assistant_tool_call_preserves_dict_arguments(): - msgs = normalize_messages_for_ollama([ - { - "role": "assistant", - "tool_calls": [{ - "type": "function", - "function": {"name": "list_issues", "arguments": {"limit": 3}}, - }], - }, - ]) - fn = msgs[0]["tool_calls"][0]["function"] - assert fn["arguments"] == {"limit": 3} diff --git a/tests/test_page_google.py b/tests/test_page_google.py index 60106b2e..57fdc77b 100644 --- a/tests/test_page_google.py +++ b/tests/test_page_google.py @@ -3,8 +3,7 @@ import json -from website_profiling.integrations.google.page_lookup import slice_from_google_row -from website_profiling.llm.page_coach import _metric_deltas +from website_profiling.integrations.google.page_lookup import metric_deltas, slice_from_google_row def test_slice_from_google_row_gsc_full_by_page(): @@ -59,7 +58,7 @@ def test_slice_fallback_top_pages(): def test_metric_deltas(): current = {"gsc": {"clicks": 20, "impressions": 100}, "ga4": {"sessions": 30, "engagementRate": 40}} baseline = {"gsc": {"clicks": 10, "impressions": 80}, "ga4": {"sessions": 25, "engagementRate": 50}} - rows = _metric_deltas(current, baseline) + rows = metric_deltas(current, baseline) by_id = {r["id"]: r for r in rows} assert by_id["gsc_clicks"]["delta"] == 10 assert by_id["ga4_sessions"]["delta"] == 5 diff --git a/tests/test_pipeline_cmd_run_unit.py b/tests/test_pipeline_cmd_run_unit.py index eff473f2..b2564046 100644 --- a/tests/test_pipeline_cmd_run_unit.py +++ b/tests/test_pipeline_cmd_run_unit.py @@ -14,7 +14,7 @@ def test_pipeline_run_calls_crawl_with_minimal_config(monkeypatch) -> None: def fake_run_crawler(**_kwargs): called["crawl"] += 1 - return pd.DataFrame([{"url": "https://site.com", "status": 200}]) + return pd.DataFrame([{"url": "https://site.com", "status": 200}]), 42 # Patch crawler module function used in _run_crawl import website_profiling.crawl.crawler as crawler_mod @@ -41,7 +41,7 @@ def test_run_crawl_passes_render_mode_to_run_crawler(monkeypatch) -> None: def fake_run_crawler(**kwargs): captured.update(kwargs) - return pd.DataFrame([{"url": "https://site.com", "status": 200}]) + return pd.DataFrame([{"url": "https://site.com", "status": 200}]), 7 import website_profiling.crawl.crawler as crawler_mod @@ -80,7 +80,8 @@ def __exit__(self, _t, _v, _tb): import website_profiling.db as db monkeypatch.setattr(db, "db_session", lambda: _Ctx()) - monkeypatch.setattr(db, "get_latest_crawl_run_id", lambda _c: 1) + monkeypatch.setattr(db, "resolve_crawl_run_id_for_cfg", lambda _c, **kw: 1) + monkeypatch.setattr(db, "get_crawl_run_info", lambda _c, _rid: {"start_url": "https://a.com"}) monkeypatch.setattr( db, "read_crawl", @@ -106,6 +107,82 @@ def fake_lh_on_pages(urls, **_kwargs): assert urls_seen["urls"] == ["https://a.com"] +def test_pipeline_lighthouse_on_pages_prefers_pipeline_crawl_run_id(monkeypatch) -> None: + from website_profiling.commands import pipeline_cmd + + class _Ctx: + def __enter__(self): + return object() + + def __exit__(self, _t, _v, _tb): + return False + + import website_profiling.db as db + + resolve_calls: list[dict] = [] + read_calls: list[int | None] = [] + + monkeypatch.setattr(db, "db_session", lambda: _Ctx()) + monkeypatch.setattr( + db, + "resolve_crawl_run_id_for_cfg", + lambda _c, **kw: resolve_calls.append(kw) or 99, + ) + monkeypatch.setattr(db, "get_crawl_run_info", lambda _c, rid: {"start_url": f"https://run-{rid}.com"}) + monkeypatch.setattr( + db, + "read_crawl", + lambda _c, rid: (read_calls.append(rid), pd.DataFrame([{"url": "https://run-42.com", "status": 200}]))[1], + ) + monkeypatch.setitem( + __import__("sys").modules, + "website_profiling.lighthouse.runner", + types.SimpleNamespace( + run_lighthouse_on_pages=lambda urls, **_k: {"attempted": len(urls), "succeeded": len(urls), "failed": 0}, + ), + ) + monkeypatch.setattr(pipeline_cmd, "lighthouse_work_dir", lambda: "/tmp/lh") + monkeypatch.setattr(pipeline_cmd, "cleanup_lighthouse_work_dir", lambda _p: None) + + pipeline_cmd._run_lighthouse_on_pages({}, lighthouse_max_pages=5, crawl_run_id=42) + assert read_calls == [42] + assert resolve_calls == [] + + read_calls.clear() + pipeline_cmd._run_lighthouse_on_pages({}, lighthouse_max_pages=5) + assert read_calls == [99] + assert len(resolve_calls) == 1 + + +def test_pipeline_run_passes_crawl_run_id_to_lighthouse(monkeypatch) -> None: + from website_profiling.commands import pipeline_cmd + + lh_crawl_ids: list[int | None] = [] + + def fake_crawl(*_a, **_k): + return 55 + + def fake_lh(cfg, max_pages, *, crawl_run_id=None): + lh_crawl_ids.append(crawl_run_id) + + monkeypatch.setattr(pipeline_cmd, "_run_crawl", fake_crawl) + monkeypatch.setattr(pipeline_cmd, "_run_lighthouse_on_pages", fake_lh) + monkeypatch.setattr(pipeline_cmd, "_run_report", lambda *_a, **_k: None) + monkeypatch.setattr(pipeline_cmd, "_run_plot", lambda *_a, **_k: None) + monkeypatch.setattr(pipeline_cmd, "_run_content_analysis", lambda *_a, **_k: None) + + cfg = { + "start_url": "https://site.com", + "run_crawl": "true", + "run_report": "false", + "run_plot": "false", + "run_lighthouse": "false", + "run_lighthouse_on_pages": "true", + } + pipeline_cmd.run(cfg, argparse.Namespace(command=None)) + assert lh_crawl_ids == [55] + + def _patch_lighthouse_on_pages_db(monkeypatch): from website_profiling.commands import pipeline_cmd @@ -119,7 +196,8 @@ def __exit__(self, _t, _v, _tb): import website_profiling.db as db monkeypatch.setattr(db, "db_session", lambda: _Ctx()) - monkeypatch.setattr(db, "get_latest_crawl_run_id", lambda _c: 1) + monkeypatch.setattr(db, "resolve_crawl_run_id_for_cfg", lambda _c, **kw: 1) + monkeypatch.setattr(db, "get_crawl_run_info", lambda _c, _rid: {"start_url": "https://a.com"}) monkeypatch.setattr( db, "read_crawl", @@ -213,7 +291,8 @@ def __exit__(self, _t, _v, _tb): import website_profiling.db as db monkeypatch.setattr(db, "db_session", lambda: _Ctx()) - monkeypatch.setattr(db, "get_latest_crawl_run_id", lambda _c: 1) + monkeypatch.setattr(db, "resolve_crawl_run_id_for_cfg", lambda _c, **kw: 1) + monkeypatch.setattr(db, "get_crawl_run_info", lambda _c, _rid: {"start_url": "https://a.com"}) monkeypatch.setattr( db, "read_crawl", diff --git a/tests/test_property_store_unit.py b/tests/test_property_store_unit.py index ef008b55..e78b2b08 100644 --- a/tests/test_property_store_unit.py +++ b/tests/test_property_store_unit.py @@ -85,18 +85,49 @@ def test_get_property_by_domain_and_id() -> None: assert get_property_by_id(conn3, 1)["name"] == "example.com" +def test_is_valid_canonical_domain() -> None: + from website_profiling.db.property_store import is_valid_canonical_domain + + assert is_valid_canonical_domain("example.com") is True + assert is_valid_canonical_domain("codefrydev.in") is True + assert is_valid_canonical_domain("www.luxtripper.co.uk") is True + assert is_valid_canonical_domain("h") is False + assert is_valid_canonical_domain("http") is False + assert is_valid_canonical_domain("https") is False + assert is_valid_canonical_domain("codefrydev") is False + assert is_valid_canonical_domain("codefrydev.i") is False + assert is_valid_canonical_domain("codefrydev.") is False + + def test_resolve_property_id_from_start_url_existing_and_create() -> None: - from website_profiling.db.property_store import resolve_property_id_from_start_url + from website_profiling.db.property_store import ( + ensure_property_from_start_url, + lookup_property_id_from_start_url, + resolve_property_id_from_start_url, + ) conn = FakeConn() conn.set_next_cursor(FakeCursor(fetchone_value=_property_row(pid=5))) - assert resolve_property_id_from_start_url(conn, "https://example.com") == 5 + assert lookup_property_id_from_start_url(conn, "https://example.com") == 5 + + conn1b = FakeConn() + conn1b.set_next_cursor(FakeCursor(fetchone_value=_property_row(pid=5))) + assert resolve_property_id_from_start_url(conn1b, "https://example.com") == 5 conn2 = FakeConn() conn2.set_next_cursor(FakeCursor(fetchone_value=None)) - conn2.set_next_cursor(FakeCursor(fetchone_value=(99,))) - assert resolve_property_id_from_start_url(conn2, "https://new.example") == 99 - assert conn2.commits == 1 + assert lookup_property_id_from_start_url(conn2, "https://new.example") is None + assert resolve_property_id_from_start_url(conn2, "https://new.example") is None + + conn3 = FakeConn() + conn3.set_next_cursor(FakeCursor(fetchone_value=None)) + conn3.set_next_cursor(FakeCursor(fetchone_value=(99,))) + assert ensure_property_from_start_url(conn3, "https://new.example") == 99 + assert conn3.commits == 1 + + conn4 = FakeConn() + assert ensure_property_from_start_url(conn4, "https://incomplete") is None + assert conn4.executed == [] def test_update_property_google_noop_when_empty_patch() -> None: diff --git a/tests/test_roadmap_extras.py b/tests/test_roadmap_extras.py index c2e4889e..8e46de44 100644 --- a/tests/test_roadmap_extras.py +++ b/tests/test_roadmap_extras.py @@ -1,9 +1,11 @@ """Roadmap extras: competitor CSV gap, audit summary, SERP overlay helpers.""" +from unittest.mock import patch + from website_profiling.integrations.google.competitor_links import ( build_competitor_domain_gap, parse_referring_domains_from_csv, ) -from website_profiling.llm.audit_summary import generate_audit_executive_summary +from website_profiling.llm_client_http import generate_audit_executive_summary def test_parse_referring_domains_from_csv() -> None: @@ -31,7 +33,18 @@ def test_executive_summary_deterministic() -> None: "google": {"gsc": {"top_pages": [{"page": "https://x.com/a", "clicks": 100}]}}, "summary": {"total_urls": 10}, } - result = generate_audit_executive_summary(payload, {}) + summary_text = "Prioritize fixes below by severity and Search Console traffic impact." + with patch( + "website_profiling.llm_client_http._post", + return_value={ + "ok": True, + "source": "deterministic", + "summary": summary_text, + "top_issues": payload["categories"][0]["issues"], + "priorities": [], + }, + ): + result = generate_audit_executive_summary(payload, {}) assert result["ok"] is True assert result["source"] == "deterministic" assert len(result["top_issues"]) >= 1 @@ -40,7 +53,11 @@ def test_executive_summary_deterministic() -> None: def test_executive_summary_empty_payload() -> None: - result = generate_audit_executive_summary({}, {}) + with patch( + "website_profiling.llm_client_http._post", + return_value={"ok": True, "source": "deterministic", "summary": "No audit data.", "top_issues": [], "priorities": []}, + ): + result = generate_audit_executive_summary({}, {}) assert result["ok"] is True assert result["source"] == "deterministic" assert isinstance(result["summary"], str) diff --git a/tests/test_text_sanitize.py b/tests/test_text_sanitize.py index bb6f71f2..dd844341 100644 --- a/tests/test_text_sanitize.py +++ b/tests/test_text_sanitize.py @@ -2,17 +2,8 @@ from __future__ import annotations import json -from unittest.mock import patch -from website_profiling.llm.agent import run_agent_turn -from website_profiling.llm.base import ChatResult, ToolCall from website_profiling.text_sanitize import sanitize_unicode_deep, strip_surrogates -from website_profiling.tools.audit_tools import AuditToolContext - -VALID_NARRATIVE = { - "power_insights": ["High-priority issues were found in the audit."], - "recommended_actions": ["Review the listed URLs and fix sitemap coverage gaps."], -} def test_strip_surrogates_replaces_lone_surrogate() -> None: @@ -38,61 +29,6 @@ def test_sanitize_unicode_deep_tuple() -> None: assert "\udc9d" not in cleaned[1]["nested"] -def test_agent_surrogate_tool_result_does_not_break_llm_request() -> None: - surrogate = "\udc9d" - tool_payload = { - "issues": [ - { - "category": "Technical SEO", - "priority": "High", - "message": f"URL in sitemap but not crawled: https://codefrydev.in/2048{surrogate}", - "url": "https://codefrydev.in/2048", - }, - ], - "total": 1, - "truncated": False, - } - - class RecordingClient: - def __init__(self) -> None: - self.last_messages: list[dict] | None = None - - def chat_with_tools(self, messages, tools, *, on_token=None): - self.last_messages = messages - if self.last_messages and any( - m.get("role") == "tool" for m in self.last_messages - ): - return ChatResult(content="Summary with no further tools.") - return ChatResult( - tool_calls=[ToolCall(id="tc1", name="list_issues", arguments={"priority": "High"})], - ) - - def complete_json(self, system, user): - return VALID_NARRATIVE - - client = RecordingClient() - events: list[dict] = [] - - with patch("website_profiling.llm.agent.load_llm_config_from_db", return_value={ - "llm_enabled": True, "llm_provider": "openai", "llm_api_key": "sk-test", - }): - with patch("website_profiling.llm.agent.get_llm_client", return_value=client): - with patch("website_profiling.llm.chat_narrative.get_llm_client", return_value=client): - with patch( - "website_profiling.llm.agent.dispatch_tool", - return_value=tool_payload, - ): - result = run_agent_turn( - [{"role": "user", "content": "high risk audit issues"}], - AuditToolContext(property_id=1), - on_event=events.append, - ) - - assert result["ok"] is True - assert client.last_messages is not None - json.dumps( - {"messages": client.last_messages, "tools": [], "stream": True}, - ensure_ascii=False, - ).encode("utf-8") - tool_end = next(e for e in events if e["type"] == "tool_end") - assert "\udc9d" not in json.dumps(tool_end["result"]) +def test_sanitize_unicode_deep_passthrough() -> None: + assert sanitize_unicode_deep(42) == 42 + assert sanitize_unicode_deep(None) is None diff --git a/tests/tools/test_audit_tools_expansion_coverage.py b/tests/tools/test_audit_tools_expansion_coverage.py index a84121c0..04cef3ad 100644 --- a/tests/tools/test_audit_tools_expansion_coverage.py +++ b/tests/tools/test_audit_tools_expansion_coverage.py @@ -419,7 +419,7 @@ def test_llm_tools_paths(conn: MagicMock, ctx: Ctx) -> None: assert llm_mod.generate_issue_fix(conn, ctx, {"message": ""})["error"] with patch("website_profiling.tools.audit_tools.integrations.llm_tools._llm_disabled_response", return_value={}), patch( - "website_profiling.llm.issue_fixes.generate_issue_fix_suggestion", + "website_profiling.llm_client_http.generate_issue_fix_suggestion", return_value={"fix": "x"}, ), patch("website_profiling.llm_config.load_llm_config_from_db", return_value={}): out = llm_mod.generate_issue_fix(conn, ctx, {"message": "Fix title"}) @@ -435,8 +435,8 @@ def test_llm_tools_paths(conn: MagicMock, ctx: Ctx) -> None: assert summary["provenance"] == "Crawl" with patch("website_profiling.tools.audit_tools.integrations.llm_tools._llm_disabled_response", return_value={}), patch( - "website_profiling.llm.base.get_llm_client", - return_value=MagicMock(complete_json=MagicMock(return_value={"summary": "Client text"})), + "website_profiling.llm_client_http.complete_json", + return_value={"summary": "Client text"}, ), patch("website_profiling.llm_config.load_llm_config_from_db", return_value={}), patch( "website_profiling.tools.audit_tools.issues.issues.get_category_issues", return_value=cat_data, ): @@ -454,8 +454,8 @@ def test_llm_tools_paths(conn: MagicMock, ctx: Ctx) -> None: with patch.object(Ctx, "load_payload", return_value={"site_name": "Ex", "top_pages": [{"url": "https://ex.com"}]}), patch( "website_profiling.tools.audit_tools.integrations.llm_tools._llm_disabled_response", return_value={}, ), patch( - "website_profiling.llm.base.get_llm_client", - return_value=MagicMock(complete_json=MagicMock(return_value={"content": "# Ex\n\nPolished"})), + "website_profiling.llm_client_http.complete_json", + return_value={"content": "# Ex\n\nPolished"}, ), patch("website_profiling.llm_config.load_llm_config_from_db", return_value={}): draft = llm_mod.draft_llms_txt(conn, ctx, {}) assert "Polished" in draft["llms_txt_draft"] @@ -465,11 +465,11 @@ def test_llm_tools_paths(conn: MagicMock, ctx: Ctx) -> None: with patch.object(Ctx, "load_payload", return_value={"categories": []}), patch( "website_profiling.tools.audit_tools.integrations.llm_tools._llm_disabled_response", return_value={}, - ), patch("website_profiling.llm.base.get_llm_client", return_value=None), patch.object( + ), patch("website_profiling.llm_client_http.complete_json", return_value=None), patch.object( Ctx, "load_google", return_value={"gsc": {}}, ), patch.object(Ctx, "load_crawl_df", return_value=pd.DataFrame([{"url": "https://ex.com", "title": "T", "meta_description": "D"}])): snippet = llm_mod.analyze_serp_snippet_for_url(conn, ctx, {"url": "https://ex.com"}) - assert snippet["provenance"] == "Crawl" + assert snippet["provenance"] == "AI insights" with patch.object(Ctx, "load_payload", return_value=None): assert llm_mod.draft_llms_txt(conn, ctx, {})["error"] @@ -477,7 +477,7 @@ def test_llm_tools_paths(conn: MagicMock, ctx: Ctx) -> None: client = MagicMock(complete_json=MagicMock(side_effect=RuntimeError("llm fail"))) with patch.object(Ctx, "load_payload", return_value={"site_name": "Ex"}), patch( "website_profiling.tools.audit_tools.integrations.llm_tools._llm_disabled_response", return_value={}, - ), patch("website_profiling.llm.base.get_llm_client", return_value=client), patch( + ), patch("website_profiling.llm_client_http.complete_json", side_effect=RuntimeError("llm fail")), patch( "website_profiling.llm_config.load_llm_config_from_db", return_value={}, ): err_snippet = llm_mod.analyze_serp_snippet_for_url(conn, ctx, {"url": "https://ex.com"}) @@ -594,16 +594,16 @@ def test_expansion_coverage_gaps(conn: MagicMock, ctx: Ctx) -> None: assert llm_mod.prioritize_fix_roadmap(conn, ctx, {"limit": "bad"})["roadmap"] == [] with patch("website_profiling.tools.audit_tools.integrations.llm_tools._llm_disabled_response", return_value={}), patch( - "website_profiling.llm.base.get_llm_client", return_value=MagicMock(complete_json=MagicMock(return_value={})), + "website_profiling.llm_client_http.complete_json", return_value={}, ), patch("website_profiling.llm_config.load_llm_config_from_db", return_value={}), patch( "website_profiling.tools.audit_tools.issues.issues.get_category_issues", return_value={"name": "T", "score": 1, "issues": []}, - ), patch("website_profiling.llm.base.parse_json_response", return_value={"summary": "parsed"}): + ), patch("website_profiling.llm_client_http.parse_json_response", return_value={"summary": "parsed"}): summary = llm_mod.summarize_category_for_client(conn, ctx, {"category_id": "t"}) assert summary.get("narrative") == "parsed" with patch.object(Ctx, "load_payload", return_value={"site_name": "Ex"}), patch( "website_profiling.tools.audit_tools.integrations.llm_tools._llm_disabled_response", return_value={}, - ), patch("website_profiling.llm.base.get_llm_client", return_value=MagicMock(complete_json=MagicMock(side_effect=RuntimeError("x")))): + ), patch("website_profiling.llm_client_http.complete_json", side_effect=RuntimeError("x")): draft = llm_mod.draft_llms_txt(conn, ctx, {}) assert "Ex" in draft["llms_txt_draft"] @@ -655,18 +655,17 @@ def test_expansion_coverage_gaps(conn: MagicMock, ctx: Ctx) -> None: assert llm_mod._llm_disabled_response() == {} with patch("website_profiling.tools.audit_tools.integrations.llm_tools._llm_disabled_response", return_value={}), patch( - "website_profiling.llm.base.get_llm_client", return_value=MagicMock(complete_json=MagicMock(side_effect=RuntimeError("boom"))), + "website_profiling.llm_client_http.complete_json", side_effect=RuntimeError("boom"), ), patch("website_profiling.llm_config.load_llm_config_from_db", return_value={}), patch( "website_profiling.tools.audit_tools.issues.issues.get_category_issues", return_value={"name": "T", "score": 1, "issues": []}, ): err_summary = llm_mod.summarize_category_for_client(conn, ctx, {"category_id": "t"}) assert "narrative_error" in err_summary - client_ok = MagicMock(complete_json=MagicMock(return_value={"title": "New", "meta_description": "Meta"})) with patch.object(Ctx, "load_google", return_value={"gsc": {}}), patch.object( Ctx, "load_crawl_df", return_value=pd.DataFrame([{"url": "https://ex.com", "title": "Old", "meta_description": "Old meta"}]), ), patch("website_profiling.tools.audit_tools.integrations.llm_tools._llm_disabled_response", return_value={}), patch( - "website_profiling.llm.base.get_llm_client", return_value=client_ok, + "website_profiling.llm_client_http.complete_json", return_value={"title": "New", "meta_description": "Meta"}, ), patch("website_profiling.llm_config.load_llm_config_from_db", return_value={}): serp = llm_mod.analyze_serp_snippet_for_url(conn, ctx, {"url": "https://ex.com"}) assert serp["provenance"] == "AI insights" diff --git a/tests/tools/test_mcp_resources.py b/tests/tools/test_mcp_resources.py deleted file mode 100644 index 3fbebaf4..00000000 --- a/tests/tools/test_mcp_resources.py +++ /dev/null @@ -1,53 +0,0 @@ -"""MCP resource URI resolution tests.""" -from __future__ import annotations - -from unittest.mock import patch - - -def _mcp_server(): - """Fresh module reference (test_mcp_server_helpers may pop/reload mcp.server).""" - from website_profiling.mcp import server - - return server - - -def test_resolve_properties_resource() -> None: - mcp_server = _mcp_server() - with patch.object(mcp_server, "dispatch_tool", return_value={"count": 0, "properties": []}): - text = mcp_server._resolve_resource("audit://properties") - assert "properties" in text - - -def test_resolve_report_latest_missing_payload() -> None: - mcp_server = _mcp_server() - with patch.object(mcp_server, "db_session") as mock_db, patch.object( - mcp_server.AuditToolContext, "load_payload", return_value=None, - ): - mock_db.return_value.__enter__.return_value = object() - text = mcp_server._resolve_resource("audit://property/1/report/latest") - assert "error" in text - - -def test_resolve_glossary_and_tools() -> None: - mcp_server = _mcp_server() - text = mcp_server._resolve_resource("audit://tools") - assert "tool_count" in text - unknown = mcp_server._resolve_resource("audit://unknown") - assert "error" in unknown - - -def test_resolve_property_and_report() -> None: - mcp_server = _mcp_server() - with patch.object(mcp_server, "dispatch_tool", side_effect=[ - {"property": {"id": 1}}, - {"health_score": 80}, - ]): - text = mcp_server._resolve_resource("audit://property/1") - assert "property" in text - - with patch.object(mcp_server, "db_session") as mock_db, patch.object( - mcp_server.AuditToolContext, "load_payload", return_value={"summary": {}, "categories": []}, - ): - mock_db.return_value.__enter__.return_value = object() - text = mcp_server._resolve_resource("audit://property/1/report/latest") - assert "summary" in text or "type" in text diff --git a/tests/tools/test_tool_selector.py b/tests/tools/test_tool_selector.py index 8378d77e..0f89591c 100644 --- a/tests/tools/test_tool_selector.py +++ b/tests/tools/test_tool_selector.py @@ -5,7 +5,12 @@ from unittest.mock import patch from website_profiling.tools.audit_tools.registry import mcp_tool_names, tier0_tool_names, tool_handler_names -from website_profiling.tools.audit_tools.tool_selector import chat_tool_max, select_tools_for_turn +from website_profiling.tools.audit_tools.tool_selector import ( + chat_tool_max, + chat_tool_search_cap, + expand_active_tools_from_result, + select_tools_for_turn, +) def test_select_tools_always_includes_tier0() -> None: @@ -75,12 +80,9 @@ def test_catalog_does_not_match_ops_domain() -> None: def test_search_expansion_applies_soft_cap() -> None: - from website_profiling.llm.agent import _expand_active_tools_from_result - from website_profiling.tools.audit_tools.tool_selector import chat_tool_search_cap, tier0_tool_names - active = tier0_tool_names() many = [f"tool_{i}" for i in range(100)] - expanded = _expand_active_tools_from_result( + expanded = expand_active_tools_from_result( "search_audit_tools", {"tool_names": many}, active, @@ -89,13 +91,22 @@ def test_search_expansion_applies_soft_cap() -> None: def test_search_audit_tools_expansion_names() -> None: - from website_profiling.llm.agent import _expand_active_tools_from_result - active = tier0_tool_names() - expanded = _expand_active_tools_from_result( + expanded = expand_active_tools_from_result( "search_audit_tools", {"tool_names": ["list_broken_links", "get_schema_coverage"]}, active, ) assert "list_broken_links" in expanded assert "get_schema_coverage" in expanded + + +def test_domain_agent_expansion_names() -> None: + active = tier0_tool_names() + expanded = expand_active_tools_from_result( + "run_domain_agent", + {"tools_used": ["get_lighthouse_summary", "list_broken_links"]}, + active, + ) + assert "get_lighthouse_summary" in expanded + assert "list_broken_links" in expanded diff --git a/tests/tools/test_tools_gate_remaining_coverage.py b/tests/tools/test_tools_gate_remaining_coverage.py index d2461499..4e315de6 100644 --- a/tests/tools/test_tools_gate_remaining_coverage.py +++ b/tests/tools/test_tools_gate_remaining_coverage.py @@ -501,8 +501,8 @@ def test_llm_generator_tools(conn: MagicMock, ctx: Ctx) -> None: "website_profiling.tools.audit_tools.integrations.llm_tools._llm_disabled_response", return_value={}, ), patch( - "website_profiling.llm.base.get_llm_client", - return_value=MagicMock(complete_json=MagicMock(return_value={"schema_json": {"@type": "WebSite", "name": "Ex"}})), + "website_profiling.llm_client_http.complete_json", + return_value={"schema_json": {"@type": "WebSite", "name": "Ex"}}, ), patch("website_profiling.llm_config.load_llm_config_from_db", return_value={}): schema = llm_mod.generate_schema(conn, ctx, {"schema_type": "FAQPage"}) assert schema["schema_type"] == "FAQPage" @@ -696,8 +696,8 @@ def test_remaining_geo_and_llm_gaps(conn: MagicMock, ctx: Ctx) -> None: "website_profiling.tools.audit_tools.integrations.llm_tools._llm_disabled_response", return_value={}, ), patch( - "website_profiling.llm.base.get_llm_client", - return_value=MagicMock(complete_json=MagicMock(side_effect=RuntimeError("llm down"))), + "website_profiling.llm_client_http.complete_json", + side_effect=RuntimeError("llm down"), ), patch("website_profiling.llm_config.load_llm_config_from_db", return_value={}): faq_schema = llm_mod.generate_schema(conn, ctx, {"schema_type": "FAQPage"}) assert len(faq_schema["schema_json"]["mainEntity"]) == 10 diff --git a/web/README.md b/web/README.md index 712a2999..efcea521 100644 --- a/web/README.md +++ b/web/README.md @@ -1,10 +1,10 @@ # Site Audit — Web UI -Vite + React SPA for [Site Audit](../README.md). The browser talks to the .NET **BFF** (`services/Bff/`) for all `/api/*` calls; the BFF proxies to FastAPI and FileService. +Vite + React SPA for [Site Audit](../README.md). The browser talks to the .NET **BFF** (`services/Bff/`) for all `/api/*` calls; the BFF proxies to FastAPI, AiService, Data, and FileService. ## Development -Use the repo root scripts — do not run `npm run dev` in isolation unless Postgres, FastAPI, and the BFF are already up: +Use the repo root scripts — do not run `npm run dev` in isolation unless Postgres, FastAPI, AiService, and the BFF are already up: ```bash ./local-run setup # first time diff --git a/web/openapi.json b/web/openapi.json index 4332aaf6..0a78df2f 100644 --- a/web/openapi.json +++ b/web/openapi.json @@ -28,293 +28,6 @@ } } }, - "/api/report/meta": { - "get": { - "tags": [ - "report" - ], - "summary": "Report Meta", - "operationId": "report_meta_api_report_meta_get", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "additionalProperties": true, - "type": "object", - "title": "Response Report Meta Api Report Meta Get" - } - } - } - } - } - } - }, - "/api/report/payload": { - "get": { - "tags": [ - "report" - ], - "summary": "Report Payload", - "operationId": "report_payload_api_report_payload_get", - "parameters": [ - { - "name": "reportId", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Reportid" - } - }, - { - "name": "domain", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Domain" - } - }, - { - "name": "section", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Section" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "title": "Response Report Payload Api Report Payload Get" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/report/history": { - "get": { - "tags": [ - "report" - ], - "summary": "Report History", - "operationId": "report_history_api_report_history_get", - "parameters": [ - { - "name": "propertyId", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Propertyid" - } - }, - { - "name": "domain", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Domain" - } - }, - { - "name": "limit", - "in": "query", - "required": false, - "schema": { - "type": "integer", - "maximum": 100, - "minimum": 1, - "default": 20, - "title": "Limit" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "title": "Response Report History Api Report History Get" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/report/crawl-payload": { - "get": { - "tags": [ - "report" - ], - "summary": "Crawl Payload", - "operationId": "crawl_payload_api_report_crawl_payload_get", - "parameters": [ - { - "name": "crawlRunId", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Crawlrunid" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "title": "Response Crawl Payload Api Report Crawl Payload Get" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/report/mobile-delta": { - "get": { - "tags": [ - "report" - ], - "summary": "Mobile Delta", - "operationId": "mobile_delta_api_report_mobile_delta_get", - "parameters": [ - { - "name": "id", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Id" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "title": "Response Mobile Delta Api Report Mobile Delta Get" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, "/api/run": { "post": { "tags": [ @@ -571,18 +284,128 @@ } } }, - "/api/chat/": { - "post": { + "/api/crawl/browser-status": { + "get": { + "tags": [ + "crawl" + ], + "summary": "Browser Status Check", + "description": "Return whether Playwright + Chromium are available.", + "operationId": "browser_status_check_api_crawl_browser_status_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Response Browser Status Check Api Crawl Browser Status Get" + } + } + } + } + } + } + }, + "/api/crawl/page-html": { + "get": { + "tags": [ + "crawl" + ], + "summary": "Get Page Html", + "description": "Return stored HTML and metadata for a URL within a crawl run.", + "operationId": "get_page_html_api_crawl_page_html_get", + "parameters": [ + { + "name": "url", + "in": "query", + "required": true, + "schema": { + "type": "string", + "description": "Page URL to retrieve stored HTML for", + "title": "Url" + }, + "description": "Page URL to retrieve stored HTML for" + }, + { + "name": "crawlRunId", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "description": "Crawl run ID", + "title": "Crawlrunid" + }, + "description": "Crawl run ID" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Get Page Html Api Crawl Page Html Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/pipeline-config": { + "get": { + "tags": [ + "config" + ], + "summary": "Get Pipeline Config", + "operationId": "get_pipeline_config_api_pipeline_config_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Response Get Pipeline Config Api Pipeline Config Get" + } + } + } + } + } + }, + "put": { "tags": [ - "chat" + "config" ], - "summary": "Chat Turn", - "operationId": "chat_turn_api_chat__post", + "summary": "Put Pipeline Config", + "operationId": "put_pipeline_config_api_pipeline_config_put", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ChatRequest" + "$ref": "#/components/schemas/PipelineConfigBody" } } }, @@ -593,7 +416,11 @@ "description": "Successful Response", "content": { "application/json": { - "schema": {} + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Response Put Pipeline Config Api Pipeline Config Put" + } } } }, @@ -610,22 +437,24 @@ } } }, - "/api/chat/sessions": { + "/api/app-settings": { "get": { "tags": [ - "chat" + "config" ], - "summary": "List Sessions", - "operationId": "list_sessions_api_chat_sessions_get", + "summary": "Get App Setting", + "operationId": "get_app_setting_api_app_settings_get", "parameters": [ { - "name": "propertyId", + "name": "key", "in": "query", "required": true, "schema": { - "type": "integer", - "title": "Propertyid" - } + "type": "string", + "description": "Settings key to retrieve", + "title": "Key" + }, + "description": "Settings key to retrieve" } ], "responses": { @@ -636,7 +465,7 @@ "schema": { "type": "object", "additionalProperties": true, - "title": "Response List Sessions Api Chat Sessions Get" + "title": "Response Get App Setting Api App Settings Get" } } } @@ -653,18 +482,18 @@ } } }, - "post": { + "put": { "tags": [ - "chat" + "config" ], - "summary": "Create Session", - "operationId": "create_session_api_chat_sessions_post", + "summary": "Put App Setting", + "operationId": "put_app_setting_api_app_settings_put", "requestBody": { "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ChatSessionCreate" + "$ref": "#/components/schemas/AppSettingBody" } } } @@ -677,7 +506,7 @@ "schema": { "type": "object", "additionalProperties": true, - "title": "Response Create Session Api Chat Sessions Post" + "title": "Response Put App Setting Api App Settings Put" } } } @@ -695,33 +524,53 @@ } } }, - "/api/chat/sessions/{session_id}": { + "/api/properties": { "get": { "tags": [ - "chat" + "properties" ], - "summary": "Get Session Route", - "operationId": "get_session_route_api_chat_sessions__session_id__get", - "parameters": [ - { - "name": "session_id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Session Id" + "summary": "List Properties", + "operationId": "list_properties_api_properties_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Response List Properties Api Properties Get" + } + } } } + } + }, + "post": { + "tags": [ + "properties" ], + "summary": "Create Property", + "operationId": "create_property_api_properties_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PropertyUpsertBody" + } + } + }, + "required": true + }, "responses": { - "200": { + "201": { "description": "Successful Response", "content": { "application/json": { "schema": { - "type": "object", "additionalProperties": true, - "title": "Response Get Session Route Api Chat Sessions Session Id Get" + "type": "object", + "title": "Response Create Property Api Properties Post" } } } @@ -737,42 +586,35 @@ } } } - }, - "delete": { + } + }, + "/api/properties/ensure": { + "post": { "tags": [ - "chat" + "properties" ], - "summary": "Delete Session Route", - "operationId": "delete_session_route_api_chat_sessions__session_id__delete", - "parameters": [ - { - "name": "session_id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Session Id" + "summary": "Ensure Property", + "description": "Create a property row when the URL is complete (OAuth / explicit actions only).", + "operationId": "ensure_property_api_properties_ensure_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PropertyEnsureBody" + } } }, - { - "name": "propertyId", - "in": "query", - "required": true, - "schema": { - "type": "integer", - "title": "Propertyid" - } - } - ], + "required": true + }, "responses": { "200": { "description": "Successful Response", "content": { "application/json": { "schema": { - "type": "object", "additionalProperties": true, - "title": "Response Delete Session Route Api Chat Sessions Session Id Delete" + "type": "object", + "title": "Response Ensure Property Api Properties Ensure Post" } } } @@ -790,31 +632,24 @@ } } }, - "/api/chat/sessions/{session_id}/messages": { + "/api/properties/resolve": { "get": { "tags": [ - "chat" + "properties" ], - "summary": "Get Session Messages", - "operationId": "get_session_messages_api_chat_sessions__session_id__messages_get", + "summary": "Resolve Property", + "operationId": "resolve_property_api_properties_resolve_get", "parameters": [ { - "name": "session_id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Session Id" - } - }, - { - "name": "propertyId", + "name": "startUrl", "in": "query", "required": true, "schema": { - "type": "integer", - "title": "Propertyid" - } + "type": "string", + "description": "Start URL to resolve a property from", + "title": "Starturl" + }, + "description": "Start URL to resolve a property from" } ], "responses": { @@ -825,7 +660,7 @@ "schema": { "type": "object", "additionalProperties": true, - "title": "Response Get Session Messages Api Chat Sessions Session Id Messages Get" + "title": "Response Resolve Property Api Properties Resolve Get" } } } @@ -843,21 +678,21 @@ } } }, - "/api/chat/artifacts/{artifact_id}": { + "/api/properties/{property_id}": { "get": { "tags": [ - "chat" + "properties" ], - "summary": "Get Artifact", - "operationId": "get_artifact_api_chat_artifacts__artifact_id__get", + "summary": "Get Property", + "operationId": "get_property_api_properties__property_id__get", "parameters": [ { - "name": "artifact_id", + "name": "property_id", "in": "path", "required": true, "schema": { - "type": "string", - "title": "Artifact Id" + "type": "integer", + "title": "Property Id" } } ], @@ -867,7 +702,9 @@ "content": { "application/json": { "schema": { - "title": "Response Get Artifact Api Chat Artifacts Artifact Id Get" + "type": "object", + "additionalProperties": true, + "title": "Response Get Property Api Properties Property Id Get" } } } @@ -883,25 +720,43 @@ } } } - } - }, - "/api/crawl/browser-status": { - "get": { + }, + "delete": { "tags": [ - "crawl" + "properties" + ], + "summary": "Delete Property Route", + "operationId": "delete_property_route_api_properties__property_id__delete", + "parameters": [ + { + "name": "property_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Property Id" + } + } ], - "summary": "Browser Status Check", - "description": "Return whether Playwright + Chromium are available.", - "operationId": "browser_status_check_api_crawl_browser_status_get", "responses": { "200": { "description": "Successful Response", "content": { "application/json": { "schema": { - "additionalProperties": true, "type": "object", - "title": "Response Browser Status Check Api Crawl Browser Status Get" + "additionalProperties": true, + "title": "Response Delete Property Route Api Properties Property Id Delete" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" } } } @@ -909,43 +764,22 @@ } } }, - "/api/crawl/page-html": { + "/api/properties/{property_id}/ops": { "get": { "tags": [ - "crawl" + "properties" ], - "summary": "Get Page Html", - "description": "Return stored HTML and metadata for a URL within a crawl run.", - "operationId": "get_page_html_api_crawl_page_html_get", + "summary": "Get Property Ops Route", + "operationId": "get_property_ops_route_api_properties__property_id__ops_get", "parameters": [ { - "name": "url", - "in": "query", + "name": "property_id", + "in": "path", "required": true, "schema": { - "type": "string", - "description": "Page URL to retrieve stored HTML for", - "title": "Url" - }, - "description": "Page URL to retrieve stored HTML for" - }, - { - "name": "crawlRunId", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "description": "Crawl run ID", - "title": "Crawlrunid" - }, - "description": "Crawl run ID" + "type": "integer", + "title": "Property Id" + } } ], "responses": { @@ -956,7 +790,7 @@ "schema": { "type": "object", "additionalProperties": true, - "title": "Response Get Page Html Api Crawl Page Html Get" + "title": "Response Get Property Ops Route Api Properties Property Id Ops Get" } } } @@ -972,45 +806,33 @@ } } } - } - }, - "/api/pipeline-config": { - "get": { + }, + "put": { "tags": [ - "config" + "properties" ], - "summary": "Get Pipeline Config", - "operationId": "get_pipeline_config_api_pipeline_config_get", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "additionalProperties": true, - "type": "object", - "title": "Response Get Pipeline Config Api Pipeline Config Get" - } - } + "summary": "Update Property Ops Route", + "operationId": "update_property_ops_route_api_properties__property_id__ops_put", + "parameters": [ + { + "name": "property_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Property Id" } } - } - }, - "put": { - "tags": [ - "config" ], - "summary": "Put Pipeline Config", - "operationId": "put_pipeline_config_api_pipeline_config_put", "requestBody": { + "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PipelineConfigBody" + "$ref": "#/components/schemas/OpsSettingsBody" } } - }, - "required": true + } }, "responses": { "200": { @@ -1018,9 +840,9 @@ "content": { "application/json": { "schema": { - "additionalProperties": true, "type": "object", - "title": "Response Put Pipeline Config Api Pipeline Config Put" + "additionalProperties": true, + "title": "Response Update Property Ops Route Api Properties Property Id Ops Put" } } } @@ -1038,22 +860,43 @@ } } }, - "/api/llm-config": { + "/api/properties/{property_id}/preset": { "get": { "tags": [ - "config" + "properties" + ], + "summary": "Get Property Preset", + "operationId": "get_property_preset_api_properties__property_id__preset_get", + "parameters": [ + { + "name": "property_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Property Id" + } + } ], - "summary": "Get Llm Config", - "operationId": "get_llm_config_api_llm_config_get", "responses": { "200": { "description": "Successful Response", "content": { "application/json": { "schema": { - "additionalProperties": true, "type": "object", - "title": "Response Get Llm Config Api Llm Config Get" + "additionalProperties": true, + "title": "Response Get Property Preset Api Properties Property Id Preset Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" } } } @@ -1062,19 +905,30 @@ }, "put": { "tags": [ - "config" + "properties" + ], + "summary": "Update Property Preset", + "operationId": "update_property_preset_api_properties__property_id__preset_put", + "parameters": [ + { + "name": "property_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Property Id" + } + } ], - "summary": "Put Llm Config", - "operationId": "put_llm_config_api_llm_config_put", "requestBody": { + "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LlmConfigBody" + "$ref": "#/components/schemas/PresetBody" } } - }, - "required": true + } }, "responses": { "200": { @@ -1082,9 +936,9 @@ "content": { "application/json": { "schema": { - "additionalProperties": true, "type": "object", - "title": "Response Put Llm Config Api Llm Config Put" + "additionalProperties": true, + "title": "Response Update Property Preset Api Properties Property Id Preset Put" } } } @@ -1102,53 +956,33 @@ } } }, - "/api/secrets": { - "get": { + "/api/properties/{property_id}/authorize": { + "post": { "tags": [ - "config" + "properties" ], - "summary": "Get Secrets", - "operationId": "get_secrets_api_secrets_get", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "additionalProperties": true, - "type": "object", - "title": "Response Get Secrets Api Secrets Get" - } - } + "summary": "Authorize Property Crawl Route", + "operationId": "authorize_property_crawl_route_api_properties__property_id__authorize_post", + "parameters": [ + { + "name": "property_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Property Id" } } - } - }, - "put": { - "tags": [ - "config" ], - "summary": "Put Secrets", - "operationId": "put_secrets_api_secrets_put", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SecretsBody" - } - } - }, - "required": true - }, "responses": { "200": { "description": "Successful Response", "content": { "application/json": { "schema": { - "additionalProperties": true, "type": "object", - "title": "Response Put Secrets Api Secrets Put" + "additionalProperties": true, + "title": "Response Authorize Property Crawl Route Api Properties Property Id Authorize Post" } } } @@ -1166,24 +1000,22 @@ } } }, - "/api/app-settings": { + "/api/properties/{property_id}/google/status": { "get": { "tags": [ - "config" + "properties" ], - "summary": "Get App Setting", - "operationId": "get_app_setting_api_app_settings_get", + "summary": "Property Google Status", + "operationId": "property_google_status_api_properties__property_id__google_status_get", "parameters": [ { - "name": "key", - "in": "query", + "name": "property_id", + "in": "path", "required": true, "schema": { - "type": "string", - "description": "Settings key to retrieve", - "title": "Key" - }, - "description": "Settings key to retrieve" + "type": "integer", + "title": "Property Id" + } } ], "responses": { @@ -1194,7 +1026,7 @@ "schema": { "type": "object", "additionalProperties": true, - "title": "Response Get App Setting Api App Settings Get" + "title": "Response Property Google Status Api Properties Property Id Google Status Get" } } } @@ -1210,23 +1042,26 @@ } } } - }, - "put": { + } + }, + "/api/properties/{property_id}/google/test": { + "post": { "tags": [ - "config" + "properties" ], - "summary": "Put App Setting", - "operationId": "put_app_setting_api_app_settings_put", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AppSettingBody" - } + "summary": "Property Google Test", + "operationId": "property_google_test_api_properties__property_id__google_test_post", + "parameters": [ + { + "name": "property_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Property Id" } } - }, + ], "responses": { "200": { "description": "Successful Response", @@ -1235,7 +1070,7 @@ "schema": { "type": "object", "additionalProperties": true, - "title": "Response Put App Setting Api App Settings Put" + "title": "Response Property Google Test Api Properties Property Id Google Test Post" } } } @@ -1253,53 +1088,33 @@ } } }, - "/api/properties": { + "/api/properties/{property_id}/google/properties": { "get": { "tags": [ "properties" ], - "summary": "List Properties", - "operationId": "list_properties_api_properties_get", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "additionalProperties": true, - "type": "object", - "title": "Response List Properties Api Properties Get" - } - } + "summary": "Property Google Properties", + "operationId": "property_google_properties_api_properties__property_id__google_properties_get", + "parameters": [ + { + "name": "property_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Property Id" } } - } - }, - "post": { - "tags": [ - "properties" ], - "summary": "Create Property", - "operationId": "create_property_api_properties_post", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PropertyUpsertBody" - } - } - }, - "required": true - }, "responses": { - "201": { + "200": { "description": "Successful Response", "content": { "application/json": { "schema": { - "additionalProperties": true, "type": "object", - "title": "Response Create Property Api Properties Post" + "additionalProperties": true, + "title": "Response Property Google Properties Api Properties Property Id Google Properties Get" } } } @@ -1317,24 +1132,22 @@ } } }, - "/api/properties/resolve": { + "/api/properties/{property_id}/google/links/status": { "get": { "tags": [ "properties" ], - "summary": "Resolve Property", - "operationId": "resolve_property_api_properties_resolve_get", + "summary": "Property Google Links Status", + "operationId": "property_google_links_status_api_properties__property_id__google_links_status_get", "parameters": [ { - "name": "startUrl", - "in": "query", + "name": "property_id", + "in": "path", "required": true, "schema": { - "type": "string", - "description": "Start URL to resolve a property from", - "title": "Starturl" - }, - "description": "Start URL to resolve a property from" + "type": "integer", + "title": "Property Id" + } } ], "responses": { @@ -1345,7 +1158,7 @@ "schema": { "type": "object", "additionalProperties": true, - "title": "Response Resolve Property Api Properties Resolve Get" + "title": "Response Property Google Links Status Api Properties Property Id Google Links Status Get" } } } @@ -1363,13 +1176,13 @@ } } }, - "/api/properties/{property_id}": { - "get": { + "/api/properties/{property_id}/google/links/import": { + "post": { "tags": [ "properties" ], - "summary": "Get Property", - "operationId": "get_property_api_properties__property_id__get", + "summary": "Property Google Links Import", + "operationId": "property_google_links_import_api_properties__property_id__google_links_import_post", "parameters": [ { "name": "property_id", @@ -1389,7 +1202,7 @@ "schema": { "type": "object", "additionalProperties": true, - "title": "Response Get Property Api Properties Property Id Get" + "title": "Response Property Google Links Import Api Properties Property Id Google Links Import Post" } } } @@ -1405,13 +1218,15 @@ } } } - }, - "delete": { + } + }, + "/api/properties/{property_id}/google/credentials": { + "patch": { "tags": [ "properties" ], - "summary": "Delete Property Route", - "operationId": "delete_property_route_api_properties__property_id__delete", + "summary": "Patch Property Google Credentials", + "operationId": "patch_property_google_credentials_api_properties__property_id__google_credentials_patch", "parameters": [ { "name": "property_id", @@ -1423,6 +1238,16 @@ } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GoogleCredentialsPatch" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -1431,7 +1256,7 @@ "schema": { "type": "object", "additionalProperties": true, - "title": "Response Delete Property Route Api Properties Property Id Delete" + "title": "Response Patch Property Google Credentials Api Properties Property Id Google Credentials Patch" } } } @@ -1447,15 +1272,13 @@ } } } - } - }, - "/api/properties/{property_id}/ops": { - "get": { + }, + "post": { "tags": [ "properties" ], - "summary": "Get Property Ops Route", - "operationId": "get_property_ops_route_api_properties__property_id__ops_get", + "summary": "Post Property Google Credentials", + "operationId": "post_property_google_credentials_api_properties__property_id__google_credentials_post", "parameters": [ { "name": "property_id", @@ -1467,6 +1290,16 @@ } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GoogleCredentialsPostBody" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -1475,7 +1308,7 @@ "schema": { "type": "object", "additionalProperties": true, - "title": "Response Get Property Ops Route Api Properties Property Id Ops Get" + "title": "Response Post Property Google Credentials Api Properties Property Id Google Credentials Post" } } } @@ -1491,13 +1324,15 @@ } } } - }, - "put": { + } + }, + "/api/properties/{property_id}/google/disconnect": { + "post": { "tags": [ "properties" ], - "summary": "Update Property Ops Route", - "operationId": "update_property_ops_route_api_properties__property_id__ops_put", + "summary": "Post Property Google Disconnect", + "operationId": "post_property_google_disconnect_api_properties__property_id__google_disconnect_post", "parameters": [ { "name": "property_id", @@ -1509,16 +1344,6 @@ } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/OpsSettingsBody" - } - } - } - }, "responses": { "200": { "description": "Successful Response", @@ -1527,7 +1352,7 @@ "schema": { "type": "object", "additionalProperties": true, - "title": "Response Update Property Ops Route Api Properties Property Id Ops Put" + "title": "Response Post Property Google Disconnect Api Properties Property Id Google Disconnect Post" } } } @@ -1545,22 +1370,24 @@ } } }, - "/api/properties/{property_id}/preset": { + "/api/dashboards": { "get": { "tags": [ - "properties" + "dashboards" ], - "summary": "Get Property Preset", - "operationId": "get_property_preset_api_properties__property_id__preset_get", + "summary": "List Dashboards", + "operationId": "list_dashboards_api_dashboards_get", "parameters": [ { - "name": "property_id", - "in": "path", + "name": "propertyId", + "in": "query", "required": true, "schema": { "type": "integer", - "title": "Property Id" - } + "description": "Property ID", + "title": "Propertyid" + }, + "description": "Property ID" } ], "responses": { @@ -1571,7 +1398,7 @@ "schema": { "type": "object", "additionalProperties": true, - "title": "Response Get Property Preset Api Properties Property Id Preset Get" + "title": "Response List Dashboards Api Dashboards Get" } } } @@ -1588,42 +1415,31 @@ } } }, - "put": { + "post": { "tags": [ - "properties" - ], - "summary": "Update Property Preset", - "operationId": "update_property_preset_api_properties__property_id__preset_put", - "parameters": [ - { - "name": "property_id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Property Id" - } - } + "dashboards" ], + "summary": "Create Dashboard", + "operationId": "create_dashboard_api_dashboards_post", "requestBody": { "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PresetBody" + "$ref": "#/components/schemas/DashboardCreateBody" } } } }, "responses": { - "200": { + "201": { "description": "Successful Response", "content": { "application/json": { "schema": { "type": "object", "additionalProperties": true, - "title": "Response Update Property Preset Api Properties Property Id Preset Put" + "title": "Response Create Dashboard Api Dashboards Post" } } } @@ -1641,22 +1457,33 @@ } } }, - "/api/properties/{property_id}/authorize": { - "post": { + "/api/dashboards/{dashboard_id}": { + "get": { "tags": [ - "properties" + "dashboards" ], - "summary": "Authorize Property Crawl Route", - "operationId": "authorize_property_crawl_route_api_properties__property_id__authorize_post", + "summary": "Get Dashboard", + "operationId": "get_dashboard_api_dashboards__dashboard_id__get", "parameters": [ { - "name": "property_id", + "name": "dashboard_id", "in": "path", "required": true, "schema": { "type": "integer", - "title": "Property Id" + "title": "Dashboard Id" } + }, + { + "name": "propertyId", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "description": "Property ID", + "title": "Propertyid" + }, + "description": "Property ID" } ], "responses": { @@ -1667,7 +1494,7 @@ "schema": { "type": "object", "additionalProperties": true, - "title": "Response Authorize Property Crawl Route Api Properties Property Id Authorize Post" + "title": "Response Get Dashboard Api Dashboards Dashboard Id Get" } } } @@ -1683,26 +1510,34 @@ } } } - } - }, - "/api/properties/{property_id}/google/status": { - "get": { + }, + "put": { "tags": [ - "properties" + "dashboards" ], - "summary": "Property Google Status", - "operationId": "property_google_status_api_properties__property_id__google_status_get", + "summary": "Update Dashboard", + "operationId": "update_dashboard_api_dashboards__dashboard_id__put", "parameters": [ { - "name": "property_id", + "name": "dashboard_id", "in": "path", "required": true, "schema": { "type": "integer", - "title": "Property Id" + "title": "Dashboard Id" } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DashboardUpdateBody" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -1711,7 +1546,7 @@ "schema": { "type": "object", "additionalProperties": true, - "title": "Response Property Google Status Api Properties Property Id Google Status Get" + "title": "Response Update Dashboard Api Dashboards Dashboard Id Put" } } } @@ -1727,24 +1562,33 @@ } } } - } - }, - "/api/properties/{property_id}/google/test": { - "post": { + }, + "delete": { "tags": [ - "properties" + "dashboards" ], - "summary": "Property Google Test", - "operationId": "property_google_test_api_properties__property_id__google_test_post", + "summary": "Delete Dashboard", + "operationId": "delete_dashboard_api_dashboards__dashboard_id__delete", "parameters": [ { - "name": "property_id", + "name": "dashboard_id", "in": "path", "required": true, "schema": { "type": "integer", - "title": "Property Id" + "title": "Dashboard Id" } + }, + { + "name": "propertyId", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "description": "Property ID", + "title": "Propertyid" + }, + "description": "Property ID" } ], "responses": { @@ -1755,7 +1599,7 @@ "schema": { "type": "object", "additionalProperties": true, - "title": "Response Property Google Test Api Properties Property Id Google Test Post" + "title": "Response Delete Dashboard Api Dashboards Dashboard Id Delete" } } } @@ -1773,34 +1617,30 @@ } } }, - "/api/properties/{property_id}/google/properties": { - "get": { + "/api/dashboards/ai-generate": { + "post": { "tags": [ - "properties" + "dashboards" ], - "summary": "Property Google Properties", - "operationId": "property_google_properties_api_properties__property_id__google_properties_get", - "parameters": [ - { - "name": "property_id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Property Id" + "summary": "Dashboards Ai Generate", + "description": "Generate DashScript, a widget, or a full dashboard via LLM.", + "operationId": "dashboards_ai_generate_api_dashboards_ai_generate_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DashboardAiGenerateBody" + } } - } - ], + }, + "required": true + }, "responses": { "200": { "description": "Successful Response", "content": { "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "title": "Response Property Google Properties Api Properties Property Id Google Properties Get" - } + "schema": {} } } }, @@ -1817,43 +1657,23 @@ } } }, - "/api/properties/{property_id}/google/links/status": { + "/api/integrations/google/credentials": { "get": { "tags": [ - "properties" - ], - "summary": "Property Google Links Status", - "operationId": "property_google_links_status_api_properties__property_id__google_links_status_get", - "parameters": [ - { - "name": "property_id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Property Id" - } - } + "integrations" ], + "summary": "Get Google Credentials", + "description": "Full app-level Google OAuth settings (server-side / local admin only).", + "operationId": "get_google_credentials_api_integrations_google_credentials_get", "responses": { "200": { "description": "Successful Response", "content": { "application/json": { "schema": { - "type": "object", "additionalProperties": true, - "title": "Response Property Google Links Status Api Properties Property Id Google Links Status Get" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" + "type": "object", + "title": "Response Get Google Credentials Api Integrations Google Credentials Get" } } } @@ -1861,43 +1681,22 @@ } } }, - "/api/properties/{property_id}/google/links/import": { - "post": { + "/api/integrations/google/status": { + "get": { "tags": [ - "properties" - ], - "summary": "Property Google Links Import", - "operationId": "property_google_links_import_api_properties__property_id__google_links_import_post", - "parameters": [ - { - "name": "property_id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Property Id" - } - } + "integrations" ], + "summary": "Google Status", + "operationId": "google_status_api_integrations_google_status_get", "responses": { "200": { "description": "Successful Response", "content": { "application/json": { "schema": { - "type": "object", "additionalProperties": true, - "title": "Response Property Google Links Import Api Properties Property Id Google Links Import Post" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" + "type": "object", + "title": "Response Google Status Api Integrations Google Status Get" } } } @@ -1905,95 +1704,93 @@ } } }, - "/api/properties/{property_id}/google/credentials": { - "patch": { + "/api/integrations/google/disconnect": { + "post": { "tags": [ - "properties" - ], - "summary": "Patch Property Google Credentials", - "operationId": "patch_property_google_credentials_api_properties__property_id__google_credentials_patch", - "parameters": [ - { - "name": "property_id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Property Id" - } - } + "integrations" ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/GoogleCredentialsPatch" - } - } - } - }, + "summary": "Google Disconnect", + "operationId": "google_disconnect_api_integrations_google_disconnect_post", "responses": { "200": { "description": "Successful Response", "content": { "application/json": { "schema": { - "type": "object", "additionalProperties": true, - "title": "Response Patch Property Google Credentials Api Properties Property Id Google Credentials Patch" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" + "type": "object", + "title": "Response Google Disconnect Api Integrations Google Disconnect Post" } } } } } - }, - "post": { + } + }, + "/api/integrations/google/auth": { + "get": { "tags": [ - "properties" + "integrations" ], - "summary": "Post Property Google Credentials", - "operationId": "post_property_google_credentials_api_properties__property_id__google_credentials_post", + "summary": "Google Oauth Start", + "operationId": "google_oauth_start_api_integrations_google_auth_get", "parameters": [ { - "name": "property_id", - "in": "path", - "required": true, + "name": "propertyId", + "in": "query", + "required": false, "schema": { - "type": "integer", - "title": "Property Id" + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Propertyid" } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/GoogleCredentialsPostBody" - } + }, + { + "name": "startUrl", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Starturl" + } + }, + { + "name": "returnTo", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Returnto" } } - }, + ], "responses": { "200": { "description": "Successful Response", "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": true, - "title": "Response Post Property Google Credentials Api Properties Property Id Google Credentials Post" + "title": "Response Google Oauth Start Api Integrations Google Auth Get" } } } @@ -2011,1170 +1808,16 @@ } } }, - "/api/properties/{property_id}/google/disconnect": { - "post": { - "tags": [ - "properties" - ], - "summary": "Post Property Google Disconnect", - "operationId": "post_property_google_disconnect_api_properties__property_id__google_disconnect_post", - "parameters": [ - { - "name": "property_id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Property Id" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "title": "Response Post Property Google Disconnect Api Properties Property Id Google Disconnect Post" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/dashboards": { - "get": { - "tags": [ - "dashboards" - ], - "summary": "List Dashboards", - "operationId": "list_dashboards_api_dashboards_get", - "parameters": [ - { - "name": "propertyId", - "in": "query", - "required": true, - "schema": { - "type": "integer", - "description": "Property ID", - "title": "Propertyid" - }, - "description": "Property ID" - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "title": "Response List Dashboards Api Dashboards Get" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - }, - "post": { - "tags": [ - "dashboards" - ], - "summary": "Create Dashboard", - "operationId": "create_dashboard_api_dashboards_post", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DashboardCreateBody" - } - } - } - }, - "responses": { - "201": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "title": "Response Create Dashboard Api Dashboards Post" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/dashboards/{dashboard_id}": { - "get": { - "tags": [ - "dashboards" - ], - "summary": "Get Dashboard", - "operationId": "get_dashboard_api_dashboards__dashboard_id__get", - "parameters": [ - { - "name": "dashboard_id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Dashboard Id" - } - }, - { - "name": "propertyId", - "in": "query", - "required": true, - "schema": { - "type": "integer", - "description": "Property ID", - "title": "Propertyid" - }, - "description": "Property ID" - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "title": "Response Get Dashboard Api Dashboards Dashboard Id Get" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - }, - "put": { - "tags": [ - "dashboards" - ], - "summary": "Update Dashboard", - "operationId": "update_dashboard_api_dashboards__dashboard_id__put", - "parameters": [ - { - "name": "dashboard_id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Dashboard Id" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DashboardUpdateBody" - } - } - } - }, - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "title": "Response Update Dashboard Api Dashboards Dashboard Id Put" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - }, - "delete": { - "tags": [ - "dashboards" - ], - "summary": "Delete Dashboard", - "operationId": "delete_dashboard_api_dashboards__dashboard_id__delete", - "parameters": [ - { - "name": "dashboard_id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Dashboard Id" - } - }, - { - "name": "propertyId", - "in": "query", - "required": true, - "schema": { - "type": "integer", - "description": "Property ID", - "title": "Propertyid" - }, - "description": "Property ID" - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "title": "Response Delete Dashboard Api Dashboards Dashboard Id Delete" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/dashboards/ai-generate": { - "post": { - "tags": [ - "dashboards" - ], - "summary": "Dashboards Ai Generate", - "description": "Generate DashScript, a widget, or a full dashboard via LLM.", - "operationId": "dashboards_ai_generate_api_dashboards_ai_generate_post", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DashboardAiGenerateBody" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": {} - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/filters": { - "get": { - "tags": [ - "filters" - ], - "summary": "List Filters", - "operationId": "list_filters_api_filters_get", - "parameters": [ - { - "name": "propertyId", - "in": "query", - "required": true, - "schema": { - "type": "integer", - "description": "Property ID", - "title": "Propertyid" - }, - "description": "Property ID" - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "title": "Response List Filters Api Filters Get" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - }, - "post": { - "tags": [ - "filters" - ], - "summary": "Upsert Filter", - "operationId": "upsert_filter_api_filters_post", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FilterUpsertBody" - } - } - } - }, - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "title": "Response Upsert Filter Api Filters Post" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - }, - "delete": { - "tags": [ - "filters" - ], - "summary": "Delete Filter", - "operationId": "delete_filter_api_filters_delete", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FilterDeleteBody" - } - } - } - }, - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "title": "Response Delete Filter Api Filters Delete" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/integrations/google/credentials": { - "get": { - "tags": [ - "integrations" - ], - "summary": "Get Google Credentials", - "description": "Full app-level Google OAuth settings (server-side / local admin only).", - "operationId": "get_google_credentials_api_integrations_google_credentials_get", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "additionalProperties": true, - "type": "object", - "title": "Response Get Google Credentials Api Integrations Google Credentials Get" - } - } - } - } - } - }, - "post": { - "tags": [ - "integrations" - ], - "summary": "Save Google Credentials", - "operationId": "save_google_credentials_api_integrations_google_credentials_post", - "requestBody": { - "content": { - "application/json": { - "schema": { - "additionalProperties": true, - "type": "object", - "title": "Body", - "default": {} - } - } - } - }, - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "additionalProperties": true, - "type": "object", - "title": "Response Save Google Credentials Api Integrations Google Credentials Post" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/integrations/google/status": { - "get": { - "tags": [ - "integrations" - ], - "summary": "Google Status", - "operationId": "google_status_api_integrations_google_status_get", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "additionalProperties": true, - "type": "object", - "title": "Response Google Status Api Integrations Google Status Get" - } - } - } - } - } - } - }, - "/api/integrations/google/credentials/upload": { - "post": { - "tags": [ - "integrations" - ], - "summary": "Upload Google Credentials", - "operationId": "upload_google_credentials_api_integrations_google_credentials_upload_post", - "requestBody": { - "content": { - "application/json": { - "schema": { - "additionalProperties": true, - "type": "object", - "title": "Body", - "default": {} - } - } - } - }, - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "additionalProperties": true, - "type": "object", - "title": "Response Upload Google Credentials Api Integrations Google Credentials Upload Post" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/integrations/google/disconnect": { - "post": { - "tags": [ - "integrations" - ], - "summary": "Google Disconnect", - "description": "Global disconnect is deprecated \u2014 use per-property disconnect.", - "operationId": "google_disconnect_api_integrations_google_disconnect_post", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "additionalProperties": true, - "type": "object", - "title": "Response Google Disconnect Api Integrations Google Disconnect Post" - } - } - } - } - } - } - }, - "/api/integrations/google/auth": { - "get": { - "tags": [ - "integrations" - ], - "summary": "Google Oauth Start", - "operationId": "google_oauth_start_api_integrations_google_auth_get", - "parameters": [ - { - "name": "propertyId", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Propertyid" - } - }, - { - "name": "startUrl", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Starturl" - } - }, - { - "name": "returnTo", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Returnto" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "title": "Response Google Oauth Start Api Integrations Google Auth Get" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/integrations/google/callback": { - "get": { - "tags": [ - "integrations" - ], - "summary": "Google Oauth Callback", - "operationId": "google_oauth_callback_api_integrations_google_callback_get", - "parameters": [ - { - "name": "code", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Code" - } - }, - { - "name": "state", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "State" - } - }, - { - "name": "error", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Error" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "title": "Response Google Oauth Callback Api Integrations Google Callback Get" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/integrations/google/properties": { - "get": { - "tags": [ - "integrations" - ], - "summary": "Google Properties Deprecated", - "description": "Deprecated \u2014 use /api/properties/{id}/google/properties.", - "operationId": "google_properties_deprecated_api_integrations_google_properties_get", - "parameters": [ - { - "name": "propertyId", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Propertyid" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "title": "Response Google Properties Deprecated Api Integrations Google Properties Get" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/integrations/google/test": { - "post": { - "tags": [ - "integrations" - ], - "summary": "Google Test", - "description": "Run `python -m src google --test` and return stdout log.", - "operationId": "google_test_api_integrations_google_test_post", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "additionalProperties": true, - "type": "object", - "title": "Response Google Test Api Integrations Google Test Post" - } - } - } - } - } - } - }, - "/api/integrations/google/page-data": { - "get": { - "tags": [ - "integrations" - ], - "summary": "Google Page Data", - "operationId": "google_page_data_api_integrations_google_page_data_get", - "parameters": [ - { - "name": "url", - "in": "query", - "required": true, - "schema": { - "type": "string", - "title": "Url" - } - }, - { - "name": "googleSnapshotId", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Googlesnapshotid" - } - }, - { - "name": "propertyId", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Propertyid" - } - }, - { - "name": "domain", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Domain" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "title": "Response Google Page Data Api Integrations Google Page Data Get" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/integrations/google/page-data/history": { - "get": { - "tags": [ - "integrations" - ], - "summary": "Google Page Data History", - "operationId": "google_page_data_history_api_integrations_google_page_data_history_get", - "parameters": [ - { - "name": "url", - "in": "query", - "required": true, - "schema": { - "type": "string", - "title": "Url" - } - }, - { - "name": "propertyId", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Propertyid" - } - }, - { - "name": "domain", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Domain" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "title": "Response Google Page Data History Api Integrations Google Page Data History Get" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/integrations/google/page-live": { - "post": { - "tags": [ - "integrations" - ], - "summary": "Google Page Live", - "operationId": "google_page_live_api_integrations_google_page_live_post", - "requestBody": { - "content": { - "application/json": { - "schema": { - "additionalProperties": true, - "type": "object", - "title": "Body", - "default": {} - } - } - } - }, - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "additionalProperties": true, - "type": "object", - "title": "Response Google Page Live Api Integrations Google Page Live Post" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/integrations/google/keywords/by-page": { - "get": { - "tags": [ - "integrations" - ], - "summary": "Google Keywords By Page", - "operationId": "google_keywords_by_page_api_integrations_google_keywords_by_page_get", - "parameters": [ - { - "name": "url", - "in": "query", - "required": true, - "schema": { - "type": "string", - "title": "Url" - } - }, - { - "name": "propertyId", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Propertyid" - } - }, - { - "name": "domain", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Domain" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "title": "Response Google Keywords By Page Api Integrations Google Keywords By Page Get" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/integrations/google/keywords/history": { + "/api/integrations/google/callback": { "get": { "tags": [ "integrations" ], - "summary": "Google Keywords History", - "operationId": "google_keywords_history_api_integrations_google_keywords_history_get", + "summary": "Google Oauth Callback", + "operationId": "google_oauth_callback_api_integrations_google_callback_get", "parameters": [ { - "name": "keyword", - "in": "query", - "required": true, - "schema": { - "type": "string", - "title": "Keyword" - } - }, - { - "name": "propertyId", + "name": "code", "in": "query", "required": false, "schema": { @@ -3186,11 +1829,11 @@ "type": "null" } ], - "title": "Propertyid" + "title": "Code" } }, { - "name": "domain", + "name": "state", "in": "query", "required": false, "schema": { @@ -3202,241 +1845,33 @@ "type": "null" } ], - "title": "Domain" - } - }, - { - "name": "limit", - "in": "query", - "required": false, - "schema": { - "type": "integer", - "maximum": 90, - "minimum": 1, - "default": 30, - "title": "Limit" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "title": "Response Google Keywords History Api Integrations Google Keywords History Get" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/integrations/bing/sync": { - "post": { - "tags": [ - "integrations" - ], - "summary": "Bing Sync", - "description": "Fetch Bing Webmaster backlinks summary using config from DB.", - "operationId": "bing_sync_api_integrations_bing_sync_post", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "additionalProperties": true, - "type": "object", - "title": "Response Bing Sync Api Integrations Bing Sync Post" - } - } - } - } - } - } - }, - "/api/integrations/google/page-compare": { - "get": { - "tags": [ - "integrations" - ], - "summary": "Google Page Compare", - "description": "Compare two page Google data snapshots.", - "operationId": "google_page_compare_api_integrations_google_page_compare_get", - "parameters": [ - { - "name": "url", - "in": "query", - "required": true, - "schema": { - "type": "string", - "title": "Url" - } - }, - { - "name": "currentType", - "in": "query", - "required": false, - "schema": { - "type": "string", - "default": "snapshot", - "title": "Currenttype" - } - }, - { - "name": "currentId", - "in": "query", - "required": true, - "schema": { - "type": "integer", - "title": "Currentid" - } - }, - { - "name": "baselineType", - "in": "query", - "required": false, - "schema": { - "type": "string", - "default": "snapshot", - "title": "Baselinetype" - } - }, - { - "name": "baselineId", - "in": "query", - "required": true, - "schema": { - "type": "integer", - "title": "Baselineid" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "title": "Response Google Page Compare Api Integrations Google Page Compare Get" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/integrations/google/page-live/history": { - "get": { - "tags": [ - "integrations" - ], - "summary": "Google Page Live History", - "description": "Return history of page Google snapshots for a URL.", - "operationId": "google_page_live_history_api_integrations_google_page_live_history_get", - "parameters": [ - { - "name": "url", - "in": "query", - "required": true, - "schema": { - "type": "string", - "title": "Url" + "title": "State" } }, { - "name": "limit", + "name": "error", "in": "query", "required": false, "schema": { - "type": "integer", - "maximum": 50, - "minimum": 1, - "default": 15, - "title": "Limit" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "title": "Response Google Page Live History Api Integrations Google Page Live History Get" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/integrations/google/keywords/history/batch": { - "post": { - "tags": [ - "integrations" - ], - "summary": "Google Keywords History Batch", - "description": "Batch keyword history: { keywords: str[], limit?: int, propertyId?: int, domain?: str }", - "operationId": "google_keywords_history_batch_api_integrations_google_keywords_history_batch_post", - "requestBody": { - "content": { - "application/json": { - "schema": { - "additionalProperties": true, - "type": "object", - "title": "Body" - } + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Error" } - }, - "required": true - }, + } + ], "responses": { "200": { "description": "Successful Response", "content": { "application/json": { "schema": { - "additionalProperties": true, - "type": "object", - "title": "Response Google Keywords History Batch Api Integrations Google Keywords History Batch Post" + "title": "Response Google Oauth Callback Api Integrations Google Callback Get" } } } @@ -3454,35 +1889,40 @@ } } }, - "/api/integrations/google/keywords/expand": { - "post": { + "/api/integrations/google/properties": { + "get": { "tags": [ "integrations" ], - "summary": "Google Keywords Expand", - "description": "Expand keyword ideas from Google Keyword Planner or suggest API.", - "operationId": "google_keywords_expand_api_integrations_google_keywords_expand_post", - "requestBody": { - "content": { - "application/json": { - "schema": { - "additionalProperties": true, - "type": "object", - "title": "Body" - } + "summary": "Google Properties Deprecated", + "operationId": "google_properties_deprecated_api_integrations_google_properties_get", + "parameters": [ + { + "name": "propertyId", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Propertyid" } - }, - "required": true - }, + } + ], "responses": { "200": { "description": "Successful Response", "content": { "application/json": { "schema": { - "additionalProperties": true, "type": "object", - "title": "Response Google Keywords Expand Api Integrations Google Keywords Expand Post" + "additionalProperties": true, + "title": "Response Google Properties Deprecated Api Integrations Google Properties Get" } } } @@ -3500,26 +1940,13 @@ } } }, - "/api/integrations/google/keywords/planner": { + "/api/integrations/google/test": { "post": { "tags": [ "integrations" ], - "summary": "Google Keywords Planner", - "description": "Fetch keyword planner data from Google Ads API.", - "operationId": "google_keywords_planner_api_integrations_google_keywords_planner_post", - "requestBody": { - "content": { - "application/json": { - "schema": { - "additionalProperties": true, - "type": "object", - "title": "Body" - } - } - }, - "required": true - }, + "summary": "Google Test", + "operationId": "google_test_api_integrations_google_test_post", "responses": { "200": { "description": "Successful Response", @@ -3528,17 +1955,7 @@ "schema": { "additionalProperties": true, "type": "object", - "title": "Response Google Keywords Planner Api Integrations Google Keywords Planner Post" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" + "title": "Response Google Test Api Integrations Google Test Post" } } } @@ -3546,22 +1963,70 @@ } } }, - "/api/issues/status": { + "/api/integrations/google/page-data": { "get": { "tags": [ - "issues" + "integrations" ], - "summary": "List Issue Status Route", - "operationId": "list_issue_status_route_api_issues_status_get", + "summary": "Google Page Data", + "operationId": "google_page_data_api_integrations_google_page_data_get", "parameters": [ { - "name": "propertyId", + "name": "url", "in": "query", "required": true, "schema": { - "type": "integer", + "type": "string", + "title": "Url" + } + }, + { + "name": "googleSnapshotId", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Googlesnapshotid" + } + }, + { + "name": "propertyId", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], "title": "Propertyid" } + }, + { + "name": "domain", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Domain" + } } ], "responses": { @@ -3572,7 +2037,7 @@ "schema": { "type": "object", "additionalProperties": true, - "title": "Response List Issue Status Route Api Issues Status Get" + "title": "Response Google Page Data Api Integrations Google Page Data Get" } } } @@ -3588,25 +2053,58 @@ } } } - }, - "put": { + } + }, + "/api/integrations/google/page-data/history": { + "get": { "tags": [ - "issues" + "integrations" ], - "summary": "Upsert Issue Status Route", - "operationId": "upsert_issue_status_route_api_issues_status_put", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true, - "default": {}, - "title": "Body" - } + "summary": "Google Page Data History", + "operationId": "google_page_data_history_api_integrations_google_page_data_history_get", + "parameters": [ + { + "name": "url", + "in": "query", + "required": true, + "schema": { + "type": "string", + "title": "Url" + } + }, + { + "name": "propertyId", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Propertyid" + } + }, + { + "name": "domain", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Domain" } } - }, + ], "responses": { "200": { "description": "Successful Response", @@ -3615,7 +2113,7 @@ "schema": { "type": "object", "additionalProperties": true, - "title": "Response Upsert Issue Status Route Api Issues Status Put" + "title": "Response Google Page Data History Api Integrations Google Page Data History Get" } } } @@ -3633,13 +2131,13 @@ } } }, - "/api/issues/fix-suggestion": { + "/api/integrations/google/page-live": { "post": { "tags": [ - "issues" + "integrations" ], - "summary": "Issues Fix Suggestion", - "operationId": "issues_fix_suggestion_api_issues_fix_suggestion_post", + "summary": "Google Page Live", + "operationId": "google_page_live_api_integrations_google_page_live_post", "requestBody": { "content": { "application/json": { @@ -3658,7 +2156,9 @@ "content": { "application/json": { "schema": { - "title": "Response Issues Fix Suggestion Api Issues Fix Suggestion Post" + "additionalProperties": true, + "type": "object", + "title": "Response Google Page Live Api Integrations Google Page Live Post" } } } @@ -3676,32 +2176,65 @@ } } }, - "/api/issues/action-plan": { - "post": { + "/api/integrations/google/keywords/by-page": { + "get": { "tags": [ - "issues" + "integrations" ], - "summary": "Issues Action Plan", - "operationId": "issues_action_plan_api_issues_action_plan_post", - "requestBody": { - "content": { - "application/json": { - "schema": { - "additionalProperties": true, - "type": "object", - "title": "Body", - "default": {} - } + "summary": "Google Keywords By Page", + "operationId": "google_keywords_by_page_api_integrations_google_keywords_by_page_get", + "parameters": [ + { + "name": "url", + "in": "query", + "required": true, + "schema": { + "type": "string", + "title": "Url" + } + }, + { + "name": "propertyId", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Propertyid" + } + }, + { + "name": "domain", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Domain" } } - }, + ], "responses": { "200": { "description": "Successful Response", "content": { "application/json": { "schema": { - "title": "Response Issues Action Plan Api Issues Action Plan Post" + "type": "object", + "additionalProperties": true, + "title": "Response Google Keywords By Page Api Integrations Google Keywords By Page Get" } } } @@ -3719,32 +2252,77 @@ } } }, - "/api/ai/fix-suggestion": { - "post": { + "/api/integrations/google/keywords/history": { + "get": { "tags": [ - "issues" + "integrations" ], - "summary": "Ai Fix Suggestion", - "operationId": "ai_fix_suggestion_api_ai_fix_suggestion_post", - "requestBody": { - "content": { - "application/json": { - "schema": { - "additionalProperties": true, - "type": "object", - "title": "Body", - "default": {} - } + "summary": "Google Keywords History", + "operationId": "google_keywords_history_api_integrations_google_keywords_history_get", + "parameters": [ + { + "name": "keyword", + "in": "query", + "required": true, + "schema": { + "type": "string", + "title": "Keyword" + } + }, + { + "name": "propertyId", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Propertyid" + } + }, + { + "name": "domain", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Domain" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 90, + "minimum": 1, + "default": 30, + "title": "Limit" } } - }, + ], "responses": { "200": { "description": "Successful Response", "content": { "application/json": { "schema": { - "title": "Response Ai Fix Suggestion Api Ai Fix Suggestion Post" + "type": "object", + "additionalProperties": true, + "title": "Response Google Keywords History Api Integrations Google Keywords History Get" } } } @@ -3762,25 +2340,13 @@ } } }, - "/api/keywords/competitor-import": { + "/api/integrations/bing/sync": { "post": { "tags": [ - "keywords" + "integrations" ], - "summary": "Keywords Competitor Import", - "operationId": "keywords_competitor_import_api_keywords_competitor_import_post", - "requestBody": { - "content": { - "application/json": { - "schema": { - "additionalProperties": true, - "type": "object", - "title": "Body", - "default": {} - } - } - } - }, + "summary": "Bing Sync", + "operationId": "bing_sync_api_integrations_bing_sync_post", "responses": { "200": { "description": "Successful Response", @@ -3789,17 +2355,7 @@ "schema": { "additionalProperties": true, "type": "object", - "title": "Response Keywords Competitor Import Api Keywords Competitor Import Post" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" + "title": "Response Bing Sync Api Integrations Bing Sync Post" } } } @@ -3807,34 +2363,71 @@ } } }, - "/api/keywords/content-brief": { - "post": { + "/api/integrations/google/page-compare": { + "get": { "tags": [ - "keywords" + "integrations" ], - "summary": "Keywords Content Brief", - "operationId": "keywords_content_brief_api_keywords_content_brief_post", - "requestBody": { - "content": { - "application/json": { - "schema": { - "additionalProperties": true, - "type": "object", - "title": "Body", - "default": {} - } + "summary": "Google Page Compare", + "operationId": "google_page_compare_api_integrations_google_page_compare_get", + "parameters": [ + { + "name": "url", + "in": "query", + "required": true, + "schema": { + "type": "string", + "title": "Url" + } + }, + { + "name": "currentType", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "snapshot", + "title": "Currenttype" + } + }, + { + "name": "currentId", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "title": "Currentid" + } + }, + { + "name": "baselineType", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "snapshot", + "title": "Baselinetype" + } + }, + { + "name": "baselineId", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "title": "Baselineid" } } - }, + ], "responses": { "200": { "description": "Successful Response", "content": { "application/json": { "schema": { - "additionalProperties": true, "type": "object", - "title": "Response Keywords Content Brief Api Keywords Content Brief Post" + "additionalProperties": true, + "title": "Response Google Page Compare Api Integrations Google Page Compare Get" } } } @@ -3852,21 +2445,33 @@ } } }, - "/api/backlinks/velocity": { + "/api/integrations/google/page-live/history": { "get": { "tags": [ - "content" + "integrations" ], - "summary": "Backlinks Velocity", - "operationId": "backlinks_velocity_api_backlinks_velocity_get", + "summary": "Google Page Live History", + "operationId": "google_page_live_history_api_integrations_google_page_live_history_get", "parameters": [ { - "name": "propertyId", + "name": "url", "in": "query", "required": true, + "schema": { + "type": "string", + "title": "Url" + } + }, + { + "name": "limit", + "in": "query", + "required": false, "schema": { "type": "integer", - "title": "Propertyid" + "maximum": 50, + "minimum": 1, + "default": 15, + "title": "Limit" } } ], @@ -3878,7 +2483,7 @@ "schema": { "type": "object", "additionalProperties": true, - "title": "Response Backlinks Velocity Api Backlinks Velocity Get" + "title": "Response Google Page Live History Api Integrations Google Page Live History Get" } } } @@ -3896,24 +2501,24 @@ } } }, - "/api/backlinks/competitor-import": { + "/api/integrations/google/keywords/history/batch": { "post": { "tags": [ - "content" + "integrations" ], - "summary": "Backlinks Competitor Import", - "operationId": "backlinks_competitor_import_api_backlinks_competitor_import_post", + "summary": "Google Keywords History Batch", + "operationId": "google_keywords_history_batch_api_integrations_google_keywords_history_batch_post", "requestBody": { "content": { "application/json": { "schema": { "additionalProperties": true, "type": "object", - "title": "Body", - "default": {} + "title": "Body" } } - } + }, + "required": true }, "responses": { "200": { @@ -3923,7 +2528,7 @@ "schema": { "additionalProperties": true, "type": "object", - "title": "Response Backlinks Competitor Import Api Backlinks Competitor Import Post" + "title": "Response Google Keywords History Batch Api Integrations Google Keywords History Batch Post" } } } @@ -3941,24 +2546,25 @@ } } }, - "/api/backlinks/third-party-import": { + "/api/integrations/google/keywords/expand": { "post": { "tags": [ - "content" + "integrations" ], - "summary": "Backlinks Third Party Import", - "operationId": "backlinks_third_party_import_api_backlinks_third_party_import_post", + "summary": "Google Keywords Expand", + "description": "Expand keyword ideas from Google Keyword Planner or suggest API.", + "operationId": "google_keywords_expand_api_integrations_google_keywords_expand_post", "requestBody": { "content": { "application/json": { "schema": { "additionalProperties": true, "type": "object", - "title": "Body", - "default": {} + "title": "Body" } } - } + }, + "required": true }, "responses": { "200": { @@ -3968,7 +2574,7 @@ "schema": { "additionalProperties": true, "type": "object", - "title": "Response Backlinks Third Party Import Api Backlinks Third Party Import Post" + "title": "Response Google Keywords Expand Api Integrations Google Keywords Expand Post" } } } @@ -3986,24 +2592,25 @@ } } }, - "/api/content/analyze": { + "/api/integrations/google/keywords/planner": { "post": { "tags": [ - "content" + "integrations" ], - "summary": "Content Analyze", - "operationId": "content_analyze_api_content_analyze_post", + "summary": "Google Keywords Planner", + "description": "Fetch keyword planner data from Google Ads API.", + "operationId": "google_keywords_planner_api_integrations_google_keywords_planner_post", "requestBody": { "content": { "application/json": { "schema": { "additionalProperties": true, "type": "object", - "title": "Body", - "default": {} + "title": "Body" } } - } + }, + "required": true }, "responses": { "200": { @@ -4013,7 +2620,7 @@ "schema": { "additionalProperties": true, "type": "object", - "title": "Response Content Analyze Api Content Analyze Post" + "title": "Response Google Keywords Planner Api Integrations Google Keywords Planner Post" } } } @@ -4031,24 +2638,24 @@ } } }, - "/api/content/score": { + "/internal/integrations/keywords/enrich": { "post": { "tags": [ - "content" + "internal-integrations" ], - "summary": "Content Score", - "operationId": "content_score_api_content_score_post", + "summary": "Internal Keyword Enrich", + "operationId": "internal_keyword_enrich_internal_integrations_keywords_enrich_post", "requestBody": { "content": { "application/json": { "schema": { "additionalProperties": true, "type": "object", - "title": "Body", - "default": {} + "title": "Body" } } - } + }, + "required": true }, "responses": { "200": { @@ -4058,7 +2665,7 @@ "schema": { "additionalProperties": true, "type": "object", - "title": "Response Content Score Api Content Score Post" + "title": "Response Internal Keyword Enrich Internal Integrations Keywords Enrich Post" } } } @@ -4076,24 +2683,24 @@ } } }, - "/api/content/wizard": { + "/internal/integrations/gsc-links/import": { "post": { "tags": [ - "content" + "internal-integrations" ], - "summary": "Content Wizard", - "operationId": "content_wizard_api_content_wizard_post", + "summary": "Internal Gsc Links Import", + "operationId": "internal_gsc_links_import_internal_integrations_gsc_links_import_post", "requestBody": { "content": { "application/json": { "schema": { "additionalProperties": true, "type": "object", - "title": "Body", - "default": {} + "title": "Body" } } - } + }, + "required": true }, "responses": { "200": { @@ -4103,7 +2710,7 @@ "schema": { "additionalProperties": true, "type": "object", - "title": "Response Content Wizard Api Content Wizard Post" + "title": "Response Internal Gsc Links Import Internal Integrations Gsc Links Import Post" } } } @@ -4121,33 +2728,34 @@ } } }, - "/api/content-drafts": { - "get": { + "/api/keywords/competitor-import": { + "post": { "tags": [ - "content" + "keywords" ], - "summary": "List Content Drafts Route", - "operationId": "list_content_drafts_route_api_content_drafts_get", - "parameters": [ - { - "name": "propertyId", - "in": "query", - "required": true, - "schema": { - "type": "integer", - "title": "Propertyid" + "summary": "Keywords Competitor Import", + "operationId": "keywords_competitor_import_api_keywords_competitor_import_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Body", + "default": {} + } } } - ], + }, "responses": { "200": { "description": "Successful Response", "content": { "application/json": { "schema": { - "type": "object", "additionalProperties": true, - "title": "Response List Content Drafts Route Api Content Drafts Get" + "type": "object", + "title": "Response Keywords Competitor Import Api Keywords Competitor Import Post" } } } @@ -4163,21 +2771,23 @@ } } } - }, + } + }, + "/api/keywords/content-brief": { "post": { "tags": [ - "content" + "keywords" ], - "summary": "Create Content Draft Route", - "operationId": "create_content_draft_route_api_content_drafts_post", + "summary": "Keywords Content Brief", + "operationId": "keywords_content_brief_api_keywords_content_brief_post", "requestBody": { "content": { "application/json": { "schema": { - "type": "object", "additionalProperties": true, - "default": {}, - "title": "Body" + "type": "object", + "title": "Body", + "default": {} } } } @@ -4188,9 +2798,9 @@ "content": { "application/json": { "schema": { - "type": "object", "additionalProperties": true, - "title": "Response Create Content Draft Route Api Content Drafts Post" + "type": "object", + "title": "Response Keywords Content Brief Api Keywords Content Brief Post" } } } @@ -4208,21 +2818,21 @@ } } }, - "/api/content-drafts/{draft_id}": { + "/api/backlinks/velocity": { "get": { "tags": [ "content" ], - "summary": "Get Content Draft Route", - "operationId": "get_content_draft_route_api_content_drafts__draft_id__get", + "summary": "Backlinks Velocity", + "operationId": "backlinks_velocity_api_backlinks_velocity_get", "parameters": [ { - "name": "draft_id", - "in": "path", + "name": "propertyId", + "in": "query", "required": true, "schema": { "type": "integer", - "title": "Draft Id" + "title": "Propertyid" } } ], @@ -4234,7 +2844,7 @@ "schema": { "type": "object", "additionalProperties": true, - "title": "Response Get Content Draft Route Api Content Drafts Draft Id Get" + "title": "Response Backlinks Velocity Api Backlinks Velocity Get" } } } @@ -4250,32 +2860,23 @@ } } } - }, - "patch": { + } + }, + "/api/backlinks/competitor-import": { + "post": { "tags": [ "content" ], - "summary": "Update Content Draft Route", - "operationId": "update_content_draft_route_api_content_drafts__draft_id__patch", - "parameters": [ - { - "name": "draft_id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Draft Id" - } - } - ], + "summary": "Backlinks Competitor Import", + "operationId": "backlinks_competitor_import_api_backlinks_competitor_import_post", "requestBody": { "content": { "application/json": { "schema": { - "type": "object", "additionalProperties": true, - "default": {}, - "title": "Body" + "type": "object", + "title": "Body", + "default": {} } } } @@ -4286,51 +2887,9 @@ "content": { "application/json": { "schema": { - "type": "object", "additionalProperties": true, - "title": "Response Update Content Draft Route Api Content Drafts Draft Id Patch" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - }, - "delete": { - "tags": [ - "content" - ], - "summary": "Delete Content Draft Route", - "operationId": "delete_content_draft_route_api_content_drafts__draft_id__delete", - "parameters": [ - { - "name": "draft_id", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Draft Id" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { "type": "object", - "additionalProperties": true, - "title": "Response Delete Content Draft Route Api Content Drafts Draft Id Delete" + "title": "Response Backlinks Competitor Import Api Backlinks Competitor Import Post" } } } @@ -4348,72 +2907,34 @@ } } }, - "/api/page-markdown": { - "get": { + "/api/backlinks/third-party-import": { + "post": { "tags": [ - "page-markdown" + "content" ], - "summary": "List Page Markdown Route", - "operationId": "list_page_markdown_route_api_page_markdown_get", - "parameters": [ - { - "name": "crawlRunId", - "in": "query", - "required": true, - "schema": { - "type": "integer", - "title": "Crawlrunid" - } - }, - { - "name": "page", - "in": "query", - "required": false, - "schema": { - "type": "integer", - "minimum": 1, - "default": 1, - "title": "Page" - } - }, - { - "name": "limit", - "in": "query", - "required": false, - "schema": { - "type": "integer", - "maximum": 100, - "minimum": 1, - "default": 25, - "title": "Limit" - } - }, - { - "name": "q", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Q" + "summary": "Backlinks Third Party Import", + "operationId": "backlinks_third_party_import_api_backlinks_third_party_import_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Body", + "default": {} + } } } - ], + }, "responses": { "200": { "description": "Successful Response", "content": { "application/json": { "schema": { - "type": "object", "additionalProperties": true, - "title": "Response List Page Markdown Route Api Page Markdown Get" + "type": "object", + "title": "Response Backlinks Third Party Import Api Backlinks Third Party Import Post" } } } @@ -4429,21 +2950,23 @@ } } } - }, - "delete": { + } + }, + "/api/content/analyze": { + "post": { "tags": [ - "page-markdown" + "content" ], - "summary": "Delete Page Markdown Route", - "operationId": "delete_page_markdown_route_api_page_markdown_delete", + "summary": "Content Analyze", + "operationId": "content_analyze_api_content_analyze_post", "requestBody": { "content": { "application/json": { "schema": { - "type": "object", "additionalProperties": true, - "default": {}, - "title": "Body" + "type": "object", + "title": "Body", + "default": {} } } } @@ -4454,9 +2977,9 @@ "content": { "application/json": { "schema": { - "type": "object", "additionalProperties": true, - "title": "Response Delete Page Markdown Route Api Page Markdown Delete" + "type": "object", + "title": "Response Content Analyze Api Content Analyze Post" } } } @@ -4474,42 +2997,34 @@ } } }, - "/api/page-markdown/content": { - "get": { + "/api/content/score": { + "post": { "tags": [ - "page-markdown" + "content" ], - "summary": "Page Markdown Content Route", - "operationId": "page_markdown_content_route_api_page_markdown_content_get", - "parameters": [ - { - "name": "crawlRunId", - "in": "query", - "required": true, - "schema": { - "type": "integer", - "title": "Crawlrunid" - } - }, - { - "name": "url", - "in": "query", - "required": true, - "schema": { - "type": "string", - "title": "Url" + "summary": "Content Score", + "operationId": "content_score_api_content_score_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Body", + "default": {} + } } } - ], + }, "responses": { "200": { "description": "Successful Response", "content": { "application/json": { "schema": { - "type": "object", "additionalProperties": true, - "title": "Response Page Markdown Content Route Api Page Markdown Content Get" + "type": "object", + "title": "Response Content Score Api Content Score Post" } } } @@ -4527,13 +3042,13 @@ } } }, - "/api/page-markdown/extract": { + "/api/content/wizard": { "post": { "tags": [ - "page-markdown" + "content" ], - "summary": "Page Markdown Extract", - "operationId": "page_markdown_extract_api_page_markdown_extract_post", + "summary": "Content Wizard", + "operationId": "content_wizard_api_content_wizard_post", "requestBody": { "content": { "application/json": { @@ -4554,7 +3069,7 @@ "schema": { "additionalProperties": true, "type": "object", - "title": "Response Page Markdown Extract Api Page Markdown Extract Post" + "title": "Response Content Wizard Api Content Wizard Post" } } } @@ -4572,27 +3087,20 @@ } } }, - "/api/page-markdown/runs": { + "/api/content-drafts": { "get": { "tags": [ - "page-markdown" + "content" ], - "summary": "Page Markdown Runs Route", - "operationId": "page_markdown_runs_route_api_page_markdown_runs_get", + "summary": "List Content Drafts Route", + "operationId": "list_content_drafts_route_api_content_drafts_get", "parameters": [ { "name": "propertyId", "in": "query", - "required": false, + "required": true, "schema": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], + "type": "integer", "title": "Propertyid" } } @@ -4605,7 +3113,7 @@ "schema": { "type": "object", "additionalProperties": true, - "title": "Response Page Markdown Runs Route Api Page Markdown Runs Get" + "title": "Response List Content Drafts Route Api Content Drafts Get" } } } @@ -4621,24 +3129,44 @@ } } } - } - }, - "/api/ollama/status": { - "get": { + }, + "post": { "tags": [ - "ollama" + "content" ], - "summary": "Ollama Status", - "operationId": "ollama_status_api_ollama_status_get", + "summary": "Create Content Draft Route", + "operationId": "create_content_draft_route_api_content_drafts_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", "content": { "application/json": { "schema": { - "additionalProperties": true, "type": "object", - "title": "Response Ollama Status Api Ollama Status Get" + "additionalProperties": true, + "title": "Response Create Content Draft Route Api Content Drafts Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" } } } @@ -4646,45 +3174,77 @@ } } }, - "/api/mcp-tools": { + "/api/content-drafts/{draft_id}": { "get": { "tags": [ - "mcp-tools" + "content" + ], + "summary": "Get Content Draft Route", + "operationId": "get_content_draft_route_api_content_drafts__draft_id__get", + "parameters": [ + { + "name": "draft_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Draft Id" + } + } ], - "summary": "Mcp Tools", - "operationId": "mcp_tools_api_mcp_tools_get", "responses": { "200": { "description": "Successful Response", "content": { "application/json": { "schema": { - "additionalProperties": true, "type": "object", - "title": "Response Mcp Tools Api Mcp Tools Get" + "additionalProperties": true, + "title": "Response Get Content Draft Route Api Content Drafts Draft Id Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" } } } } } - } - }, - "/api/portfolio/delete": { - "delete": { + }, + "patch": { "tags": [ - "portfolio" + "content" + ], + "summary": "Update Content Draft Route", + "operationId": "update_content_draft_route_api_content_drafts__draft_id__patch", + "parameters": [ + { + "name": "draft_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Draft Id" + } + } ], - "summary": "Delete Portfolio Item", - "operationId": "delete_portfolio_item_api_portfolio_delete_delete", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeletePortfolioBody" + "type": "object", + "additionalProperties": true, + "default": {}, + "title": "Body" } } - }, - "required": true + } }, "responses": { "200": { @@ -4692,9 +3252,9 @@ "content": { "application/json": { "schema": { - "additionalProperties": true, "type": "object", - "title": "Response Delete Portfolio Item Api Portfolio Delete Delete" + "additionalProperties": true, + "title": "Response Update Content Draft Route Api Content Drafts Draft Id Patch" } } } @@ -4710,23 +3270,21 @@ } } } - } - }, - "/api/alerts/check": { - "post": { + }, + "delete": { "tags": [ - "alerts" + "content" ], - "summary": "Alerts Check", - "operationId": "alerts_check_api_alerts_check_post", + "summary": "Delete Content Draft Route", + "operationId": "delete_content_draft_route_api_content_drafts__draft_id__delete", "parameters": [ { - "name": "propertyId", - "in": "query", + "name": "draft_id", + "in": "path", "required": true, "schema": { "type": "integer", - "title": "Propertyid" + "title": "Draft Id" } } ], @@ -4738,7 +3296,7 @@ "schema": { "type": "object", "additionalProperties": true, - "title": "Response Alerts Check Api Alerts Check Post" + "title": "Response Delete Content Draft Route Api Content Drafts Draft Id Delete" } } } @@ -4756,55 +3314,72 @@ } } }, - "/api/schedule/check": { - "post": { + "/api/page-markdown": { + "get": { "tags": [ - "schedule" + "page-markdown" ], - "summary": "Schedule Check", - "operationId": "schedule_check_api_schedule_check_post", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "additionalProperties": true, - "type": "object", - "title": "Response Schedule Check Api Schedule Check Post" + "summary": "List Page Markdown Route", + "operationId": "list_page_markdown_route_api_page_markdown_get", + "parameters": [ + { + "name": "crawlRunId", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "title": "Crawlrunid" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "default": 1, + "title": "Page" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 100, + "minimum": 1, + "default": 25, + "title": "Limit" + } + }, + { + "name": "q", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" } - } + ], + "title": "Q" } } - } - } - }, - "/api/logs/upload": { - "post": { - "tags": [ - "logs" ], - "summary": "Logs Upload", - "operationId": "logs_upload_api_logs_upload_post", - "requestBody": { - "content": { - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/Body_logs_upload_api_logs_upload_post" - } - } - }, - "required": true - }, "responses": { "200": { "description": "Successful Response", "content": { "application/json": { "schema": { - "additionalProperties": true, "type": "object", - "title": "Response Logs Upload Api Logs Upload Post" + "additionalProperties": true, + "title": "Response List Page Markdown Route Api Page Markdown Get" } } } @@ -4820,31 +3395,35 @@ } } } - } - }, - "/api/compare/export": { - "post": { + }, + "delete": { "tags": [ - "compare" + "page-markdown" ], - "summary": "Compare Export", - "operationId": "compare_export_api_compare_export_post", + "summary": "Delete Page Markdown Route", + "operationId": "delete_page_markdown_route_api_page_markdown_delete", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CompareExportBody" + "type": "object", + "additionalProperties": true, + "default": {}, + "title": "Body" } } - }, - "required": true + } }, "responses": { "200": { "description": "Successful Response", "content": { "application/json": { - "schema": {} + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Delete Page Markdown Route Api Page Markdown Delete" + } } } }, @@ -4861,32 +3440,42 @@ } } }, - "/api/links/page-coach": { - "post": { + "/api/page-markdown/content": { + "get": { "tags": [ - "page-coach" + "page-markdown" ], - "summary": "Page Coach", - "operationId": "page_coach_api_links_page_coach_post", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PageCoachBody" - } + "summary": "Page Markdown Content Route", + "operationId": "page_markdown_content_route_api_page_markdown_content_get", + "parameters": [ + { + "name": "crawlRunId", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "title": "Crawlrunid" } }, - "required": true - }, + { + "name": "url", + "in": "query", + "required": true, + "schema": { + "type": "string", + "title": "Url" + } + } + ], "responses": { "200": { "description": "Successful Response", "content": { "application/json": { "schema": { - "additionalProperties": true, "type": "object", - "title": "Response Page Coach Api Links Page Coach Post" + "additionalProperties": true, + "title": "Response Page Markdown Content Route Api Page Markdown Content Get" } } } @@ -4904,22 +3493,24 @@ } } }, - "/api/report/audit-tool": { + "/api/page-markdown/extract": { "post": { "tags": [ - "report-audit-tool" + "page-markdown" ], - "summary": "Run Audit Tool", - "operationId": "run_audit_tool_api_report_audit_tool_post", + "summary": "Page Markdown Extract", + "operationId": "page_markdown_extract_api_page_markdown_extract_post", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AuditToolBody" + "additionalProperties": true, + "type": "object", + "title": "Body", + "default": {} } } - }, - "required": true + } }, "responses": { "200": { @@ -4929,7 +3520,7 @@ "schema": { "additionalProperties": true, "type": "object", - "title": "Response Run Audit Tool Api Report Audit Tool Post" + "title": "Response Page Markdown Extract Api Page Markdown Extract Post" } } } @@ -4947,26 +3538,16 @@ } } }, - "/api/report/export": { + "/api/page-markdown/runs": { "get": { "tags": [ - "report-export" + "page-markdown" ], - "summary": "Export Report", - "operationId": "export_report_api_report_export_get", + "summary": "Page Markdown Runs Route", + "operationId": "page_markdown_runs_route_api_page_markdown_runs_get", "parameters": [ { - "name": "format", - "in": "query", - "required": false, - "schema": { - "type": "string", - "default": "csv", - "title": "Format" - } - }, - { - "name": "reportId", + "name": "propertyId", "in": "query", "required": false, "schema": { @@ -4978,7 +3559,7 @@ "type": "null" } ], - "title": "Reportid" + "title": "Propertyid" } } ], @@ -4987,7 +3568,11 @@ "description": "Successful Response", "content": { "application/json": { - "schema": {} + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Page Markdown Runs Route Api Page Markdown Runs Get" + } } } }, @@ -5004,28 +3589,21 @@ } } }, - "/api/report/export-sitemap": { - "get": { + "/api/alerts/check": { + "post": { "tags": [ - "report-export" + "alerts" ], - "summary": "Export Sitemap", - "operationId": "export_sitemap_api_report_export_sitemap_get", + "summary": "Alerts Check", + "operationId": "alerts_check_api_alerts_check_post", "parameters": [ { - "name": "reportId", + "name": "propertyId", "in": "query", - "required": false, + "required": true, "schema": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Reportid" + "type": "integer", + "title": "Propertyid" } } ], @@ -5034,7 +3612,11 @@ "description": "Successful Response", "content": { "application/json": { - "schema": {} + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Alerts Check Api Alerts Check Post" + } } } }, @@ -5051,83 +3633,137 @@ } } }, - "/api/report/portfolio": { - "get": { + "/api/schedule/check": { + "post": { "tags": [ - "report-portfolio" + "schedule" ], - "summary": "Report Portfolio", - "description": "Return portfolio data \u2014 groups, crawl history, summary, or single card.", - "operationId": "report_portfolio_api_report_portfolio_get", - "parameters": [ - { - "name": "widget", - "in": "query", - "required": false, - "schema": { - "type": "string", - "default": "full", - "title": "Widget" + "summary": "Schedule Check", + "operationId": "schedule_check_api_schedule_check_post", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Response Schedule Check Api Schedule Check Post" + } + } + } + } + } + } + }, + "/api/logs/upload": { + "post": { + "tags": [ + "logs" + ], + "summary": "Logs Upload", + "operationId": "logs_upload_api_logs_upload_post", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Body_logs_upload_api_logs_upload_post" + } } }, - { - "name": "ids", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Response Logs Upload Api Logs Upload Post" } - ], - "title": "Ids" + } } }, - { - "name": "reportId", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" } - ], - "title": "Reportid" + } + } + } + } + } + }, + "/api/compare/export": { + "post": { + "tags": [ + "compare" + ], + "summary": "Compare Export", + "operationId": "compare_export_api_compare_export_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CompareExportBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } } }, - { - "name": "crawlRunId", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" } - ], - "title": "Crawlrunid" + } } } + } + } + }, + "/api/report/audit-tool": { + "post": { + "tags": [ + "report-audit-tool" ], + "summary": "Run Audit Tool", + "operationId": "run_audit_tool_api_report_audit_tool_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuditToolBody" + } + } + }, + "required": true + }, "responses": { "200": { "description": "Successful Response", "content": { "application/json": { "schema": { - "type": "object", "additionalProperties": true, - "title": "Response Report Portfolio Api Report Portfolio Get" + "type": "object", + "title": "Response Run Audit Tool Api Report Audit Tool Post" } } } @@ -5249,58 +3885,6 @@ ], "title": "CancelResponse" }, - "ChatRequest": { - "properties": { - "sessionId": { - "type": "integer", - "title": "Sessionid" - }, - "propertyId": { - "type": "integer", - "title": "Propertyid" - }, - "message": { - "type": "string", - "title": "Message" - }, - "reportId": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Reportid" - } - }, - "type": "object", - "required": [ - "sessionId", - "propertyId", - "message" - ], - "title": "ChatRequest" - }, - "ChatSessionCreate": { - "properties": { - "propertyId": { - "type": "integer", - "title": "Propertyid" - }, - "title": { - "type": "string", - "title": "Title", - "default": "New chat" - } - }, - "type": "object", - "required": [ - "propertyId" - ], - "title": "ChatSessionCreate" - }, "CompareExportBody": { "properties": { "reportIdA": { @@ -5500,79 +4084,6 @@ ], "title": "DashboardUpdateBody" }, - "DeletePortfolioBody": { - "properties": { - "reportId": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Reportid" - }, - "crawlRunId": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Crawlrunid" - } - }, - "type": "object", - "title": "DeletePortfolioBody" - }, - "FilterDeleteBody": { - "properties": { - "propertyId": { - "type": "integer", - "title": "Propertyid" - }, - "name": { - "type": "string", - "title": "Name" - } - }, - "type": "object", - "required": [ - "propertyId", - "name" - ], - "title": "FilterDeleteBody" - }, - "FilterUpsertBody": { - "properties": { - "propertyId": { - "type": "integer", - "title": "Propertyid" - }, - "name": { - "type": "string", - "title": "Name" - }, - "filterJson": { - "anyOf": [ - {}, - { - "type": "null" - } - ], - "title": "Filterjson" - } - }, - "type": "object", - "required": [ - "propertyId", - "name" - ], - "title": "FilterUpsertBody" - }, "GoogleCredentialsPatch": { "properties": { "refreshToken": { @@ -5742,20 +4253,6 @@ ], "title": "JobsListResponse" }, - "LlmConfigBody": { - "properties": { - "state": { - "additionalProperties": true, - "type": "object", - "title": "State" - } - }, - "type": "object", - "required": [ - "state" - ], - "title": "LlmConfigBody" - }, "OpsSettingsBody": { "properties": { "scheduleCron": { @@ -5795,83 +4292,6 @@ "type": "object", "title": "OpsSettingsBody" }, - "PageCoachBody": { - "properties": { - "url": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Url" - }, - "refresh": { - "type": "boolean", - "title": "Refresh", - "default": false - }, - "currentType": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Currenttype" - }, - "currentId": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Currentid" - }, - "baselineType": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Baselinetype" - }, - "baselineId": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Baselineid" - }, - "propertyId": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Propertyid" - } - }, - "type": "object", - "title": "PageCoachBody" - }, "PauseResponse": { "properties": { "ok": { @@ -5944,6 +4364,23 @@ "type": "object", "title": "PresetBody" }, + "PropertyEnsureBody": { + "properties": { + "startUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Starturl" + } + }, + "type": "object", + "title": "PropertyEnsureBody" + }, "PropertyUpsertBody": { "properties": { "name": { @@ -6050,18 +4487,6 @@ "type": "array", "title": "Unknownkeys" }, - "llmState": { - "anyOf": [ - { - "additionalProperties": true, - "type": "object" - }, - { - "type": "null" - } - ], - "title": "Llmstate" - }, "propertyId": { "anyOf": [ { @@ -6112,20 +4537,6 @@ ], "title": "RunResponse" }, - "SecretsBody": { - "properties": { - "state": { - "additionalProperties": true, - "type": "object", - "title": "State" - } - }, - "type": "object", - "required": [ - "state" - ], - "title": "SecretsBody" - }, "UnknownKeyEntry": { "properties": { "key": { diff --git a/web/src/client/index.ts b/web/src/client/index.ts index d9708e81..1338fc54 100644 --- a/web/src/client/index.ts +++ b/web/src/client/index.ts @@ -1,4 +1,4 @@ // This file is auto-generated by @hey-api/openapi-ts -export { aiFixSuggestionApiAiFixSuggestionPost, alertsCheckApiAlertsCheckPost, authorizePropertyCrawlApiPropertiesPropertyIdAuthorizePost, backlinksCompetitorImportApiBacklinksCompetitorImportPost, backlinksThirdPartyImportApiBacklinksThirdPartyImportPost, backlinksVelocityApiBacklinksVelocityGet, bingSyncApiIntegrationsBingSyncPost, browserStatusCheckApiCrawlBrowserStatusGet, cancelPipelineJobApiJobsJobIdCancelPost, chatTurnApiChatPost, compareExportApiCompareExportPost, contentAnalyzeApiContentAnalyzePost, contentScoreApiContentScorePost, contentWizardApiContentWizardPost, crawlPayloadApiReportCrawlPayloadGet, createContentDraftApiContentDraftsPost, createDashboardApiDashboardsPost, createPropertyApiPropertiesPost, createSessionApiChatSessionsPost, dashboardsAiGenerateApiDashboardsAiGeneratePost, deleteContentDraftApiContentDraftsDraftIdDelete, deleteDashboardApiDashboardsDashboardIdDelete, deleteFilterApiFiltersDelete, deletePageMarkdownApiPageMarkdownDelete, deletePortfolioItemApiPortfolioDeleteDelete, deletePropertyApiPropertiesPropertyIdDelete, deleteSessionRouteApiChatSessionsSessionIdDelete, exportReportApiReportExportGet, exportSitemapApiReportExportSitemapGet, exportWorkbookApiReportExportWorkbookGet, getAppSettingApiAppSettingsGet, getArtifactApiChatArtifactsArtifactIdGet, getContentDraftApiContentDraftsDraftIdGet, getDashboardApiDashboardsDashboardIdGet, getLlmConfigApiLlmConfigGet, getPageHtmlApiCrawlPageHtmlGet, getPipelineConfigApiPipelineConfigGet, getPipelineJobApiJobsJobIdGet, getPropertyApiPropertiesPropertyIdGet, getPropertyOpsApiPropertiesPropertyIdOpsGet, getPropertyPresetApiPropertiesPropertyIdPresetGet, getSecretsApiSecretsGet, getSessionMessagesApiChatSessionsSessionIdMessagesGet, getSessionRouteApiChatSessionsSessionIdGet, googleDisconnectApiIntegrationsGoogleDisconnectPost, googleKeywordsByPageApiIntegrationsGoogleKeywordsByPageGet, googleKeywordsExpandApiIntegrationsGoogleKeywordsExpandPost, googleKeywordsHistoryApiIntegrationsGoogleKeywordsHistoryGet, googleKeywordsHistoryBatchApiIntegrationsGoogleKeywordsHistoryBatchPost, googleKeywordsPlannerApiIntegrationsGoogleKeywordsPlannerPost, googlePageCompareApiIntegrationsGooglePageCompareGet, googlePageDataApiIntegrationsGooglePageDataGet, googlePageDataHistoryApiIntegrationsGooglePageDataHistoryGet, googlePageLiveApiIntegrationsGooglePageLivePost, googlePageLiveHistoryApiIntegrationsGooglePageLiveHistoryGet, googlePropertiesDeprecatedApiIntegrationsGooglePropertiesGet, googleStatusApiIntegrationsGoogleStatusGet, googleTestApiIntegrationsGoogleTestPost, healthCheckApiHealthGet, issuesActionPlanApiIssuesActionPlanPost, issuesFixSuggestionApiIssuesFixSuggestionPost, keywordsCompetitorImportApiKeywordsCompetitorImportPost, keywordsContentBriefApiKeywordsContentBriefPost, listContentDraftsApiContentDraftsGet, listDashboardsApiDashboardsGet, listFiltersApiFiltersGet, listIssueStatusApiIssuesStatusGet, listPageMarkdownApiPageMarkdownGet, listPipelineJobsApiJobsGet, listPropertiesApiPropertiesGet, listSessionsApiChatSessionsGet, logsUploadApiLogsUploadPost, mcpToolsApiMcpToolsGet, mobileDeltaApiReportMobileDeltaGet, ollamaStatusApiOllamaStatusGet, type Options, pageCoachApiLinksPageCoachPost, pageMarkdownContentApiPageMarkdownContentGet, pageMarkdownExtractApiPageMarkdownExtractPost, pageMarkdownRunsApiPageMarkdownRunsGet, patchPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPatch, pausePipelineJobApiJobsJobIdPausePost, postPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPost, postPropertyGoogleDisconnectApiPropertiesPropertyIdGoogleDisconnectPost, propertyGoogleLinksImportApiPropertiesPropertyIdGoogleLinksImportPost, propertyGoogleLinksStatusApiPropertiesPropertyIdGoogleLinksStatusGet, propertyGooglePropertiesApiPropertiesPropertyIdGooglePropertiesGet, propertyGoogleStatusApiPropertiesPropertyIdGoogleStatusGet, propertyGoogleTestApiPropertiesPropertyIdGoogleTestPost, putAppSettingApiAppSettingsPut, putLlmConfigApiLlmConfigPut, putPipelineConfigApiPipelineConfigPut, putSecretsApiSecretsPut, reportHistoryApiReportHistoryGet, reportMetaApiReportMetaGet, reportPayloadApiReportPayloadGet, reportPortfolioApiReportPortfolioGet, resolvePropertyApiPropertiesResolveGet, resumePipelineJobApiJobsJobIdResumePost, runAuditToolApiReportAuditToolPost, runPipelineApiRunPost, saveGoogleCredentialsApiIntegrationsGoogleCredentialsPost, scheduleCheckApiScheduleCheckPost, updateContentDraftApiContentDraftsDraftIdPatch, updateDashboardApiDashboardsDashboardIdPut, updatePropertyOpsApiPropertiesPropertyIdOpsPut, updatePropertyPresetApiPropertiesPropertyIdPresetPut, uploadGoogleCredentialsApiIntegrationsGoogleCredentialsUploadPost, upsertFilterApiFiltersPost, upsertIssueStatusApiIssuesStatusPut } from './sdk.gen'; -export type { AiFixSuggestionApiAiFixSuggestionPostData, AiFixSuggestionApiAiFixSuggestionPostError, AiFixSuggestionApiAiFixSuggestionPostErrors, AiFixSuggestionApiAiFixSuggestionPostResponses, AlertsCheckApiAlertsCheckPostData, AlertsCheckApiAlertsCheckPostError, AlertsCheckApiAlertsCheckPostErrors, AlertsCheckApiAlertsCheckPostResponse, AlertsCheckApiAlertsCheckPostResponses, AppSettingBody, AuditToolBody, AuthorizePropertyCrawlApiPropertiesPropertyIdAuthorizePostData, AuthorizePropertyCrawlApiPropertiesPropertyIdAuthorizePostError, AuthorizePropertyCrawlApiPropertiesPropertyIdAuthorizePostErrors, AuthorizePropertyCrawlApiPropertiesPropertyIdAuthorizePostResponse, AuthorizePropertyCrawlApiPropertiesPropertyIdAuthorizePostResponses, BacklinksCompetitorImportApiBacklinksCompetitorImportPostData, BacklinksCompetitorImportApiBacklinksCompetitorImportPostError, BacklinksCompetitorImportApiBacklinksCompetitorImportPostErrors, BacklinksCompetitorImportApiBacklinksCompetitorImportPostResponse, BacklinksCompetitorImportApiBacklinksCompetitorImportPostResponses, BacklinksThirdPartyImportApiBacklinksThirdPartyImportPostData, BacklinksThirdPartyImportApiBacklinksThirdPartyImportPostError, BacklinksThirdPartyImportApiBacklinksThirdPartyImportPostErrors, BacklinksThirdPartyImportApiBacklinksThirdPartyImportPostResponse, BacklinksThirdPartyImportApiBacklinksThirdPartyImportPostResponses, BacklinksVelocityApiBacklinksVelocityGetData, BacklinksVelocityApiBacklinksVelocityGetError, BacklinksVelocityApiBacklinksVelocityGetErrors, BacklinksVelocityApiBacklinksVelocityGetResponse, BacklinksVelocityApiBacklinksVelocityGetResponses, BingSyncApiIntegrationsBingSyncPostData, BingSyncApiIntegrationsBingSyncPostResponse, BingSyncApiIntegrationsBingSyncPostResponses, BodyLogsUploadApiLogsUploadPost, BrowserStatusCheckApiCrawlBrowserStatusGetData, BrowserStatusCheckApiCrawlBrowserStatusGetResponse, BrowserStatusCheckApiCrawlBrowserStatusGetResponses, CancelPipelineJobApiJobsJobIdCancelPostData, CancelPipelineJobApiJobsJobIdCancelPostError, CancelPipelineJobApiJobsJobIdCancelPostErrors, CancelPipelineJobApiJobsJobIdCancelPostResponse, CancelPipelineJobApiJobsJobIdCancelPostResponses, CancelResponse, ChatRequest, ChatSessionCreate, ChatTurnApiChatPostData, ChatTurnApiChatPostError, ChatTurnApiChatPostErrors, ChatTurnApiChatPostResponses, ClientOptions, CompareExportApiCompareExportPostData, CompareExportApiCompareExportPostError, CompareExportApiCompareExportPostErrors, CompareExportApiCompareExportPostResponses, CompareExportBody, ContentAnalyzeApiContentAnalyzePostData, ContentAnalyzeApiContentAnalyzePostError, ContentAnalyzeApiContentAnalyzePostErrors, ContentAnalyzeApiContentAnalyzePostResponse, ContentAnalyzeApiContentAnalyzePostResponses, ContentScoreApiContentScorePostData, ContentScoreApiContentScorePostError, ContentScoreApiContentScorePostErrors, ContentScoreApiContentScorePostResponse, ContentScoreApiContentScorePostResponses, ContentWizardApiContentWizardPostData, ContentWizardApiContentWizardPostError, ContentWizardApiContentWizardPostErrors, ContentWizardApiContentWizardPostResponse, ContentWizardApiContentWizardPostResponses, CrawlPayloadApiReportCrawlPayloadGetData, CrawlPayloadApiReportCrawlPayloadGetError, CrawlPayloadApiReportCrawlPayloadGetErrors, CrawlPayloadApiReportCrawlPayloadGetResponse, CrawlPayloadApiReportCrawlPayloadGetResponses, CreateContentDraftApiContentDraftsPostData, CreateContentDraftApiContentDraftsPostError, CreateContentDraftApiContentDraftsPostErrors, CreateContentDraftApiContentDraftsPostResponse, CreateContentDraftApiContentDraftsPostResponses, CreateDashboardApiDashboardsPostData, CreateDashboardApiDashboardsPostError, CreateDashboardApiDashboardsPostErrors, CreateDashboardApiDashboardsPostResponse, CreateDashboardApiDashboardsPostResponses, CreatePropertyApiPropertiesPostData, CreatePropertyApiPropertiesPostError, CreatePropertyApiPropertiesPostErrors, CreatePropertyApiPropertiesPostResponse, CreatePropertyApiPropertiesPostResponses, CreateSessionApiChatSessionsPostData, CreateSessionApiChatSessionsPostError, CreateSessionApiChatSessionsPostErrors, CreateSessionApiChatSessionsPostResponse, CreateSessionApiChatSessionsPostResponses, DashboardAiGenerateBody, DashboardCreateBody, DashboardsAiGenerateApiDashboardsAiGeneratePostData, DashboardsAiGenerateApiDashboardsAiGeneratePostError, DashboardsAiGenerateApiDashboardsAiGeneratePostErrors, DashboardsAiGenerateApiDashboardsAiGeneratePostResponses, DashboardUpdateBody, DeleteContentDraftApiContentDraftsDraftIdDeleteData, DeleteContentDraftApiContentDraftsDraftIdDeleteError, DeleteContentDraftApiContentDraftsDraftIdDeleteErrors, DeleteContentDraftApiContentDraftsDraftIdDeleteResponse, DeleteContentDraftApiContentDraftsDraftIdDeleteResponses, DeleteDashboardApiDashboardsDashboardIdDeleteData, DeleteDashboardApiDashboardsDashboardIdDeleteError, DeleteDashboardApiDashboardsDashboardIdDeleteErrors, DeleteDashboardApiDashboardsDashboardIdDeleteResponse, DeleteDashboardApiDashboardsDashboardIdDeleteResponses, DeleteFilterApiFiltersDeleteData, DeleteFilterApiFiltersDeleteError, DeleteFilterApiFiltersDeleteErrors, DeleteFilterApiFiltersDeleteResponse, DeleteFilterApiFiltersDeleteResponses, DeletePageMarkdownApiPageMarkdownDeleteData, DeletePageMarkdownApiPageMarkdownDeleteError, DeletePageMarkdownApiPageMarkdownDeleteErrors, DeletePageMarkdownApiPageMarkdownDeleteResponse, DeletePageMarkdownApiPageMarkdownDeleteResponses, DeletePortfolioBody, DeletePortfolioItemApiPortfolioDeleteDeleteData, DeletePortfolioItemApiPortfolioDeleteDeleteError, DeletePortfolioItemApiPortfolioDeleteDeleteErrors, DeletePortfolioItemApiPortfolioDeleteDeleteResponse, DeletePortfolioItemApiPortfolioDeleteDeleteResponses, DeletePropertyApiPropertiesPropertyIdDeleteData, DeletePropertyApiPropertiesPropertyIdDeleteError, DeletePropertyApiPropertiesPropertyIdDeleteErrors, DeletePropertyApiPropertiesPropertyIdDeleteResponse, DeletePropertyApiPropertiesPropertyIdDeleteResponses, DeleteSessionRouteApiChatSessionsSessionIdDeleteData, DeleteSessionRouteApiChatSessionsSessionIdDeleteError, DeleteSessionRouteApiChatSessionsSessionIdDeleteErrors, DeleteSessionRouteApiChatSessionsSessionIdDeleteResponse, DeleteSessionRouteApiChatSessionsSessionIdDeleteResponses, ExportReportApiReportExportGetData, ExportReportApiReportExportGetError, ExportReportApiReportExportGetErrors, ExportReportApiReportExportGetResponses, ExportSitemapApiReportExportSitemapGetData, ExportSitemapApiReportExportSitemapGetError, ExportSitemapApiReportExportSitemapGetErrors, ExportSitemapApiReportExportSitemapGetResponses, ExportWorkbookApiReportExportWorkbookGetData, ExportWorkbookApiReportExportWorkbookGetError, ExportWorkbookApiReportExportWorkbookGetErrors, ExportWorkbookApiReportExportWorkbookGetResponses, FilterDeleteBody, FilterUpsertBody, GetAppSettingApiAppSettingsGetData, GetAppSettingApiAppSettingsGetError, GetAppSettingApiAppSettingsGetErrors, GetAppSettingApiAppSettingsGetResponse, GetAppSettingApiAppSettingsGetResponses, GetArtifactApiChatArtifactsArtifactIdGetData, GetArtifactApiChatArtifactsArtifactIdGetError, GetArtifactApiChatArtifactsArtifactIdGetErrors, GetArtifactApiChatArtifactsArtifactIdGetResponses, GetContentDraftApiContentDraftsDraftIdGetData, GetContentDraftApiContentDraftsDraftIdGetError, GetContentDraftApiContentDraftsDraftIdGetErrors, GetContentDraftApiContentDraftsDraftIdGetResponse, GetContentDraftApiContentDraftsDraftIdGetResponses, GetDashboardApiDashboardsDashboardIdGetData, GetDashboardApiDashboardsDashboardIdGetError, GetDashboardApiDashboardsDashboardIdGetErrors, GetDashboardApiDashboardsDashboardIdGetResponse, GetDashboardApiDashboardsDashboardIdGetResponses, GetLlmConfigApiLlmConfigGetData, GetLlmConfigApiLlmConfigGetResponse, GetLlmConfigApiLlmConfigGetResponses, GetPageHtmlApiCrawlPageHtmlGetData, GetPageHtmlApiCrawlPageHtmlGetError, GetPageHtmlApiCrawlPageHtmlGetErrors, GetPageHtmlApiCrawlPageHtmlGetResponse, GetPageHtmlApiCrawlPageHtmlGetResponses, GetPipelineConfigApiPipelineConfigGetData, GetPipelineConfigApiPipelineConfigGetResponse, GetPipelineConfigApiPipelineConfigGetResponses, GetPipelineJobApiJobsJobIdGetData, GetPipelineJobApiJobsJobIdGetError, GetPipelineJobApiJobsJobIdGetErrors, GetPipelineJobApiJobsJobIdGetResponse, GetPipelineJobApiJobsJobIdGetResponses, GetPropertyApiPropertiesPropertyIdGetData, GetPropertyApiPropertiesPropertyIdGetError, GetPropertyApiPropertiesPropertyIdGetErrors, GetPropertyApiPropertiesPropertyIdGetResponse, GetPropertyApiPropertiesPropertyIdGetResponses, GetPropertyOpsApiPropertiesPropertyIdOpsGetData, GetPropertyOpsApiPropertiesPropertyIdOpsGetError, GetPropertyOpsApiPropertiesPropertyIdOpsGetErrors, GetPropertyOpsApiPropertiesPropertyIdOpsGetResponse, GetPropertyOpsApiPropertiesPropertyIdOpsGetResponses, GetPropertyPresetApiPropertiesPropertyIdPresetGetData, GetPropertyPresetApiPropertiesPropertyIdPresetGetError, GetPropertyPresetApiPropertiesPropertyIdPresetGetErrors, GetPropertyPresetApiPropertiesPropertyIdPresetGetResponse, GetPropertyPresetApiPropertiesPropertyIdPresetGetResponses, GetSecretsApiSecretsGetData, GetSecretsApiSecretsGetResponse, GetSecretsApiSecretsGetResponses, GetSessionMessagesApiChatSessionsSessionIdMessagesGetData, GetSessionMessagesApiChatSessionsSessionIdMessagesGetError, GetSessionMessagesApiChatSessionsSessionIdMessagesGetErrors, GetSessionMessagesApiChatSessionsSessionIdMessagesGetResponse, GetSessionMessagesApiChatSessionsSessionIdMessagesGetResponses, GetSessionRouteApiChatSessionsSessionIdGetData, GetSessionRouteApiChatSessionsSessionIdGetError, GetSessionRouteApiChatSessionsSessionIdGetErrors, GetSessionRouteApiChatSessionsSessionIdGetResponse, GetSessionRouteApiChatSessionsSessionIdGetResponses, GoogleCredentialsPatch, GoogleCredentialsPostBody, GoogleDisconnectApiIntegrationsGoogleDisconnectPostData, GoogleDisconnectApiIntegrationsGoogleDisconnectPostResponse, GoogleDisconnectApiIntegrationsGoogleDisconnectPostResponses, GoogleKeywordsByPageApiIntegrationsGoogleKeywordsByPageGetData, GoogleKeywordsByPageApiIntegrationsGoogleKeywordsByPageGetError, GoogleKeywordsByPageApiIntegrationsGoogleKeywordsByPageGetErrors, GoogleKeywordsByPageApiIntegrationsGoogleKeywordsByPageGetResponse, GoogleKeywordsByPageApiIntegrationsGoogleKeywordsByPageGetResponses, GoogleKeywordsExpandApiIntegrationsGoogleKeywordsExpandPostData, GoogleKeywordsExpandApiIntegrationsGoogleKeywordsExpandPostError, GoogleKeywordsExpandApiIntegrationsGoogleKeywordsExpandPostErrors, GoogleKeywordsExpandApiIntegrationsGoogleKeywordsExpandPostResponse, GoogleKeywordsExpandApiIntegrationsGoogleKeywordsExpandPostResponses, GoogleKeywordsHistoryApiIntegrationsGoogleKeywordsHistoryGetData, GoogleKeywordsHistoryApiIntegrationsGoogleKeywordsHistoryGetError, GoogleKeywordsHistoryApiIntegrationsGoogleKeywordsHistoryGetErrors, GoogleKeywordsHistoryApiIntegrationsGoogleKeywordsHistoryGetResponse, GoogleKeywordsHistoryApiIntegrationsGoogleKeywordsHistoryGetResponses, GoogleKeywordsHistoryBatchApiIntegrationsGoogleKeywordsHistoryBatchPostData, GoogleKeywordsHistoryBatchApiIntegrationsGoogleKeywordsHistoryBatchPostError, GoogleKeywordsHistoryBatchApiIntegrationsGoogleKeywordsHistoryBatchPostErrors, GoogleKeywordsHistoryBatchApiIntegrationsGoogleKeywordsHistoryBatchPostResponse, GoogleKeywordsHistoryBatchApiIntegrationsGoogleKeywordsHistoryBatchPostResponses, GoogleKeywordsPlannerApiIntegrationsGoogleKeywordsPlannerPostData, GoogleKeywordsPlannerApiIntegrationsGoogleKeywordsPlannerPostError, GoogleKeywordsPlannerApiIntegrationsGoogleKeywordsPlannerPostErrors, GoogleKeywordsPlannerApiIntegrationsGoogleKeywordsPlannerPostResponse, GoogleKeywordsPlannerApiIntegrationsGoogleKeywordsPlannerPostResponses, GooglePageCompareApiIntegrationsGooglePageCompareGetData, GooglePageCompareApiIntegrationsGooglePageCompareGetError, GooglePageCompareApiIntegrationsGooglePageCompareGetErrors, GooglePageCompareApiIntegrationsGooglePageCompareGetResponse, GooglePageCompareApiIntegrationsGooglePageCompareGetResponses, GooglePageDataApiIntegrationsGooglePageDataGetData, GooglePageDataApiIntegrationsGooglePageDataGetError, GooglePageDataApiIntegrationsGooglePageDataGetErrors, GooglePageDataApiIntegrationsGooglePageDataGetResponse, GooglePageDataApiIntegrationsGooglePageDataGetResponses, GooglePageDataHistoryApiIntegrationsGooglePageDataHistoryGetData, GooglePageDataHistoryApiIntegrationsGooglePageDataHistoryGetError, GooglePageDataHistoryApiIntegrationsGooglePageDataHistoryGetErrors, GooglePageDataHistoryApiIntegrationsGooglePageDataHistoryGetResponse, GooglePageDataHistoryApiIntegrationsGooglePageDataHistoryGetResponses, GooglePageLiveApiIntegrationsGooglePageLivePostData, GooglePageLiveApiIntegrationsGooglePageLivePostError, GooglePageLiveApiIntegrationsGooglePageLivePostErrors, GooglePageLiveApiIntegrationsGooglePageLivePostResponse, GooglePageLiveApiIntegrationsGooglePageLivePostResponses, GooglePageLiveHistoryApiIntegrationsGooglePageLiveHistoryGetData, GooglePageLiveHistoryApiIntegrationsGooglePageLiveHistoryGetError, GooglePageLiveHistoryApiIntegrationsGooglePageLiveHistoryGetErrors, GooglePageLiveHistoryApiIntegrationsGooglePageLiveHistoryGetResponse, GooglePageLiveHistoryApiIntegrationsGooglePageLiveHistoryGetResponses, GooglePropertiesDeprecatedApiIntegrationsGooglePropertiesGetData, GooglePropertiesDeprecatedApiIntegrationsGooglePropertiesGetError, GooglePropertiesDeprecatedApiIntegrationsGooglePropertiesGetErrors, GooglePropertiesDeprecatedApiIntegrationsGooglePropertiesGetResponse, GooglePropertiesDeprecatedApiIntegrationsGooglePropertiesGetResponses, GoogleStatusApiIntegrationsGoogleStatusGetData, GoogleStatusApiIntegrationsGoogleStatusGetResponse, GoogleStatusApiIntegrationsGoogleStatusGetResponses, GoogleTestApiIntegrationsGoogleTestPostData, GoogleTestApiIntegrationsGoogleTestPostResponse, GoogleTestApiIntegrationsGoogleTestPostResponses, HealthCheckApiHealthGetData, HealthCheckApiHealthGetResponse, HealthCheckApiHealthGetResponses, HttpValidationError, IssuesActionPlanApiIssuesActionPlanPostData, IssuesActionPlanApiIssuesActionPlanPostError, IssuesActionPlanApiIssuesActionPlanPostErrors, IssuesActionPlanApiIssuesActionPlanPostResponses, IssuesFixSuggestionApiIssuesFixSuggestionPostData, IssuesFixSuggestionApiIssuesFixSuggestionPostError, IssuesFixSuggestionApiIssuesFixSuggestionPostErrors, IssuesFixSuggestionApiIssuesFixSuggestionPostResponses, JobsListResponse, KeywordsCompetitorImportApiKeywordsCompetitorImportPostData, KeywordsCompetitorImportApiKeywordsCompetitorImportPostError, KeywordsCompetitorImportApiKeywordsCompetitorImportPostErrors, KeywordsCompetitorImportApiKeywordsCompetitorImportPostResponse, KeywordsCompetitorImportApiKeywordsCompetitorImportPostResponses, KeywordsContentBriefApiKeywordsContentBriefPostData, KeywordsContentBriefApiKeywordsContentBriefPostError, KeywordsContentBriefApiKeywordsContentBriefPostErrors, KeywordsContentBriefApiKeywordsContentBriefPostResponse, KeywordsContentBriefApiKeywordsContentBriefPostResponses, ListContentDraftsApiContentDraftsGetData, ListContentDraftsApiContentDraftsGetError, ListContentDraftsApiContentDraftsGetErrors, ListContentDraftsApiContentDraftsGetResponse, ListContentDraftsApiContentDraftsGetResponses, ListDashboardsApiDashboardsGetData, ListDashboardsApiDashboardsGetError, ListDashboardsApiDashboardsGetErrors, ListDashboardsApiDashboardsGetResponse, ListDashboardsApiDashboardsGetResponses, ListFiltersApiFiltersGetData, ListFiltersApiFiltersGetError, ListFiltersApiFiltersGetErrors, ListFiltersApiFiltersGetResponse, ListFiltersApiFiltersGetResponses, ListIssueStatusApiIssuesStatusGetData, ListIssueStatusApiIssuesStatusGetError, ListIssueStatusApiIssuesStatusGetErrors, ListIssueStatusApiIssuesStatusGetResponse, ListIssueStatusApiIssuesStatusGetResponses, ListPageMarkdownApiPageMarkdownGetData, ListPageMarkdownApiPageMarkdownGetError, ListPageMarkdownApiPageMarkdownGetErrors, ListPageMarkdownApiPageMarkdownGetResponse, ListPageMarkdownApiPageMarkdownGetResponses, ListPipelineJobsApiJobsGetData, ListPipelineJobsApiJobsGetError, ListPipelineJobsApiJobsGetErrors, ListPipelineJobsApiJobsGetResponse, ListPipelineJobsApiJobsGetResponses, ListPropertiesApiPropertiesGetData, ListPropertiesApiPropertiesGetResponse, ListPropertiesApiPropertiesGetResponses, ListSessionsApiChatSessionsGetData, ListSessionsApiChatSessionsGetError, ListSessionsApiChatSessionsGetErrors, ListSessionsApiChatSessionsGetResponse, ListSessionsApiChatSessionsGetResponses, LlmConfigBody, LogsUploadApiLogsUploadPostData, LogsUploadApiLogsUploadPostError, LogsUploadApiLogsUploadPostErrors, LogsUploadApiLogsUploadPostResponse, LogsUploadApiLogsUploadPostResponses, McpToolsApiMcpToolsGetData, McpToolsApiMcpToolsGetResponse, McpToolsApiMcpToolsGetResponses, MobileDeltaApiReportMobileDeltaGetData, MobileDeltaApiReportMobileDeltaGetError, MobileDeltaApiReportMobileDeltaGetErrors, MobileDeltaApiReportMobileDeltaGetResponse, MobileDeltaApiReportMobileDeltaGetResponses, OllamaStatusApiOllamaStatusGetData, OllamaStatusApiOllamaStatusGetResponse, OllamaStatusApiOllamaStatusGetResponses, OpsSettingsBody, PageCoachApiLinksPageCoachPostData, PageCoachApiLinksPageCoachPostError, PageCoachApiLinksPageCoachPostErrors, PageCoachApiLinksPageCoachPostResponse, PageCoachApiLinksPageCoachPostResponses, PageCoachBody, PageMarkdownContentApiPageMarkdownContentGetData, PageMarkdownContentApiPageMarkdownContentGetError, PageMarkdownContentApiPageMarkdownContentGetErrors, PageMarkdownContentApiPageMarkdownContentGetResponse, PageMarkdownContentApiPageMarkdownContentGetResponses, PageMarkdownExtractApiPageMarkdownExtractPostData, PageMarkdownExtractApiPageMarkdownExtractPostError, PageMarkdownExtractApiPageMarkdownExtractPostErrors, PageMarkdownExtractApiPageMarkdownExtractPostResponse, PageMarkdownExtractApiPageMarkdownExtractPostResponses, PageMarkdownRunsApiPageMarkdownRunsGetData, PageMarkdownRunsApiPageMarkdownRunsGetError, PageMarkdownRunsApiPageMarkdownRunsGetErrors, PageMarkdownRunsApiPageMarkdownRunsGetResponse, PageMarkdownRunsApiPageMarkdownRunsGetResponses, PatchPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPatchData, PatchPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPatchError, PatchPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPatchErrors, PatchPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPatchResponse, PatchPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPatchResponses, PausePipelineJobApiJobsJobIdPausePostData, PausePipelineJobApiJobsJobIdPausePostError, PausePipelineJobApiJobsJobIdPausePostErrors, PausePipelineJobApiJobsJobIdPausePostResponse, PausePipelineJobApiJobsJobIdPausePostResponses, PauseResponse, PipelineConfigBody, PostPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPostData, PostPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPostError, PostPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPostErrors, PostPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPostResponse, PostPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPostResponses, PostPropertyGoogleDisconnectApiPropertiesPropertyIdGoogleDisconnectPostData, PostPropertyGoogleDisconnectApiPropertiesPropertyIdGoogleDisconnectPostError, PostPropertyGoogleDisconnectApiPropertiesPropertyIdGoogleDisconnectPostErrors, PostPropertyGoogleDisconnectApiPropertiesPropertyIdGoogleDisconnectPostResponse, PostPropertyGoogleDisconnectApiPropertiesPropertyIdGoogleDisconnectPostResponses, PresetBody, PropertyGoogleLinksImportApiPropertiesPropertyIdGoogleLinksImportPostData, PropertyGoogleLinksImportApiPropertiesPropertyIdGoogleLinksImportPostError, PropertyGoogleLinksImportApiPropertiesPropertyIdGoogleLinksImportPostErrors, PropertyGoogleLinksImportApiPropertiesPropertyIdGoogleLinksImportPostResponse, PropertyGoogleLinksImportApiPropertiesPropertyIdGoogleLinksImportPostResponses, PropertyGoogleLinksStatusApiPropertiesPropertyIdGoogleLinksStatusGetData, PropertyGoogleLinksStatusApiPropertiesPropertyIdGoogleLinksStatusGetError, PropertyGoogleLinksStatusApiPropertiesPropertyIdGoogleLinksStatusGetErrors, PropertyGoogleLinksStatusApiPropertiesPropertyIdGoogleLinksStatusGetResponse, PropertyGoogleLinksStatusApiPropertiesPropertyIdGoogleLinksStatusGetResponses, PropertyGooglePropertiesApiPropertiesPropertyIdGooglePropertiesGetData, PropertyGooglePropertiesApiPropertiesPropertyIdGooglePropertiesGetError, PropertyGooglePropertiesApiPropertiesPropertyIdGooglePropertiesGetErrors, PropertyGooglePropertiesApiPropertiesPropertyIdGooglePropertiesGetResponse, PropertyGooglePropertiesApiPropertiesPropertyIdGooglePropertiesGetResponses, PropertyGoogleStatusApiPropertiesPropertyIdGoogleStatusGetData, PropertyGoogleStatusApiPropertiesPropertyIdGoogleStatusGetError, PropertyGoogleStatusApiPropertiesPropertyIdGoogleStatusGetErrors, PropertyGoogleStatusApiPropertiesPropertyIdGoogleStatusGetResponse, PropertyGoogleStatusApiPropertiesPropertyIdGoogleStatusGetResponses, PropertyGoogleTestApiPropertiesPropertyIdGoogleTestPostData, PropertyGoogleTestApiPropertiesPropertyIdGoogleTestPostError, PropertyGoogleTestApiPropertiesPropertyIdGoogleTestPostErrors, PropertyGoogleTestApiPropertiesPropertyIdGoogleTestPostResponse, PropertyGoogleTestApiPropertiesPropertyIdGoogleTestPostResponses, PropertyUpsertBody, PutAppSettingApiAppSettingsPutData, PutAppSettingApiAppSettingsPutError, PutAppSettingApiAppSettingsPutErrors, PutAppSettingApiAppSettingsPutResponse, PutAppSettingApiAppSettingsPutResponses, PutLlmConfigApiLlmConfigPutData, PutLlmConfigApiLlmConfigPutError, PutLlmConfigApiLlmConfigPutErrors, PutLlmConfigApiLlmConfigPutResponse, PutLlmConfigApiLlmConfigPutResponses, PutPipelineConfigApiPipelineConfigPutData, PutPipelineConfigApiPipelineConfigPutError, PutPipelineConfigApiPipelineConfigPutErrors, PutPipelineConfigApiPipelineConfigPutResponse, PutPipelineConfigApiPipelineConfigPutResponses, PutSecretsApiSecretsPutData, PutSecretsApiSecretsPutError, PutSecretsApiSecretsPutErrors, PutSecretsApiSecretsPutResponse, PutSecretsApiSecretsPutResponses, ReportHistoryApiReportHistoryGetData, ReportHistoryApiReportHistoryGetError, ReportHistoryApiReportHistoryGetErrors, ReportHistoryApiReportHistoryGetResponse, ReportHistoryApiReportHistoryGetResponses, ReportMetaApiReportMetaGetData, ReportMetaApiReportMetaGetResponse, ReportMetaApiReportMetaGetResponses, ReportPayloadApiReportPayloadGetData, ReportPayloadApiReportPayloadGetError, ReportPayloadApiReportPayloadGetErrors, ReportPayloadApiReportPayloadGetResponse, ReportPayloadApiReportPayloadGetResponses, ReportPortfolioApiReportPortfolioGetData, ReportPortfolioApiReportPortfolioGetError, ReportPortfolioApiReportPortfolioGetErrors, ReportPortfolioApiReportPortfolioGetResponse, ReportPortfolioApiReportPortfolioGetResponses, ResolvePropertyApiPropertiesResolveGetData, ResolvePropertyApiPropertiesResolveGetError, ResolvePropertyApiPropertiesResolveGetErrors, ResolvePropertyApiPropertiesResolveGetResponse, ResolvePropertyApiPropertiesResolveGetResponses, ResumePipelineJobApiJobsJobIdResumePostData, ResumePipelineJobApiJobsJobIdResumePostError, ResumePipelineJobApiJobsJobIdResumePostErrors, ResumePipelineJobApiJobsJobIdResumePostResponse, ResumePipelineJobApiJobsJobIdResumePostResponses, ResumeResponse, RunAuditToolApiReportAuditToolPostData, RunAuditToolApiReportAuditToolPostError, RunAuditToolApiReportAuditToolPostErrors, RunAuditToolApiReportAuditToolPostResponse, RunAuditToolApiReportAuditToolPostResponses, RunPipelineApiRunPostData, RunPipelineApiRunPostError, RunPipelineApiRunPostErrors, RunPipelineApiRunPostResponse, RunPipelineApiRunPostResponses, RunPostBody, RunResponse, SaveGoogleCredentialsApiIntegrationsGoogleCredentialsPostData, SaveGoogleCredentialsApiIntegrationsGoogleCredentialsPostError, SaveGoogleCredentialsApiIntegrationsGoogleCredentialsPostErrors, SaveGoogleCredentialsApiIntegrationsGoogleCredentialsPostResponse, SaveGoogleCredentialsApiIntegrationsGoogleCredentialsPostResponses, ScheduleCheckApiScheduleCheckPostData, ScheduleCheckApiScheduleCheckPostResponse, ScheduleCheckApiScheduleCheckPostResponses, SecretsBody, UnknownKeyEntry, UpdateContentDraftApiContentDraftsDraftIdPatchData, UpdateContentDraftApiContentDraftsDraftIdPatchError, UpdateContentDraftApiContentDraftsDraftIdPatchErrors, UpdateContentDraftApiContentDraftsDraftIdPatchResponse, UpdateContentDraftApiContentDraftsDraftIdPatchResponses, UpdateDashboardApiDashboardsDashboardIdPutData, UpdateDashboardApiDashboardsDashboardIdPutError, UpdateDashboardApiDashboardsDashboardIdPutErrors, UpdateDashboardApiDashboardsDashboardIdPutResponse, UpdateDashboardApiDashboardsDashboardIdPutResponses, UpdatePropertyOpsApiPropertiesPropertyIdOpsPutData, UpdatePropertyOpsApiPropertiesPropertyIdOpsPutError, UpdatePropertyOpsApiPropertiesPropertyIdOpsPutErrors, UpdatePropertyOpsApiPropertiesPropertyIdOpsPutResponse, UpdatePropertyOpsApiPropertiesPropertyIdOpsPutResponses, UpdatePropertyPresetApiPropertiesPropertyIdPresetPutData, UpdatePropertyPresetApiPropertiesPropertyIdPresetPutError, UpdatePropertyPresetApiPropertiesPropertyIdPresetPutErrors, UpdatePropertyPresetApiPropertiesPropertyIdPresetPutResponse, UpdatePropertyPresetApiPropertiesPropertyIdPresetPutResponses, UploadGoogleCredentialsApiIntegrationsGoogleCredentialsUploadPostData, UploadGoogleCredentialsApiIntegrationsGoogleCredentialsUploadPostError, UploadGoogleCredentialsApiIntegrationsGoogleCredentialsUploadPostErrors, UploadGoogleCredentialsApiIntegrationsGoogleCredentialsUploadPostResponse, UploadGoogleCredentialsApiIntegrationsGoogleCredentialsUploadPostResponses, UpsertFilterApiFiltersPostData, UpsertFilterApiFiltersPostError, UpsertFilterApiFiltersPostErrors, UpsertFilterApiFiltersPostResponse, UpsertFilterApiFiltersPostResponses, UpsertIssueStatusApiIssuesStatusPutData, UpsertIssueStatusApiIssuesStatusPutError, UpsertIssueStatusApiIssuesStatusPutErrors, UpsertIssueStatusApiIssuesStatusPutResponse, UpsertIssueStatusApiIssuesStatusPutResponses, ValidationError } from './types.gen'; +export { alertsCheckApiAlertsCheckPost, authorizePropertyCrawlRouteApiPropertiesPropertyIdAuthorizePost, backlinksCompetitorImportApiBacklinksCompetitorImportPost, backlinksThirdPartyImportApiBacklinksThirdPartyImportPost, backlinksVelocityApiBacklinksVelocityGet, bingSyncApiIntegrationsBingSyncPost, browserStatusCheckApiCrawlBrowserStatusGet, cancelPipelineJobApiJobsJobIdCancelPost, compareExportApiCompareExportPost, contentAnalyzeApiContentAnalyzePost, contentScoreApiContentScorePost, contentWizardApiContentWizardPost, createContentDraftRouteApiContentDraftsPost, createDashboardApiDashboardsPost, createPropertyApiPropertiesPost, dashboardsAiGenerateApiDashboardsAiGeneratePost, deleteContentDraftRouteApiContentDraftsDraftIdDelete, deleteDashboardApiDashboardsDashboardIdDelete, deletePageMarkdownRouteApiPageMarkdownDelete, deletePropertyRouteApiPropertiesPropertyIdDelete, getAppSettingApiAppSettingsGet, getContentDraftRouteApiContentDraftsDraftIdGet, getDashboardApiDashboardsDashboardIdGet, getGoogleCredentialsApiIntegrationsGoogleCredentialsGet, getPageHtmlApiCrawlPageHtmlGet, getPipelineConfigApiPipelineConfigGet, getPipelineJobApiJobsJobIdGet, getPropertyApiPropertiesPropertyIdGet, getPropertyOpsRouteApiPropertiesPropertyIdOpsGet, getPropertyPresetApiPropertiesPropertyIdPresetGet, googleDisconnectApiIntegrationsGoogleDisconnectPost, googleKeywordsByPageApiIntegrationsGoogleKeywordsByPageGet, googleKeywordsExpandApiIntegrationsGoogleKeywordsExpandPost, googleKeywordsHistoryApiIntegrationsGoogleKeywordsHistoryGet, googleKeywordsHistoryBatchApiIntegrationsGoogleKeywordsHistoryBatchPost, googleKeywordsPlannerApiIntegrationsGoogleKeywordsPlannerPost, googleOauthCallbackApiIntegrationsGoogleCallbackGet, googleOauthStartApiIntegrationsGoogleAuthGet, googlePageCompareApiIntegrationsGooglePageCompareGet, googlePageDataApiIntegrationsGooglePageDataGet, googlePageDataHistoryApiIntegrationsGooglePageDataHistoryGet, googlePageLiveApiIntegrationsGooglePageLivePost, googlePageLiveHistoryApiIntegrationsGooglePageLiveHistoryGet, googlePropertiesDeprecatedApiIntegrationsGooglePropertiesGet, googleStatusApiIntegrationsGoogleStatusGet, googleTestApiIntegrationsGoogleTestPost, healthCheckApiHealthGet, keywordsCompetitorImportApiKeywordsCompetitorImportPost, keywordsContentBriefApiKeywordsContentBriefPost, listContentDraftsRouteApiContentDraftsGet, listDashboardsApiDashboardsGet, listPageMarkdownRouteApiPageMarkdownGet, listPipelineJobsApiJobsGet, listPropertiesApiPropertiesGet, logsUploadApiLogsUploadPost, type Options, pageMarkdownContentRouteApiPageMarkdownContentGet, pageMarkdownExtractApiPageMarkdownExtractPost, pageMarkdownRunsRouteApiPageMarkdownRunsGet, patchPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPatch, pausePipelineJobApiJobsJobIdPausePost, postPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPost, postPropertyGoogleDisconnectApiPropertiesPropertyIdGoogleDisconnectPost, propertyGoogleLinksImportApiPropertiesPropertyIdGoogleLinksImportPost, propertyGoogleLinksStatusApiPropertiesPropertyIdGoogleLinksStatusGet, propertyGooglePropertiesApiPropertiesPropertyIdGooglePropertiesGet, propertyGoogleStatusApiPropertiesPropertyIdGoogleStatusGet, propertyGoogleTestApiPropertiesPropertyIdGoogleTestPost, putAppSettingApiAppSettingsPut, putPipelineConfigApiPipelineConfigPut, resolvePropertyApiPropertiesResolveGet, resumePipelineJobApiJobsJobIdResumePost, runAuditToolApiReportAuditToolPost, runPipelineApiRunPost, scheduleCheckApiScheduleCheckPost, updateContentDraftRouteApiContentDraftsDraftIdPatch, updateDashboardApiDashboardsDashboardIdPut, updatePropertyOpsRouteApiPropertiesPropertyIdOpsPut, updatePropertyPresetApiPropertiesPropertyIdPresetPut } from './sdk.gen'; +export type { AlertsCheckApiAlertsCheckPostData, AlertsCheckApiAlertsCheckPostError, AlertsCheckApiAlertsCheckPostErrors, AlertsCheckApiAlertsCheckPostResponse, AlertsCheckApiAlertsCheckPostResponses, AppSettingBody, AuditToolBody, AuthorizePropertyCrawlRouteApiPropertiesPropertyIdAuthorizePostData, AuthorizePropertyCrawlRouteApiPropertiesPropertyIdAuthorizePostError, AuthorizePropertyCrawlRouteApiPropertiesPropertyIdAuthorizePostErrors, AuthorizePropertyCrawlRouteApiPropertiesPropertyIdAuthorizePostResponse, AuthorizePropertyCrawlRouteApiPropertiesPropertyIdAuthorizePostResponses, BacklinksCompetitorImportApiBacklinksCompetitorImportPostData, BacklinksCompetitorImportApiBacklinksCompetitorImportPostError, BacklinksCompetitorImportApiBacklinksCompetitorImportPostErrors, BacklinksCompetitorImportApiBacklinksCompetitorImportPostResponse, BacklinksCompetitorImportApiBacklinksCompetitorImportPostResponses, BacklinksThirdPartyImportApiBacklinksThirdPartyImportPostData, BacklinksThirdPartyImportApiBacklinksThirdPartyImportPostError, BacklinksThirdPartyImportApiBacklinksThirdPartyImportPostErrors, BacklinksThirdPartyImportApiBacklinksThirdPartyImportPostResponse, BacklinksThirdPartyImportApiBacklinksThirdPartyImportPostResponses, BacklinksVelocityApiBacklinksVelocityGetData, BacklinksVelocityApiBacklinksVelocityGetError, BacklinksVelocityApiBacklinksVelocityGetErrors, BacklinksVelocityApiBacklinksVelocityGetResponse, BacklinksVelocityApiBacklinksVelocityGetResponses, BingSyncApiIntegrationsBingSyncPostData, BingSyncApiIntegrationsBingSyncPostResponse, BingSyncApiIntegrationsBingSyncPostResponses, BodyLogsUploadApiLogsUploadPost, BrowserStatusCheckApiCrawlBrowserStatusGetData, BrowserStatusCheckApiCrawlBrowserStatusGetResponse, BrowserStatusCheckApiCrawlBrowserStatusGetResponses, CancelPipelineJobApiJobsJobIdCancelPostData, CancelPipelineJobApiJobsJobIdCancelPostError, CancelPipelineJobApiJobsJobIdCancelPostErrors, CancelPipelineJobApiJobsJobIdCancelPostResponse, CancelPipelineJobApiJobsJobIdCancelPostResponses, CancelResponse, ClientOptions, CompareExportApiCompareExportPostData, CompareExportApiCompareExportPostError, CompareExportApiCompareExportPostErrors, CompareExportApiCompareExportPostResponses, CompareExportBody, ContentAnalyzeApiContentAnalyzePostData, ContentAnalyzeApiContentAnalyzePostError, ContentAnalyzeApiContentAnalyzePostErrors, ContentAnalyzeApiContentAnalyzePostResponse, ContentAnalyzeApiContentAnalyzePostResponses, ContentScoreApiContentScorePostData, ContentScoreApiContentScorePostError, ContentScoreApiContentScorePostErrors, ContentScoreApiContentScorePostResponse, ContentScoreApiContentScorePostResponses, ContentWizardApiContentWizardPostData, ContentWizardApiContentWizardPostError, ContentWizardApiContentWizardPostErrors, ContentWizardApiContentWizardPostResponse, ContentWizardApiContentWizardPostResponses, CreateContentDraftRouteApiContentDraftsPostData, CreateContentDraftRouteApiContentDraftsPostError, CreateContentDraftRouteApiContentDraftsPostErrors, CreateContentDraftRouteApiContentDraftsPostResponse, CreateContentDraftRouteApiContentDraftsPostResponses, CreateDashboardApiDashboardsPostData, CreateDashboardApiDashboardsPostError, CreateDashboardApiDashboardsPostErrors, CreateDashboardApiDashboardsPostResponse, CreateDashboardApiDashboardsPostResponses, CreatePropertyApiPropertiesPostData, CreatePropertyApiPropertiesPostError, CreatePropertyApiPropertiesPostErrors, CreatePropertyApiPropertiesPostResponse, CreatePropertyApiPropertiesPostResponses, DashboardAiGenerateBody, DashboardCreateBody, DashboardsAiGenerateApiDashboardsAiGeneratePostData, DashboardsAiGenerateApiDashboardsAiGeneratePostError, DashboardsAiGenerateApiDashboardsAiGeneratePostErrors, DashboardsAiGenerateApiDashboardsAiGeneratePostResponses, DashboardUpdateBody, DeleteContentDraftRouteApiContentDraftsDraftIdDeleteData, DeleteContentDraftRouteApiContentDraftsDraftIdDeleteError, DeleteContentDraftRouteApiContentDraftsDraftIdDeleteErrors, DeleteContentDraftRouteApiContentDraftsDraftIdDeleteResponse, DeleteContentDraftRouteApiContentDraftsDraftIdDeleteResponses, DeleteDashboardApiDashboardsDashboardIdDeleteData, DeleteDashboardApiDashboardsDashboardIdDeleteError, DeleteDashboardApiDashboardsDashboardIdDeleteErrors, DeleteDashboardApiDashboardsDashboardIdDeleteResponse, DeleteDashboardApiDashboardsDashboardIdDeleteResponses, DeletePageMarkdownRouteApiPageMarkdownDeleteData, DeletePageMarkdownRouteApiPageMarkdownDeleteError, DeletePageMarkdownRouteApiPageMarkdownDeleteErrors, DeletePageMarkdownRouteApiPageMarkdownDeleteResponse, DeletePageMarkdownRouteApiPageMarkdownDeleteResponses, DeletePropertyRouteApiPropertiesPropertyIdDeleteData, DeletePropertyRouteApiPropertiesPropertyIdDeleteError, DeletePropertyRouteApiPropertiesPropertyIdDeleteErrors, DeletePropertyRouteApiPropertiesPropertyIdDeleteResponse, DeletePropertyRouteApiPropertiesPropertyIdDeleteResponses, GetAppSettingApiAppSettingsGetData, GetAppSettingApiAppSettingsGetError, GetAppSettingApiAppSettingsGetErrors, GetAppSettingApiAppSettingsGetResponse, GetAppSettingApiAppSettingsGetResponses, GetContentDraftRouteApiContentDraftsDraftIdGetData, GetContentDraftRouteApiContentDraftsDraftIdGetError, GetContentDraftRouteApiContentDraftsDraftIdGetErrors, GetContentDraftRouteApiContentDraftsDraftIdGetResponse, GetContentDraftRouteApiContentDraftsDraftIdGetResponses, GetDashboardApiDashboardsDashboardIdGetData, GetDashboardApiDashboardsDashboardIdGetError, GetDashboardApiDashboardsDashboardIdGetErrors, GetDashboardApiDashboardsDashboardIdGetResponse, GetDashboardApiDashboardsDashboardIdGetResponses, GetGoogleCredentialsApiIntegrationsGoogleCredentialsGetData, GetGoogleCredentialsApiIntegrationsGoogleCredentialsGetResponse, GetGoogleCredentialsApiIntegrationsGoogleCredentialsGetResponses, GetPageHtmlApiCrawlPageHtmlGetData, GetPageHtmlApiCrawlPageHtmlGetError, GetPageHtmlApiCrawlPageHtmlGetErrors, GetPageHtmlApiCrawlPageHtmlGetResponse, GetPageHtmlApiCrawlPageHtmlGetResponses, GetPipelineConfigApiPipelineConfigGetData, GetPipelineConfigApiPipelineConfigGetResponse, GetPipelineConfigApiPipelineConfigGetResponses, GetPipelineJobApiJobsJobIdGetData, GetPipelineJobApiJobsJobIdGetError, GetPipelineJobApiJobsJobIdGetErrors, GetPipelineJobApiJobsJobIdGetResponse, GetPipelineJobApiJobsJobIdGetResponses, GetPropertyApiPropertiesPropertyIdGetData, GetPropertyApiPropertiesPropertyIdGetError, GetPropertyApiPropertiesPropertyIdGetErrors, GetPropertyApiPropertiesPropertyIdGetResponse, GetPropertyApiPropertiesPropertyIdGetResponses, GetPropertyOpsRouteApiPropertiesPropertyIdOpsGetData, GetPropertyOpsRouteApiPropertiesPropertyIdOpsGetError, GetPropertyOpsRouteApiPropertiesPropertyIdOpsGetErrors, GetPropertyOpsRouteApiPropertiesPropertyIdOpsGetResponse, GetPropertyOpsRouteApiPropertiesPropertyIdOpsGetResponses, GetPropertyPresetApiPropertiesPropertyIdPresetGetData, GetPropertyPresetApiPropertiesPropertyIdPresetGetError, GetPropertyPresetApiPropertiesPropertyIdPresetGetErrors, GetPropertyPresetApiPropertiesPropertyIdPresetGetResponse, GetPropertyPresetApiPropertiesPropertyIdPresetGetResponses, GoogleCredentialsPatch, GoogleCredentialsPostBody, GoogleDisconnectApiIntegrationsGoogleDisconnectPostData, GoogleDisconnectApiIntegrationsGoogleDisconnectPostResponse, GoogleDisconnectApiIntegrationsGoogleDisconnectPostResponses, GoogleKeywordsByPageApiIntegrationsGoogleKeywordsByPageGetData, GoogleKeywordsByPageApiIntegrationsGoogleKeywordsByPageGetError, GoogleKeywordsByPageApiIntegrationsGoogleKeywordsByPageGetErrors, GoogleKeywordsByPageApiIntegrationsGoogleKeywordsByPageGetResponse, GoogleKeywordsByPageApiIntegrationsGoogleKeywordsByPageGetResponses, GoogleKeywordsExpandApiIntegrationsGoogleKeywordsExpandPostData, GoogleKeywordsExpandApiIntegrationsGoogleKeywordsExpandPostError, GoogleKeywordsExpandApiIntegrationsGoogleKeywordsExpandPostErrors, GoogleKeywordsExpandApiIntegrationsGoogleKeywordsExpandPostResponse, GoogleKeywordsExpandApiIntegrationsGoogleKeywordsExpandPostResponses, GoogleKeywordsHistoryApiIntegrationsGoogleKeywordsHistoryGetData, GoogleKeywordsHistoryApiIntegrationsGoogleKeywordsHistoryGetError, GoogleKeywordsHistoryApiIntegrationsGoogleKeywordsHistoryGetErrors, GoogleKeywordsHistoryApiIntegrationsGoogleKeywordsHistoryGetResponse, GoogleKeywordsHistoryApiIntegrationsGoogleKeywordsHistoryGetResponses, GoogleKeywordsHistoryBatchApiIntegrationsGoogleKeywordsHistoryBatchPostData, GoogleKeywordsHistoryBatchApiIntegrationsGoogleKeywordsHistoryBatchPostError, GoogleKeywordsHistoryBatchApiIntegrationsGoogleKeywordsHistoryBatchPostErrors, GoogleKeywordsHistoryBatchApiIntegrationsGoogleKeywordsHistoryBatchPostResponse, GoogleKeywordsHistoryBatchApiIntegrationsGoogleKeywordsHistoryBatchPostResponses, GoogleKeywordsPlannerApiIntegrationsGoogleKeywordsPlannerPostData, GoogleKeywordsPlannerApiIntegrationsGoogleKeywordsPlannerPostError, GoogleKeywordsPlannerApiIntegrationsGoogleKeywordsPlannerPostErrors, GoogleKeywordsPlannerApiIntegrationsGoogleKeywordsPlannerPostResponse, GoogleKeywordsPlannerApiIntegrationsGoogleKeywordsPlannerPostResponses, GoogleOauthCallbackApiIntegrationsGoogleCallbackGetData, GoogleOauthCallbackApiIntegrationsGoogleCallbackGetError, GoogleOauthCallbackApiIntegrationsGoogleCallbackGetErrors, GoogleOauthCallbackApiIntegrationsGoogleCallbackGetResponses, GoogleOauthStartApiIntegrationsGoogleAuthGetData, GoogleOauthStartApiIntegrationsGoogleAuthGetError, GoogleOauthStartApiIntegrationsGoogleAuthGetErrors, GoogleOauthStartApiIntegrationsGoogleAuthGetResponses, GooglePageCompareApiIntegrationsGooglePageCompareGetData, GooglePageCompareApiIntegrationsGooglePageCompareGetError, GooglePageCompareApiIntegrationsGooglePageCompareGetErrors, GooglePageCompareApiIntegrationsGooglePageCompareGetResponse, GooglePageCompareApiIntegrationsGooglePageCompareGetResponses, GooglePageDataApiIntegrationsGooglePageDataGetData, GooglePageDataApiIntegrationsGooglePageDataGetError, GooglePageDataApiIntegrationsGooglePageDataGetErrors, GooglePageDataApiIntegrationsGooglePageDataGetResponse, GooglePageDataApiIntegrationsGooglePageDataGetResponses, GooglePageDataHistoryApiIntegrationsGooglePageDataHistoryGetData, GooglePageDataHistoryApiIntegrationsGooglePageDataHistoryGetError, GooglePageDataHistoryApiIntegrationsGooglePageDataHistoryGetErrors, GooglePageDataHistoryApiIntegrationsGooglePageDataHistoryGetResponse, GooglePageDataHistoryApiIntegrationsGooglePageDataHistoryGetResponses, GooglePageLiveApiIntegrationsGooglePageLivePostData, GooglePageLiveApiIntegrationsGooglePageLivePostError, GooglePageLiveApiIntegrationsGooglePageLivePostErrors, GooglePageLiveApiIntegrationsGooglePageLivePostResponse, GooglePageLiveApiIntegrationsGooglePageLivePostResponses, GooglePageLiveHistoryApiIntegrationsGooglePageLiveHistoryGetData, GooglePageLiveHistoryApiIntegrationsGooglePageLiveHistoryGetError, GooglePageLiveHistoryApiIntegrationsGooglePageLiveHistoryGetErrors, GooglePageLiveHistoryApiIntegrationsGooglePageLiveHistoryGetResponse, GooglePageLiveHistoryApiIntegrationsGooglePageLiveHistoryGetResponses, GooglePropertiesDeprecatedApiIntegrationsGooglePropertiesGetData, GooglePropertiesDeprecatedApiIntegrationsGooglePropertiesGetError, GooglePropertiesDeprecatedApiIntegrationsGooglePropertiesGetErrors, GooglePropertiesDeprecatedApiIntegrationsGooglePropertiesGetResponse, GooglePropertiesDeprecatedApiIntegrationsGooglePropertiesGetResponses, GoogleStatusApiIntegrationsGoogleStatusGetData, GoogleStatusApiIntegrationsGoogleStatusGetResponse, GoogleStatusApiIntegrationsGoogleStatusGetResponses, GoogleTestApiIntegrationsGoogleTestPostData, GoogleTestApiIntegrationsGoogleTestPostResponse, GoogleTestApiIntegrationsGoogleTestPostResponses, HealthCheckApiHealthGetData, HealthCheckApiHealthGetResponse, HealthCheckApiHealthGetResponses, HttpValidationError, JobsListResponse, KeywordsCompetitorImportApiKeywordsCompetitorImportPostData, KeywordsCompetitorImportApiKeywordsCompetitorImportPostError, KeywordsCompetitorImportApiKeywordsCompetitorImportPostErrors, KeywordsCompetitorImportApiKeywordsCompetitorImportPostResponse, KeywordsCompetitorImportApiKeywordsCompetitorImportPostResponses, KeywordsContentBriefApiKeywordsContentBriefPostData, KeywordsContentBriefApiKeywordsContentBriefPostError, KeywordsContentBriefApiKeywordsContentBriefPostErrors, KeywordsContentBriefApiKeywordsContentBriefPostResponse, KeywordsContentBriefApiKeywordsContentBriefPostResponses, ListContentDraftsRouteApiContentDraftsGetData, ListContentDraftsRouteApiContentDraftsGetError, ListContentDraftsRouteApiContentDraftsGetErrors, ListContentDraftsRouteApiContentDraftsGetResponse, ListContentDraftsRouteApiContentDraftsGetResponses, ListDashboardsApiDashboardsGetData, ListDashboardsApiDashboardsGetError, ListDashboardsApiDashboardsGetErrors, ListDashboardsApiDashboardsGetResponse, ListDashboardsApiDashboardsGetResponses, ListPageMarkdownRouteApiPageMarkdownGetData, ListPageMarkdownRouteApiPageMarkdownGetError, ListPageMarkdownRouteApiPageMarkdownGetErrors, ListPageMarkdownRouteApiPageMarkdownGetResponse, ListPageMarkdownRouteApiPageMarkdownGetResponses, ListPipelineJobsApiJobsGetData, ListPipelineJobsApiJobsGetError, ListPipelineJobsApiJobsGetErrors, ListPipelineJobsApiJobsGetResponse, ListPipelineJobsApiJobsGetResponses, ListPropertiesApiPropertiesGetData, ListPropertiesApiPropertiesGetResponse, ListPropertiesApiPropertiesGetResponses, LogsUploadApiLogsUploadPostData, LogsUploadApiLogsUploadPostError, LogsUploadApiLogsUploadPostErrors, LogsUploadApiLogsUploadPostResponse, LogsUploadApiLogsUploadPostResponses, OpsSettingsBody, PageMarkdownContentRouteApiPageMarkdownContentGetData, PageMarkdownContentRouteApiPageMarkdownContentGetError, PageMarkdownContentRouteApiPageMarkdownContentGetErrors, PageMarkdownContentRouteApiPageMarkdownContentGetResponse, PageMarkdownContentRouteApiPageMarkdownContentGetResponses, PageMarkdownExtractApiPageMarkdownExtractPostData, PageMarkdownExtractApiPageMarkdownExtractPostError, PageMarkdownExtractApiPageMarkdownExtractPostErrors, PageMarkdownExtractApiPageMarkdownExtractPostResponse, PageMarkdownExtractApiPageMarkdownExtractPostResponses, PageMarkdownRunsRouteApiPageMarkdownRunsGetData, PageMarkdownRunsRouteApiPageMarkdownRunsGetError, PageMarkdownRunsRouteApiPageMarkdownRunsGetErrors, PageMarkdownRunsRouteApiPageMarkdownRunsGetResponse, PageMarkdownRunsRouteApiPageMarkdownRunsGetResponses, PatchPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPatchData, PatchPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPatchError, PatchPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPatchErrors, PatchPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPatchResponse, PatchPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPatchResponses, PausePipelineJobApiJobsJobIdPausePostData, PausePipelineJobApiJobsJobIdPausePostError, PausePipelineJobApiJobsJobIdPausePostErrors, PausePipelineJobApiJobsJobIdPausePostResponse, PausePipelineJobApiJobsJobIdPausePostResponses, PauseResponse, PipelineConfigBody, PostPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPostData, PostPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPostError, PostPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPostErrors, PostPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPostResponse, PostPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPostResponses, PostPropertyGoogleDisconnectApiPropertiesPropertyIdGoogleDisconnectPostData, PostPropertyGoogleDisconnectApiPropertiesPropertyIdGoogleDisconnectPostError, PostPropertyGoogleDisconnectApiPropertiesPropertyIdGoogleDisconnectPostErrors, PostPropertyGoogleDisconnectApiPropertiesPropertyIdGoogleDisconnectPostResponse, PostPropertyGoogleDisconnectApiPropertiesPropertyIdGoogleDisconnectPostResponses, PresetBody, PropertyGoogleLinksImportApiPropertiesPropertyIdGoogleLinksImportPostData, PropertyGoogleLinksImportApiPropertiesPropertyIdGoogleLinksImportPostError, PropertyGoogleLinksImportApiPropertiesPropertyIdGoogleLinksImportPostErrors, PropertyGoogleLinksImportApiPropertiesPropertyIdGoogleLinksImportPostResponse, PropertyGoogleLinksImportApiPropertiesPropertyIdGoogleLinksImportPostResponses, PropertyGoogleLinksStatusApiPropertiesPropertyIdGoogleLinksStatusGetData, PropertyGoogleLinksStatusApiPropertiesPropertyIdGoogleLinksStatusGetError, PropertyGoogleLinksStatusApiPropertiesPropertyIdGoogleLinksStatusGetErrors, PropertyGoogleLinksStatusApiPropertiesPropertyIdGoogleLinksStatusGetResponse, PropertyGoogleLinksStatusApiPropertiesPropertyIdGoogleLinksStatusGetResponses, PropertyGooglePropertiesApiPropertiesPropertyIdGooglePropertiesGetData, PropertyGooglePropertiesApiPropertiesPropertyIdGooglePropertiesGetError, PropertyGooglePropertiesApiPropertiesPropertyIdGooglePropertiesGetErrors, PropertyGooglePropertiesApiPropertiesPropertyIdGooglePropertiesGetResponse, PropertyGooglePropertiesApiPropertiesPropertyIdGooglePropertiesGetResponses, PropertyGoogleStatusApiPropertiesPropertyIdGoogleStatusGetData, PropertyGoogleStatusApiPropertiesPropertyIdGoogleStatusGetError, PropertyGoogleStatusApiPropertiesPropertyIdGoogleStatusGetErrors, PropertyGoogleStatusApiPropertiesPropertyIdGoogleStatusGetResponse, PropertyGoogleStatusApiPropertiesPropertyIdGoogleStatusGetResponses, PropertyGoogleTestApiPropertiesPropertyIdGoogleTestPostData, PropertyGoogleTestApiPropertiesPropertyIdGoogleTestPostError, PropertyGoogleTestApiPropertiesPropertyIdGoogleTestPostErrors, PropertyGoogleTestApiPropertiesPropertyIdGoogleTestPostResponse, PropertyGoogleTestApiPropertiesPropertyIdGoogleTestPostResponses, PropertyUpsertBody, PutAppSettingApiAppSettingsPutData, PutAppSettingApiAppSettingsPutError, PutAppSettingApiAppSettingsPutErrors, PutAppSettingApiAppSettingsPutResponse, PutAppSettingApiAppSettingsPutResponses, PutPipelineConfigApiPipelineConfigPutData, PutPipelineConfigApiPipelineConfigPutError, PutPipelineConfigApiPipelineConfigPutErrors, PutPipelineConfigApiPipelineConfigPutResponse, PutPipelineConfigApiPipelineConfigPutResponses, ResolvePropertyApiPropertiesResolveGetData, ResolvePropertyApiPropertiesResolveGetError, ResolvePropertyApiPropertiesResolveGetErrors, ResolvePropertyApiPropertiesResolveGetResponse, ResolvePropertyApiPropertiesResolveGetResponses, ResumePipelineJobApiJobsJobIdResumePostData, ResumePipelineJobApiJobsJobIdResumePostError, ResumePipelineJobApiJobsJobIdResumePostErrors, ResumePipelineJobApiJobsJobIdResumePostResponse, ResumePipelineJobApiJobsJobIdResumePostResponses, ResumeResponse, RunAuditToolApiReportAuditToolPostData, RunAuditToolApiReportAuditToolPostError, RunAuditToolApiReportAuditToolPostErrors, RunAuditToolApiReportAuditToolPostResponse, RunAuditToolApiReportAuditToolPostResponses, RunPipelineApiRunPostData, RunPipelineApiRunPostError, RunPipelineApiRunPostErrors, RunPipelineApiRunPostResponse, RunPipelineApiRunPostResponses, RunPostBody, RunResponse, ScheduleCheckApiScheduleCheckPostData, ScheduleCheckApiScheduleCheckPostResponse, ScheduleCheckApiScheduleCheckPostResponses, UnknownKeyEntry, UpdateContentDraftRouteApiContentDraftsDraftIdPatchData, UpdateContentDraftRouteApiContentDraftsDraftIdPatchError, UpdateContentDraftRouteApiContentDraftsDraftIdPatchErrors, UpdateContentDraftRouteApiContentDraftsDraftIdPatchResponse, UpdateContentDraftRouteApiContentDraftsDraftIdPatchResponses, UpdateDashboardApiDashboardsDashboardIdPutData, UpdateDashboardApiDashboardsDashboardIdPutError, UpdateDashboardApiDashboardsDashboardIdPutErrors, UpdateDashboardApiDashboardsDashboardIdPutResponse, UpdateDashboardApiDashboardsDashboardIdPutResponses, UpdatePropertyOpsRouteApiPropertiesPropertyIdOpsPutData, UpdatePropertyOpsRouteApiPropertiesPropertyIdOpsPutError, UpdatePropertyOpsRouteApiPropertiesPropertyIdOpsPutErrors, UpdatePropertyOpsRouteApiPropertiesPropertyIdOpsPutResponse, UpdatePropertyOpsRouteApiPropertiesPropertyIdOpsPutResponses, UpdatePropertyPresetApiPropertiesPropertyIdPresetPutData, UpdatePropertyPresetApiPropertiesPropertyIdPresetPutError, UpdatePropertyPresetApiPropertiesPropertyIdPresetPutErrors, UpdatePropertyPresetApiPropertiesPropertyIdPresetPutResponse, UpdatePropertyPresetApiPropertiesPropertyIdPresetPutResponses, ValidationError } from './types.gen'; diff --git a/web/src/client/sdk.gen.ts b/web/src/client/sdk.gen.ts index 0586f231..66499ddd 100644 --- a/web/src/client/sdk.gen.ts +++ b/web/src/client/sdk.gen.ts @@ -2,7 +2,7 @@ import { type Client, type ClientMeta, formDataBodySerializer, type Options as Options2, type RequestResult, type TDataShape } from './client'; import { client } from './client.gen'; -import type { AiFixSuggestionApiAiFixSuggestionPostData, AiFixSuggestionApiAiFixSuggestionPostErrors, AiFixSuggestionApiAiFixSuggestionPostResponses, AlertsCheckApiAlertsCheckPostData, AlertsCheckApiAlertsCheckPostErrors, AlertsCheckApiAlertsCheckPostResponses, AuthorizePropertyCrawlApiPropertiesPropertyIdAuthorizePostData, AuthorizePropertyCrawlApiPropertiesPropertyIdAuthorizePostErrors, AuthorizePropertyCrawlApiPropertiesPropertyIdAuthorizePostResponses, BacklinksCompetitorImportApiBacklinksCompetitorImportPostData, BacklinksCompetitorImportApiBacklinksCompetitorImportPostErrors, BacklinksCompetitorImportApiBacklinksCompetitorImportPostResponses, BacklinksThirdPartyImportApiBacklinksThirdPartyImportPostData, BacklinksThirdPartyImportApiBacklinksThirdPartyImportPostErrors, BacklinksThirdPartyImportApiBacklinksThirdPartyImportPostResponses, BacklinksVelocityApiBacklinksVelocityGetData, BacklinksVelocityApiBacklinksVelocityGetErrors, BacklinksVelocityApiBacklinksVelocityGetResponses, BingSyncApiIntegrationsBingSyncPostData, BingSyncApiIntegrationsBingSyncPostResponses, BrowserStatusCheckApiCrawlBrowserStatusGetData, BrowserStatusCheckApiCrawlBrowserStatusGetResponses, CancelPipelineJobApiJobsJobIdCancelPostData, CancelPipelineJobApiJobsJobIdCancelPostErrors, CancelPipelineJobApiJobsJobIdCancelPostResponses, ChatTurnApiChatPostData, ChatTurnApiChatPostErrors, ChatTurnApiChatPostResponses, CompareExportApiCompareExportPostData, CompareExportApiCompareExportPostErrors, CompareExportApiCompareExportPostResponses, ContentAnalyzeApiContentAnalyzePostData, ContentAnalyzeApiContentAnalyzePostErrors, ContentAnalyzeApiContentAnalyzePostResponses, ContentScoreApiContentScorePostData, ContentScoreApiContentScorePostErrors, ContentScoreApiContentScorePostResponses, ContentWizardApiContentWizardPostData, ContentWizardApiContentWizardPostErrors, ContentWizardApiContentWizardPostResponses, CrawlPayloadApiReportCrawlPayloadGetData, CrawlPayloadApiReportCrawlPayloadGetErrors, CrawlPayloadApiReportCrawlPayloadGetResponses, CreateContentDraftApiContentDraftsPostData, CreateContentDraftApiContentDraftsPostErrors, CreateContentDraftApiContentDraftsPostResponses, CreateDashboardApiDashboardsPostData, CreateDashboardApiDashboardsPostErrors, CreateDashboardApiDashboardsPostResponses, CreatePropertyApiPropertiesPostData, CreatePropertyApiPropertiesPostErrors, CreatePropertyApiPropertiesPostResponses, CreateSessionApiChatSessionsPostData, CreateSessionApiChatSessionsPostErrors, CreateSessionApiChatSessionsPostResponses, DashboardsAiGenerateApiDashboardsAiGeneratePostData, DashboardsAiGenerateApiDashboardsAiGeneratePostErrors, DashboardsAiGenerateApiDashboardsAiGeneratePostResponses, DeleteContentDraftApiContentDraftsDraftIdDeleteData, DeleteContentDraftApiContentDraftsDraftIdDeleteErrors, DeleteContentDraftApiContentDraftsDraftIdDeleteResponses, DeleteDashboardApiDashboardsDashboardIdDeleteData, DeleteDashboardApiDashboardsDashboardIdDeleteErrors, DeleteDashboardApiDashboardsDashboardIdDeleteResponses, DeleteFilterApiFiltersDeleteData, DeleteFilterApiFiltersDeleteErrors, DeleteFilterApiFiltersDeleteResponses, DeletePageMarkdownApiPageMarkdownDeleteData, DeletePageMarkdownApiPageMarkdownDeleteErrors, DeletePageMarkdownApiPageMarkdownDeleteResponses, DeletePortfolioItemApiPortfolioDeleteDeleteData, DeletePortfolioItemApiPortfolioDeleteDeleteErrors, DeletePortfolioItemApiPortfolioDeleteDeleteResponses, DeletePropertyApiPropertiesPropertyIdDeleteData, DeletePropertyApiPropertiesPropertyIdDeleteErrors, DeletePropertyApiPropertiesPropertyIdDeleteResponses, DeleteSessionRouteApiChatSessionsSessionIdDeleteData, DeleteSessionRouteApiChatSessionsSessionIdDeleteErrors, DeleteSessionRouteApiChatSessionsSessionIdDeleteResponses, ExportReportApiReportExportGetData, ExportReportApiReportExportGetErrors, ExportReportApiReportExportGetResponses, ExportSitemapApiReportExportSitemapGetData, ExportSitemapApiReportExportSitemapGetErrors, ExportSitemapApiReportExportSitemapGetResponses, ExportWorkbookApiReportExportWorkbookGetData, ExportWorkbookApiReportExportWorkbookGetErrors, ExportWorkbookApiReportExportWorkbookGetResponses, GetAppSettingApiAppSettingsGetData, GetAppSettingApiAppSettingsGetErrors, GetAppSettingApiAppSettingsGetResponses, GetArtifactApiChatArtifactsArtifactIdGetData, GetArtifactApiChatArtifactsArtifactIdGetErrors, GetArtifactApiChatArtifactsArtifactIdGetResponses, GetContentDraftApiContentDraftsDraftIdGetData, GetContentDraftApiContentDraftsDraftIdGetErrors, GetContentDraftApiContentDraftsDraftIdGetResponses, GetDashboardApiDashboardsDashboardIdGetData, GetDashboardApiDashboardsDashboardIdGetErrors, GetDashboardApiDashboardsDashboardIdGetResponses, GetLlmConfigApiLlmConfigGetData, GetLlmConfigApiLlmConfigGetResponses, GetPageHtmlApiCrawlPageHtmlGetData, GetPageHtmlApiCrawlPageHtmlGetErrors, GetPageHtmlApiCrawlPageHtmlGetResponses, GetPipelineConfigApiPipelineConfigGetData, GetPipelineConfigApiPipelineConfigGetResponses, GetPipelineJobApiJobsJobIdGetData, GetPipelineJobApiJobsJobIdGetErrors, GetPipelineJobApiJobsJobIdGetResponses, GetPropertyApiPropertiesPropertyIdGetData, GetPropertyApiPropertiesPropertyIdGetErrors, GetPropertyApiPropertiesPropertyIdGetResponses, GetPropertyOpsApiPropertiesPropertyIdOpsGetData, GetPropertyOpsApiPropertiesPropertyIdOpsGetErrors, GetPropertyOpsApiPropertiesPropertyIdOpsGetResponses, GetPropertyPresetApiPropertiesPropertyIdPresetGetData, GetPropertyPresetApiPropertiesPropertyIdPresetGetErrors, GetPropertyPresetApiPropertiesPropertyIdPresetGetResponses, GetSecretsApiSecretsGetData, GetSecretsApiSecretsGetResponses, GetSessionMessagesApiChatSessionsSessionIdMessagesGetData, GetSessionMessagesApiChatSessionsSessionIdMessagesGetErrors, GetSessionMessagesApiChatSessionsSessionIdMessagesGetResponses, GetSessionRouteApiChatSessionsSessionIdGetData, GetSessionRouteApiChatSessionsSessionIdGetErrors, GetSessionRouteApiChatSessionsSessionIdGetResponses, GoogleDisconnectApiIntegrationsGoogleDisconnectPostData, GoogleDisconnectApiIntegrationsGoogleDisconnectPostResponses, GoogleKeywordsByPageApiIntegrationsGoogleKeywordsByPageGetData, GoogleKeywordsByPageApiIntegrationsGoogleKeywordsByPageGetErrors, GoogleKeywordsByPageApiIntegrationsGoogleKeywordsByPageGetResponses, GoogleKeywordsExpandApiIntegrationsGoogleKeywordsExpandPostData, GoogleKeywordsExpandApiIntegrationsGoogleKeywordsExpandPostErrors, GoogleKeywordsExpandApiIntegrationsGoogleKeywordsExpandPostResponses, GoogleKeywordsHistoryApiIntegrationsGoogleKeywordsHistoryGetData, GoogleKeywordsHistoryApiIntegrationsGoogleKeywordsHistoryGetErrors, GoogleKeywordsHistoryApiIntegrationsGoogleKeywordsHistoryGetResponses, GoogleKeywordsHistoryBatchApiIntegrationsGoogleKeywordsHistoryBatchPostData, GoogleKeywordsHistoryBatchApiIntegrationsGoogleKeywordsHistoryBatchPostErrors, GoogleKeywordsHistoryBatchApiIntegrationsGoogleKeywordsHistoryBatchPostResponses, GoogleKeywordsPlannerApiIntegrationsGoogleKeywordsPlannerPostData, GoogleKeywordsPlannerApiIntegrationsGoogleKeywordsPlannerPostErrors, GoogleKeywordsPlannerApiIntegrationsGoogleKeywordsPlannerPostResponses, GooglePageCompareApiIntegrationsGooglePageCompareGetData, GooglePageCompareApiIntegrationsGooglePageCompareGetErrors, GooglePageCompareApiIntegrationsGooglePageCompareGetResponses, GooglePageDataApiIntegrationsGooglePageDataGetData, GooglePageDataApiIntegrationsGooglePageDataGetErrors, GooglePageDataApiIntegrationsGooglePageDataGetResponses, GooglePageDataHistoryApiIntegrationsGooglePageDataHistoryGetData, GooglePageDataHistoryApiIntegrationsGooglePageDataHistoryGetErrors, GooglePageDataHistoryApiIntegrationsGooglePageDataHistoryGetResponses, GooglePageLiveApiIntegrationsGooglePageLivePostData, GooglePageLiveApiIntegrationsGooglePageLivePostErrors, GooglePageLiveApiIntegrationsGooglePageLivePostResponses, GooglePageLiveHistoryApiIntegrationsGooglePageLiveHistoryGetData, GooglePageLiveHistoryApiIntegrationsGooglePageLiveHistoryGetErrors, GooglePageLiveHistoryApiIntegrationsGooglePageLiveHistoryGetResponses, GooglePropertiesDeprecatedApiIntegrationsGooglePropertiesGetData, GooglePropertiesDeprecatedApiIntegrationsGooglePropertiesGetErrors, GooglePropertiesDeprecatedApiIntegrationsGooglePropertiesGetResponses, GoogleStatusApiIntegrationsGoogleStatusGetData, GoogleStatusApiIntegrationsGoogleStatusGetResponses, GoogleTestApiIntegrationsGoogleTestPostData, GoogleTestApiIntegrationsGoogleTestPostResponses, HealthCheckApiHealthGetData, HealthCheckApiHealthGetResponses, IssuesActionPlanApiIssuesActionPlanPostData, IssuesActionPlanApiIssuesActionPlanPostErrors, IssuesActionPlanApiIssuesActionPlanPostResponses, IssuesFixSuggestionApiIssuesFixSuggestionPostData, IssuesFixSuggestionApiIssuesFixSuggestionPostErrors, IssuesFixSuggestionApiIssuesFixSuggestionPostResponses, KeywordsCompetitorImportApiKeywordsCompetitorImportPostData, KeywordsCompetitorImportApiKeywordsCompetitorImportPostErrors, KeywordsCompetitorImportApiKeywordsCompetitorImportPostResponses, KeywordsContentBriefApiKeywordsContentBriefPostData, KeywordsContentBriefApiKeywordsContentBriefPostErrors, KeywordsContentBriefApiKeywordsContentBriefPostResponses, ListContentDraftsApiContentDraftsGetData, ListContentDraftsApiContentDraftsGetErrors, ListContentDraftsApiContentDraftsGetResponses, ListDashboardsApiDashboardsGetData, ListDashboardsApiDashboardsGetErrors, ListDashboardsApiDashboardsGetResponses, ListFiltersApiFiltersGetData, ListFiltersApiFiltersGetErrors, ListFiltersApiFiltersGetResponses, ListIssueStatusApiIssuesStatusGetData, ListIssueStatusApiIssuesStatusGetErrors, ListIssueStatusApiIssuesStatusGetResponses, ListPageMarkdownApiPageMarkdownGetData, ListPageMarkdownApiPageMarkdownGetErrors, ListPageMarkdownApiPageMarkdownGetResponses, ListPipelineJobsApiJobsGetData, ListPipelineJobsApiJobsGetErrors, ListPipelineJobsApiJobsGetResponses, ListPropertiesApiPropertiesGetData, ListPropertiesApiPropertiesGetResponses, ListSessionsApiChatSessionsGetData, ListSessionsApiChatSessionsGetErrors, ListSessionsApiChatSessionsGetResponses, LogsUploadApiLogsUploadPostData, LogsUploadApiLogsUploadPostErrors, LogsUploadApiLogsUploadPostResponses, McpToolsApiMcpToolsGetData, McpToolsApiMcpToolsGetResponses, MobileDeltaApiReportMobileDeltaGetData, MobileDeltaApiReportMobileDeltaGetErrors, MobileDeltaApiReportMobileDeltaGetResponses, OllamaStatusApiOllamaStatusGetData, OllamaStatusApiOllamaStatusGetResponses, PageCoachApiLinksPageCoachPostData, PageCoachApiLinksPageCoachPostErrors, PageCoachApiLinksPageCoachPostResponses, PageMarkdownContentApiPageMarkdownContentGetData, PageMarkdownContentApiPageMarkdownContentGetErrors, PageMarkdownContentApiPageMarkdownContentGetResponses, PageMarkdownExtractApiPageMarkdownExtractPostData, PageMarkdownExtractApiPageMarkdownExtractPostErrors, PageMarkdownExtractApiPageMarkdownExtractPostResponses, PageMarkdownRunsApiPageMarkdownRunsGetData, PageMarkdownRunsApiPageMarkdownRunsGetErrors, PageMarkdownRunsApiPageMarkdownRunsGetResponses, PatchPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPatchData, PatchPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPatchErrors, PatchPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPatchResponses, PausePipelineJobApiJobsJobIdPausePostData, PausePipelineJobApiJobsJobIdPausePostErrors, PausePipelineJobApiJobsJobIdPausePostResponses, PostPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPostData, PostPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPostErrors, PostPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPostResponses, PostPropertyGoogleDisconnectApiPropertiesPropertyIdGoogleDisconnectPostData, PostPropertyGoogleDisconnectApiPropertiesPropertyIdGoogleDisconnectPostErrors, PostPropertyGoogleDisconnectApiPropertiesPropertyIdGoogleDisconnectPostResponses, PropertyGoogleLinksImportApiPropertiesPropertyIdGoogleLinksImportPostData, PropertyGoogleLinksImportApiPropertiesPropertyIdGoogleLinksImportPostErrors, PropertyGoogleLinksImportApiPropertiesPropertyIdGoogleLinksImportPostResponses, PropertyGoogleLinksStatusApiPropertiesPropertyIdGoogleLinksStatusGetData, PropertyGoogleLinksStatusApiPropertiesPropertyIdGoogleLinksStatusGetErrors, PropertyGoogleLinksStatusApiPropertiesPropertyIdGoogleLinksStatusGetResponses, PropertyGooglePropertiesApiPropertiesPropertyIdGooglePropertiesGetData, PropertyGooglePropertiesApiPropertiesPropertyIdGooglePropertiesGetErrors, PropertyGooglePropertiesApiPropertiesPropertyIdGooglePropertiesGetResponses, PropertyGoogleStatusApiPropertiesPropertyIdGoogleStatusGetData, PropertyGoogleStatusApiPropertiesPropertyIdGoogleStatusGetErrors, PropertyGoogleStatusApiPropertiesPropertyIdGoogleStatusGetResponses, PropertyGoogleTestApiPropertiesPropertyIdGoogleTestPostData, PropertyGoogleTestApiPropertiesPropertyIdGoogleTestPostErrors, PropertyGoogleTestApiPropertiesPropertyIdGoogleTestPostResponses, PutAppSettingApiAppSettingsPutData, PutAppSettingApiAppSettingsPutErrors, PutAppSettingApiAppSettingsPutResponses, PutLlmConfigApiLlmConfigPutData, PutLlmConfigApiLlmConfigPutErrors, PutLlmConfigApiLlmConfigPutResponses, PutPipelineConfigApiPipelineConfigPutData, PutPipelineConfigApiPipelineConfigPutErrors, PutPipelineConfigApiPipelineConfigPutResponses, PutSecretsApiSecretsPutData, PutSecretsApiSecretsPutErrors, PutSecretsApiSecretsPutResponses, ReportHistoryApiReportHistoryGetData, ReportHistoryApiReportHistoryGetErrors, ReportHistoryApiReportHistoryGetResponses, ReportMetaApiReportMetaGetData, ReportMetaApiReportMetaGetResponses, ReportPayloadApiReportPayloadGetData, ReportPayloadApiReportPayloadGetErrors, ReportPayloadApiReportPayloadGetResponses, ReportPortfolioApiReportPortfolioGetData, ReportPortfolioApiReportPortfolioGetErrors, ReportPortfolioApiReportPortfolioGetResponses, ResolvePropertyApiPropertiesResolveGetData, ResolvePropertyApiPropertiesResolveGetErrors, ResolvePropertyApiPropertiesResolveGetResponses, ResumePipelineJobApiJobsJobIdResumePostData, ResumePipelineJobApiJobsJobIdResumePostErrors, ResumePipelineJobApiJobsJobIdResumePostResponses, RunAuditToolApiReportAuditToolPostData, RunAuditToolApiReportAuditToolPostErrors, RunAuditToolApiReportAuditToolPostResponses, RunPipelineApiRunPostData, RunPipelineApiRunPostErrors, RunPipelineApiRunPostResponses, SaveGoogleCredentialsApiIntegrationsGoogleCredentialsPostData, SaveGoogleCredentialsApiIntegrationsGoogleCredentialsPostErrors, SaveGoogleCredentialsApiIntegrationsGoogleCredentialsPostResponses, ScheduleCheckApiScheduleCheckPostData, ScheduleCheckApiScheduleCheckPostResponses, UpdateContentDraftApiContentDraftsDraftIdPatchData, UpdateContentDraftApiContentDraftsDraftIdPatchErrors, UpdateContentDraftApiContentDraftsDraftIdPatchResponses, UpdateDashboardApiDashboardsDashboardIdPutData, UpdateDashboardApiDashboardsDashboardIdPutErrors, UpdateDashboardApiDashboardsDashboardIdPutResponses, UpdatePropertyOpsApiPropertiesPropertyIdOpsPutData, UpdatePropertyOpsApiPropertiesPropertyIdOpsPutErrors, UpdatePropertyOpsApiPropertiesPropertyIdOpsPutResponses, UpdatePropertyPresetApiPropertiesPropertyIdPresetPutData, UpdatePropertyPresetApiPropertiesPropertyIdPresetPutErrors, UpdatePropertyPresetApiPropertiesPropertyIdPresetPutResponses, UploadGoogleCredentialsApiIntegrationsGoogleCredentialsUploadPostData, UploadGoogleCredentialsApiIntegrationsGoogleCredentialsUploadPostErrors, UploadGoogleCredentialsApiIntegrationsGoogleCredentialsUploadPostResponses, UpsertFilterApiFiltersPostData, UpsertFilterApiFiltersPostErrors, UpsertFilterApiFiltersPostResponses, UpsertIssueStatusApiIssuesStatusPutData, UpsertIssueStatusApiIssuesStatusPutErrors, UpsertIssueStatusApiIssuesStatusPutResponses } from './types.gen'; +import type { AlertsCheckApiAlertsCheckPostData, AlertsCheckApiAlertsCheckPostErrors, AlertsCheckApiAlertsCheckPostResponses, AuthorizePropertyCrawlRouteApiPropertiesPropertyIdAuthorizePostData, AuthorizePropertyCrawlRouteApiPropertiesPropertyIdAuthorizePostErrors, AuthorizePropertyCrawlRouteApiPropertiesPropertyIdAuthorizePostResponses, BacklinksCompetitorImportApiBacklinksCompetitorImportPostData, BacklinksCompetitorImportApiBacklinksCompetitorImportPostErrors, BacklinksCompetitorImportApiBacklinksCompetitorImportPostResponses, BacklinksThirdPartyImportApiBacklinksThirdPartyImportPostData, BacklinksThirdPartyImportApiBacklinksThirdPartyImportPostErrors, BacklinksThirdPartyImportApiBacklinksThirdPartyImportPostResponses, BacklinksVelocityApiBacklinksVelocityGetData, BacklinksVelocityApiBacklinksVelocityGetErrors, BacklinksVelocityApiBacklinksVelocityGetResponses, BingSyncApiIntegrationsBingSyncPostData, BingSyncApiIntegrationsBingSyncPostResponses, BrowserStatusCheckApiCrawlBrowserStatusGetData, BrowserStatusCheckApiCrawlBrowserStatusGetResponses, CancelPipelineJobApiJobsJobIdCancelPostData, CancelPipelineJobApiJobsJobIdCancelPostErrors, CancelPipelineJobApiJobsJobIdCancelPostResponses, CompareExportApiCompareExportPostData, CompareExportApiCompareExportPostErrors, CompareExportApiCompareExportPostResponses, ContentAnalyzeApiContentAnalyzePostData, ContentAnalyzeApiContentAnalyzePostErrors, ContentAnalyzeApiContentAnalyzePostResponses, ContentScoreApiContentScorePostData, ContentScoreApiContentScorePostErrors, ContentScoreApiContentScorePostResponses, ContentWizardApiContentWizardPostData, ContentWizardApiContentWizardPostErrors, ContentWizardApiContentWizardPostResponses, CreateContentDraftRouteApiContentDraftsPostData, CreateContentDraftRouteApiContentDraftsPostErrors, CreateContentDraftRouteApiContentDraftsPostResponses, CreateDashboardApiDashboardsPostData, CreateDashboardApiDashboardsPostErrors, CreateDashboardApiDashboardsPostResponses, CreatePropertyApiPropertiesPostData, CreatePropertyApiPropertiesPostErrors, CreatePropertyApiPropertiesPostResponses, DashboardsAiGenerateApiDashboardsAiGeneratePostData, DashboardsAiGenerateApiDashboardsAiGeneratePostErrors, DashboardsAiGenerateApiDashboardsAiGeneratePostResponses, DeleteContentDraftRouteApiContentDraftsDraftIdDeleteData, DeleteContentDraftRouteApiContentDraftsDraftIdDeleteErrors, DeleteContentDraftRouteApiContentDraftsDraftIdDeleteResponses, DeleteDashboardApiDashboardsDashboardIdDeleteData, DeleteDashboardApiDashboardsDashboardIdDeleteErrors, DeleteDashboardApiDashboardsDashboardIdDeleteResponses, DeletePageMarkdownRouteApiPageMarkdownDeleteData, DeletePageMarkdownRouteApiPageMarkdownDeleteErrors, DeletePageMarkdownRouteApiPageMarkdownDeleteResponses, DeletePropertyRouteApiPropertiesPropertyIdDeleteData, DeletePropertyRouteApiPropertiesPropertyIdDeleteErrors, DeletePropertyRouteApiPropertiesPropertyIdDeleteResponses, GetAppSettingApiAppSettingsGetData, GetAppSettingApiAppSettingsGetErrors, GetAppSettingApiAppSettingsGetResponses, GetContentDraftRouteApiContentDraftsDraftIdGetData, GetContentDraftRouteApiContentDraftsDraftIdGetErrors, GetContentDraftRouteApiContentDraftsDraftIdGetResponses, GetDashboardApiDashboardsDashboardIdGetData, GetDashboardApiDashboardsDashboardIdGetErrors, GetDashboardApiDashboardsDashboardIdGetResponses, GetGoogleCredentialsApiIntegrationsGoogleCredentialsGetData, GetGoogleCredentialsApiIntegrationsGoogleCredentialsGetResponses, GetPageHtmlApiCrawlPageHtmlGetData, GetPageHtmlApiCrawlPageHtmlGetErrors, GetPageHtmlApiCrawlPageHtmlGetResponses, GetPipelineConfigApiPipelineConfigGetData, GetPipelineConfigApiPipelineConfigGetResponses, GetPipelineJobApiJobsJobIdGetData, GetPipelineJobApiJobsJobIdGetErrors, GetPipelineJobApiJobsJobIdGetResponses, GetPropertyApiPropertiesPropertyIdGetData, GetPropertyApiPropertiesPropertyIdGetErrors, GetPropertyApiPropertiesPropertyIdGetResponses, GetPropertyOpsRouteApiPropertiesPropertyIdOpsGetData, GetPropertyOpsRouteApiPropertiesPropertyIdOpsGetErrors, GetPropertyOpsRouteApiPropertiesPropertyIdOpsGetResponses, GetPropertyPresetApiPropertiesPropertyIdPresetGetData, GetPropertyPresetApiPropertiesPropertyIdPresetGetErrors, GetPropertyPresetApiPropertiesPropertyIdPresetGetResponses, GoogleDisconnectApiIntegrationsGoogleDisconnectPostData, GoogleDisconnectApiIntegrationsGoogleDisconnectPostResponses, GoogleKeywordsByPageApiIntegrationsGoogleKeywordsByPageGetData, GoogleKeywordsByPageApiIntegrationsGoogleKeywordsByPageGetErrors, GoogleKeywordsByPageApiIntegrationsGoogleKeywordsByPageGetResponses, GoogleKeywordsExpandApiIntegrationsGoogleKeywordsExpandPostData, GoogleKeywordsExpandApiIntegrationsGoogleKeywordsExpandPostErrors, GoogleKeywordsExpandApiIntegrationsGoogleKeywordsExpandPostResponses, GoogleKeywordsHistoryApiIntegrationsGoogleKeywordsHistoryGetData, GoogleKeywordsHistoryApiIntegrationsGoogleKeywordsHistoryGetErrors, GoogleKeywordsHistoryApiIntegrationsGoogleKeywordsHistoryGetResponses, GoogleKeywordsHistoryBatchApiIntegrationsGoogleKeywordsHistoryBatchPostData, GoogleKeywordsHistoryBatchApiIntegrationsGoogleKeywordsHistoryBatchPostErrors, GoogleKeywordsHistoryBatchApiIntegrationsGoogleKeywordsHistoryBatchPostResponses, GoogleKeywordsPlannerApiIntegrationsGoogleKeywordsPlannerPostData, GoogleKeywordsPlannerApiIntegrationsGoogleKeywordsPlannerPostErrors, GoogleKeywordsPlannerApiIntegrationsGoogleKeywordsPlannerPostResponses, GoogleOauthCallbackApiIntegrationsGoogleCallbackGetData, GoogleOauthCallbackApiIntegrationsGoogleCallbackGetErrors, GoogleOauthCallbackApiIntegrationsGoogleCallbackGetResponses, GoogleOauthStartApiIntegrationsGoogleAuthGetData, GoogleOauthStartApiIntegrationsGoogleAuthGetErrors, GoogleOauthStartApiIntegrationsGoogleAuthGetResponses, GooglePageCompareApiIntegrationsGooglePageCompareGetData, GooglePageCompareApiIntegrationsGooglePageCompareGetErrors, GooglePageCompareApiIntegrationsGooglePageCompareGetResponses, GooglePageDataApiIntegrationsGooglePageDataGetData, GooglePageDataApiIntegrationsGooglePageDataGetErrors, GooglePageDataApiIntegrationsGooglePageDataGetResponses, GooglePageDataHistoryApiIntegrationsGooglePageDataHistoryGetData, GooglePageDataHistoryApiIntegrationsGooglePageDataHistoryGetErrors, GooglePageDataHistoryApiIntegrationsGooglePageDataHistoryGetResponses, GooglePageLiveApiIntegrationsGooglePageLivePostData, GooglePageLiveApiIntegrationsGooglePageLivePostErrors, GooglePageLiveApiIntegrationsGooglePageLivePostResponses, GooglePageLiveHistoryApiIntegrationsGooglePageLiveHistoryGetData, GooglePageLiveHistoryApiIntegrationsGooglePageLiveHistoryGetErrors, GooglePageLiveHistoryApiIntegrationsGooglePageLiveHistoryGetResponses, GooglePropertiesDeprecatedApiIntegrationsGooglePropertiesGetData, GooglePropertiesDeprecatedApiIntegrationsGooglePropertiesGetErrors, GooglePropertiesDeprecatedApiIntegrationsGooglePropertiesGetResponses, GoogleStatusApiIntegrationsGoogleStatusGetData, GoogleStatusApiIntegrationsGoogleStatusGetResponses, GoogleTestApiIntegrationsGoogleTestPostData, GoogleTestApiIntegrationsGoogleTestPostResponses, HealthCheckApiHealthGetData, HealthCheckApiHealthGetResponses, KeywordsCompetitorImportApiKeywordsCompetitorImportPostData, KeywordsCompetitorImportApiKeywordsCompetitorImportPostErrors, KeywordsCompetitorImportApiKeywordsCompetitorImportPostResponses, KeywordsContentBriefApiKeywordsContentBriefPostData, KeywordsContentBriefApiKeywordsContentBriefPostErrors, KeywordsContentBriefApiKeywordsContentBriefPostResponses, ListContentDraftsRouteApiContentDraftsGetData, ListContentDraftsRouteApiContentDraftsGetErrors, ListContentDraftsRouteApiContentDraftsGetResponses, ListDashboardsApiDashboardsGetData, ListDashboardsApiDashboardsGetErrors, ListDashboardsApiDashboardsGetResponses, ListPageMarkdownRouteApiPageMarkdownGetData, ListPageMarkdownRouteApiPageMarkdownGetErrors, ListPageMarkdownRouteApiPageMarkdownGetResponses, ListPipelineJobsApiJobsGetData, ListPipelineJobsApiJobsGetErrors, ListPipelineJobsApiJobsGetResponses, ListPropertiesApiPropertiesGetData, ListPropertiesApiPropertiesGetResponses, LogsUploadApiLogsUploadPostData, LogsUploadApiLogsUploadPostErrors, LogsUploadApiLogsUploadPostResponses, PageMarkdownContentRouteApiPageMarkdownContentGetData, PageMarkdownContentRouteApiPageMarkdownContentGetErrors, PageMarkdownContentRouteApiPageMarkdownContentGetResponses, PageMarkdownExtractApiPageMarkdownExtractPostData, PageMarkdownExtractApiPageMarkdownExtractPostErrors, PageMarkdownExtractApiPageMarkdownExtractPostResponses, PageMarkdownRunsRouteApiPageMarkdownRunsGetData, PageMarkdownRunsRouteApiPageMarkdownRunsGetErrors, PageMarkdownRunsRouteApiPageMarkdownRunsGetResponses, PatchPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPatchData, PatchPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPatchErrors, PatchPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPatchResponses, PausePipelineJobApiJobsJobIdPausePostData, PausePipelineJobApiJobsJobIdPausePostErrors, PausePipelineJobApiJobsJobIdPausePostResponses, PostPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPostData, PostPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPostErrors, PostPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPostResponses, PostPropertyGoogleDisconnectApiPropertiesPropertyIdGoogleDisconnectPostData, PostPropertyGoogleDisconnectApiPropertiesPropertyIdGoogleDisconnectPostErrors, PostPropertyGoogleDisconnectApiPropertiesPropertyIdGoogleDisconnectPostResponses, PropertyGoogleLinksImportApiPropertiesPropertyIdGoogleLinksImportPostData, PropertyGoogleLinksImportApiPropertiesPropertyIdGoogleLinksImportPostErrors, PropertyGoogleLinksImportApiPropertiesPropertyIdGoogleLinksImportPostResponses, PropertyGoogleLinksStatusApiPropertiesPropertyIdGoogleLinksStatusGetData, PropertyGoogleLinksStatusApiPropertiesPropertyIdGoogleLinksStatusGetErrors, PropertyGoogleLinksStatusApiPropertiesPropertyIdGoogleLinksStatusGetResponses, PropertyGooglePropertiesApiPropertiesPropertyIdGooglePropertiesGetData, PropertyGooglePropertiesApiPropertiesPropertyIdGooglePropertiesGetErrors, PropertyGooglePropertiesApiPropertiesPropertyIdGooglePropertiesGetResponses, PropertyGoogleStatusApiPropertiesPropertyIdGoogleStatusGetData, PropertyGoogleStatusApiPropertiesPropertyIdGoogleStatusGetErrors, PropertyGoogleStatusApiPropertiesPropertyIdGoogleStatusGetResponses, PropertyGoogleTestApiPropertiesPropertyIdGoogleTestPostData, PropertyGoogleTestApiPropertiesPropertyIdGoogleTestPostErrors, PropertyGoogleTestApiPropertiesPropertyIdGoogleTestPostResponses, PutAppSettingApiAppSettingsPutData, PutAppSettingApiAppSettingsPutErrors, PutAppSettingApiAppSettingsPutResponses, PutPipelineConfigApiPipelineConfigPutData, PutPipelineConfigApiPipelineConfigPutErrors, PutPipelineConfigApiPipelineConfigPutResponses, ResolvePropertyApiPropertiesResolveGetData, ResolvePropertyApiPropertiesResolveGetErrors, ResolvePropertyApiPropertiesResolveGetResponses, ResumePipelineJobApiJobsJobIdResumePostData, ResumePipelineJobApiJobsJobIdResumePostErrors, ResumePipelineJobApiJobsJobIdResumePostResponses, RunAuditToolApiReportAuditToolPostData, RunAuditToolApiReportAuditToolPostErrors, RunAuditToolApiReportAuditToolPostResponses, RunPipelineApiRunPostData, RunPipelineApiRunPostErrors, RunPipelineApiRunPostResponses, ScheduleCheckApiScheduleCheckPostData, ScheduleCheckApiScheduleCheckPostResponses, UpdateContentDraftRouteApiContentDraftsDraftIdPatchData, UpdateContentDraftRouteApiContentDraftsDraftIdPatchErrors, UpdateContentDraftRouteApiContentDraftsDraftIdPatchResponses, UpdateDashboardApiDashboardsDashboardIdPutData, UpdateDashboardApiDashboardsDashboardIdPutErrors, UpdateDashboardApiDashboardsDashboardIdPutResponses, UpdatePropertyOpsRouteApiPropertiesPropertyIdOpsPutData, UpdatePropertyOpsRouteApiPropertiesPropertyIdOpsPutErrors, UpdatePropertyOpsRouteApiPropertiesPropertyIdOpsPutResponses, UpdatePropertyPresetApiPropertiesPropertyIdPresetPutData, UpdatePropertyPresetApiPropertiesPropertyIdPresetPutErrors, UpdatePropertyPresetApiPropertiesPropertyIdPresetPutResponses } from './types.gen'; export type Options = Options2 & { /** @@ -23,31 +23,6 @@ export type Options(options?: Options): RequestResult => (options?.client ?? client).get({ url: '/api/health', ...options }); -/** - * Report Meta - */ -export const reportMetaApiReportMetaGet = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/api/report/meta', ...options }); - -/** - * Report Payload - */ -export const reportPayloadApiReportPayloadGet = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/api/report/payload', ...options }); - -/** - * Report History - */ -export const reportHistoryApiReportHistoryGet = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/api/report/history', ...options }); - -/** - * Crawl Payload - */ -export const crawlPayloadApiReportCrawlPayloadGet = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/api/report/crawl-payload', ...options }); - -/** - * Mobile Delta - */ -export const mobileDeltaApiReportMobileDeltaGet = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/api/report/mobile-delta', ...options }); - /** * Run Pipeline */ @@ -85,55 +60,6 @@ export const pausePipelineJobApiJobsJobIdPausePost = (options: Options): RequestResult => (options.client ?? client).post({ url: '/api/jobs/{job_id}/resume', ...options }); -/** - * Chat Turn - */ -export const chatTurnApiChatPost = (options: Options): RequestResult => (options.client ?? client).post({ - url: '/api/chat/', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -/** - * List Sessions - */ -export const listSessionsApiChatSessionsGet = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/chat/sessions', ...options }); - -/** - * Create Session - */ -export const createSessionApiChatSessionsPost = (options: Options): RequestResult => (options.client ?? client).post({ - url: '/api/chat/sessions', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -/** - * Delete Session Route - */ -export const deleteSessionRouteApiChatSessionsSessionIdDelete = (options: Options): RequestResult => (options.client ?? client).delete({ url: '/api/chat/sessions/{session_id}', ...options }); - -/** - * Get Session Route - */ -export const getSessionRouteApiChatSessionsSessionIdGet = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/chat/sessions/{session_id}', ...options }); - -/** - * Get Session Messages - */ -export const getSessionMessagesApiChatSessionsSessionIdMessagesGet = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/chat/sessions/{session_id}/messages', ...options }); - -/** - * Get Artifact - */ -export const getArtifactApiChatArtifactsArtifactIdGet = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/chat/artifacts/{artifact_id}', ...options }); - /** * Browser Status Check * @@ -165,40 +91,6 @@ export const putPipelineConfigApiPipelineConfigPut = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/api/llm-config', ...options }); - -/** - * Put Llm Config - */ -export const putLlmConfigApiLlmConfigPut = (options: Options): RequestResult => (options.client ?? client).put({ - url: '/api/llm-config', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -/** - * Get Secrets - */ -export const getSecretsApiSecretsGet = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/api/secrets', ...options }); - -/** - * Put Secrets - */ -export const putSecretsApiSecretsPut = (options: Options): RequestResult => (options.client ?? client).put({ - url: '/api/secrets', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - /** * Get App Setting */ @@ -239,9 +131,9 @@ export const createPropertyApiPropertiesPost = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/properties/resolve', ...options }); /** - * Delete Property + * Delete Property Route */ -export const deletePropertyApiPropertiesPropertyIdDelete = (options: Options): RequestResult => (options.client ?? client).delete({ url: '/api/properties/{property_id}', ...options }); +export const deletePropertyRouteApiPropertiesPropertyIdDelete = (options: Options): RequestResult => (options.client ?? client).delete({ url: '/api/properties/{property_id}', ...options }); /** * Get Property @@ -249,14 +141,14 @@ export const deletePropertyApiPropertiesPropertyIdDelete = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/properties/{property_id}', ...options }); /** - * Get Property Ops + * Get Property Ops Route */ -export const getPropertyOpsApiPropertiesPropertyIdOpsGet = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/properties/{property_id}/ops', ...options }); +export const getPropertyOpsRouteApiPropertiesPropertyIdOpsGet = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/properties/{property_id}/ops', ...options }); /** - * Update Property Ops + * Update Property Ops Route */ -export const updatePropertyOpsApiPropertiesPropertyIdOpsPut = (options: Options): RequestResult => (options.client ?? client).put({ +export const updatePropertyOpsRouteApiPropertiesPropertyIdOpsPut = (options: Options): RequestResult => (options.client ?? client).put({ url: '/api/properties/{property_id}/ops', ...options, headers: { @@ -283,51 +175,37 @@ export const updatePropertyPresetApiPropertiesPropertyIdPresetPut = (options: Options): RequestResult => (options.client ?? client).post({ url: '/api/properties/{property_id}/authorize', ...options }); +export const authorizePropertyCrawlRouteApiPropertiesPropertyIdAuthorizePost = (options: Options): RequestResult => (options.client ?? client).post({ url: '/api/properties/{property_id}/authorize', ...options }); /** * Property Google Status - * - * Return property-level Google integration status. */ export const propertyGoogleStatusApiPropertiesPropertyIdGoogleStatusGet = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/properties/{property_id}/google/status', ...options }); /** * Property Google Test - * - * Run a quick Google API connectivity test for the property. */ export const propertyGoogleTestApiPropertiesPropertyIdGoogleTestPost = (options: Options): RequestResult => (options.client ?? client).post({ url: '/api/properties/{property_id}/google/test', ...options }); /** * Property Google Properties - * - * List GA4 / GSC properties available for this account. */ export const propertyGooglePropertiesApiPropertiesPropertyIdGooglePropertiesGet = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/properties/{property_id}/google/properties', ...options }); /** * Property Google Links Status - * - * Return the status of GSC backlinks import for this property. */ export const propertyGoogleLinksStatusApiPropertiesPropertyIdGoogleLinksStatusGet = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/properties/{property_id}/google/links/status', ...options }); /** * Property Google Links Import - * - * Trigger a GSC backlinks import for this property. */ export const propertyGoogleLinksImportApiPropertiesPropertyIdGoogleLinksImportPost = (options: Options): RequestResult => (options.client ?? client).post({ url: '/api/properties/{property_id}/google/links/import', ...options }); /** * Patch Property Google Credentials - * - * Update Google credentials/settings for a property (used by OAuth callback). */ export const patchPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPatch = (options: Options): RequestResult => (options.client ?? client).patch({ url: '/api/properties/{property_id}/google/credentials', @@ -340,8 +218,6 @@ export const patchPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredenti /** * Post Property Google Credentials - * - * Update Google site/property settings from the integrations UI. */ export const postPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentialsPost = (options: Options): RequestResult => (options.client ?? client).post({ url: '/api/properties/{property_id}/google/credentials', @@ -354,8 +230,6 @@ export const postPropertyGoogleCredentialsApiPropertiesPropertyIdGoogleCredentia /** * Post Property Google Disconnect - * - * Clear OAuth tokens for a property. */ export const postPropertyGoogleDisconnectApiPropertiesPropertyIdGoogleDisconnectPost = (options: Options): RequestResult => (options.client ?? client).post({ url: '/api/properties/{property_id}/google/disconnect', ...options }); @@ -413,33 +287,11 @@ export const dashboardsAiGenerateApiDashboardsAiGeneratePost = (options: Options): RequestResult => (options.client ?? client).delete({ - url: '/api/filters', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -/** - * List Filters - */ -export const listFiltersApiFiltersGet = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/filters', ...options }); - -/** - * Upsert Filter + * Get Google Credentials + * + * Full app-level Google OAuth settings (server-side / local admin only). */ -export const upsertFilterApiFiltersPost = (options: Options): RequestResult => (options.client ?? client).post({ - url: '/api/filters', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); +export const getGoogleCredentialsApiIntegrationsGoogleCredentialsGet = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/api/integrations/google/credentials', ...options }); /** * Google Status @@ -447,35 +299,21 @@ export const upsertFilterApiFiltersPost = export const googleStatusApiIntegrationsGoogleStatusGet = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/api/integrations/google/status', ...options }); /** - * Save Google Credentials + * Google Disconnect + * + * Global disconnect is deprecated — use per-property disconnect. */ -export const saveGoogleCredentialsApiIntegrationsGoogleCredentialsPost = (options?: Options): RequestResult => (options?.client ?? client).post({ - url: '/api/integrations/google/credentials', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options?.headers - } -}); +export const googleDisconnectApiIntegrationsGoogleDisconnectPost = (options?: Options): RequestResult => (options?.client ?? client).post({ url: '/api/integrations/google/disconnect', ...options }); /** - * Upload Google Credentials + * Google Oauth Start */ -export const uploadGoogleCredentialsApiIntegrationsGoogleCredentialsUploadPost = (options?: Options): RequestResult => (options?.client ?? client).post({ - url: '/api/integrations/google/credentials/upload', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options?.headers - } -}); +export const googleOauthStartApiIntegrationsGoogleAuthGet = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/api/integrations/google/auth', ...options }); /** - * Google Disconnect - * - * Global disconnect is deprecated — use per-property disconnect. + * Google Oauth Callback */ -export const googleDisconnectApiIntegrationsGoogleDisconnectPost = (options?: Options): RequestResult => (options?.client ?? client).post({ url: '/api/integrations/google/disconnect', ...options }); +export const googleOauthCallbackApiIntegrationsGoogleCallbackGet = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/api/integrations/google/callback', ...options }); /** * Google Properties Deprecated @@ -586,59 +424,6 @@ export const googleKeywordsPlannerApiIntegrationsGoogleKeywordsPlannerPost = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/issues/status', ...options }); - -/** - * Upsert Issue Status - */ -export const upsertIssueStatusApiIssuesStatusPut = (options?: Options): RequestResult => (options?.client ?? client).put({ - url: '/api/issues/status', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options?.headers - } -}); - -/** - * Issues Fix Suggestion - */ -export const issuesFixSuggestionApiIssuesFixSuggestionPost = (options?: Options): RequestResult => (options?.client ?? client).post({ - url: '/api/issues/fix-suggestion', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options?.headers - } -}); - -/** - * Issues Action Plan - */ -export const issuesActionPlanApiIssuesActionPlanPost = (options?: Options): RequestResult => (options?.client ?? client).post({ - url: '/api/issues/action-plan', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options?.headers - } -}); - -/** - * Ai Fix Suggestion - */ -export const aiFixSuggestionApiAiFixSuggestionPost = (options?: Options): RequestResult => (options?.client ?? client).post({ - url: '/api/ai/fix-suggestion', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options?.headers - } -}); - /** * Keywords Competitor Import */ @@ -729,14 +514,14 @@ export const contentWizardApiContentWizardPost = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/content-drafts', ...options }); +export const listContentDraftsRouteApiContentDraftsGet = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/content-drafts', ...options }); /** - * Create Content Draft + * Create Content Draft Route */ -export const createContentDraftApiContentDraftsPost = (options?: Options): RequestResult => (options?.client ?? client).post({ +export const createContentDraftRouteApiContentDraftsPost = (options?: Options): RequestResult => (options?.client ?? client).post({ url: '/api/content-drafts', ...options, headers: { @@ -746,19 +531,19 @@ export const createContentDraftApiContentDraftsPost = (options: Options): RequestResult => (options.client ?? client).delete({ url: '/api/content-drafts/{draft_id}', ...options }); +export const deleteContentDraftRouteApiContentDraftsDraftIdDelete = (options: Options): RequestResult => (options.client ?? client).delete({ url: '/api/content-drafts/{draft_id}', ...options }); /** - * Get Content Draft + * Get Content Draft Route */ -export const getContentDraftApiContentDraftsDraftIdGet = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/content-drafts/{draft_id}', ...options }); +export const getContentDraftRouteApiContentDraftsDraftIdGet = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/content-drafts/{draft_id}', ...options }); /** - * Update Content Draft + * Update Content Draft Route */ -export const updateContentDraftApiContentDraftsDraftIdPatch = (options: Options): RequestResult => (options.client ?? client).patch({ +export const updateContentDraftRouteApiContentDraftsDraftIdPatch = (options: Options): RequestResult => (options.client ?? client).patch({ url: '/api/content-drafts/{draft_id}', ...options, headers: { @@ -768,9 +553,9 @@ export const updateContentDraftApiContentDraftsDraftIdPatch = (options?: Options): RequestResult => (options?.client ?? client).delete({ +export const deletePageMarkdownRouteApiPageMarkdownDelete = (options?: Options): RequestResult => (options?.client ?? client).delete({ url: '/api/page-markdown', ...options, headers: { @@ -780,14 +565,14 @@ export const deletePageMarkdownApiPageMarkdownDelete = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/page-markdown', ...options }); +export const listPageMarkdownRouteApiPageMarkdownGet = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/page-markdown', ...options }); /** - * Page Markdown Content + * Page Markdown Content Route */ -export const pageMarkdownContentApiPageMarkdownContentGet = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/page-markdown/content', ...options }); +export const pageMarkdownContentRouteApiPageMarkdownContentGet = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/page-markdown/content', ...options }); /** * Page Markdown Extract @@ -802,31 +587,9 @@ export const pageMarkdownExtractApiPageMarkdownExtractPost = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/api/page-markdown/runs', ...options }); - -/** - * Ollama Status - */ -export const ollamaStatusApiOllamaStatusGet = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/api/ollama/status', ...options }); - -/** - * Mcp Tools - */ -export const mcpToolsApiMcpToolsGet = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/api/mcp-tools', ...options }); - -/** - * Delete Portfolio Item - */ -export const deletePortfolioItemApiPortfolioDeleteDelete = (options: Options): RequestResult => (options.client ?? client).delete({ - url: '/api/portfolio/delete', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); +export const pageMarkdownRunsRouteApiPageMarkdownRunsGet = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/api/page-markdown/runs', ...options }); /** * Alerts Check @@ -863,18 +626,6 @@ export const compareExportApiCompareExportPost = (options: Options): RequestResult => (options.client ?? client).post({ - url: '/api/links/page-coach', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - /** * Run Audit Tool */ @@ -886,25 +637,3 @@ export const runAuditToolApiReportAuditToolPost = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/api/report/export', ...options }); - -/** - * Export Sitemap - */ -export const exportSitemapApiReportExportSitemapGet = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/api/report/export-sitemap', ...options }); - -/** - * Export Workbook - */ -export const exportWorkbookApiReportExportWorkbookGet = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/api/report/export-workbook', ...options }); - -/** - * Report Portfolio - * - * Return portfolio data — groups, crawl history, summary, or single card. - */ -export const reportPortfolioApiReportPortfolioGet = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/api/report/portfolio', ...options }); diff --git a/web/src/client/types.gen.ts b/web/src/client/types.gen.ts index ca20365c..3cbf5123 100644 --- a/web/src/client/types.gen.ts +++ b/web/src/client/types.gen.ts @@ -74,42 +74,6 @@ export type CancelResponse = { error?: string | null; }; -/** - * ChatRequest - */ -export type ChatRequest = { - /** - * Sessionid - */ - sessionId: number; - /** - * Propertyid - */ - propertyId: number; - /** - * Message - */ - message: string; - /** - * Reportid - */ - reportId?: number | null; -}; - -/** - * ChatSessionCreate - */ -export type ChatSessionCreate = { - /** - * Propertyid - */ - propertyId: number; - /** - * Title - */ - title?: string; -}; - /** * CompareExportBody */ @@ -216,52 +180,6 @@ export type DashboardUpdateBody = { isDefault?: boolean | null; }; -/** - * DeletePortfolioBody - */ -export type DeletePortfolioBody = { - /** - * Reportid - */ - reportId?: number | null; - /** - * Crawlrunid - */ - crawlRunId?: number | null; -}; - -/** - * FilterDeleteBody - */ -export type FilterDeleteBody = { - /** - * Propertyid - */ - propertyId: number; - /** - * Name - */ - name: string; -}; - -/** - * FilterUpsertBody - */ -export type FilterUpsertBody = { - /** - * Propertyid - */ - propertyId: number; - /** - * Name - */ - name: string; - /** - * Filterjson - */ - filterJson?: unknown | null; -}; - /** * GoogleCredentialsPatch */ @@ -346,18 +264,6 @@ export type JobsListResponse = { reconciled?: number; }; -/** - * LlmConfigBody - */ -export type LlmConfigBody = { - /** - * State - */ - state: { - [key: string]: unknown; - }; -}; - /** * OpsSettingsBody */ @@ -376,40 +282,6 @@ export type OpsSettingsBody = { alertEmail?: string | null; }; -/** - * PageCoachBody - */ -export type PageCoachBody = { - /** - * Url - */ - url?: string | null; - /** - * Refresh - */ - refresh?: boolean; - /** - * Currenttype - */ - currentType?: string | null; - /** - * Currentid - */ - currentId?: number | null; - /** - * Baselinetype - */ - baselineType?: string | null; - /** - * Baselineid - */ - baselineId?: number | null; - /** - * Propertyid - */ - propertyId?: number | null; -}; - /** * PauseResponse */ @@ -506,12 +378,6 @@ export type RunPostBody = { * Unknownkeys */ unknownKeys?: Array; - /** - * Llmstate - */ - llmState?: { - [key: string]: unknown; - } | null; /** * Propertyid */ @@ -536,18 +402,6 @@ export type RunResponse = { jobId: string; }; -/** - * SecretsBody - */ -export type SecretsBody = { - /** - * State - */ - state: { - [key: string]: unknown; - }; -}; - /** * UnknownKeyEntry */ @@ -610,178 +464,6 @@ export type HealthCheckApiHealthGetResponses = { export type HealthCheckApiHealthGetResponse = HealthCheckApiHealthGetResponses[keyof HealthCheckApiHealthGetResponses]; -export type ReportMetaApiReportMetaGetData = { - body?: never; - path?: never; - query?: never; - url: '/api/report/meta'; -}; - -export type ReportMetaApiReportMetaGetResponses = { - /** - * Response Report Meta Api Report Meta Get - * - * Successful Response - */ - 200: { - [key: string]: unknown; - }; -}; - -export type ReportMetaApiReportMetaGetResponse = ReportMetaApiReportMetaGetResponses[keyof ReportMetaApiReportMetaGetResponses]; - -export type ReportPayloadApiReportPayloadGetData = { - body?: never; - path?: never; - query?: { - /** - * Reportid - */ - reportId?: number | null; - /** - * Domain - */ - domain?: string | null; - /** - * Section - */ - section?: string | null; - }; - url: '/api/report/payload'; -}; - -export type ReportPayloadApiReportPayloadGetErrors = { - /** - * Validation Error - */ - 422: HttpValidationError; -}; - -export type ReportPayloadApiReportPayloadGetError = ReportPayloadApiReportPayloadGetErrors[keyof ReportPayloadApiReportPayloadGetErrors]; - -export type ReportPayloadApiReportPayloadGetResponses = { - /** - * Response Report Payload Api Report Payload Get - * - * Successful Response - */ - 200: { - [key: string]: unknown; - }; -}; - -export type ReportPayloadApiReportPayloadGetResponse = ReportPayloadApiReportPayloadGetResponses[keyof ReportPayloadApiReportPayloadGetResponses]; - -export type ReportHistoryApiReportHistoryGetData = { - body?: never; - path?: never; - query?: { - /** - * Propertyid - */ - propertyId?: number | null; - /** - * Domain - */ - domain?: string | null; - /** - * Limit - */ - limit?: number; - }; - url: '/api/report/history'; -}; - -export type ReportHistoryApiReportHistoryGetErrors = { - /** - * Validation Error - */ - 422: HttpValidationError; -}; - -export type ReportHistoryApiReportHistoryGetError = ReportHistoryApiReportHistoryGetErrors[keyof ReportHistoryApiReportHistoryGetErrors]; - -export type ReportHistoryApiReportHistoryGetResponses = { - /** - * Response Report History Api Report History Get - * - * Successful Response - */ - 200: { - [key: string]: unknown; - }; -}; - -export type ReportHistoryApiReportHistoryGetResponse = ReportHistoryApiReportHistoryGetResponses[keyof ReportHistoryApiReportHistoryGetResponses]; - -export type CrawlPayloadApiReportCrawlPayloadGetData = { - body?: never; - path?: never; - query?: { - /** - * Crawlrunid - */ - crawlRunId?: number | null; - }; - url: '/api/report/crawl-payload'; -}; - -export type CrawlPayloadApiReportCrawlPayloadGetErrors = { - /** - * Validation Error - */ - 422: HttpValidationError; -}; - -export type CrawlPayloadApiReportCrawlPayloadGetError = CrawlPayloadApiReportCrawlPayloadGetErrors[keyof CrawlPayloadApiReportCrawlPayloadGetErrors]; - -export type CrawlPayloadApiReportCrawlPayloadGetResponses = { - /** - * Response Crawl Payload Api Report Crawl Payload Get - * - * Successful Response - */ - 200: { - [key: string]: unknown; - }; -}; - -export type CrawlPayloadApiReportCrawlPayloadGetResponse = CrawlPayloadApiReportCrawlPayloadGetResponses[keyof CrawlPayloadApiReportCrawlPayloadGetResponses]; - -export type MobileDeltaApiReportMobileDeltaGetData = { - body?: never; - path?: never; - query?: { - /** - * Id - */ - id?: number | null; - }; - url: '/api/report/mobile-delta'; -}; - -export type MobileDeltaApiReportMobileDeltaGetErrors = { - /** - * Validation Error - */ - 422: HttpValidationError; -}; - -export type MobileDeltaApiReportMobileDeltaGetError = MobileDeltaApiReportMobileDeltaGetErrors[keyof MobileDeltaApiReportMobileDeltaGetErrors]; - -export type MobileDeltaApiReportMobileDeltaGetResponses = { - /** - * Response Mobile Delta Api Report Mobile Delta Get - * - * Successful Response - */ - 200: { - [key: string]: unknown; - }; -}; - -export type MobileDeltaApiReportMobileDeltaGetResponse = MobileDeltaApiReportMobileDeltaGetResponses[keyof MobileDeltaApiReportMobileDeltaGetResponses]; - export type RunPipelineApiRunPostData = { body: RunPostBody; path?: never; @@ -961,250 +643,22 @@ export type ResumePipelineJobApiJobsJobIdResumePostResponses = { export type ResumePipelineJobApiJobsJobIdResumePostResponse = ResumePipelineJobApiJobsJobIdResumePostResponses[keyof ResumePipelineJobApiJobsJobIdResumePostResponses]; -export type ChatTurnApiChatPostData = { - body: ChatRequest; +export type BrowserStatusCheckApiCrawlBrowserStatusGetData = { + body?: never; path?: never; query?: never; - url: '/api/chat/'; -}; - -export type ChatTurnApiChatPostErrors = { - /** - * Validation Error - */ - 422: HttpValidationError; + url: '/api/crawl/browser-status'; }; -export type ChatTurnApiChatPostError = ChatTurnApiChatPostErrors[keyof ChatTurnApiChatPostErrors]; - -export type ChatTurnApiChatPostResponses = { +export type BrowserStatusCheckApiCrawlBrowserStatusGetResponses = { /** + * Response Browser Status Check Api Crawl Browser Status Get + * * Successful Response */ - 200: unknown; -}; - -export type ListSessionsApiChatSessionsGetData = { - body?: never; - path?: never; - query: { - /** - * Propertyid - */ - propertyId: number; - }; - url: '/api/chat/sessions'; -}; - -export type ListSessionsApiChatSessionsGetErrors = { - /** - * Validation Error - */ - 422: HttpValidationError; -}; - -export type ListSessionsApiChatSessionsGetError = ListSessionsApiChatSessionsGetErrors[keyof ListSessionsApiChatSessionsGetErrors]; - -export type ListSessionsApiChatSessionsGetResponses = { - /** - * Response List Sessions Api Chat Sessions Get - * - * Successful Response - */ - 200: { - [key: string]: unknown; - }; -}; - -export type ListSessionsApiChatSessionsGetResponse = ListSessionsApiChatSessionsGetResponses[keyof ListSessionsApiChatSessionsGetResponses]; - -export type CreateSessionApiChatSessionsPostData = { - body: ChatSessionCreate; - path?: never; - query?: never; - url: '/api/chat/sessions'; -}; - -export type CreateSessionApiChatSessionsPostErrors = { - /** - * Validation Error - */ - 422: HttpValidationError; -}; - -export type CreateSessionApiChatSessionsPostError = CreateSessionApiChatSessionsPostErrors[keyof CreateSessionApiChatSessionsPostErrors]; - -export type CreateSessionApiChatSessionsPostResponses = { - /** - * Response Create Session Api Chat Sessions Post - * - * Successful Response - */ - 200: { - [key: string]: unknown; - }; -}; - -export type CreateSessionApiChatSessionsPostResponse = CreateSessionApiChatSessionsPostResponses[keyof CreateSessionApiChatSessionsPostResponses]; - -export type DeleteSessionRouteApiChatSessionsSessionIdDeleteData = { - body?: never; - path: { - /** - * Session Id - */ - session_id: number; - }; - query: { - /** - * Propertyid - */ - propertyId: number; - }; - url: '/api/chat/sessions/{session_id}'; -}; - -export type DeleteSessionRouteApiChatSessionsSessionIdDeleteErrors = { - /** - * Validation Error - */ - 422: HttpValidationError; -}; - -export type DeleteSessionRouteApiChatSessionsSessionIdDeleteError = DeleteSessionRouteApiChatSessionsSessionIdDeleteErrors[keyof DeleteSessionRouteApiChatSessionsSessionIdDeleteErrors]; - -export type DeleteSessionRouteApiChatSessionsSessionIdDeleteResponses = { - /** - * Response Delete Session Route Api Chat Sessions Session Id Delete - * - * Successful Response - */ - 200: { - [key: string]: unknown; - }; -}; - -export type DeleteSessionRouteApiChatSessionsSessionIdDeleteResponse = DeleteSessionRouteApiChatSessionsSessionIdDeleteResponses[keyof DeleteSessionRouteApiChatSessionsSessionIdDeleteResponses]; - -export type GetSessionRouteApiChatSessionsSessionIdGetData = { - body?: never; - path: { - /** - * Session Id - */ - session_id: number; - }; - query?: never; - url: '/api/chat/sessions/{session_id}'; -}; - -export type GetSessionRouteApiChatSessionsSessionIdGetErrors = { - /** - * Validation Error - */ - 422: HttpValidationError; -}; - -export type GetSessionRouteApiChatSessionsSessionIdGetError = GetSessionRouteApiChatSessionsSessionIdGetErrors[keyof GetSessionRouteApiChatSessionsSessionIdGetErrors]; - -export type GetSessionRouteApiChatSessionsSessionIdGetResponses = { - /** - * Response Get Session Route Api Chat Sessions Session Id Get - * - * Successful Response - */ - 200: { - [key: string]: unknown; - }; -}; - -export type GetSessionRouteApiChatSessionsSessionIdGetResponse = GetSessionRouteApiChatSessionsSessionIdGetResponses[keyof GetSessionRouteApiChatSessionsSessionIdGetResponses]; - -export type GetSessionMessagesApiChatSessionsSessionIdMessagesGetData = { - body?: never; - path: { - /** - * Session Id - */ - session_id: number; - }; - query: { - /** - * Propertyid - */ - propertyId: number; - }; - url: '/api/chat/sessions/{session_id}/messages'; -}; - -export type GetSessionMessagesApiChatSessionsSessionIdMessagesGetErrors = { - /** - * Validation Error - */ - 422: HttpValidationError; -}; - -export type GetSessionMessagesApiChatSessionsSessionIdMessagesGetError = GetSessionMessagesApiChatSessionsSessionIdMessagesGetErrors[keyof GetSessionMessagesApiChatSessionsSessionIdMessagesGetErrors]; - -export type GetSessionMessagesApiChatSessionsSessionIdMessagesGetResponses = { - /** - * Response Get Session Messages Api Chat Sessions Session Id Messages Get - * - * Successful Response - */ - 200: { - [key: string]: unknown; - }; -}; - -export type GetSessionMessagesApiChatSessionsSessionIdMessagesGetResponse = GetSessionMessagesApiChatSessionsSessionIdMessagesGetResponses[keyof GetSessionMessagesApiChatSessionsSessionIdMessagesGetResponses]; - -export type GetArtifactApiChatArtifactsArtifactIdGetData = { - body?: never; - path: { - /** - * Artifact Id - */ - artifact_id: string; - }; - query?: never; - url: '/api/chat/artifacts/{artifact_id}'; -}; - -export type GetArtifactApiChatArtifactsArtifactIdGetErrors = { - /** - * Validation Error - */ - 422: HttpValidationError; -}; - -export type GetArtifactApiChatArtifactsArtifactIdGetError = GetArtifactApiChatArtifactsArtifactIdGetErrors[keyof GetArtifactApiChatArtifactsArtifactIdGetErrors]; - -export type GetArtifactApiChatArtifactsArtifactIdGetResponses = { - /** - * Response Get Artifact Api Chat Artifacts Artifact Id Get - * - * Successful Response - */ - 200: unknown; -}; - -export type BrowserStatusCheckApiCrawlBrowserStatusGetData = { - body?: never; - path?: never; - query?: never; - url: '/api/crawl/browser-status'; -}; - -export type BrowserStatusCheckApiCrawlBrowserStatusGetResponses = { - /** - * Response Browser Status Check Api Crawl Browser Status Get - * - * Successful Response - */ - 200: { - [key: string]: unknown; - }; + 200: { + [key: string]: unknown; + }; }; export type BrowserStatusCheckApiCrawlBrowserStatusGetResponse = BrowserStatusCheckApiCrawlBrowserStatusGetResponses[keyof BrowserStatusCheckApiCrawlBrowserStatusGetResponses]; @@ -1300,104 +754,6 @@ export type PutPipelineConfigApiPipelineConfigPutResponses = { export type PutPipelineConfigApiPipelineConfigPutResponse = PutPipelineConfigApiPipelineConfigPutResponses[keyof PutPipelineConfigApiPipelineConfigPutResponses]; -export type GetLlmConfigApiLlmConfigGetData = { - body?: never; - path?: never; - query?: never; - url: '/api/llm-config'; -}; - -export type GetLlmConfigApiLlmConfigGetResponses = { - /** - * Response Get Llm Config Api Llm Config Get - * - * Successful Response - */ - 200: { - [key: string]: unknown; - }; -}; - -export type GetLlmConfigApiLlmConfigGetResponse = GetLlmConfigApiLlmConfigGetResponses[keyof GetLlmConfigApiLlmConfigGetResponses]; - -export type PutLlmConfigApiLlmConfigPutData = { - body: LlmConfigBody; - path?: never; - query?: never; - url: '/api/llm-config'; -}; - -export type PutLlmConfigApiLlmConfigPutErrors = { - /** - * Validation Error - */ - 422: HttpValidationError; -}; - -export type PutLlmConfigApiLlmConfigPutError = PutLlmConfigApiLlmConfigPutErrors[keyof PutLlmConfigApiLlmConfigPutErrors]; - -export type PutLlmConfigApiLlmConfigPutResponses = { - /** - * Response Put Llm Config Api Llm Config Put - * - * Successful Response - */ - 200: { - [key: string]: unknown; - }; -}; - -export type PutLlmConfigApiLlmConfigPutResponse = PutLlmConfigApiLlmConfigPutResponses[keyof PutLlmConfigApiLlmConfigPutResponses]; - -export type GetSecretsApiSecretsGetData = { - body?: never; - path?: never; - query?: never; - url: '/api/secrets'; -}; - -export type GetSecretsApiSecretsGetResponses = { - /** - * Response Get Secrets Api Secrets Get - * - * Successful Response - */ - 200: { - [key: string]: unknown; - }; -}; - -export type GetSecretsApiSecretsGetResponse = GetSecretsApiSecretsGetResponses[keyof GetSecretsApiSecretsGetResponses]; - -export type PutSecretsApiSecretsPutData = { - body: SecretsBody; - path?: never; - query?: never; - url: '/api/secrets'; -}; - -export type PutSecretsApiSecretsPutErrors = { - /** - * Validation Error - */ - 422: HttpValidationError; -}; - -export type PutSecretsApiSecretsPutError = PutSecretsApiSecretsPutErrors[keyof PutSecretsApiSecretsPutErrors]; - -export type PutSecretsApiSecretsPutResponses = { - /** - * Response Put Secrets Api Secrets Put - * - * Successful Response - */ - 200: { - [key: string]: unknown; - }; -}; - -export type PutSecretsApiSecretsPutResponse = PutSecretsApiSecretsPutResponses[keyof PutSecretsApiSecretsPutResponses]; - export type GetAppSettingApiAppSettingsGetData = { body?: never; path?: never; @@ -1548,7 +904,7 @@ export type ResolvePropertyApiPropertiesResolveGetResponses = { export type ResolvePropertyApiPropertiesResolveGetResponse = ResolvePropertyApiPropertiesResolveGetResponses[keyof ResolvePropertyApiPropertiesResolveGetResponses]; -export type DeletePropertyApiPropertiesPropertyIdDeleteData = { +export type DeletePropertyRouteApiPropertiesPropertyIdDeleteData = { body?: never; path: { /** @@ -1560,18 +916,18 @@ export type DeletePropertyApiPropertiesPropertyIdDeleteData = { url: '/api/properties/{property_id}'; }; -export type DeletePropertyApiPropertiesPropertyIdDeleteErrors = { +export type DeletePropertyRouteApiPropertiesPropertyIdDeleteErrors = { /** * Validation Error */ 422: HttpValidationError; }; -export type DeletePropertyApiPropertiesPropertyIdDeleteError = DeletePropertyApiPropertiesPropertyIdDeleteErrors[keyof DeletePropertyApiPropertiesPropertyIdDeleteErrors]; +export type DeletePropertyRouteApiPropertiesPropertyIdDeleteError = DeletePropertyRouteApiPropertiesPropertyIdDeleteErrors[keyof DeletePropertyRouteApiPropertiesPropertyIdDeleteErrors]; -export type DeletePropertyApiPropertiesPropertyIdDeleteResponses = { +export type DeletePropertyRouteApiPropertiesPropertyIdDeleteResponses = { /** - * Response Delete Property Api Properties Property Id Delete + * Response Delete Property Route Api Properties Property Id Delete * * Successful Response */ @@ -1580,7 +936,7 @@ export type DeletePropertyApiPropertiesPropertyIdDeleteResponses = { }; }; -export type DeletePropertyApiPropertiesPropertyIdDeleteResponse = DeletePropertyApiPropertiesPropertyIdDeleteResponses[keyof DeletePropertyApiPropertiesPropertyIdDeleteResponses]; +export type DeletePropertyRouteApiPropertiesPropertyIdDeleteResponse = DeletePropertyRouteApiPropertiesPropertyIdDeleteResponses[keyof DeletePropertyRouteApiPropertiesPropertyIdDeleteResponses]; export type GetPropertyApiPropertiesPropertyIdGetData = { body?: never; @@ -1616,7 +972,7 @@ export type GetPropertyApiPropertiesPropertyIdGetResponses = { export type GetPropertyApiPropertiesPropertyIdGetResponse = GetPropertyApiPropertiesPropertyIdGetResponses[keyof GetPropertyApiPropertiesPropertyIdGetResponses]; -export type GetPropertyOpsApiPropertiesPropertyIdOpsGetData = { +export type GetPropertyOpsRouteApiPropertiesPropertyIdOpsGetData = { body?: never; path: { /** @@ -1628,18 +984,18 @@ export type GetPropertyOpsApiPropertiesPropertyIdOpsGetData = { url: '/api/properties/{property_id}/ops'; }; -export type GetPropertyOpsApiPropertiesPropertyIdOpsGetErrors = { +export type GetPropertyOpsRouteApiPropertiesPropertyIdOpsGetErrors = { /** * Validation Error */ 422: HttpValidationError; }; -export type GetPropertyOpsApiPropertiesPropertyIdOpsGetError = GetPropertyOpsApiPropertiesPropertyIdOpsGetErrors[keyof GetPropertyOpsApiPropertiesPropertyIdOpsGetErrors]; +export type GetPropertyOpsRouteApiPropertiesPropertyIdOpsGetError = GetPropertyOpsRouteApiPropertiesPropertyIdOpsGetErrors[keyof GetPropertyOpsRouteApiPropertiesPropertyIdOpsGetErrors]; -export type GetPropertyOpsApiPropertiesPropertyIdOpsGetResponses = { +export type GetPropertyOpsRouteApiPropertiesPropertyIdOpsGetResponses = { /** - * Response Get Property Ops Api Properties Property Id Ops Get + * Response Get Property Ops Route Api Properties Property Id Ops Get * * Successful Response */ @@ -1648,9 +1004,9 @@ export type GetPropertyOpsApiPropertiesPropertyIdOpsGetResponses = { }; }; -export type GetPropertyOpsApiPropertiesPropertyIdOpsGetResponse = GetPropertyOpsApiPropertiesPropertyIdOpsGetResponses[keyof GetPropertyOpsApiPropertiesPropertyIdOpsGetResponses]; +export type GetPropertyOpsRouteApiPropertiesPropertyIdOpsGetResponse = GetPropertyOpsRouteApiPropertiesPropertyIdOpsGetResponses[keyof GetPropertyOpsRouteApiPropertiesPropertyIdOpsGetResponses]; -export type UpdatePropertyOpsApiPropertiesPropertyIdOpsPutData = { +export type UpdatePropertyOpsRouteApiPropertiesPropertyIdOpsPutData = { body: OpsSettingsBody; path: { /** @@ -1662,18 +1018,18 @@ export type UpdatePropertyOpsApiPropertiesPropertyIdOpsPutData = { url: '/api/properties/{property_id}/ops'; }; -export type UpdatePropertyOpsApiPropertiesPropertyIdOpsPutErrors = { +export type UpdatePropertyOpsRouteApiPropertiesPropertyIdOpsPutErrors = { /** * Validation Error */ 422: HttpValidationError; }; -export type UpdatePropertyOpsApiPropertiesPropertyIdOpsPutError = UpdatePropertyOpsApiPropertiesPropertyIdOpsPutErrors[keyof UpdatePropertyOpsApiPropertiesPropertyIdOpsPutErrors]; +export type UpdatePropertyOpsRouteApiPropertiesPropertyIdOpsPutError = UpdatePropertyOpsRouteApiPropertiesPropertyIdOpsPutErrors[keyof UpdatePropertyOpsRouteApiPropertiesPropertyIdOpsPutErrors]; -export type UpdatePropertyOpsApiPropertiesPropertyIdOpsPutResponses = { +export type UpdatePropertyOpsRouteApiPropertiesPropertyIdOpsPutResponses = { /** - * Response Update Property Ops Api Properties Property Id Ops Put + * Response Update Property Ops Route Api Properties Property Id Ops Put * * Successful Response */ @@ -1682,7 +1038,7 @@ export type UpdatePropertyOpsApiPropertiesPropertyIdOpsPutResponses = { }; }; -export type UpdatePropertyOpsApiPropertiesPropertyIdOpsPutResponse = UpdatePropertyOpsApiPropertiesPropertyIdOpsPutResponses[keyof UpdatePropertyOpsApiPropertiesPropertyIdOpsPutResponses]; +export type UpdatePropertyOpsRouteApiPropertiesPropertyIdOpsPutResponse = UpdatePropertyOpsRouteApiPropertiesPropertyIdOpsPutResponses[keyof UpdatePropertyOpsRouteApiPropertiesPropertyIdOpsPutResponses]; export type GetPropertyPresetApiPropertiesPropertyIdPresetGetData = { body?: never; @@ -1752,7 +1108,7 @@ export type UpdatePropertyPresetApiPropertiesPropertyIdPresetPutResponses = { export type UpdatePropertyPresetApiPropertiesPropertyIdPresetPutResponse = UpdatePropertyPresetApiPropertiesPropertyIdPresetPutResponses[keyof UpdatePropertyPresetApiPropertiesPropertyIdPresetPutResponses]; -export type AuthorizePropertyCrawlApiPropertiesPropertyIdAuthorizePostData = { +export type AuthorizePropertyCrawlRouteApiPropertiesPropertyIdAuthorizePostData = { body?: never; path: { /** @@ -1764,18 +1120,18 @@ export type AuthorizePropertyCrawlApiPropertiesPropertyIdAuthorizePostData = { url: '/api/properties/{property_id}/authorize'; }; -export type AuthorizePropertyCrawlApiPropertiesPropertyIdAuthorizePostErrors = { +export type AuthorizePropertyCrawlRouteApiPropertiesPropertyIdAuthorizePostErrors = { /** * Validation Error */ 422: HttpValidationError; }; -export type AuthorizePropertyCrawlApiPropertiesPropertyIdAuthorizePostError = AuthorizePropertyCrawlApiPropertiesPropertyIdAuthorizePostErrors[keyof AuthorizePropertyCrawlApiPropertiesPropertyIdAuthorizePostErrors]; +export type AuthorizePropertyCrawlRouteApiPropertiesPropertyIdAuthorizePostError = AuthorizePropertyCrawlRouteApiPropertiesPropertyIdAuthorizePostErrors[keyof AuthorizePropertyCrawlRouteApiPropertiesPropertyIdAuthorizePostErrors]; -export type AuthorizePropertyCrawlApiPropertiesPropertyIdAuthorizePostResponses = { +export type AuthorizePropertyCrawlRouteApiPropertiesPropertyIdAuthorizePostResponses = { /** - * Response Authorize Property Crawl Api Properties Property Id Authorize Post + * Response Authorize Property Crawl Route Api Properties Property Id Authorize Post * * Successful Response */ @@ -1784,7 +1140,7 @@ export type AuthorizePropertyCrawlApiPropertiesPropertyIdAuthorizePostResponses }; }; -export type AuthorizePropertyCrawlApiPropertiesPropertyIdAuthorizePostResponse = AuthorizePropertyCrawlApiPropertiesPropertyIdAuthorizePostResponses[keyof AuthorizePropertyCrawlApiPropertiesPropertyIdAuthorizePostResponses]; +export type AuthorizePropertyCrawlRouteApiPropertiesPropertyIdAuthorizePostResponse = AuthorizePropertyCrawlRouteApiPropertiesPropertyIdAuthorizePostResponses[keyof AuthorizePropertyCrawlRouteApiPropertiesPropertyIdAuthorizePostResponses]; export type PropertyGoogleStatusApiPropertiesPropertyIdGoogleStatusGetData = { body?: never; @@ -2262,25 +1618,16 @@ export type DashboardsAiGenerateApiDashboardsAiGeneratePostResponses = { 200: unknown; }; -export type DeleteFilterApiFiltersDeleteData = { - body: FilterDeleteBody; +export type GetGoogleCredentialsApiIntegrationsGoogleCredentialsGetData = { + body?: never; path?: never; query?: never; - url: '/api/filters'; -}; - -export type DeleteFilterApiFiltersDeleteErrors = { - /** - * Validation Error - */ - 422: HttpValidationError; + url: '/api/integrations/google/credentials'; }; -export type DeleteFilterApiFiltersDeleteError = DeleteFilterApiFiltersDeleteErrors[keyof DeleteFilterApiFiltersDeleteErrors]; - -export type DeleteFilterApiFiltersDeleteResponses = { +export type GetGoogleCredentialsApiIntegrationsGoogleCredentialsGetResponses = { /** - * Response Delete Filter Api Filters Delete + * Response Get Google Credentials Api Integrations Google Credentials Get * * Successful Response */ @@ -2289,63 +1636,18 @@ export type DeleteFilterApiFiltersDeleteResponses = { }; }; -export type DeleteFilterApiFiltersDeleteResponse = DeleteFilterApiFiltersDeleteResponses[keyof DeleteFilterApiFiltersDeleteResponses]; +export type GetGoogleCredentialsApiIntegrationsGoogleCredentialsGetResponse = GetGoogleCredentialsApiIntegrationsGoogleCredentialsGetResponses[keyof GetGoogleCredentialsApiIntegrationsGoogleCredentialsGetResponses]; -export type ListFiltersApiFiltersGetData = { +export type GoogleStatusApiIntegrationsGoogleStatusGetData = { body?: never; path?: never; - query: { - /** - * Propertyid - * - * Property ID - */ - propertyId: number; - }; - url: '/api/filters'; -}; - -export type ListFiltersApiFiltersGetErrors = { - /** - * Validation Error - */ - 422: HttpValidationError; -}; - -export type ListFiltersApiFiltersGetError = ListFiltersApiFiltersGetErrors[keyof ListFiltersApiFiltersGetErrors]; - -export type ListFiltersApiFiltersGetResponses = { - /** - * Response List Filters Api Filters Get - * - * Successful Response - */ - 200: { - [key: string]: unknown; - }; -}; - -export type ListFiltersApiFiltersGetResponse = ListFiltersApiFiltersGetResponses[keyof ListFiltersApiFiltersGetResponses]; - -export type UpsertFilterApiFiltersPostData = { - body: FilterUpsertBody; - path?: never; query?: never; - url: '/api/filters'; -}; - -export type UpsertFilterApiFiltersPostErrors = { - /** - * Validation Error - */ - 422: HttpValidationError; + url: '/api/integrations/google/status'; }; -export type UpsertFilterApiFiltersPostError = UpsertFilterApiFiltersPostErrors[keyof UpsertFilterApiFiltersPostErrors]; - -export type UpsertFilterApiFiltersPostResponses = { +export type GoogleStatusApiIntegrationsGoogleStatusGetResponses = { /** - * Response Upsert Filter Api Filters Post + * Response Google Status Api Integrations Google Status Get * * Successful Response */ @@ -2354,18 +1656,18 @@ export type UpsertFilterApiFiltersPostResponses = { }; }; -export type UpsertFilterApiFiltersPostResponse = UpsertFilterApiFiltersPostResponses[keyof UpsertFilterApiFiltersPostResponses]; +export type GoogleStatusApiIntegrationsGoogleStatusGetResponse = GoogleStatusApiIntegrationsGoogleStatusGetResponses[keyof GoogleStatusApiIntegrationsGoogleStatusGetResponses]; -export type GoogleStatusApiIntegrationsGoogleStatusGetData = { +export type GoogleDisconnectApiIntegrationsGoogleDisconnectPostData = { body?: never; path?: never; query?: never; - url: '/api/integrations/google/status'; + url: '/api/integrations/google/disconnect'; }; -export type GoogleStatusApiIntegrationsGoogleStatusGetResponses = { +export type GoogleDisconnectApiIntegrationsGoogleDisconnectPostResponses = { /** - * Response Google Status Api Integrations Google Status Get + * Response Google Disconnect Api Integrations Google Disconnect Post * * Successful Response */ @@ -2374,96 +1676,84 @@ export type GoogleStatusApiIntegrationsGoogleStatusGetResponses = { }; }; -export type GoogleStatusApiIntegrationsGoogleStatusGetResponse = GoogleStatusApiIntegrationsGoogleStatusGetResponses[keyof GoogleStatusApiIntegrationsGoogleStatusGetResponses]; +export type GoogleDisconnectApiIntegrationsGoogleDisconnectPostResponse = GoogleDisconnectApiIntegrationsGoogleDisconnectPostResponses[keyof GoogleDisconnectApiIntegrationsGoogleDisconnectPostResponses]; -export type SaveGoogleCredentialsApiIntegrationsGoogleCredentialsPostData = { - /** - * Body - */ - body?: { - [key: string]: unknown; - }; +export type GoogleOauthStartApiIntegrationsGoogleAuthGetData = { + body?: never; path?: never; - query?: never; - url: '/api/integrations/google/credentials'; + query?: { + /** + * Propertyid + */ + propertyId?: number | null; + /** + * Starturl + */ + startUrl?: string | null; + /** + * Returnto + */ + returnTo?: string | null; + }; + url: '/api/integrations/google/auth'; }; -export type SaveGoogleCredentialsApiIntegrationsGoogleCredentialsPostErrors = { +export type GoogleOauthStartApiIntegrationsGoogleAuthGetErrors = { /** * Validation Error */ 422: HttpValidationError; }; -export type SaveGoogleCredentialsApiIntegrationsGoogleCredentialsPostError = SaveGoogleCredentialsApiIntegrationsGoogleCredentialsPostErrors[keyof SaveGoogleCredentialsApiIntegrationsGoogleCredentialsPostErrors]; +export type GoogleOauthStartApiIntegrationsGoogleAuthGetError = GoogleOauthStartApiIntegrationsGoogleAuthGetErrors[keyof GoogleOauthStartApiIntegrationsGoogleAuthGetErrors]; -export type SaveGoogleCredentialsApiIntegrationsGoogleCredentialsPostResponses = { +export type GoogleOauthStartApiIntegrationsGoogleAuthGetResponses = { /** - * Response Save Google Credentials Api Integrations Google Credentials Post + * Response Google Oauth Start Api Integrations Google Auth Get * * Successful Response */ - 200: { - [key: string]: unknown; - }; + 200: unknown; }; -export type SaveGoogleCredentialsApiIntegrationsGoogleCredentialsPostResponse = SaveGoogleCredentialsApiIntegrationsGoogleCredentialsPostResponses[keyof SaveGoogleCredentialsApiIntegrationsGoogleCredentialsPostResponses]; - -export type UploadGoogleCredentialsApiIntegrationsGoogleCredentialsUploadPostData = { - /** - * Body - */ - body?: { - [key: string]: unknown; - }; +export type GoogleOauthCallbackApiIntegrationsGoogleCallbackGetData = { + body?: never; path?: never; - query?: never; - url: '/api/integrations/google/credentials/upload'; + query?: { + /** + * Code + */ + code?: string | null; + /** + * State + */ + state?: string | null; + /** + * Error + */ + error?: string | null; + }; + url: '/api/integrations/google/callback'; }; -export type UploadGoogleCredentialsApiIntegrationsGoogleCredentialsUploadPostErrors = { +export type GoogleOauthCallbackApiIntegrationsGoogleCallbackGetErrors = { /** * Validation Error */ 422: HttpValidationError; }; -export type UploadGoogleCredentialsApiIntegrationsGoogleCredentialsUploadPostError = UploadGoogleCredentialsApiIntegrationsGoogleCredentialsUploadPostErrors[keyof UploadGoogleCredentialsApiIntegrationsGoogleCredentialsUploadPostErrors]; +export type GoogleOauthCallbackApiIntegrationsGoogleCallbackGetError = GoogleOauthCallbackApiIntegrationsGoogleCallbackGetErrors[keyof GoogleOauthCallbackApiIntegrationsGoogleCallbackGetErrors]; -export type UploadGoogleCredentialsApiIntegrationsGoogleCredentialsUploadPostResponses = { +export type GoogleOauthCallbackApiIntegrationsGoogleCallbackGetResponses = { /** - * Response Upload Google Credentials Api Integrations Google Credentials Upload Post + * Response Google Oauth Callback Api Integrations Google Callback Get * * Successful Response */ - 200: { - [key: string]: unknown; - }; -}; - -export type UploadGoogleCredentialsApiIntegrationsGoogleCredentialsUploadPostResponse = UploadGoogleCredentialsApiIntegrationsGoogleCredentialsUploadPostResponses[keyof UploadGoogleCredentialsApiIntegrationsGoogleCredentialsUploadPostResponses]; - -export type GoogleDisconnectApiIntegrationsGoogleDisconnectPostData = { - body?: never; - path?: never; - query?: never; - url: '/api/integrations/google/disconnect'; -}; - -export type GoogleDisconnectApiIntegrationsGoogleDisconnectPostResponses = { - /** - * Response Google Disconnect Api Integrations Google Disconnect Post - * - * Successful Response - */ - 200: { - [key: string]: unknown; - }; + 200: unknown; }; -export type GoogleDisconnectApiIntegrationsGoogleDisconnectPostResponse = GoogleDisconnectApiIntegrationsGoogleDisconnectPostResponses[keyof GoogleDisconnectApiIntegrationsGoogleDisconnectPostResponses]; - export type GooglePropertiesDeprecatedApiIntegrationsGooglePropertiesGetData = { body?: never; path?: never; @@ -2770,164 +2060,24 @@ export type GooglePageCompareApiIntegrationsGooglePageCompareGetData = { baselineType?: string; /** * Baselineid - */ - baselineId: number; - }; - url: '/api/integrations/google/page-compare'; -}; - -export type GooglePageCompareApiIntegrationsGooglePageCompareGetErrors = { - /** - * Validation Error - */ - 422: HttpValidationError; -}; - -export type GooglePageCompareApiIntegrationsGooglePageCompareGetError = GooglePageCompareApiIntegrationsGooglePageCompareGetErrors[keyof GooglePageCompareApiIntegrationsGooglePageCompareGetErrors]; - -export type GooglePageCompareApiIntegrationsGooglePageCompareGetResponses = { - /** - * Response Google Page Compare Api Integrations Google Page Compare Get - * - * Successful Response - */ - 200: { - [key: string]: unknown; - }; -}; - -export type GooglePageCompareApiIntegrationsGooglePageCompareGetResponse = GooglePageCompareApiIntegrationsGooglePageCompareGetResponses[keyof GooglePageCompareApiIntegrationsGooglePageCompareGetResponses]; - -export type GooglePageLiveHistoryApiIntegrationsGooglePageLiveHistoryGetData = { - body?: never; - path?: never; - query: { - /** - * Url - */ - url: string; - /** - * Limit - */ - limit?: number; - }; - url: '/api/integrations/google/page-live/history'; -}; - -export type GooglePageLiveHistoryApiIntegrationsGooglePageLiveHistoryGetErrors = { - /** - * Validation Error - */ - 422: HttpValidationError; -}; - -export type GooglePageLiveHistoryApiIntegrationsGooglePageLiveHistoryGetError = GooglePageLiveHistoryApiIntegrationsGooglePageLiveHistoryGetErrors[keyof GooglePageLiveHistoryApiIntegrationsGooglePageLiveHistoryGetErrors]; - -export type GooglePageLiveHistoryApiIntegrationsGooglePageLiveHistoryGetResponses = { - /** - * Response Google Page Live History Api Integrations Google Page Live History Get - * - * Successful Response - */ - 200: { - [key: string]: unknown; - }; -}; - -export type GooglePageLiveHistoryApiIntegrationsGooglePageLiveHistoryGetResponse = GooglePageLiveHistoryApiIntegrationsGooglePageLiveHistoryGetResponses[keyof GooglePageLiveHistoryApiIntegrationsGooglePageLiveHistoryGetResponses]; - -export type GoogleKeywordsHistoryBatchApiIntegrationsGoogleKeywordsHistoryBatchPostData = { - /** - * Body - */ - body: { - [key: string]: unknown; - }; - path?: never; - query?: never; - url: '/api/integrations/google/keywords/history/batch'; -}; - -export type GoogleKeywordsHistoryBatchApiIntegrationsGoogleKeywordsHistoryBatchPostErrors = { - /** - * Validation Error - */ - 422: HttpValidationError; -}; - -export type GoogleKeywordsHistoryBatchApiIntegrationsGoogleKeywordsHistoryBatchPostError = GoogleKeywordsHistoryBatchApiIntegrationsGoogleKeywordsHistoryBatchPostErrors[keyof GoogleKeywordsHistoryBatchApiIntegrationsGoogleKeywordsHistoryBatchPostErrors]; - -export type GoogleKeywordsHistoryBatchApiIntegrationsGoogleKeywordsHistoryBatchPostResponses = { - /** - * Response Google Keywords History Batch Api Integrations Google Keywords History Batch Post - * - * Successful Response - */ - 200: { - [key: string]: unknown; - }; -}; - -export type GoogleKeywordsHistoryBatchApiIntegrationsGoogleKeywordsHistoryBatchPostResponse = GoogleKeywordsHistoryBatchApiIntegrationsGoogleKeywordsHistoryBatchPostResponses[keyof GoogleKeywordsHistoryBatchApiIntegrationsGoogleKeywordsHistoryBatchPostResponses]; - -export type GoogleKeywordsExpandApiIntegrationsGoogleKeywordsExpandPostData = { - /** - * Body - */ - body: { - [key: string]: unknown; - }; - path?: never; - query?: never; - url: '/api/integrations/google/keywords/expand'; -}; - -export type GoogleKeywordsExpandApiIntegrationsGoogleKeywordsExpandPostErrors = { - /** - * Validation Error - */ - 422: HttpValidationError; -}; - -export type GoogleKeywordsExpandApiIntegrationsGoogleKeywordsExpandPostError = GoogleKeywordsExpandApiIntegrationsGoogleKeywordsExpandPostErrors[keyof GoogleKeywordsExpandApiIntegrationsGoogleKeywordsExpandPostErrors]; - -export type GoogleKeywordsExpandApiIntegrationsGoogleKeywordsExpandPostResponses = { - /** - * Response Google Keywords Expand Api Integrations Google Keywords Expand Post - * - * Successful Response - */ - 200: { - [key: string]: unknown; - }; -}; - -export type GoogleKeywordsExpandApiIntegrationsGoogleKeywordsExpandPostResponse = GoogleKeywordsExpandApiIntegrationsGoogleKeywordsExpandPostResponses[keyof GoogleKeywordsExpandApiIntegrationsGoogleKeywordsExpandPostResponses]; - -export type GoogleKeywordsPlannerApiIntegrationsGoogleKeywordsPlannerPostData = { - /** - * Body - */ - body: { - [key: string]: unknown; + */ + baselineId: number; }; - path?: never; - query?: never; - url: '/api/integrations/google/keywords/planner'; + url: '/api/integrations/google/page-compare'; }; -export type GoogleKeywordsPlannerApiIntegrationsGoogleKeywordsPlannerPostErrors = { +export type GooglePageCompareApiIntegrationsGooglePageCompareGetErrors = { /** * Validation Error */ 422: HttpValidationError; }; -export type GoogleKeywordsPlannerApiIntegrationsGoogleKeywordsPlannerPostError = GoogleKeywordsPlannerApiIntegrationsGoogleKeywordsPlannerPostErrors[keyof GoogleKeywordsPlannerApiIntegrationsGoogleKeywordsPlannerPostErrors]; +export type GooglePageCompareApiIntegrationsGooglePageCompareGetError = GooglePageCompareApiIntegrationsGooglePageCompareGetErrors[keyof GooglePageCompareApiIntegrationsGooglePageCompareGetErrors]; -export type GoogleKeywordsPlannerApiIntegrationsGoogleKeywordsPlannerPostResponses = { +export type GooglePageCompareApiIntegrationsGooglePageCompareGetResponses = { /** - * Response Google Keywords Planner Api Integrations Google Keywords Planner Post + * Response Google Page Compare Api Integrations Google Page Compare Get * * Successful Response */ @@ -2936,32 +2086,36 @@ export type GoogleKeywordsPlannerApiIntegrationsGoogleKeywordsPlannerPostRespons }; }; -export type GoogleKeywordsPlannerApiIntegrationsGoogleKeywordsPlannerPostResponse = GoogleKeywordsPlannerApiIntegrationsGoogleKeywordsPlannerPostResponses[keyof GoogleKeywordsPlannerApiIntegrationsGoogleKeywordsPlannerPostResponses]; +export type GooglePageCompareApiIntegrationsGooglePageCompareGetResponse = GooglePageCompareApiIntegrationsGooglePageCompareGetResponses[keyof GooglePageCompareApiIntegrationsGooglePageCompareGetResponses]; -export type ListIssueStatusApiIssuesStatusGetData = { +export type GooglePageLiveHistoryApiIntegrationsGooglePageLiveHistoryGetData = { body?: never; path?: never; query: { /** - * Propertyid + * Url */ - propertyId: number; + url: string; + /** + * Limit + */ + limit?: number; }; - url: '/api/issues/status'; + url: '/api/integrations/google/page-live/history'; }; -export type ListIssueStatusApiIssuesStatusGetErrors = { +export type GooglePageLiveHistoryApiIntegrationsGooglePageLiveHistoryGetErrors = { /** * Validation Error */ 422: HttpValidationError; }; -export type ListIssueStatusApiIssuesStatusGetError = ListIssueStatusApiIssuesStatusGetErrors[keyof ListIssueStatusApiIssuesStatusGetErrors]; +export type GooglePageLiveHistoryApiIntegrationsGooglePageLiveHistoryGetError = GooglePageLiveHistoryApiIntegrationsGooglePageLiveHistoryGetErrors[keyof GooglePageLiveHistoryApiIntegrationsGooglePageLiveHistoryGetErrors]; -export type ListIssueStatusApiIssuesStatusGetResponses = { +export type GooglePageLiveHistoryApiIntegrationsGooglePageLiveHistoryGetResponses = { /** - * Response List Issue Status Api Issues Status Get + * Response Google Page Live History Api Integrations Google Page Live History Get * * Successful Response */ @@ -2970,32 +2124,32 @@ export type ListIssueStatusApiIssuesStatusGetResponses = { }; }; -export type ListIssueStatusApiIssuesStatusGetResponse = ListIssueStatusApiIssuesStatusGetResponses[keyof ListIssueStatusApiIssuesStatusGetResponses]; +export type GooglePageLiveHistoryApiIntegrationsGooglePageLiveHistoryGetResponse = GooglePageLiveHistoryApiIntegrationsGooglePageLiveHistoryGetResponses[keyof GooglePageLiveHistoryApiIntegrationsGooglePageLiveHistoryGetResponses]; -export type UpsertIssueStatusApiIssuesStatusPutData = { +export type GoogleKeywordsHistoryBatchApiIntegrationsGoogleKeywordsHistoryBatchPostData = { /** * Body */ - body?: { + body: { [key: string]: unknown; }; path?: never; query?: never; - url: '/api/issues/status'; + url: '/api/integrations/google/keywords/history/batch'; }; -export type UpsertIssueStatusApiIssuesStatusPutErrors = { +export type GoogleKeywordsHistoryBatchApiIntegrationsGoogleKeywordsHistoryBatchPostErrors = { /** * Validation Error */ 422: HttpValidationError; }; -export type UpsertIssueStatusApiIssuesStatusPutError = UpsertIssueStatusApiIssuesStatusPutErrors[keyof UpsertIssueStatusApiIssuesStatusPutErrors]; +export type GoogleKeywordsHistoryBatchApiIntegrationsGoogleKeywordsHistoryBatchPostError = GoogleKeywordsHistoryBatchApiIntegrationsGoogleKeywordsHistoryBatchPostErrors[keyof GoogleKeywordsHistoryBatchApiIntegrationsGoogleKeywordsHistoryBatchPostErrors]; -export type UpsertIssueStatusApiIssuesStatusPutResponses = { +export type GoogleKeywordsHistoryBatchApiIntegrationsGoogleKeywordsHistoryBatchPostResponses = { /** - * Response Upsert Issue Status Api Issues Status Put + * Response Google Keywords History Batch Api Integrations Google Keywords History Batch Post * * Successful Response */ @@ -3004,98 +2158,76 @@ export type UpsertIssueStatusApiIssuesStatusPutResponses = { }; }; -export type UpsertIssueStatusApiIssuesStatusPutResponse = UpsertIssueStatusApiIssuesStatusPutResponses[keyof UpsertIssueStatusApiIssuesStatusPutResponses]; +export type GoogleKeywordsHistoryBatchApiIntegrationsGoogleKeywordsHistoryBatchPostResponse = GoogleKeywordsHistoryBatchApiIntegrationsGoogleKeywordsHistoryBatchPostResponses[keyof GoogleKeywordsHistoryBatchApiIntegrationsGoogleKeywordsHistoryBatchPostResponses]; -export type IssuesFixSuggestionApiIssuesFixSuggestionPostData = { +export type GoogleKeywordsExpandApiIntegrationsGoogleKeywordsExpandPostData = { /** * Body */ - body?: { + body: { [key: string]: unknown; }; path?: never; query?: never; - url: '/api/issues/fix-suggestion'; + url: '/api/integrations/google/keywords/expand'; }; -export type IssuesFixSuggestionApiIssuesFixSuggestionPostErrors = { +export type GoogleKeywordsExpandApiIntegrationsGoogleKeywordsExpandPostErrors = { /** * Validation Error */ 422: HttpValidationError; }; -export type IssuesFixSuggestionApiIssuesFixSuggestionPostError = IssuesFixSuggestionApiIssuesFixSuggestionPostErrors[keyof IssuesFixSuggestionApiIssuesFixSuggestionPostErrors]; +export type GoogleKeywordsExpandApiIntegrationsGoogleKeywordsExpandPostError = GoogleKeywordsExpandApiIntegrationsGoogleKeywordsExpandPostErrors[keyof GoogleKeywordsExpandApiIntegrationsGoogleKeywordsExpandPostErrors]; -export type IssuesFixSuggestionApiIssuesFixSuggestionPostResponses = { +export type GoogleKeywordsExpandApiIntegrationsGoogleKeywordsExpandPostResponses = { /** - * Response Issues Fix Suggestion Api Issues Fix Suggestion Post + * Response Google Keywords Expand Api Integrations Google Keywords Expand Post * * Successful Response */ - 200: unknown; -}; - -export type IssuesActionPlanApiIssuesActionPlanPostData = { - /** - * Body - */ - body?: { + 200: { [key: string]: unknown; }; - path?: never; - query?: never; - url: '/api/issues/action-plan'; -}; - -export type IssuesActionPlanApiIssuesActionPlanPostErrors = { - /** - * Validation Error - */ - 422: HttpValidationError; }; -export type IssuesActionPlanApiIssuesActionPlanPostError = IssuesActionPlanApiIssuesActionPlanPostErrors[keyof IssuesActionPlanApiIssuesActionPlanPostErrors]; - -export type IssuesActionPlanApiIssuesActionPlanPostResponses = { - /** - * Response Issues Action Plan Api Issues Action Plan Post - * - * Successful Response - */ - 200: unknown; -}; +export type GoogleKeywordsExpandApiIntegrationsGoogleKeywordsExpandPostResponse = GoogleKeywordsExpandApiIntegrationsGoogleKeywordsExpandPostResponses[keyof GoogleKeywordsExpandApiIntegrationsGoogleKeywordsExpandPostResponses]; -export type AiFixSuggestionApiAiFixSuggestionPostData = { +export type GoogleKeywordsPlannerApiIntegrationsGoogleKeywordsPlannerPostData = { /** * Body */ - body?: { + body: { [key: string]: unknown; }; path?: never; query?: never; - url: '/api/ai/fix-suggestion'; + url: '/api/integrations/google/keywords/planner'; }; -export type AiFixSuggestionApiAiFixSuggestionPostErrors = { +export type GoogleKeywordsPlannerApiIntegrationsGoogleKeywordsPlannerPostErrors = { /** * Validation Error */ 422: HttpValidationError; }; -export type AiFixSuggestionApiAiFixSuggestionPostError = AiFixSuggestionApiAiFixSuggestionPostErrors[keyof AiFixSuggestionApiAiFixSuggestionPostErrors]; +export type GoogleKeywordsPlannerApiIntegrationsGoogleKeywordsPlannerPostError = GoogleKeywordsPlannerApiIntegrationsGoogleKeywordsPlannerPostErrors[keyof GoogleKeywordsPlannerApiIntegrationsGoogleKeywordsPlannerPostErrors]; -export type AiFixSuggestionApiAiFixSuggestionPostResponses = { +export type GoogleKeywordsPlannerApiIntegrationsGoogleKeywordsPlannerPostResponses = { /** - * Response Ai Fix Suggestion Api Ai Fix Suggestion Post + * Response Google Keywords Planner Api Integrations Google Keywords Planner Post * * Successful Response */ - 200: unknown; + 200: { + [key: string]: unknown; + }; }; +export type GoogleKeywordsPlannerApiIntegrationsGoogleKeywordsPlannerPostResponse = GoogleKeywordsPlannerApiIntegrationsGoogleKeywordsPlannerPostResponses[keyof GoogleKeywordsPlannerApiIntegrationsGoogleKeywordsPlannerPostResponses]; + export type KeywordsCompetitorImportApiKeywordsCompetitorImportPostData = { /** * Body @@ -3368,7 +2500,7 @@ export type ContentWizardApiContentWizardPostResponses = { export type ContentWizardApiContentWizardPostResponse = ContentWizardApiContentWizardPostResponses[keyof ContentWizardApiContentWizardPostResponses]; -export type ListContentDraftsApiContentDraftsGetData = { +export type ListContentDraftsRouteApiContentDraftsGetData = { body?: never; path?: never; query: { @@ -3380,18 +2512,18 @@ export type ListContentDraftsApiContentDraftsGetData = { url: '/api/content-drafts'; }; -export type ListContentDraftsApiContentDraftsGetErrors = { +export type ListContentDraftsRouteApiContentDraftsGetErrors = { /** * Validation Error */ 422: HttpValidationError; }; -export type ListContentDraftsApiContentDraftsGetError = ListContentDraftsApiContentDraftsGetErrors[keyof ListContentDraftsApiContentDraftsGetErrors]; +export type ListContentDraftsRouteApiContentDraftsGetError = ListContentDraftsRouteApiContentDraftsGetErrors[keyof ListContentDraftsRouteApiContentDraftsGetErrors]; -export type ListContentDraftsApiContentDraftsGetResponses = { +export type ListContentDraftsRouteApiContentDraftsGetResponses = { /** - * Response List Content Drafts Api Content Drafts Get + * Response List Content Drafts Route Api Content Drafts Get * * Successful Response */ @@ -3400,9 +2532,9 @@ export type ListContentDraftsApiContentDraftsGetResponses = { }; }; -export type ListContentDraftsApiContentDraftsGetResponse = ListContentDraftsApiContentDraftsGetResponses[keyof ListContentDraftsApiContentDraftsGetResponses]; +export type ListContentDraftsRouteApiContentDraftsGetResponse = ListContentDraftsRouteApiContentDraftsGetResponses[keyof ListContentDraftsRouteApiContentDraftsGetResponses]; -export type CreateContentDraftApiContentDraftsPostData = { +export type CreateContentDraftRouteApiContentDraftsPostData = { /** * Body */ @@ -3414,18 +2546,18 @@ export type CreateContentDraftApiContentDraftsPostData = { url: '/api/content-drafts'; }; -export type CreateContentDraftApiContentDraftsPostErrors = { +export type CreateContentDraftRouteApiContentDraftsPostErrors = { /** * Validation Error */ 422: HttpValidationError; }; -export type CreateContentDraftApiContentDraftsPostError = CreateContentDraftApiContentDraftsPostErrors[keyof CreateContentDraftApiContentDraftsPostErrors]; +export type CreateContentDraftRouteApiContentDraftsPostError = CreateContentDraftRouteApiContentDraftsPostErrors[keyof CreateContentDraftRouteApiContentDraftsPostErrors]; -export type CreateContentDraftApiContentDraftsPostResponses = { +export type CreateContentDraftRouteApiContentDraftsPostResponses = { /** - * Response Create Content Draft Api Content Drafts Post + * Response Create Content Draft Route Api Content Drafts Post * * Successful Response */ @@ -3434,9 +2566,9 @@ export type CreateContentDraftApiContentDraftsPostResponses = { }; }; -export type CreateContentDraftApiContentDraftsPostResponse = CreateContentDraftApiContentDraftsPostResponses[keyof CreateContentDraftApiContentDraftsPostResponses]; +export type CreateContentDraftRouteApiContentDraftsPostResponse = CreateContentDraftRouteApiContentDraftsPostResponses[keyof CreateContentDraftRouteApiContentDraftsPostResponses]; -export type DeleteContentDraftApiContentDraftsDraftIdDeleteData = { +export type DeleteContentDraftRouteApiContentDraftsDraftIdDeleteData = { body?: never; path: { /** @@ -3448,18 +2580,18 @@ export type DeleteContentDraftApiContentDraftsDraftIdDeleteData = { url: '/api/content-drafts/{draft_id}'; }; -export type DeleteContentDraftApiContentDraftsDraftIdDeleteErrors = { +export type DeleteContentDraftRouteApiContentDraftsDraftIdDeleteErrors = { /** * Validation Error */ 422: HttpValidationError; }; -export type DeleteContentDraftApiContentDraftsDraftIdDeleteError = DeleteContentDraftApiContentDraftsDraftIdDeleteErrors[keyof DeleteContentDraftApiContentDraftsDraftIdDeleteErrors]; +export type DeleteContentDraftRouteApiContentDraftsDraftIdDeleteError = DeleteContentDraftRouteApiContentDraftsDraftIdDeleteErrors[keyof DeleteContentDraftRouteApiContentDraftsDraftIdDeleteErrors]; -export type DeleteContentDraftApiContentDraftsDraftIdDeleteResponses = { +export type DeleteContentDraftRouteApiContentDraftsDraftIdDeleteResponses = { /** - * Response Delete Content Draft Api Content Drafts Draft Id Delete + * Response Delete Content Draft Route Api Content Drafts Draft Id Delete * * Successful Response */ @@ -3468,9 +2600,9 @@ export type DeleteContentDraftApiContentDraftsDraftIdDeleteResponses = { }; }; -export type DeleteContentDraftApiContentDraftsDraftIdDeleteResponse = DeleteContentDraftApiContentDraftsDraftIdDeleteResponses[keyof DeleteContentDraftApiContentDraftsDraftIdDeleteResponses]; +export type DeleteContentDraftRouteApiContentDraftsDraftIdDeleteResponse = DeleteContentDraftRouteApiContentDraftsDraftIdDeleteResponses[keyof DeleteContentDraftRouteApiContentDraftsDraftIdDeleteResponses]; -export type GetContentDraftApiContentDraftsDraftIdGetData = { +export type GetContentDraftRouteApiContentDraftsDraftIdGetData = { body?: never; path: { /** @@ -3482,18 +2614,18 @@ export type GetContentDraftApiContentDraftsDraftIdGetData = { url: '/api/content-drafts/{draft_id}'; }; -export type GetContentDraftApiContentDraftsDraftIdGetErrors = { +export type GetContentDraftRouteApiContentDraftsDraftIdGetErrors = { /** * Validation Error */ 422: HttpValidationError; }; -export type GetContentDraftApiContentDraftsDraftIdGetError = GetContentDraftApiContentDraftsDraftIdGetErrors[keyof GetContentDraftApiContentDraftsDraftIdGetErrors]; +export type GetContentDraftRouteApiContentDraftsDraftIdGetError = GetContentDraftRouteApiContentDraftsDraftIdGetErrors[keyof GetContentDraftRouteApiContentDraftsDraftIdGetErrors]; -export type GetContentDraftApiContentDraftsDraftIdGetResponses = { +export type GetContentDraftRouteApiContentDraftsDraftIdGetResponses = { /** - * Response Get Content Draft Api Content Drafts Draft Id Get + * Response Get Content Draft Route Api Content Drafts Draft Id Get * * Successful Response */ @@ -3502,9 +2634,9 @@ export type GetContentDraftApiContentDraftsDraftIdGetResponses = { }; }; -export type GetContentDraftApiContentDraftsDraftIdGetResponse = GetContentDraftApiContentDraftsDraftIdGetResponses[keyof GetContentDraftApiContentDraftsDraftIdGetResponses]; +export type GetContentDraftRouteApiContentDraftsDraftIdGetResponse = GetContentDraftRouteApiContentDraftsDraftIdGetResponses[keyof GetContentDraftRouteApiContentDraftsDraftIdGetResponses]; -export type UpdateContentDraftApiContentDraftsDraftIdPatchData = { +export type UpdateContentDraftRouteApiContentDraftsDraftIdPatchData = { /** * Body */ @@ -3521,18 +2653,18 @@ export type UpdateContentDraftApiContentDraftsDraftIdPatchData = { url: '/api/content-drafts/{draft_id}'; }; -export type UpdateContentDraftApiContentDraftsDraftIdPatchErrors = { +export type UpdateContentDraftRouteApiContentDraftsDraftIdPatchErrors = { /** * Validation Error */ 422: HttpValidationError; }; -export type UpdateContentDraftApiContentDraftsDraftIdPatchError = UpdateContentDraftApiContentDraftsDraftIdPatchErrors[keyof UpdateContentDraftApiContentDraftsDraftIdPatchErrors]; +export type UpdateContentDraftRouteApiContentDraftsDraftIdPatchError = UpdateContentDraftRouteApiContentDraftsDraftIdPatchErrors[keyof UpdateContentDraftRouteApiContentDraftsDraftIdPatchErrors]; -export type UpdateContentDraftApiContentDraftsDraftIdPatchResponses = { +export type UpdateContentDraftRouteApiContentDraftsDraftIdPatchResponses = { /** - * Response Update Content Draft Api Content Drafts Draft Id Patch + * Response Update Content Draft Route Api Content Drafts Draft Id Patch * * Successful Response */ @@ -3541,9 +2673,9 @@ export type UpdateContentDraftApiContentDraftsDraftIdPatchResponses = { }; }; -export type UpdateContentDraftApiContentDraftsDraftIdPatchResponse = UpdateContentDraftApiContentDraftsDraftIdPatchResponses[keyof UpdateContentDraftApiContentDraftsDraftIdPatchResponses]; +export type UpdateContentDraftRouteApiContentDraftsDraftIdPatchResponse = UpdateContentDraftRouteApiContentDraftsDraftIdPatchResponses[keyof UpdateContentDraftRouteApiContentDraftsDraftIdPatchResponses]; -export type DeletePageMarkdownApiPageMarkdownDeleteData = { +export type DeletePageMarkdownRouteApiPageMarkdownDeleteData = { /** * Body */ @@ -3555,18 +2687,18 @@ export type DeletePageMarkdownApiPageMarkdownDeleteData = { url: '/api/page-markdown'; }; -export type DeletePageMarkdownApiPageMarkdownDeleteErrors = { +export type DeletePageMarkdownRouteApiPageMarkdownDeleteErrors = { /** * Validation Error */ 422: HttpValidationError; }; -export type DeletePageMarkdownApiPageMarkdownDeleteError = DeletePageMarkdownApiPageMarkdownDeleteErrors[keyof DeletePageMarkdownApiPageMarkdownDeleteErrors]; +export type DeletePageMarkdownRouteApiPageMarkdownDeleteError = DeletePageMarkdownRouteApiPageMarkdownDeleteErrors[keyof DeletePageMarkdownRouteApiPageMarkdownDeleteErrors]; -export type DeletePageMarkdownApiPageMarkdownDeleteResponses = { +export type DeletePageMarkdownRouteApiPageMarkdownDeleteResponses = { /** - * Response Delete Page Markdown Api Page Markdown Delete + * Response Delete Page Markdown Route Api Page Markdown Delete * * Successful Response */ @@ -3575,9 +2707,9 @@ export type DeletePageMarkdownApiPageMarkdownDeleteResponses = { }; }; -export type DeletePageMarkdownApiPageMarkdownDeleteResponse = DeletePageMarkdownApiPageMarkdownDeleteResponses[keyof DeletePageMarkdownApiPageMarkdownDeleteResponses]; +export type DeletePageMarkdownRouteApiPageMarkdownDeleteResponse = DeletePageMarkdownRouteApiPageMarkdownDeleteResponses[keyof DeletePageMarkdownRouteApiPageMarkdownDeleteResponses]; -export type ListPageMarkdownApiPageMarkdownGetData = { +export type ListPageMarkdownRouteApiPageMarkdownGetData = { body?: never; path?: never; query: { @@ -3601,18 +2733,18 @@ export type ListPageMarkdownApiPageMarkdownGetData = { url: '/api/page-markdown'; }; -export type ListPageMarkdownApiPageMarkdownGetErrors = { +export type ListPageMarkdownRouteApiPageMarkdownGetErrors = { /** * Validation Error */ 422: HttpValidationError; }; -export type ListPageMarkdownApiPageMarkdownGetError = ListPageMarkdownApiPageMarkdownGetErrors[keyof ListPageMarkdownApiPageMarkdownGetErrors]; +export type ListPageMarkdownRouteApiPageMarkdownGetError = ListPageMarkdownRouteApiPageMarkdownGetErrors[keyof ListPageMarkdownRouteApiPageMarkdownGetErrors]; -export type ListPageMarkdownApiPageMarkdownGetResponses = { +export type ListPageMarkdownRouteApiPageMarkdownGetResponses = { /** - * Response List Page Markdown Api Page Markdown Get + * Response List Page Markdown Route Api Page Markdown Get * * Successful Response */ @@ -3621,9 +2753,9 @@ export type ListPageMarkdownApiPageMarkdownGetResponses = { }; }; -export type ListPageMarkdownApiPageMarkdownGetResponse = ListPageMarkdownApiPageMarkdownGetResponses[keyof ListPageMarkdownApiPageMarkdownGetResponses]; +export type ListPageMarkdownRouteApiPageMarkdownGetResponse = ListPageMarkdownRouteApiPageMarkdownGetResponses[keyof ListPageMarkdownRouteApiPageMarkdownGetResponses]; -export type PageMarkdownContentApiPageMarkdownContentGetData = { +export type PageMarkdownContentRouteApiPageMarkdownContentGetData = { body?: never; path?: never; query: { @@ -3639,18 +2771,18 @@ export type PageMarkdownContentApiPageMarkdownContentGetData = { url: '/api/page-markdown/content'; }; -export type PageMarkdownContentApiPageMarkdownContentGetErrors = { +export type PageMarkdownContentRouteApiPageMarkdownContentGetErrors = { /** * Validation Error */ 422: HttpValidationError; }; -export type PageMarkdownContentApiPageMarkdownContentGetError = PageMarkdownContentApiPageMarkdownContentGetErrors[keyof PageMarkdownContentApiPageMarkdownContentGetErrors]; +export type PageMarkdownContentRouteApiPageMarkdownContentGetError = PageMarkdownContentRouteApiPageMarkdownContentGetErrors[keyof PageMarkdownContentRouteApiPageMarkdownContentGetErrors]; -export type PageMarkdownContentApiPageMarkdownContentGetResponses = { +export type PageMarkdownContentRouteApiPageMarkdownContentGetResponses = { /** - * Response Page Markdown Content Api Page Markdown Content Get + * Response Page Markdown Content Route Api Page Markdown Content Get * * Successful Response */ @@ -3659,7 +2791,7 @@ export type PageMarkdownContentApiPageMarkdownContentGetResponses = { }; }; -export type PageMarkdownContentApiPageMarkdownContentGetResponse = PageMarkdownContentApiPageMarkdownContentGetResponses[keyof PageMarkdownContentApiPageMarkdownContentGetResponses]; +export type PageMarkdownContentRouteApiPageMarkdownContentGetResponse = PageMarkdownContentRouteApiPageMarkdownContentGetResponses[keyof PageMarkdownContentRouteApiPageMarkdownContentGetResponses]; export type PageMarkdownExtractApiPageMarkdownExtractPostData = { /** @@ -3695,7 +2827,7 @@ export type PageMarkdownExtractApiPageMarkdownExtractPostResponses = { export type PageMarkdownExtractApiPageMarkdownExtractPostResponse = PageMarkdownExtractApiPageMarkdownExtractPostResponses[keyof PageMarkdownExtractApiPageMarkdownExtractPostResponses]; -export type PageMarkdownRunsApiPageMarkdownRunsGetData = { +export type PageMarkdownRunsRouteApiPageMarkdownRunsGetData = { body?: never; path?: never; query?: { @@ -3707,87 +2839,18 @@ export type PageMarkdownRunsApiPageMarkdownRunsGetData = { url: '/api/page-markdown/runs'; }; -export type PageMarkdownRunsApiPageMarkdownRunsGetErrors = { - /** - * Validation Error - */ - 422: HttpValidationError; -}; - -export type PageMarkdownRunsApiPageMarkdownRunsGetError = PageMarkdownRunsApiPageMarkdownRunsGetErrors[keyof PageMarkdownRunsApiPageMarkdownRunsGetErrors]; - -export type PageMarkdownRunsApiPageMarkdownRunsGetResponses = { - /** - * Response Page Markdown Runs Api Page Markdown Runs Get - * - * Successful Response - */ - 200: { - [key: string]: unknown; - }; -}; - -export type PageMarkdownRunsApiPageMarkdownRunsGetResponse = PageMarkdownRunsApiPageMarkdownRunsGetResponses[keyof PageMarkdownRunsApiPageMarkdownRunsGetResponses]; - -export type OllamaStatusApiOllamaStatusGetData = { - body?: never; - path?: never; - query?: never; - url: '/api/ollama/status'; -}; - -export type OllamaStatusApiOllamaStatusGetResponses = { - /** - * Response Ollama Status Api Ollama Status Get - * - * Successful Response - */ - 200: { - [key: string]: unknown; - }; -}; - -export type OllamaStatusApiOllamaStatusGetResponse = OllamaStatusApiOllamaStatusGetResponses[keyof OllamaStatusApiOllamaStatusGetResponses]; - -export type McpToolsApiMcpToolsGetData = { - body?: never; - path?: never; - query?: never; - url: '/api/mcp-tools'; -}; - -export type McpToolsApiMcpToolsGetResponses = { - /** - * Response Mcp Tools Api Mcp Tools Get - * - * Successful Response - */ - 200: { - [key: string]: unknown; - }; -}; - -export type McpToolsApiMcpToolsGetResponse = McpToolsApiMcpToolsGetResponses[keyof McpToolsApiMcpToolsGetResponses]; - -export type DeletePortfolioItemApiPortfolioDeleteDeleteData = { - body: DeletePortfolioBody; - path?: never; - query?: never; - url: '/api/portfolio/delete'; -}; - -export type DeletePortfolioItemApiPortfolioDeleteDeleteErrors = { +export type PageMarkdownRunsRouteApiPageMarkdownRunsGetErrors = { /** * Validation Error */ 422: HttpValidationError; }; -export type DeletePortfolioItemApiPortfolioDeleteDeleteError = DeletePortfolioItemApiPortfolioDeleteDeleteErrors[keyof DeletePortfolioItemApiPortfolioDeleteDeleteErrors]; +export type PageMarkdownRunsRouteApiPageMarkdownRunsGetError = PageMarkdownRunsRouteApiPageMarkdownRunsGetErrors[keyof PageMarkdownRunsRouteApiPageMarkdownRunsGetErrors]; -export type DeletePortfolioItemApiPortfolioDeleteDeleteResponses = { +export type PageMarkdownRunsRouteApiPageMarkdownRunsGetResponses = { /** - * Response Delete Portfolio Item Api Portfolio Delete Delete + * Response Page Markdown Runs Route Api Page Markdown Runs Get * * Successful Response */ @@ -3796,7 +2859,7 @@ export type DeletePortfolioItemApiPortfolioDeleteDeleteResponses = { }; }; -export type DeletePortfolioItemApiPortfolioDeleteDeleteResponse = DeletePortfolioItemApiPortfolioDeleteDeleteResponses[keyof DeletePortfolioItemApiPortfolioDeleteDeleteResponses]; +export type PageMarkdownRunsRouteApiPageMarkdownRunsGetResponse = PageMarkdownRunsRouteApiPageMarkdownRunsGetResponses[keyof PageMarkdownRunsRouteApiPageMarkdownRunsGetResponses]; export type AlertsCheckApiAlertsCheckPostData = { body?: never; @@ -3904,35 +2967,6 @@ export type CompareExportApiCompareExportPostResponses = { 200: unknown; }; -export type PageCoachApiLinksPageCoachPostData = { - body: PageCoachBody; - path?: never; - query?: never; - url: '/api/links/page-coach'; -}; - -export type PageCoachApiLinksPageCoachPostErrors = { - /** - * Validation Error - */ - 422: HttpValidationError; -}; - -export type PageCoachApiLinksPageCoachPostError = PageCoachApiLinksPageCoachPostErrors[keyof PageCoachApiLinksPageCoachPostErrors]; - -export type PageCoachApiLinksPageCoachPostResponses = { - /** - * Response Page Coach Api Links Page Coach Post - * - * Successful Response - */ - 200: { - [key: string]: unknown; - }; -}; - -export type PageCoachApiLinksPageCoachPostResponse = PageCoachApiLinksPageCoachPostResponses[keyof PageCoachApiLinksPageCoachPostResponses]; - export type RunAuditToolApiReportAuditToolPostData = { body: AuditToolBody; path?: never; @@ -3961,137 +2995,3 @@ export type RunAuditToolApiReportAuditToolPostResponses = { }; export type RunAuditToolApiReportAuditToolPostResponse = RunAuditToolApiReportAuditToolPostResponses[keyof RunAuditToolApiReportAuditToolPostResponses]; - -export type ExportReportApiReportExportGetData = { - body?: never; - path?: never; - query?: { - /** - * Format - */ - format?: string; - /** - * Reportid - */ - reportId?: number | null; - }; - url: '/api/report/export'; -}; - -export type ExportReportApiReportExportGetErrors = { - /** - * Validation Error - */ - 422: HttpValidationError; -}; - -export type ExportReportApiReportExportGetError = ExportReportApiReportExportGetErrors[keyof ExportReportApiReportExportGetErrors]; - -export type ExportReportApiReportExportGetResponses = { - /** - * Successful Response - */ - 200: unknown; -}; - -export type ExportSitemapApiReportExportSitemapGetData = { - body?: never; - path?: never; - query?: { - /** - * Reportid - */ - reportId?: number | null; - }; - url: '/api/report/export-sitemap'; -}; - -export type ExportSitemapApiReportExportSitemapGetErrors = { - /** - * Validation Error - */ - 422: HttpValidationError; -}; - -export type ExportSitemapApiReportExportSitemapGetError = ExportSitemapApiReportExportSitemapGetErrors[keyof ExportSitemapApiReportExportSitemapGetErrors]; - -export type ExportSitemapApiReportExportSitemapGetResponses = { - /** - * Successful Response - */ - 200: unknown; -}; - -export type ExportWorkbookApiReportExportWorkbookGetData = { - body?: never; - path?: never; - query?: { - /** - * Reportid - */ - reportId?: number | null; - }; - url: '/api/report/export-workbook'; -}; - -export type ExportWorkbookApiReportExportWorkbookGetErrors = { - /** - * Validation Error - */ - 422: HttpValidationError; -}; - -export type ExportWorkbookApiReportExportWorkbookGetError = ExportWorkbookApiReportExportWorkbookGetErrors[keyof ExportWorkbookApiReportExportWorkbookGetErrors]; - -export type ExportWorkbookApiReportExportWorkbookGetResponses = { - /** - * Successful Response - */ - 200: unknown; -}; - -export type ReportPortfolioApiReportPortfolioGetData = { - body?: never; - path?: never; - query?: { - /** - * Widget - */ - widget?: string; - /** - * Ids - */ - ids?: string | null; - /** - * Reportid - */ - reportId?: number | null; - /** - * Crawlrunid - */ - crawlRunId?: number | null; - }; - url: '/api/report/portfolio'; -}; - -export type ReportPortfolioApiReportPortfolioGetErrors = { - /** - * Validation Error - */ - 422: HttpValidationError; -}; - -export type ReportPortfolioApiReportPortfolioGetError = ReportPortfolioApiReportPortfolioGetErrors[keyof ReportPortfolioApiReportPortfolioGetErrors]; - -export type ReportPortfolioApiReportPortfolioGetResponses = { - /** - * Response Report Portfolio Api Report Portfolio Get - * - * Successful Response - */ - 200: { - [key: string]: unknown; - }; -}; - -export type ReportPortfolioApiReportPortfolioGetResponse = ReportPortfolioApiReportPortfolioGetResponses[keyof ReportPortfolioApiReportPortfolioGetResponses]; diff --git a/web/src/components/GoogleIntegrationsPanel.tsx b/web/src/components/GoogleIntegrationsPanel.tsx index a5d6896b..837bc764 100644 --- a/web/src/components/GoogleIntegrationsPanel.tsx +++ b/web/src/components/GoogleIntegrationsPanel.tsx @@ -391,14 +391,26 @@ export default function GoogleIntegrationsPanel({ const ensurePropertyIdForOAuth = useCallback(async (): Promise => { if (effectivePropertyId != null) return effectivePropertyId; const url = startUrl.trim(); - if (!url) return null; + if (!url || !url.includes('.')) return null; try { - const res = await apiFetch(apiUrl(`/properties/resolve?startUrl=${encodeURIComponent(url)}`)); - if (!res.ok) return null; - const data = (await res.json()) as { id?: number }; - if (data.id == null || !Number.isFinite(data.id)) return null; - setSelectedPropertyId(data.id); - return data.id; + const resolveRes = await apiFetch(apiUrl(`/properties/resolve?startUrl=${encodeURIComponent(url)}`)); + if (resolveRes.ok) { + const resolved = (await resolveRes.json()) as { id?: number | null }; + if (resolved.id != null && Number.isFinite(resolved.id)) { + setSelectedPropertyId(resolved.id); + return resolved.id; + } + } + const ensureRes = await apiFetch(apiUrl('/properties/ensure'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ startUrl: url }), + }); + if (!ensureRes.ok) return null; + const ensured = (await ensureRes.json()) as { id?: number }; + if (ensured.id == null || !Number.isFinite(ensured.id)) return null; + setSelectedPropertyId(ensured.id); + return ensured.id; } catch { return null; } diff --git a/web/src/components/chat/ChatApiKeyBanner.tsx b/web/src/components/chat/ChatApiKeyBanner.tsx new file mode 100644 index 00000000..1f00b42d --- /dev/null +++ b/web/src/components/chat/ChatApiKeyBanner.tsx @@ -0,0 +1,38 @@ +import { Link } from 'react-router-dom'; +import { AlertCircle } from 'lucide-react'; +import { + LLM_PROVIDER_LABELS, + isLlmCloudProvider, +} from '@/lib/llmProviderApiKeys'; +import { format, strings } from '@/lib/strings'; + +const c = strings.components.chat; + +export interface ChatApiKeyBannerProps { + provider: string; + compact?: boolean; +} + +export default function ChatApiKeyBanner({ provider, compact }: ChatApiKeyBannerProps) { + const label = isLlmCloudProvider(provider) ? LLM_PROVIDER_LABELS[provider] : provider; + + return ( +
+ +
+

+ {c.apiKeyMissingTitle} +

+

{format(c.apiKeyMissingHint, { provider: label })}

+ + {c.openSecrets} + +
+
+ ); +} diff --git a/web/src/components/chat/ChatAssistantMessage.tsx b/web/src/components/chat/ChatAssistantMessage.tsx index cb0f3791..b8914f18 100644 --- a/web/src/components/chat/ChatAssistantMessage.tsx +++ b/web/src/components/chat/ChatAssistantMessage.tsx @@ -1,5 +1,7 @@ +import { useMemo } from 'react'; import { Sparkles } from 'lucide-react'; +import ChatStreamingStatus from '@/components/chat/ChatStreamingStatus'; import ChatBlocks from '@/components/chat/blocks/ChatBlocks'; import ChatInsightSections from '@/components/chat/ChatInsightSections'; import ChatNarrativeSections from '@/components/chat/ChatNarrativeSections'; @@ -38,13 +40,17 @@ export default function ChatAssistantMessage({ }: ChatAssistantMessageProps) { const useStructuredNarrative = Boolean(narrative); - const processed = postprocessChatContent( - useStructuredNarrative ? '' : content, - toolActivity, - { - agentError, - partialError: useStructuredNarrative ? false : partialError, - }, + const processed = useMemo( + () => + postprocessChatContent( + useStructuredNarrative ? '' : content, + toolActivity, + { + agentError, + partialError: useStructuredNarrative ? false : partialError, + }, + ), + [content, toolActivity, agentError, partialError, useStructuredNarrative], ); const blocks = blocksOverride ?? processed.blocks; const prose = processed.prose; @@ -78,16 +84,28 @@ export default function ChatAssistantMessage({ ); } + const showStreamingPanel = + streaming && + !fatalError && + !showNarrative && + !showProse && + blocks.length === 0 && + Boolean(statusText || (toolActivity?.length ?? 0) > 0); + return (
- {(streaming || (!content && !blocks.length && !showNarrative)) && !fatalError ? ( + {showStreamingPanel ? ( + + ) : (streaming || (!content && !blocks.length && !showNarrative)) && !fatalError ? ( ) : null} - {toolActivity?.length ? : null} + {toolActivity?.length && !showStreamingPanel ? ( + + ) : null} {blocks.length > 0 ? : null} @@ -115,7 +133,7 @@ export default function ChatAssistantMessage({ streaming={streaming} /> ) : streaming && statusText ? ( - {statusText} + ) : null}
); diff --git a/web/src/components/chat/ChatFabDrawer.tsx b/web/src/components/chat/ChatFabDrawer.tsx index 7f80f612..2c7a3870 100644 --- a/web/src/components/chat/ChatFabDrawer.tsx +++ b/web/src/components/chat/ChatFabDrawer.tsx @@ -8,7 +8,9 @@ import ChatComposer from '@/components/chat/ChatComposer'; import ChatMarkdown from '@/components/chat/ChatMarkdown'; import ChatModelPicker from '@/components/chat/ChatModelPicker'; import ChatProviderPicker from '@/components/chat/ChatProviderPicker'; +import ChatApiKeyBanner from '@/components/chat/ChatApiKeyBanner'; import { usePipeline } from '@/context/PipelineContext'; +import { isLlmInsightsEnabled } from '@/lib/llmConfigSchema'; import { resolveChatAssistantName } from '@/lib/chatAssistantBranding'; import { useChatFabPopup } from '@/hooks/useChatFabPopup'; @@ -26,14 +28,15 @@ interface ChatFabDrawerProps { } export default function ChatFabDrawer({ open, domain, onClose }: ChatFabDrawerProps) { - const { llmConfigState } = usePipeline(); + const { llmConfigState, llmApiKeyConfigured, configLoaded } = usePipeline(); const { messages, busy, propertyName, resolving, sendMessage, reset, openFullChat } = useChatFabPopup(domain); const bottomRef = useRef(null); - const llmEnabled = - llmConfigState.llm_enabled === true && - String(llmConfigState.llm_provider || 'none') !== 'none'; + const llmEnabled = isLlmInsightsEnabled(llmConfigState); + const llmProvider = String(llmConfigState.llm_provider || 'none'); + const needsApiKey = + llmEnabled && configLoaded && !llmApiKeyConfigured && llmProvider !== 'none' && llmProvider !== 'ollama'; const assistantName = resolveChatAssistantName( String(llmConfigState.llm_chat_assistant_name || ''), ); @@ -157,6 +160,7 @@ export default function ChatFabDrawer({ open, domain, onClose }: ChatFabDrawerPr {/* Empty state */} {isEmpty && (
+ {needsApiKey ? : null}

{domain ? `Ask anything about ${domain}` : c.emptyHint}

@@ -166,7 +170,7 @@ export default function ChatFabDrawer({ open, domain, onClose }: ChatFabDrawerPr key={prompt} type="button" onClick={() => sendMessage(prompt)} - disabled={busy} + disabled={busy || needsApiKey} className="rounded-xl border border-default/60 bg-[var(--chat-surface)]/30 px-3 py-2.5 text-left text-[12px] text-muted-foreground transition-all hover:border-[var(--accent-border)] hover:bg-[var(--chat-surface)]/70 hover:text-foreground disabled:opacity-40" > {prompt} @@ -251,7 +255,12 @@ export default function ChatFabDrawer({ open, domain, onClose }: ChatFabDrawerPr {/* ── Composer ─────────────────────────────────────── */}
- + {needsApiKey && !isEmpty ? ( +
+ +
+ ) : null} +
diff --git a/web/src/components/chat/ChatMessageList.tsx b/web/src/components/chat/ChatMessageList.tsx index 722b4dc8..99c771e6 100644 --- a/web/src/components/chat/ChatMessageList.tsx +++ b/web/src/components/chat/ChatMessageList.tsx @@ -5,7 +5,7 @@ import type { ToolActivityItem } from '@/components/chat/ChatToolActivity'; import { toolEventsToActivity } from '@/components/chat/deriveChatBlocks'; import type { ChatNarrative } from '@/types/chatNarrative'; -import { narrativeFromToolResult } from '@/types/chatNarrative'; +import { narrativeFromToolResult, narrativeFromLegacyContent } from '@/types/chatNarrative'; export interface ChatMessage { id: string | number; @@ -94,4 +94,4 @@ export function agentErrorFromToolResult( return typeof err === 'string' && err.trim() ? err.trim() : null; } -export { narrativeFromToolResult }; +export { narrativeFromToolResult, narrativeFromLegacyContent }; diff --git a/web/src/components/chat/ChatStreamingStatus.tsx b/web/src/components/chat/ChatStreamingStatus.tsx new file mode 100644 index 00000000..2ef0a8f5 --- /dev/null +++ b/web/src/components/chat/ChatStreamingStatus.tsx @@ -0,0 +1,89 @@ +import { useState } from 'react'; +import { ChevronDown, ChevronRight, Loader2 } from 'lucide-react'; +import type { ToolActivityItem } from '@/components/chat/ChatToolActivity'; +import { formatToolDisplayName } from '@/components/chat/chatStatusLabels'; +import { format, strings } from '@/lib/strings'; + +const c = strings.components.chat; + +export interface ChatStreamingStatusProps { + statusText?: string; + toolActivity?: ToolActivityItem[]; +} + +function isFailed(item: ToolActivityItem): boolean { + return item.status === 'done' && Boolean(item.result && typeof item.result.error === 'string'); +} + +export default function ChatStreamingStatus({ + statusText, + toolActivity, +}: ChatStreamingStatusProps) { + const running = toolActivity?.filter((t) => t.status === 'running') ?? []; + const doneCount = toolActivity?.filter((t) => t.status === 'done').length ?? 0; + const totalTools = toolActivity?.length ?? 0; + const hasToolDetails = totalTools > 0; + const [open, setOpen] = useState(true); + const expanded = open || running.length > 0; + + return ( +
+
+ +
+
+

{statusText || c.thinking}

+ {hasToolDetails ? ( + + ) : null} +
+ + {hasToolDetails && expanded ? ( +
    + {toolActivity!.map((tool) => ( +
  • + {tool.status === 'running' ? ( + + ) : isFailed(tool) ? ( + + ) : ( + + )} + + {formatToolDisplayName(tool.name)} + {tool.status === 'running' ? ( + {c.toolRunning} + ) : isFailed(tool) ? ( + + {String(tool.result?.error)} + + ) : ( + {c.toolDone} + )} + +
  • + ))} +
+ ) : null} +
+
+
+ ); +} diff --git a/web/src/components/chat/ChatToolActivity.tsx b/web/src/components/chat/ChatToolActivity.tsx index 1d9cd690..79d2a0b1 100644 --- a/web/src/components/chat/ChatToolActivity.tsx +++ b/web/src/components/chat/ChatToolActivity.tsx @@ -15,6 +15,8 @@ export interface ToolActivityItem { export interface ChatToolActivityProps { items: ToolActivityItem[]; + /** When true, expand the tool list while work is in flight. */ + streaming?: boolean; } const WORKFLOW_TOOLS = new Set([ @@ -36,8 +38,10 @@ function groupLabel(name: string): string { return c.toolGroupData; } -export default function ChatToolActivity({ items }: ChatToolActivityProps) { +export default function ChatToolActivity({ items, streaming }: ChatToolActivityProps) { + const hasRunning = items.some((i) => i.status === 'running'); const [open, setOpen] = useState(false); + const expanded = open || Boolean(streaming && hasRunning); const failed = useMemo(() => items.filter(isFailed), [items]); const groups = useMemo(() => { @@ -73,13 +77,13 @@ export default function ChatToolActivity({ items }: ChatToolActivityProps) { type="button" onClick={() => setOpen((v) => !v)} className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground" - aria-expanded={open} + aria-expanded={expanded} > - {open ? : } + {expanded ? : } {format(c.toolsUsedSummary, { count: items.length })} - {open ? ( + {expanded ? (
{groups.map(([label, groupItems]) => (
diff --git a/web/src/components/chat/ChatUnlimitedToolsToggle.tsx b/web/src/components/chat/ChatUnlimitedToolsToggle.tsx index 8aa29a8b..8a4aedf2 100644 --- a/web/src/components/chat/ChatUnlimitedToolsToggle.tsx +++ b/web/src/components/chat/ChatUnlimitedToolsToggle.tsx @@ -1,6 +1,7 @@ import { Infinity } from 'lucide-react'; import { strings } from '@/lib/strings'; +import { parseLlmBool } from '@/lib/llmConfigSchema'; import { usePipeline } from '@/context/PipelineContext'; const c = strings.components.chat; @@ -11,7 +12,7 @@ export interface ChatUnlimitedToolsToggleProps { export default function ChatUnlimitedToolsToggle({ disabled }: ChatUnlimitedToolsToggleProps) { const { llmConfigState, saveLlmChatUnlimitedTools, saving } = usePipeline(); - const enabled = llmConfigState.llm_chat_unlimited_tool_rounds === true; + const enabled = parseLlmBool(llmConfigState.llm_chat_unlimited_tool_rounds); const busy = disabled || saving; return ( diff --git a/web/src/components/chat/chatStatusLabels.test.ts b/web/src/components/chat/chatStatusLabels.test.ts new file mode 100644 index 00000000..354232ce --- /dev/null +++ b/web/src/components/chat/chatStatusLabels.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { formatToolDisplayName, statusFromSseEvent } from '@/components/chat/chatStatusLabels'; + +describe('chatStatusLabels', () => { + it('formats known tool names', () => { + expect(formatToolDisplayName('get_traffic_health_check')).toBe('Traffic health'); + expect(formatToolDisplayName('list_issues')).toBe('Issue list'); + }); + + it('maps model status to step label', () => { + expect( + statusFromSseEvent({ + type: 'status', + phase: 'model', + detail: 'Planning step 2 of 10…', + }), + ).toContain('2'); + }); + + it('maps tool_start to running label', () => { + const label = statusFromSseEvent({ + type: 'tool_start', + name: 'list_issues', + }); + expect(label.toLowerCase()).toContain('issue'); + }); + + it('maps token to writing summary label', () => { + const label = statusFromSseEvent({ type: 'token' }); + expect(label.toLowerCase()).toContain('summary'); + }); +}); diff --git a/web/src/components/chat/chatStatusLabels.ts b/web/src/components/chat/chatStatusLabels.ts new file mode 100644 index 00000000..8535f378 --- /dev/null +++ b/web/src/components/chat/chatStatusLabels.ts @@ -0,0 +1,63 @@ +import { format, strings } from '@/lib/strings'; + +const c = strings.components.chat; + +/** Human-readable labels for audit tool names in the activity UI. */ +const TOOL_LABELS: Record = { + run_insight_workflow: 'Insight workflow', + run_technical_workflow: 'Technical audit workflow', + run_keyword_workflow: 'Keyword workflow', + run_domain_agent: 'Domain exploration', + search_audit_tools: 'Searching tools', + get_report_summary: 'Report summary', + list_issues: 'Issue list', + get_critical_issues: 'Critical issues', + get_opportunity_matrix: 'Opportunity matrix', + get_traffic_health_check: 'Traffic health', + get_landing_page_blended_table: 'Landing page metrics', +}; + +export function formatToolDisplayName(name: string): string { + const key = name.trim(); + if (!key) return 'audit tool'; + if (TOOL_LABELS[key]) return TOOL_LABELS[key]; + return key.replace(/_/g, ' '); +} + +export function statusFromSseEvent(evt: { + type: string; + phase?: string; + detail?: string; + name?: string; +}): string { + if (evt.type === 'tool_start' && evt.name) { + return format(c.toolStatus, { name: formatToolDisplayName(evt.name) }); + } + if (evt.type === 'tool_progress' && evt.detail) { + return evt.detail; + } + if (evt.type === 'status') { + const phase = evt.phase || ''; + const detail = evt.detail || ''; + if (phase === 'synthesizing') { + return detail.toLowerCase().includes('retry') + ? c.synthesizingRetry + : c.synthesizing; + } + if (phase === 'model' && detail) { + const stepMatch = /step (\d+) of (\d+)/i.exec(detail); + if (stepMatch) { + return format(c.thinkingStep, { + step: stepMatch[1], + total: stepMatch[2], + }); + } + return detail; + } + return detail || c.thinking; + } + if (evt.type === 'token') { + return c.writingSummary; + } + return c.thinking; +} diff --git a/web/src/components/chat/expandWorkflowToolActivity.test.ts b/web/src/components/chat/expandWorkflowToolActivity.test.ts new file mode 100644 index 00000000..aa4512fb --- /dev/null +++ b/web/src/components/chat/expandWorkflowToolActivity.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { expandWorkflowToolActivity } from '@/components/chat/expandWorkflowToolActivity'; +import { deriveChatBlocks } from '@/components/chat/deriveChatBlocks'; + +describe('expandWorkflowToolActivity', () => { + it('expands workflow steps for block derivation', () => { + const activity = expandWorkflowToolActivity([ + { + id: 'wf-1', + name: 'run_insight_workflow', + status: 'done', + result: { + workflow: 'insight', + steps: [ + { + tool: 'get_report_summary', + result: { + health_score: 72, + site_name: 'Example', + counts: { critical: 1, high: 2, medium: 3, low: 4 }, + total_issues: 10, + total_urls: 100, + success_rate: 0.98, + }, + }, + ], + }, + }, + ]); + + expect(activity.some((a) => a.name === 'get_report_summary')).toBe(true); + const blocks = deriveChatBlocks(activity); + expect(blocks.some((b) => b.type === 'issue_summary')).toBe(true); + }); +}); diff --git a/web/src/components/chat/expandWorkflowToolActivity.ts b/web/src/components/chat/expandWorkflowToolActivity.ts new file mode 100644 index 00000000..3b4c633b --- /dev/null +++ b/web/src/components/chat/expandWorkflowToolActivity.ts @@ -0,0 +1,47 @@ +import type { ToolActivityItem } from '@/components/chat/ChatToolActivity'; + +const WORKFLOW_TOOLS = new Set([ + 'run_insight_workflow', + 'run_technical_workflow', + 'run_keyword_workflow', + 'run_domain_agent', +]); + +function asRecord(v: unknown): Record | null { + return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record) : null; +} + +/** Expand workflow parent tools into their child step results for chart/block renderers. */ +export function expandWorkflowToolActivity(items: ToolActivityItem[]): ToolActivityItem[] { + const out: ToolActivityItem[] = []; + + for (const item of items) { + if (item.status !== 'done' || !item.result || !WORKFLOW_TOOLS.has(item.name)) { + out.push(item); + continue; + } + + const steps = item.result.steps; + if (!Array.isArray(steps) || steps.length === 0) { + out.push(item); + continue; + } + + for (let i = 0; i < steps.length; i++) { + const step = asRecord(steps[i]); + if (!step) continue; + const stepTool = String(step.tool || step.name || `step_${i + 1}`); + const stepResult = asRecord(step.result); + if (!stepResult) continue; + out.push({ + id: `${item.id}-step-${i}`, + name: stepTool, + args: item.args, + result: stepResult, + status: 'done', + }); + } + } + + return out.length ? out : items; +} diff --git a/web/src/components/chat/parseChatSse.resolve.test.ts b/web/src/components/chat/parseChatSse.resolve.test.ts new file mode 100644 index 00000000..2e003ca0 --- /dev/null +++ b/web/src/components/chat/parseChatSse.resolve.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; +import { resolveToolActivityIndex } from './parseChatSse'; + +describe('resolveToolActivityIndex', () => { + it('matches by call_id when duplicate tool names are running', () => { + const tools = [ + { id: 'call-a', name: 'list_issues', status: 'running' }, + { id: 'call-b', name: 'list_issues', status: 'running' }, + ]; + expect(resolveToolActivityIndex(tools, { callId: 'call-b', name: 'list_issues' })).toBe(1); + }); + + it('falls back to name when call_id is missing', () => { + const tools = [{ id: 'x', name: 'get_report_summary', status: 'running' }]; + expect(resolveToolActivityIndex(tools, { name: 'get_report_summary' })).toBe(0); + }); +}); diff --git a/web/src/components/chat/parseChatSse.test.ts b/web/src/components/chat/parseChatSse.test.ts index 4b9b2f9d..8c3af2a5 100644 --- a/web/src/components/chat/parseChatSse.test.ts +++ b/web/src/components/chat/parseChatSse.test.ts @@ -13,6 +13,23 @@ describe('parseSseChunk', () => { expect(rest).toBe(''); }); + it('parses tool events with call_id', () => { + const chunk = + 'event: tool_start\ndata: {"call_id":"abc","name":"list_issues","args":{"limit":5}}\n\n' + + 'event: tool_end\ndata: {"call_id":"abc","name":"list_issues","result":{"total":1},"truncated":false}\n\n'; + const { events } = parseSseChunk(chunk); + expect(events[0]).toMatchObject({ + type: 'tool_start', + callId: 'abc', + name: 'list_issues', + }); + expect(events[1]).toMatchObject({ + type: 'tool_end', + callId: 'abc', + name: 'list_issues', + }); + }); + it('parses tool events', () => { const chunk = 'event: tool_start\ndata: {"name":"list_issues","args":{"limit":5}}\n\n'; @@ -39,4 +56,15 @@ describe('parseSseChunk', () => { narrative: { power_insights: ['A'], recommended_actions: ['B'] }, }); }); + + it('parses narrative_partial events', () => { + const chunk = + 'event: narrative_partial\ndata: {"narrative":{"power_insights":["A"],"recommended_actions":[]}}\n\n'; + const { events } = parseSseChunk(chunk); + expect(events).toHaveLength(1); + expect(events[0]).toEqual({ + type: 'narrative_partial', + narrative: { power_insights: ['A'], recommended_actions: [] }, + }); + }); }); diff --git a/web/src/components/chat/parseChatSse.ts b/web/src/components/chat/parseChatSse.ts index d3ad59cc..31816a8f 100644 --- a/web/src/components/chat/parseChatSse.ts +++ b/web/src/components/chat/parseChatSse.ts @@ -1,13 +1,57 @@ export type ChatSseEvent = | { type: 'token'; text: string } | { type: 'status'; phase?: string; detail?: string } - | { type: 'tool_start'; name?: string; args?: Record } - | { type: 'tool_end'; name?: string; result?: Record } + | { + type: 'tool_start'; + callId?: string; + name?: string; + args?: Record; + } + | { + type: 'tool_end'; + callId?: string; + name?: string; + result?: Record; + truncated?: boolean; + resultBytes?: number; + } + | { + type: 'tool_progress'; + callId?: string; + name?: string; + detail?: string; + } | { type: 'narrative'; narrative: { power_insights: string[]; recommended_actions: string[] } } + | { type: 'narrative_partial'; narrative: { power_insights: string[]; recommended_actions: string[] } } | { type: 'done'; message?: string } | { type: 'partial_done'; message?: string } | { type: 'error'; message?: string }; +export function resolveToolActivityIndex( + tools: ReadonlyArray<{ id: string; name: string; status: string }>, + evt: { callId?: string; name?: string }, +): number { + if (evt.callId) { + const byId = tools.findIndex((t) => t.id === evt.callId); + if (byId >= 0) { + return byId; + } + } + + return tools.findIndex((t) => t.name === evt.name && t.status === 'running'); +} + +function parseNarrativePayload(data: Record) { + const narrative = data.narrative as Record | undefined; + const insights = Array.isArray(narrative?.power_insights) + ? (narrative.power_insights as unknown[]).map(String) + : []; + const actions = Array.isArray(narrative?.recommended_actions) + ? (narrative.recommended_actions as unknown[]).map(String) + : []; + return { power_insights: insights, recommended_actions: actions }; +} + export function parseSseChunk(buffer: string): { events: ChatSseEvent[]; rest: string } { const events: ChatSseEvent[] = []; const parts = buffer.split('\n\n'); @@ -38,26 +82,30 @@ export function parseSseChunk(buffer: string): { events: ChatSseEvent[]; rest: s } else if (eventType === 'tool_start') { events.push({ type: 'tool_start', + callId: data.call_id ? String(data.call_id) : undefined, name: String(data.name || ''), args: (data.args as Record) || {}, }); } else if (eventType === 'tool_end') { events.push({ type: 'tool_end', + callId: data.call_id ? String(data.call_id) : undefined, name: String(data.name || ''), result: (data.result as Record) || {}, + truncated: Boolean(data.truncated), + resultBytes: typeof data.result_bytes === 'number' ? data.result_bytes : undefined, + }); + } else if (eventType === 'tool_progress') { + events.push({ + type: 'tool_progress', + callId: data.call_id ? String(data.call_id) : undefined, + name: String(data.name || ''), + detail: String(data.detail || ''), }); - } else if (eventType === 'narrative') { - const narrative = data.narrative as Record | undefined; - const insights = Array.isArray(narrative?.power_insights) - ? (narrative.power_insights as unknown[]).map(String) - : []; - const actions = Array.isArray(narrative?.recommended_actions) - ? (narrative.recommended_actions as unknown[]).map(String) - : []; + } else if (eventType === 'narrative' || eventType === 'narrative_partial') { events.push({ - type: 'narrative', - narrative: { power_insights: insights, recommended_actions: actions }, + type: eventType, + narrative: parseNarrativePayload(data), }); } else if (eventType === 'done') { events.push({ type: 'done', message: String(data.message || '') }); diff --git a/web/src/components/chat/postprocessChatContent.ts b/web/src/components/chat/postprocessChatContent.ts index d0fd3756..097ece58 100644 --- a/web/src/components/chat/postprocessChatContent.ts +++ b/web/src/components/chat/postprocessChatContent.ts @@ -1,4 +1,5 @@ import type { ToolActivityItem } from '@/components/chat/ChatToolActivity'; +import { expandWorkflowToolActivity } from '@/components/chat/expandWorkflowToolActivity'; import { deriveChatBlocks, deriveFallbackBlocks, @@ -32,7 +33,7 @@ export function postprocessChatContent( toolActivity: ToolActivityItem[] | undefined, options: PostprocessChatContentOptions = {}, ): PostprocessedChatContent { - const tools = toolActivity ?? []; + const tools = expandWorkflowToolActivity(toolActivity ?? []); const vizBlocks = deriveChatBlocks(tools); const fallbackBlocks = deriveFallbackBlocks(tools, vizBlocks); const blocks = mergeChatBlocks(vizBlocks, fallbackBlocks); diff --git a/web/src/components/google/GoogleDataRefreshButton.tsx b/web/src/components/google/GoogleDataRefreshButton.tsx new file mode 100644 index 00000000..17f520c2 --- /dev/null +++ b/web/src/components/google/GoogleDataRefreshButton.tsx @@ -0,0 +1,116 @@ +import { useCallback, useEffect, useState } from 'react'; +import { RefreshCw, Loader2 } from 'lucide-react'; +import Button from '@/components/Button'; +import { strings } from '@/lib/strings'; +import { dispatchOpenIntegrations } from '@/lib/pipelineJobEvents'; +import { useGoogleDataRefresh } from '@/hooks/useGoogleDataRefresh'; +import type { IntegrationToast } from '@/types/api'; + +type GoogleDataRefreshVariant = 'gsc' | 'google'; + +type Props = { + /** `gsc` for Search Performance; `google` for Traffic (GSC + GA4). */ + variant?: GoogleDataRefreshVariant; +}; + +function stringsForVariant(variant: GoogleDataRefreshVariant) { + if (variant === 'google') { + const t = strings.views.traffic; + return { + label: t.refreshGoogle, + refreshing: t.refreshingGoogle, + success: t.refreshGoogleSuccess, + failed: t.refreshGoogleFailed, + noProperty: t.refreshGoogleNoProperty, + readOnly: t.refreshGoogleReadOnly, + }; + } + const s = strings.views.searchPerformance; + return { + label: s.refreshGsc, + refreshing: s.refreshingGsc, + success: s.refreshGscSuccess, + failed: s.refreshGscFailed, + noProperty: s.refreshGscNoProperty, + readOnly: s.refreshGscReadOnly, + }; +} + +export default function GoogleDataRefreshButton({ variant = 'gsc' }: Props) { + const copy = stringsForVariant(variant); + const { refresh, refreshing, readOnly, propertyReady, propertyId, stale } = useGoogleDataRefresh(); + const [toast, setToast] = useState(null); + + useEffect(() => { + if (!toast) return; + const t = setTimeout(() => setToast(null), 6000); + return () => clearTimeout(t); + }, [toast]); + + const handleClick = useCallback(async () => { + if (!propertyReady || propertyId == null) { + dispatchOpenIntegrations(); + return; + } + const result = await refresh(); + if (result.ok) { + setToast({ type: 'success', message: copy.success }); + return; + } + if (result.message === 'readOnly') { + setToast({ type: 'error', message: copy.readOnly }); + return; + } + if (result.message === 'noProperty') { + setToast({ type: 'error', message: copy.noProperty }); + dispatchOpenIntegrations(); + return; + } + setToast({ type: 'error', message: `${copy.failed} ${result.message}`.trim() }); + }, [copy, propertyId, propertyReady, refresh]); + + const disabled = readOnly || refreshing || !propertyReady; + const title = + propertyId == null && propertyReady + ? copy.noProperty + : readOnly + ? copy.readOnly + : stale + ? copy.label + : undefined; + + return ( +
+ + {toast ? ( +

+ {toast.message} +

+ ) : stale && propertyId != null ? ( +

{copy.label} — data may be outdated

+ ) : null} +
+ ); +} diff --git a/web/src/components/issues/IssueTaskBoard.tsx b/web/src/components/issues/IssueTaskBoard.tsx index 6d995dfb..03c5f0e9 100644 --- a/web/src/components/issues/IssueTaskBoard.tsx +++ b/web/src/components/issues/IssueTaskBoard.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { apiUrl, apiFetch } from '@/lib/publicBase'; import { strings } from '@/lib/strings'; +import { issueDisplayMessage } from '@/lib/issueDisplayMessage'; import type { ReportIssue } from '@/types'; import UrlInspectorButton from '@/components/UrlInspectorButton'; import { LabelWithHint } from '@/components'; @@ -116,7 +117,7 @@ export default function IssueTaskBoard({ propertyId, reportId, issues }: IssueTa

{sorted.map((item, i) => { - const msg = item.issue.message || ''; + const msg = issueDisplayMessage(item.issue.message) || ''; const fp = Object.values(statusByFingerprint).find( (r) => r.message === msg && (r.url || '') === (item.issue.url || ''), )?.issueFingerprint; diff --git a/web/src/components/links/SortTh.tsx b/web/src/components/links/SortTh.tsx index 1eacdfd5..e887145a 100644 --- a/web/src/components/links/SortTh.tsx +++ b/web/src/components/links/SortTh.tsx @@ -14,12 +14,13 @@ export interface SortThProps { export default function SortTh({ label, field, sortBy, sortDesc, onSort, className = '', hint }: SortThProps) { const active = sortBy === field; const hintContent = normalizeHintContent(hint); + const alignEnd = className.includes('text-right'); return ( onSort(field)} > -
+
{label} {hintContent ? ( ) : null} - +
{hoveredRow && (() => { @@ -138,8 +138,8 @@ export function LinksExplorerTableTab({ {sj.tableSwipeHint}

- - +
+ {hasCustomExtract ? ( ))} - - + {pageLinks.length === 0 ? ( + + + + ) : null} {pageLinks.map((link, i) => { const hrefLines = formatPageHrefLines(link.url); const stickyBg = i % 2 === 1 ? 'bg-brand-900/40' : 'bg-brand-800'; @@ -264,25 +275,37 @@ export function LinksExplorerTableTab({ {hrefLines.label} - {((!visibleCols.has('depth') && link.depth != null) || - (!visibleCols.has('word_count') && (link.word_count ?? 0) > 0)) && ( + {link.depth != null && !visibleCols.has('depth') ? (

- {!visibleCols.has('depth') && link.depth != null && ( - - {vl.thCrawlDepth}: {String(link.depth)} - - )} - {!visibleCols.has('depth') && link.depth != null && - !visibleCols.has('word_count') && (link.word_count ?? 0) > 0 && ( - · - )} - {!visibleCols.has('word_count') && (link.word_count ?? 0) > 0 && ( - + {vl.thCrawlDepth}: {String(link.depth)} + {!visibleCols.has('word_count') && (link.word_count ?? 0) > 0 ? ( + <> + · {vl.thWords}: {(link.word_count ?? 0).toLocaleString()} - - )} + + ) : null}

- )} + ) : null} + {!visibleCols.has('word_count') && visibleCols.has('depth') && (link.word_count ?? 0) > 0 ? ( +

+ {vl.thWords}: {(link.word_count ?? 0).toLocaleString()} +

+ ) : null} + {visibleCols.has('depth') && link.depth != null ? ( +

+ {vl.thCrawlDepth}: {String(link.depth)} + {visibleCols.has('word_count') && (link.word_count ?? 0) > 0 ? ( + <> + · + {vl.thWords}: {(link.word_count ?? 0).toLocaleString()} + + ) : null} +

+ ) : visibleCols.has('word_count') && (link.word_count ?? 0) > 0 ? ( +

+ {vl.thWords}: {(link.word_count ?? 0).toLocaleString()} +

+ ) : null}
- - {hasCustomExtract ? ( @@ -344,9 +367,11 @@ export function LinksExplorerTableTab({ onClick={() => onInspect(link.url, linkHasBrowserErrors(link) ? 'analysis' : 'overview') } - className="inline-flex items-center justify-center gap-1.5 min-h-11 min-w-[2.75rem] sm:min-h-0 sm:min-w-0 text-muted-foreground hover:text-bright bg-brand-800 hover:bg-brand-700 px-3 py-2.5 sm:px-2 sm:py-1 rounded-lg sm:rounded text-xs font-medium transition-colors touch-manipulation" + className="inline-flex items-center justify-center gap-1.5 min-h-11 min-w-[2.75rem] sm:min-h-0 sm:min-w-0 text-muted-foreground hover:text-bright bg-brand-800 hover:bg-brand-700 px-3 py-2.5 sm:px-2.5 sm:py-1.5 rounded-lg sm:rounded-md text-xs font-medium transition-colors touch-manipulation" + title={vl.inspect} > - {vl.inspect} + + {vl.inspect} diff --git a/web/src/components/links/tabs/IssuesTab.tsx b/web/src/components/links/tabs/IssuesTab.tsx index ecbca7c8..1d2664af 100644 --- a/web/src/components/links/tabs/IssuesTab.tsx +++ b/web/src/components/links/tabs/IssuesTab.tsx @@ -7,6 +7,7 @@ import { SELECT_CLASS, SEO_ISSUE_RECOMMENDATIONS, severityBg } from '../../../ut import { formatLhMetric } from '../../../utils/linkUtils'; import { palette, scoreBandColor } from '../../../utils/chartPalette'; import { registerChartJsBase, barOptionsHorizontal } from '../../../utils/chartJsDefaults'; +import { lighthouseFailureLabel } from '@/lib/issueDisplayMessage'; import { RankedBarChart } from '../../../components/charts'; import { formatCompositionAria } from '../../../lib/chartDoughnutUtils'; import AiSuggestionButton from '@/components/ai/AiSuggestionButton'; @@ -165,14 +166,17 @@ export default function IssuesTab({ lhData, inspectorDetails, pageUrl }: IssuesT <>
{it.lighthouseFailures}
- {topFailures.map((f: LighthouseAuditRef, i: number) => ( + {topFailures.map((f: LighthouseAuditRef, i: number) => { + const label = lighthouseFailureLabel(f); + return (
- {f.helpText || f.id} + {label || f.id}
- ))} + ); + })}
)} diff --git a/web/src/components/settings/ChatSettingsPanel.tsx b/web/src/components/settings/ChatSettingsPanel.tsx index 81219ebc..8be849be 100644 --- a/web/src/components/settings/ChatSettingsPanel.tsx +++ b/web/src/components/settings/ChatSettingsPanel.tsx @@ -11,6 +11,7 @@ import { DEFAULT_CHAT_ASSISTANT_AVATAR, } from '@/lib/chatAssistantBranding'; import { apiUrl, apiFetch } from '@/lib/publicBase'; +import { parseLlmBool } from '@/lib/llmConfigSchema'; import { strings } from '@/lib/strings'; const s = strings.settings; @@ -102,9 +103,7 @@ async function loadChatLlmSettings(): Promise { return { llm_chat_assistant_name: String(state.llm_chat_assistant_name ?? ''), llm_chat_assistant_avatar_url: String(state.llm_chat_assistant_avatar_url ?? ''), - llm_chat_unlimited_tool_rounds: - state.llm_chat_unlimited_tool_rounds === true || - state.llm_chat_unlimited_tool_rounds === 'true', + llm_chat_unlimited_tool_rounds: parseLlmBool(state.llm_chat_unlimited_tool_rounds as string | boolean), }; } catch { return null; diff --git a/web/src/context/PipelineContext.tsx b/web/src/context/PipelineContext.tsx index d10b0811..b5d52f27 100644 --- a/web/src/context/PipelineContext.tsx +++ b/web/src/context/PipelineContext.tsx @@ -29,7 +29,9 @@ import { validatePipelineRun, validateRequiredPipelineFields, } from '@/lib/pipelineConfigSchema'; -import { buildInitialLlmConfigState } from '@/lib/llmConfigSchema'; +import { buildInitialLlmConfigState, normalizeLlmConfigState } from '@/lib/llmConfigSchema'; +import { isLlmProviderApiKeyField } from '@/lib/llmProviderApiKeys'; +import { resolvePipelineRunState } from '@/lib/pipelineRunPreview'; import { applyLlmModelChange, applyLlmProviderChange } from '@/lib/llmProviderModels'; import { applyPreset, @@ -50,6 +52,8 @@ export interface PipelineContextValue { customCommand: string; configState: PipelineConfigState; llmConfigState: LlmConfigState; + /** Server truth: active cloud provider has API key in DB or env (from GET /llm-config). */ + llmApiKeyConfigured: boolean; unknownKeys: PipelineUnknownKey[]; configSource: PipelineConfigSource | null; legacyBannerDismissed: boolean; @@ -116,6 +120,7 @@ export function PipelineProvider({ children }: { children: ReactNode }) { const [customCommand, setCustomCommand] = useState(''); const [configState, setConfigState] = useState(buildInitialPipelineConfigState); const [llmConfigState, setLlmConfigState] = useState(buildInitialLlmConfigState); + const [llmApiKeyConfigured, setLlmApiKeyConfigured] = useState(false); const [llmConfigMasked, setLlmConfigMasked] = useState>({}); const [unknownKeys, setUnknownKeys] = useState([]); const [configPath, setConfigPath] = useState(''); @@ -139,6 +144,13 @@ export function PipelineProvider({ children }: { children: ReactNode }) { const [crawlPresetId, setCrawlPresetId] = useState(''); const pollStopRef = useRef<(() => void) | null>(null); const activeJobIdRef = useRef(''); + const startUrlPresetTimerRef = useRef | null>(null); + const saveMsgTimerRef = useRef | null>(null); + + useEffect(() => () => { + if (startUrlPresetTimerRef.current) clearTimeout(startUrlPresetTimerRef.current); + if (saveMsgTimerRef.current) clearTimeout(saveMsgTimerRef.current); + }, []); const refreshBrowserCrawlStatus = useCallback(async () => { setBrowserCrawlChecking(true); @@ -282,14 +294,22 @@ export function PipelineProvider({ children }: { children: ReactNode }) { setConfigPath(data.dbPath || data.configPath || ''); setConfigSource(data.source || 'defaults'); if (llmRes.ok && llmData.state) { - setLlmConfigState(llmData.state); + setLlmConfigState(normalizeLlmConfigState(llmData.state as LlmConfigState)); + setLlmApiKeyConfigured(Boolean(llmData.apiKeyConfigured)); const masked: Record = {}; for (const [k, v] of Object.entries(llmData.state as Record)) { - if (k.endsWith('_masked')) masked[k] = Boolean(v); + if (k.endsWith('_masked')) { + masked[k] = Boolean(v); + continue; + } + if (isLlmProviderApiKeyField(k) && String(v ?? '').trim() === '*') { + masked[`${k}_masked`] = true; + } } setLlmConfigMasked(masked); } else { setLlmConfigState(buildInitialLlmConfigState()); + setLlmApiKeyConfigured(false); setLlmConfigMasked({}); } setLegacyBannerDismissed(false); @@ -298,6 +318,7 @@ export function PipelineProvider({ children }: { children: ReactNode }) { setLoadError(e instanceof Error ? e.message : String(e)); setConfigState(buildInitialPipelineConfigState()); setLlmConfigState(buildInitialLlmConfigState()); + setLlmApiKeyConfigured(false); setConfigLoaded(true); } finally { setLoading(false); @@ -310,6 +331,14 @@ export function PipelineProvider({ children }: { children: ReactNode }) { } }, [configLoaded, loadConfig]); + useEffect(() => { + const onLlmConfigChanged = () => { + void loadConfig(); + }; + window.addEventListener('llm-config-changed', onLlmConfigChanged); + return () => window.removeEventListener('llm-config-changed', onLlmConfigChanged); + }, [loadConfig]); + /** Resume polling the active DB-backed job after refresh or server restart. */ useEffect(() => { if (!configLoaded || busy || status === 'running') return; @@ -381,7 +410,7 @@ export function PipelineProvider({ children }: { children: ReactNode }) { const applyPropertyCrawlPreset = useCallback(async (startUrlValue: string) => { const trimmed = startUrlValue.trim(); - if (!trimmed) return; + if (!trimmed || !trimmed.includes('.')) return; try { const res = await apiFetch(apiUrl(`/properties/resolve?startUrl=${encodeURIComponent(trimmed)}`)); const data = await res.json().catch(() => ({})); @@ -412,7 +441,12 @@ export function PipelineProvider({ children }: { children: ReactNode }) { } return { ...prev, start_url: value }; }); - void applyPropertyCrawlPreset(value); + if (startUrlPresetTimerRef.current) { + clearTimeout(startUrlPresetTimerRef.current); + } + startUrlPresetTimerRef.current = setTimeout(() => { + void applyPropertyCrawlPreset(value); + }, 400); }, [applyPropertyCrawlPreset]); const handleCrawlPresetChange = useCallback((preset: CrawlPresetId) => { @@ -463,10 +497,14 @@ export function PipelineProvider({ children }: { children: ReactNode }) { }); const llmData = await llmRes.json().catch(() => ({})); if (!llmRes.ok) throw new Error(llmData.error || llmRes.statusText); + if (typeof llmData.apiKeyConfigured === 'boolean') { + setLlmApiKeyConfigured(llmData.apiKeyConfigured); + } setConfigPath(data.configPath || data.dbPath || configPath); setConfigSource('store'); setSaveMsg(s.saved); - setTimeout(() => setSaveMsg(''), 3000); + if (saveMsgTimerRef.current) clearTimeout(saveMsgTimerRef.current); + saveMsgTimerRef.current = setTimeout(() => setSaveMsg(''), 3000); return true; } catch (e) { const message = e instanceof Error ? e.message : String(e); @@ -494,6 +532,9 @@ export function PipelineProvider({ children }: { children: ReactNode }) { }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || res.statusText); + if (typeof data.apiKeyConfigured === 'boolean') { + setLlmApiKeyConfigured(data.apiKeyConfigured); + } return true; } catch { return false; @@ -521,6 +562,9 @@ export function PipelineProvider({ children }: { children: ReactNode }) { }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || res.statusText); + if (typeof data.apiKeyConfigured === 'boolean') { + setLlmApiKeyConfigured(data.apiKeyConfigured); + } return true; } catch { return false; @@ -545,6 +589,9 @@ export function PipelineProvider({ children }: { children: ReactNode }) { }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || res.statusText); + if (typeof data.apiKeyConfigured === 'boolean') { + setLlmApiKeyConfigured(data.apiKeyConfigured); + } return true; } catch { return false; @@ -557,13 +604,14 @@ export function PipelineProvider({ children }: { children: ReactNode }) { const run = useCallback(async () => { const command = effectiveCommand || null; + const runState = resolvePipelineRunState(presetId, configState, crawlPresetId); let browserStatus = browserCrawlStatus; - if (crawlRenderModeUsesBrowser(configState)) { + if (crawlRenderModeUsesBrowser(runState)) { browserStatus = await fetchBrowserCrawlStatus(); setBrowserCrawlStatus(browserStatus); } const validationErrors = validatePipelineRun({ - state: configState, + state: runState, command, browserStatus, }); @@ -581,14 +629,24 @@ export function PipelineProvider({ children }: { children: ReactNode }) { setStatus('starting'); setBackgroundMode(false); try { + const llmRes = await apiFetch(apiUrl('/llm-config'), { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ state: buildLlmPayload() }), + }); + const llmData = await llmRes.json().catch(() => ({})); + if (!llmRes.ok) throw new Error(llmData.error || llmRes.statusText); + if (typeof llmData.apiKeyConfigured === 'boolean') { + setLlmApiKeyConfigured(llmData.apiKeyConfigured); + } + const res = await apiFetch(apiUrl('/run'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ command, - state: configState, + state: runState, unknownKeys, - llmState: buildLlmPayload(), python: pythonExe.trim() || undefined, repoRoot: repoRoot.trim() || undefined, }), @@ -615,7 +673,9 @@ export function PipelineProvider({ children }: { children: ReactNode }) { } }, [ effectiveCommand, + presetId, configState, + crawlPresetId, unknownKeys, buildLlmPayload, pythonExe, @@ -670,6 +730,7 @@ export function PipelineProvider({ children }: { children: ReactNode }) { customCommand, configState, llmConfigState, + llmApiKeyConfigured, unknownKeys, configSource, legacyBannerDismissed, @@ -718,6 +779,7 @@ export function PipelineProvider({ children }: { children: ReactNode }) { customCommand, configState, llmConfigState, + llmApiKeyConfigured, unknownKeys, configSource, legacyBannerDismissed, diff --git a/web/src/context/ReportContext.tsx b/web/src/context/ReportContext.tsx index 5f7e5c73..b8c062e2 100644 --- a/web/src/context/ReportContext.tsx +++ b/web/src/context/ReportContext.tsx @@ -146,11 +146,15 @@ export function ReportProvider({ children, domainSlug = null }: ReportProviderPr return scopedList; }, [domainSlug, reportListFull, scopedList]); - const loadSection = useCallback(async (section: SectionKey, reportId: number | null) => { + const fetchSection = useCallback(async ( + section: SectionKey, + reportId: number | null, + force: boolean, + ) => { if (inFlightSectionsRef.current.has(section)) return; inFlightSectionsRef.current.add(section); setSectionStatus((prev) => { - if (prev[section] === 'loaded') return prev; + if (!force && prev[section] === 'loaded') return prev; return { ...prev, [section]: 'loading' }; }); const cacheKeySnapshot = sectionCacheKeyRef.current; @@ -196,7 +200,16 @@ export function ReportProvider({ children, domainSlug = null }: ReportProviderPr } finally { inFlightSectionsRef.current.delete(section); } - }, []); // all dependencies are stable refs or stable setters + }, []); + + const loadSection = useCallback(async (section: SectionKey, reportId: number | null) => { + await fetchSection(section, reportId, false); + }, [fetchSection]); + + const reloadSection = useCallback(async (section: SectionKey, reportId: number | null) => { + inFlightSectionsRef.current.delete(section); + await fetchSection(section, reportId, true); + }, [fetchSection]); const applyPayload = useCallback(async (reportId: number | null) => { const scoped = domainSlugRef.current; @@ -596,6 +609,7 @@ export function ReportProvider({ children, domainSlug = null }: ReportProviderPr domainSlug: domainSlug ?? null, sectionStatus, loadSection, + reloadSection, }), [ data, @@ -619,6 +633,7 @@ export function ReportProvider({ children, domainSlug = null }: ReportProviderPr domainSlug, sectionStatus, loadSection, + reloadSection, ], ); diff --git a/web/src/context/reportContextTypes.ts b/web/src/context/reportContextTypes.ts index 0a534a12..c4f9f739 100644 --- a/web/src/context/reportContextTypes.ts +++ b/web/src/context/reportContextTypes.ts @@ -36,4 +36,6 @@ export interface ReportContextValue { sectionStatus: Partial>; /** Trigger loading of a specific section. Idempotent — skips if already loading or loaded. */ loadSection: (section: SectionKey, reportId: number | null) => Promise; + /** Re-fetch a section even when already loaded (e.g. after google_data refresh). */ + reloadSection: (section: SectionKey, reportId: number | null) => Promise; } diff --git a/web/src/hooks/useChatFabPopup.ts b/web/src/hooks/useChatFabPopup.ts index 8722dd77..3a0f60f4 100644 --- a/web/src/hooks/useChatFabPopup.ts +++ b/web/src/hooks/useChatFabPopup.ts @@ -3,6 +3,7 @@ import { useCallback, useRef, useState } from 'react'; import { apiUrl, apiFetch } from '@/lib/publicBase'; import { consumeChatSse, type ChatSseEvent } from '@/components/chat/parseChatSse'; import type { ChatNarrative } from '@/types/chatNarrative'; +import { strings } from '@/lib/strings'; export interface FabChatMessage { id: string; @@ -149,12 +150,24 @@ export function useChatFabPopup(domain: string | null): UseChatFabPopupReturn { } let lastNarrative: ChatNarrative | null = null; + let lastProgressAt = 0; await consumeChatSse(res, (evt: ChatSseEvent) => { if (evt.type === 'token') { setMessages((prev) => prev.map((m) => - m.id === assistantId ? { ...m, content: m.content + evt.text } : m, + m.id === assistantId + ? { ...m, toolStatus: strings.components.chat.writingSummary, streaming: true } + : m, + ), + ); + } else if (evt.type === 'narrative_partial') { + lastNarrative = evt.narrative; + setMessages((prev) => + prev.map((m) => + m.id === assistantId + ? { ...m, narrative: evt.narrative, toolStatus: undefined, streaming: true } + : m, ), ); } else if (evt.type === 'status') { @@ -173,6 +186,17 @@ export function useChatFabPopup(domain: string | null): UseChatFabPopupReturn { : m, ), ); + } else if (evt.type === 'tool_progress' && evt.detail) { + const now = Date.now(); + if (now - lastProgressAt < 100) { + return; + } + lastProgressAt = now; + setMessages((prev) => + prev.map((m) => + m.id === assistantId ? { ...m, toolStatus: evt.detail } : m, + ), + ); } else if (evt.type === 'tool_end') { setMessages((prev) => prev.map((m) => diff --git a/web/src/hooks/useGoogleDataRefresh.ts b/web/src/hooks/useGoogleDataRefresh.ts new file mode 100644 index 00000000..340786e8 --- /dev/null +++ b/web/src/hooks/useGoogleDataRefresh.ts @@ -0,0 +1,95 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useReport } from '@/context/useReport'; +import { usePropertyForDomain } from '@/lib/dashboard/hooks/usePropertyForDomain'; +import { useReadOnlySession } from '@/hooks/useReadOnlySession'; +import { apiUrl, apiFetch } from '@/lib/publicBase'; +import { dispatchPipelineJobStarted, pollPipelineJob } from '@/lib/pipelineJobEvents'; +import { googleSnapshotStatus } from '@/lib/googleSnapshot'; + +export type GoogleDataRefreshResult = { ok: true; message: string } | { ok: false; message: string }; + +export function useGoogleDataRefresh() { + const { reloadSection, selectedReportId, data } = useReport(); + const { propertyId, ready: propertyReady } = usePropertyForDomain(); + const { readOnly, loading: sessionLoading } = useReadOnlySession(); + const [refreshing, setRefreshing] = useState(false); + const pollStopRef = useRef<(() => void) | null>(null); + + useEffect(() => { + return () => { + pollStopRef.current?.(); + pollStopRef.current = null; + }; + }, []); + + const snapshot = googleSnapshotStatus(data?.google); + + const refresh = useCallback(async (): Promise => { + if (sessionLoading) { + return { ok: false, message: 'Session loading…' }; + } + if (readOnly) { + return { ok: false, message: 'readOnly' }; + } + if (!propertyReady) { + return { ok: false, message: 'Property loading…' }; + } + if (propertyId == null) { + return { ok: false, message: 'noProperty' }; + } + + pollStopRef.current?.(); + pollStopRef.current = null; + setRefreshing(true); + + try { + const res = await apiFetch(apiUrl('/run'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + command: 'google', + propertyId, + }), + }); + const body = (await res.json().catch(() => ({}))) as { jobId?: string; error?: string }; + if (!res.ok) { + return { ok: false, message: body.error || 'Fetch failed' }; + } + + const jobId = body.jobId; + if (!jobId) { + return { ok: false, message: 'No job id returned' }; + } + + dispatchPipelineJobStarted(jobId, { command: 'google', openRunner: false }); + + return await new Promise((resolve) => { + pollStopRef.current = pollPipelineJob(jobId, (job) => { + if (job.status === 'success') { + void reloadSection('traffic', selectedReportId).finally(() => { + setRefreshing(false); + resolve({ ok: true, message: 'success' }); + }); + } else if (job.status === 'error') { + setRefreshing(false); + const excerpt = (job.log || job.error || 'Google fetch failed').trim().slice(-400); + resolve({ ok: false, message: excerpt || 'Google fetch failed' }); + } + }); + }); + } catch (e) { + setRefreshing(false); + const msg = e instanceof Error ? e.message : String(e); + return { ok: false, message: msg }; + } + }, [sessionLoading, readOnly, propertyReady, propertyId, reloadSection, selectedReportId]); + + return { + refresh, + refreshing, + readOnly: sessionLoading || readOnly, + propertyReady, + propertyId, + stale: snapshot.stale, + }; +} diff --git a/web/src/hooks/useResolvedPropertyId.ts b/web/src/hooks/useResolvedPropertyId.ts index f702c49c..2ebd5f07 100644 --- a/web/src/hooks/useResolvedPropertyId.ts +++ b/web/src/hooks/useResolvedPropertyId.ts @@ -1,8 +1,7 @@ - import { useEffect, useState } from 'react'; import { apiUrl, apiFetch } from '@/lib/publicBase'; -/** Resolve or create a properties row from the audit Site URL. */ +/** Resolve an existing properties row from the audit Site URL (read-only; no create). */ export function useResolvedPropertyId( explicitPropertyId: number | null | undefined, startUrl: string, @@ -19,25 +18,30 @@ export function useResolvedPropertyId( return; } const url = startUrl.trim(); - if (!url) { + if (!url || !url.includes('.')) { setResolved(null); return; } let cancelled = false; - void (async () => { - try { - const res = await apiFetch( - apiUrl(`/properties/resolve?startUrl=${encodeURIComponent(url)}`), - ); - if (!res.ok) return; - const data = (await res.json()) as { id?: number }; - if (!cancelled && data.id != null) setResolved(Number(data.id)); - } catch { - /* ignore */ - } - })(); + const timer = setTimeout(() => { + void (async () => { + try { + const res = await apiFetch( + apiUrl(`/properties/resolve?startUrl=${encodeURIComponent(url)}`), + ); + if (!res.ok) return; + const data = (await res.json()) as { id?: number | null }; + if (!cancelled) { + setResolved(data.id != null && Number.isFinite(data.id) ? Number(data.id) : null); + } + } catch { + /* ignore */ + } + })(); + }, 400); return () => { cancelled = true; + clearTimeout(timer); }; }, [explicitPropertyId, startUrl]); diff --git a/web/src/hooks/useRiskSettings.ts b/web/src/hooks/useRiskSettings.ts index 1bffdf1c..6bc0f79f 100644 --- a/web/src/hooks/useRiskSettings.ts +++ b/web/src/hooks/useRiskSettings.ts @@ -60,6 +60,20 @@ function parseDisabledTools(raw: string | boolean | undefined): Set { return new Set(); } +/** Parses mcp_enabled_domains JSON array (custom bundle mode). */ +export function parseEnabledDomains(raw: string | boolean | undefined): Set { + if (!raw || typeof raw !== 'string') return new Set(['core', 'insight']); + try { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed) && parsed.length > 0) { + return new Set(parsed.map((d) => String(d).trim().toLowerCase()).filter(Boolean)); + } + } catch { + /* invalid JSON */ + } + return new Set(['core', 'insight']); +} + export function useRiskSettings() { const secrets = useSecrets(); @@ -92,6 +106,7 @@ export function useRiskSettings() { // ── Disabled tools (pipeline_config: mcp_disabled_tools) ───────────────── const disabledTools = parseDisabledTools(secrets.state.mcp_disabled_tools); + const enabledDomains = parseEnabledDomains(secrets.state.mcp_enabled_domains); const setToolDisabled = useCallback( (name: string, disabled: boolean) => { @@ -106,6 +121,20 @@ export function useRiskSettings() { [secrets], ); + const setDomainEnabled = useCallback( + (domain: string, enabled: boolean) => { + const current = parseEnabledDomains(secrets.state.mcp_enabled_domains); + if (enabled) { + current.add(domain); + } else { + current.delete(domain); + } + const ordered = Array.from(current).sort(); + secrets.setField('mcp_enabled_domains', JSON.stringify(ordered.length ? ordered : ['core', 'insight'])); + }, + [secrets], + ); + // ── Feature visibility (pipeline_config: feature_*) ─────────────────────── const featureEnabled = useCallback( (id: string): boolean => { @@ -153,6 +182,8 @@ export function useRiskSettings() { // Disabled tools disabledTools, setToolDisabled, + enabledDomains, + setDomainEnabled, // Feature visibility featureEnabled, setFeatureEnabled, diff --git a/web/src/hooks/useSecrets.ts b/web/src/hooks/useSecrets.ts index 157aec52..b9fdc12e 100644 --- a/web/src/hooks/useSecrets.ts +++ b/web/src/hooks/useSecrets.ts @@ -22,7 +22,7 @@ export function useSecrets() { throw new Error(data.error || `HTTP ${res.status}`); } const data = (await res.json()) as SecretsLoadResult; - setState(data.state); + setState({ ...buildInitialSecretsState(), ...data.state }); setEnvHints(data.envHints || {}); } catch (e) { setLoadError(e instanceof Error ? e.message : String(e)); @@ -58,8 +58,13 @@ export function useSecrets() { if (!res.ok) { throw new Error(data.error || `HTTP ${res.status}`); } - setState(data.state); - setEnvHints(data.envHints || {}); + if (data.state) { + setState({ ...buildInitialSecretsState(), ...data.state }); + setEnvHints(data.envHints || {}); + } else { + await load(); + } + window.dispatchEvent(new CustomEvent('llm-config-changed')); setSaveMsg('Secrets saved.'); return true; } catch (e) { @@ -68,7 +73,7 @@ export function useSecrets() { } finally { setSaving(false); } - }, [state]); + }, [state, load]); return { state, diff --git a/web/src/lib/chatUrlState.test.ts b/web/src/lib/chatUrlState.test.ts index 3df829c1..9c959415 100644 --- a/web/src/lib/chatUrlState.test.ts +++ b/web/src/lib/chatUrlState.test.ts @@ -7,6 +7,10 @@ import { isChatFabVisiblePath, parseChatUrlContext, readChatComposerDraft, + readSessionPropertyId, + resolvePreferredChatSession, + sessionIdsEqual, + upsertChatSession, writeChatComposerDraft, } from './chatUrlState'; @@ -70,4 +74,58 @@ describe('chatUrlState', () => { expect(isChatFabVisiblePath('/pipeline')).toBe(false); expect(isChatFabVisiblePath('/content-studio')).toBe(false); }); + + it('resolvePreferredChatSession prefers URL, then stored for same property, then latest', () => { + expect( + resolvePreferredChatSession( + 3, + { propertyId: 3, sessionId: 99 }, + { propertyId: 3, sessionId: 40 }, + [{ id: 10 }], + ), + ).toBe(99); + expect( + resolvePreferredChatSession( + 3, + { propertyId: 3, sessionId: null }, + { propertyId: 3, sessionId: 40 }, + [{ id: 10 }], + ), + ).toBe(40); + expect( + resolvePreferredChatSession( + 3, + { propertyId: 3, sessionId: null }, + { propertyId: 5, sessionId: 40 }, + [{ id: 10 }, { id: 11 }], + ), + ).toBe(10); + expect( + resolvePreferredChatSession(3, { propertyId: 3, sessionId: null }, { propertyId: null, sessionId: null }, []), + ).toBeNull(); + }); + + it('readSessionPropertyId accepts API camelCase and legacy snake_case', () => { + expect(readSessionPropertyId({ propertyId: 3 })).toBe(3); + expect(readSessionPropertyId({ property_id: 7 })).toBe(7); + }); + + it('buildChatSearchQuery adds session when restoring from property-only URL', () => { + expect(buildChatSearchQuery('property=3', { propertyId: 3, sessionId: 42 })).toBe( + 'property=3&session=42', + ); + }); + + it('sessionIdsEqual normalizes string and number ids', () => { + expect(sessionIdsEqual(10, '10')).toBe(true); + expect(sessionIdsEqual(10, 11)).toBe(false); + }); + + it('upsertChatSession replaces existing row and prepends', () => { + const rows = upsertChatSession( + [{ id: 1, propertyId: 3, title: 'old' }], + { id: 1, propertyId: 3, title: 'hi' }, + ); + expect(rows).toEqual([{ id: 1, propertyId: 3, title: 'hi' }]); + }); }); diff --git a/web/src/lib/chatUrlState.ts b/web/src/lib/chatUrlState.ts index c861b53e..0762f465 100644 --- a/web/src/lib/chatUrlState.ts +++ b/web/src/lib/chatUrlState.ts @@ -146,3 +146,63 @@ export function applyChatUrlContext( params.delete('sessionId'); } } + +/** Session id to open after refresh: URL → stored (same property) → most recent in list. */ +export function resolvePreferredChatSession( + propertyId: number, + urlCtx: ChatUrlContext, + stored: ChatUrlContext, + sessions: ReadonlyArray<{ id: number }>, +): number | null { + if (urlCtx.sessionId) return urlCtx.sessionId; + if (stored.propertyId === propertyId && stored.sessionId) return stored.sessionId; + return sessions[0]?.id ?? null; +} + +export function readSessionPropertyId(session: { + propertyId?: unknown; + property_id?: unknown; +}): number | null { + const raw = session.propertyId ?? session.property_id; + const n = typeof raw === 'number' ? raw : Number(raw); + return Number.isFinite(n) && n > 0 ? n : null; +} + +export function normalizeSessionId(value: unknown): number | null { + const n = typeof value === 'number' ? value : Number(value); + if (!Number.isFinite(n) || n <= 0 || !Number.isInteger(n)) return null; + return n; +} + +export function sessionIdsEqual(a: unknown, b: unknown): boolean { + const na = normalizeSessionId(a); + const nb = normalizeSessionId(b); + return na != null && nb != null && na === nb; +} + +export interface ChatSessionRow { + id: number; + propertyId: number; + title: string; +} + +export function normalizeChatSessionRow(raw: { + id?: unknown; + propertyId?: unknown; + property_id?: unknown; + title?: unknown; +}): ChatSessionRow | null { + const id = normalizeSessionId(raw.id); + const propertyId = readSessionPropertyId(raw); + if (id == null || propertyId == null) return null; + const title = String(raw.title ?? '').trim() || 'New chat'; + return { id, propertyId, title }; +} + +export function upsertChatSession( + sessions: ReadonlyArray, + session: ChatSessionRow, +): ChatSessionRow[] { + const rest = sessions.filter((s) => !sessionIdsEqual(s.id, session.id)); + return [session, ...rest]; +} diff --git a/web/src/lib/issueDisplayMessage.test.ts b/web/src/lib/issueDisplayMessage.test.ts new file mode 100644 index 00000000..ca70d314 --- /dev/null +++ b/web/src/lib/issueDisplayMessage.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { issueDisplayMessage, lighthouseFailureLabel } from './issueDisplayMessage'; + +describe('issueDisplayMessage', () => { + it('repairs legacy bare audit id messages', () => { + expect(issueDisplayMessage('image-alt:')).toBe('Image Alt'); + expect(issueDisplayMessage('link-name:')).toBe('Link Name'); + }); + + it('passes through normal messages', () => { + expect(issueDisplayMessage('LCP exceeds threshold')).toBe('LCP exceeds threshold'); + }); +}); + +describe('lighthouseFailureLabel', () => { + it('prefers title and description', () => { + expect( + lighthouseFailureLabel({ + id: 'image-alt', + title: 'Image elements do not have alt', + description: 'Add alt text to images.', + }), + ).toBe('Image elements do not have alt: Add alt text to images.'); + }); + + it('falls back to title only', () => { + expect( + lighthouseFailureLabel({ + id: 'image-alt', + title: 'Image elements do not have alt', + }), + ).toBe('Image elements do not have alt'); + }); +}); diff --git a/web/src/lib/issueDisplayMessage.ts b/web/src/lib/issueDisplayMessage.ts new file mode 100644 index 00000000..59a7f420 --- /dev/null +++ b/web/src/lib/issueDisplayMessage.ts @@ -0,0 +1,29 @@ +/** Human-readable issue message; repairs legacy bare Lighthouse audit ids. */ +export function issueDisplayMessage(message?: string | null): string { + const raw = (message ?? '').trim(); + if (!raw) return ''; + // Legacy reports stored only the audit id, e.g. "image-alt:" + if (/^[a-z0-9-]+:$/i.test(raw)) { + const id = raw.slice(0, -1); + return id.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); + } + return raw; +} + +export function lighthouseFailureLabel(f: { + title?: string; + helpText?: string; + description?: string; + id?: string; +}): string { + const title = (f.title ?? '').trim(); + const help = (f.helpText ?? f.description ?? '').trim(); + if (title && help && title.toLowerCase() !== help.toLowerCase()) { + return `${title}: ${help}`; + } + if (title) return title; + if (help) return help; + const id = (f.id ?? '').trim(); + if (!id) return ''; + return id.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); +} diff --git a/web/src/lib/llmConfigSchema.test.ts b/web/src/lib/llmConfigSchema.test.ts new file mode 100644 index 00000000..8961634c --- /dev/null +++ b/web/src/lib/llmConfigSchema.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import { + isLlmInsightsEnabled, + normalizeLlmConfigState, + parseLlmBool, +} from '@/lib/llmConfigSchema'; + +describe('llmConfigSchema', () => { + it('parseLlmBool accepts PostgreSQL string values', () => { + expect(parseLlmBool('true')).toBe(true); + expect(parseLlmBool('false')).toBe(false); + expect(parseLlmBool(true)).toBe(true); + }); + + it('isLlmInsightsEnabled treats string llm_enabled as enabled', () => { + expect( + isLlmInsightsEnabled({ + llm_enabled: 'true', + llm_provider: 'openai', + }), + ).toBe(true); + }); + + it('normalizeLlmConfigState coerces bool fields from API strings', () => { + const normalized = normalizeLlmConfigState({ + llm_enabled: 'true', + llm_provider: 'ollama', + llm_chat_allow_crawl: 'false', + }); + expect(normalized.llm_enabled).toBe(true); + expect(normalized.llm_chat_allow_crawl).toBe(false); + }); + + it('normalizeLlmConfigState preserves per-provider API keys from DB', () => { + const normalized = normalizeLlmConfigState({ + llm_provider: 'groq', + llm_api_key_groq: '*', + llm_model_groq: 'openai/gpt-oss-120b', + }); + expect(normalized.llm_api_key_groq).toBe('*'); + expect(normalized.llm_model_groq).toBe('openai/gpt-oss-120b'); + }); + + it('parseLlmBool handles unlimited tool rounds from DB strings', () => { + expect(parseLlmBool('true')).toBe(true); + expect(parseLlmBool('false')).toBe(false); + }); +}); diff --git a/web/src/lib/llmConfigSchema.ts b/web/src/lib/llmConfigSchema.ts index 10309f2e..2189c32f 100644 --- a/web/src/lib/llmConfigSchema.ts +++ b/web/src/lib/llmConfigSchema.ts @@ -3,6 +3,8 @@ * Not part of audit settings files or CLI --config. */ import type { LlmConfigState } from '@/types/api'; +import { isLlmProviderApiKeyField } from '@/lib/llmProviderApiKeys'; +import { backfillProviderModelsFromActive, isLlmProviderModelField } from '@/lib/llmProviderModels'; export const LLM_CONFIG_SECTIONS = [ { @@ -197,6 +199,50 @@ export function buildInitialLlmConfigState(): LlmConfigState { return out; } +/** Parse llm_config bool values stored as strings in PostgreSQL. */ +export function parseLlmBool( + value: string | boolean | undefined, + defaultValue = false, +): boolean { + if (value === true || value === false) return value; + const normalized = String(value ?? '').trim().toLowerCase(); + if (normalized === 'true' || normalized === '1' || normalized === 'yes') return true; + if (normalized === 'false' || normalized === '0' || normalized === 'no') return false; + return defaultValue; +} + +/** Coerce API/DB llm_config rows to typed UI state (bools as boolean, not "true" strings). */ +export function normalizeLlmConfigState(raw: LlmConfigState): LlmConfigState { + const out = buildInitialLlmConfigState(); + for (const section of LLM_CONFIG_SECTIONS) { + for (const f of section.fields) { + const value = raw[f.key]; + if (value === undefined) continue; + if (f.type === 'bool') { + out[f.key] = parseLlmBool(value, f.defaultValue as boolean); + } else { + out[f.key] = String(value ?? ''); + } + } + } + + const parsedMap: Record = {}; + for (const [key, value] of Object.entries(raw)) { + if (value === undefined || value === null) continue; + parsedMap[key] = String(value); + if (isLlmProviderApiKeyField(key) || isLlmProviderModelField(key)) { + out[key] = String(value ?? ''); + } + } + backfillProviderModelsFromActive(parsedMap, out); + return out; +} + +/** True when AI insights are on and a provider is selected (matches backend llm_is_enabled). */ +export function isLlmInsightsEnabled(state: LlmConfigState): boolean { + return parseLlmBool(state.llm_enabled) && String(state.llm_provider || 'none') !== 'none'; +} + /** Mask stored API key for GET responses. */ export function maskLlmSecretForClient(key: string, value: string | boolean | undefined): string { if (!isLlmSecretKey(key) || !value || String(value).trim() === '') { diff --git a/web/src/lib/llmProviderApiKeys.test.ts b/web/src/lib/llmProviderApiKeys.test.ts index f99f567a..f07354a4 100644 --- a/web/src/lib/llmProviderApiKeys.test.ts +++ b/web/src/lib/llmProviderApiKeys.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { llmProviderApiKeyField, resolveLlmApiKey, + isLlmApiKeyConfigured, } from '@/lib/llmProviderApiKeys'; describe('resolveLlmApiKey', () => { @@ -24,7 +25,7 @@ describe('resolveLlmApiKey', () => { ).toBe('sk-legacy'); }); - it('ignores masked values', () => { + it('ignores masked values for resolution', () => { expect( resolveLlmApiKey({ llm_provider: 'openai', @@ -32,6 +33,27 @@ describe('resolveLlmApiKey', () => { }), ).toBe(''); }); + + it('treats masked DB values as configured', () => { + expect( + isLlmApiKeyConfigured({ + llm_provider: 'groq', + llm_api_key_groq: '*', + }), + ).toBe(true); + }); + + it('reports missing key for cloud provider without stored value', () => { + expect( + isLlmApiKeyConfigured({ + llm_provider: 'groq', + }), + ).toBe(false); + }); + + it('treats ollama as not needing an API key', () => { + expect(isLlmApiKeyConfigured({ llm_provider: 'ollama' })).toBe(true); + }); }); describe('llmProviderApiKeyField', () => { diff --git a/web/src/lib/llmProviderApiKeys.ts b/web/src/lib/llmProviderApiKeys.ts index 0444b0f7..7921e439 100644 --- a/web/src/lib/llmProviderApiKeys.ts +++ b/web/src/lib/llmProviderApiKeys.ts @@ -36,6 +36,43 @@ export function isLlmCloudProvider(provider: string): provider is LlmCloudProvid return (LLM_CLOUD_PROVIDERS as readonly string[]).includes(provider); } +/** Masked DB / secrets values mean a key is stored server-side. */ +export function isLlmApiKeyMaskedStored(value: string | boolean | undefined): boolean { + if (value === true) return true; + const trimmed = String(value ?? '').trim(); + return trimmed === '*' || trimmed.startsWith('••••') || trimmed === '{configured}'; +} + +/** + * True when the active provider has an API key in Postgres (masked `*` counts) or Ollama (no key). + * Pass `serverConfigured` from GET /llm-config when available (includes env vars). + */ +export function isLlmApiKeyConfigured( + cfg: Record, + options?: { provider?: string; serverConfigured?: boolean }, +): boolean { + if (options?.serverConfigured === true) { + return true; + } + + const selected = (options?.provider ?? String(cfg.llm_provider ?? 'none')).trim().toLowerCase(); + if (!selected || selected === 'none') return false; + if (selected === 'ollama') return true; + + if (isLlmCloudProvider(selected)) { + const field = llmProviderApiKeyField(selected); + if (cfg[`${field}_masked`] === true) return true; + const perProvider = String(cfg[field] ?? '').trim(); + if (perProvider && !isLlmApiKeyMaskedStored(perProvider)) return true; + if (isLlmApiKeyMaskedStored(perProvider)) return true; + } + + if (cfg.llm_api_key_masked === true) return true; + const legacy = String(cfg.llm_api_key ?? '').trim(); + if (legacy && !isLlmApiKeyMaskedStored(legacy)) return true; + return isLlmApiKeyMaskedStored(legacy); +} + /** Resolve the API key for the selected (or given) cloud provider. */ export function resolveLlmApiKey( cfg: Record, @@ -45,12 +82,12 @@ export function resolveLlmApiKey( if (isLlmCloudProvider(selected)) { const field = llmProviderApiKeyField(selected); const perProvider = String(cfg[field] ?? '').trim(); - if (perProvider && !perProvider.startsWith('••••')) { + if (perProvider && !isLlmApiKeyMaskedStored(perProvider)) { return perProvider; } } const legacy = String(cfg.llm_api_key ?? '').trim(); - if (legacy && !legacy.startsWith('••••')) { + if (legacy && !isLlmApiKeyMaskedStored(legacy)) { return legacy; } return ''; diff --git a/web/src/lib/mcpClientConfig.test.ts b/web/src/lib/mcpClientConfig.test.ts index 67f36bd0..4d66f15f 100644 --- a/web/src/lib/mcpClientConfig.test.ts +++ b/web/src/lib/mcpClientConfig.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { + buildLocalStdioConfig, buildRemoteCursorConfig, generateMcpToken, hostFromPublicUrl, @@ -30,6 +31,19 @@ describe('mcpClientConfig', () => { expect(generateMcpToken().startsWith('wp_mcp_')).toBe(true); }); + it('builds local stdio json for AiService', () => { + const json = buildLocalStdioConfig({ + publicUrl: '', + token: '', + domain: 'core', + propertyId: '2', + }); + expect(json).toContain('"command": "dotnet"'); + expect(json).toContain('services/AiService/src/AiService.Api'); + expect(json).toContain('"FASTAPI_URL": "http://127.0.0.1:8001"'); + expect(json).toContain('"WP_PROPERTY_ID": "2"'); + }); + it('normalizes domain bundle', () => { expect(normalizeMcpDomain('full')).toBe('full'); expect(normalizeMcpDomain('bogus')).toBe('core'); diff --git a/web/src/lib/mcpClientConfig.ts b/web/src/lib/mcpClientConfig.ts index 57c1b33c..fb55ed52 100644 --- a/web/src/lib/mcpClientConfig.ts +++ b/web/src/lib/mcpClientConfig.ts @@ -74,11 +74,11 @@ export function buildLocalStdioConfig(input: McpClientConfigInput): string { const payload = { mcpServers: { 'site-audit-local': { - command: 'python', - args: ['-m', 'website_profiling.mcp'], + command: 'dotnet', + args: ['run', '--project', 'services/AiService/src/AiService.Api', '--no-launch-profile'], env: { DATABASE_URL: databaseUrl, - PYTHONPATH: 'src', + FASTAPI_URL: 'http://127.0.0.1:8001', WP_MCP_DOMAIN: domain, WP_PROPERTY_ID: propertyId, }, @@ -89,11 +89,18 @@ export function buildLocalStdioConfig(input: McpClientConfigInput): string { } export function buildDockerStartCommand(): string { - return 'docker compose -f docker-compose.prod.yml up -d mcp'; + return 'docker compose -f docker-compose.prod.yml --profile mcp up -d mcp'; } export function buildHttpStartCommand(): string { - return 'python -m website_profiling.mcp.http'; + return [ + 'cd services/AiService', + 'export DATABASE_URL=postgres://USER:PASS@localhost:5432/website_profiling', + 'export FASTAPI_URL=http://127.0.0.1:8001', + 'export ASPNETCORE_URLS=http://0.0.0.0:8092', + 'export WP_MCP_HTTP=1', + 'dotnet run --project src/AiService.Api', + ].join('\n'); } export function tokenForSnippet(rawToken: string, masked: boolean): string { diff --git a/web/src/lib/pipelineRunPreview.test.ts b/web/src/lib/pipelineRunPreview.test.ts index 630a85a3..3b682545 100644 --- a/web/src/lib/pipelineRunPreview.test.ts +++ b/web/src/lib/pipelineRunPreview.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'vitest'; import { buildInitialPipelineConfigState } from '@/lib/pipelineConfigSchema'; -import { buildPipelineRunPreview, formatPipelineRunDuration } from '@/lib/pipelineRunPreview'; +import { + buildPipelineRunPreview, + formatPipelineRunDuration, + resolvePipelineRunState, +} from '@/lib/pipelineRunPreview'; import { applyCrawlPreset } from '@/lib/crawlPresets'; describe('buildPipelineRunPreview', () => { @@ -63,3 +67,20 @@ describe('buildPipelineRunPreview', () => { expect(preview.timeMaxSeconds).toBeLessThan(6 * 60); }); }); + +describe('resolvePipelineRunState', () => { + it('applies full-audit preset over saved flags that disabled crawl', () => { + const saved = { + ...buildInitialPipelineConfigState(), + run_crawl: false, + run_report: false, + run_plot: false, + start_url: 'https://codefrydev.in', + }; + const runState = resolvePipelineRunState('full-audit', saved, ''); + expect(runState.run_crawl).toBe(true); + expect(runState.run_report).toBe(true); + expect(runState.run_plot).toBe(true); + expect(runState.start_url).toBe('https://codefrydev.in'); + }); +}); diff --git a/web/src/lib/pipelineRunPreview.ts b/web/src/lib/pipelineRunPreview.ts index 9faa2c32..b2daaf6d 100644 --- a/web/src/lib/pipelineRunPreview.ts +++ b/web/src/lib/pipelineRunPreview.ts @@ -188,6 +188,17 @@ function resolvePreviewState( return configState; } +/** State sent to the worker: crawl preset + pipeline preset patches applied. */ +export function resolvePipelineRunState( + presetId: PipelinePresetId, + configState: PipelineConfigState, + crawlPresetId: CrawlPresetId | '', +): PipelineConfigState { + const mergedConfig = resolvePreviewState(configState, crawlPresetId); + const { configState: state } = applyPreset(presetId, mergedConfig); + return state; +} + export function buildPipelineRunPreview({ presetId, configState, diff --git a/web/src/lib/secretsConfigSchema.ts b/web/src/lib/secretsConfigSchema.ts index f84c9a80..5bca457a 100644 --- a/web/src/lib/secretsConfigSchema.ts +++ b/web/src/lib/secretsConfigSchema.ts @@ -52,6 +52,7 @@ export const MCP_MANAGED_KEYS = new Set([ 'mcp_allowed_origins', 'mcp_public_url', 'mcp_domain', + 'mcp_enabled_domains', ]); export const SECRETS_SECTIONS: SecretsSection[] = [ @@ -199,6 +200,7 @@ export const MCP_SETTINGS_FIELDS: SecretsField[] = [ /** Keys managed on the /risk-settings page — stored in pipeline_config but hidden from /secrets UI. */ export const RISK_SETTINGS_KEYS = new Set([ 'mcp_disabled_tools', + 'mcp_enabled_domains', 'feature_pipeline_enabled', 'feature_write_enabled', 'feature_pages_md_enabled', @@ -266,8 +268,9 @@ export function buildInitialSecretsState(): SecretsState { out[f.key] = f.key === 'mcp_domain' ? 'core' : ''; } out['mcp_disabled_tools'] = ''; + out['mcp_enabled_domains'] = JSON.stringify(['core', 'insight']); for (const key of RISK_SETTINGS_KEYS) { - if (key !== 'mcp_disabled_tools') { + if (key !== 'mcp_disabled_tools' && key !== 'mcp_enabled_domains') { out[key] = 'true'; } } diff --git a/web/src/strings.json b/web/src/strings.json index 3bd5c479..549ea451 100644 --- a/web/src/strings.json +++ b/web/src/strings.json @@ -1658,7 +1658,7 @@ "stat3Value": "Crawl · Lighthouse · Issues", "stat3Hint": "Full-site crawl, performance scores, and issue triage", "stat4Label": "AI workflow", - "stat4Value": "340 MCP tools", + "stat4Value": "369 MCP tools", "stat4Hint": "AI chat plus programmatic access from Cursor and Claude Desktop", "trustTitle": "Runs on your stack", "trustGithub": "View on GitHub", @@ -1724,7 +1724,7 @@ "spotlight5Bullets": [ "\"What are my top critical issues?\" — real issue tables", "\"Export a PDF report\" — download buttons in the thread", - "340 MCP tools for Cursor and Claude Desktop — same data, programmatic" + "369 MCP tools for Cursor and Claude Desktop — same data, programmatic" ], "spotlight5Cta": "Open AI Chat", "spotlight5CtaHref": "/chat", @@ -2084,15 +2084,15 @@ "title": "Before you start", "items": [ "PostgreSQL running with at least one crawled property.", - "Python environment from ./local-run setup (for local stdio MCP).", - "For remote HTTP: a public URL or VPN access to your Site Audit host." + ".NET SDK 10+ and repo checkout (for local stdio MCP via AiService).", + "For remote HTTP: AiService with WP_MCP_HTTP=1 (./local-run enables this on :8092)." ] }, "localStdio": { "title": "Local stdio (same machine)", "items": [ - "Set DATABASE_URL and PYTHONPATH=src in your MCP client config.", - "Command: python -m website_profiling.mcp", + "Run from the repo root with DATABASE_URL and FASTAPI_URL in your MCP client env.", + "Command: dotnet run --project services/AiService/src/AiService.Api", "Set WP_MCP_DOMAIN to core (default), crawl, google, links, or full.", "Set WP_PROPERTY_ID to the default property id for tools that omit property_id.", "Copy the Local stdio snippet from MCP settings after saving a token (optional for localhost)." @@ -2103,7 +2103,7 @@ "items": [ "On MCP settings: generate a bearer token and add allowed hostnames.", "Set Public MCP base URL to your deployed origin (no trailing slash).", - "Start the MCP HTTP service on the server (see copy-paste commands on MCP settings).", + "Start AiService with WP_MCP_HTTP=1 on the server (see copy-paste commands on MCP settings).", "In Cursor or Claude Desktop, use the Remote Streamable HTTP config with Authorization: Bearer …" ] }, @@ -2113,7 +2113,7 @@ "Open MCP settings (/mcp) from the app sidebar.", "Generate token → add hostname from URL → pick tool bundle (WP_MCP_DOMAIN).", "Save settings, then copy the client JSON into .cursor/mcp.json or Claude Desktop config.", - "See docs/MCP.md in the repo for domain bundles and the full 340-tool catalog." + "See docs/MCP.md in the repo for domain bundles and the full 369-tool catalog." ] }, "examplePrompts": { @@ -3095,6 +3095,12 @@ "emptyTitle": "No Search Console data yet", "emptyBody": "Connect your Google account to see real search queries, impressions, and click data from Google Search Console.", "emptyIntegrationsHint": "Open Integrations from the top-right header to connect Google Search Console.", + "refreshGsc": "Refresh GSC data", + "refreshingGsc": "Refreshing…", + "refreshGscSuccess": "Search Console data updated.", + "refreshGscFailed": "Refresh failed:", + "refreshGscNoProperty": "Connect Google in Integrations to refresh Search Console data.", + "refreshGscReadOnly": "Read-only session — cannot refresh Google data.", "tabs": { "overview": "Overview", "queries": "Queries", @@ -3274,6 +3280,12 @@ "emptyBody": "Connect your Google account to see real sessions, users, and page view data from Google Analytics 4.", "emptyIntegrationsHint": "Open Integrations from the top-right header to connect Google Analytics 4.", "notConfigured": "Analytics data not configured. Open Integrations to set your Analytics property ID.", + "refreshGoogle": "Refresh GSC & GA4", + "refreshingGoogle": "Refreshing…", + "refreshGoogleSuccess": "Search Console and Analytics data updated.", + "refreshGoogleFailed": "Refresh failed:", + "refreshGoogleNoProperty": "Connect Google in Integrations to refresh analytics data.", + "refreshGoogleReadOnly": "Read-only session — cannot refresh Google data.", "tabs": { "overview": "Overview", "pages": "Pages", @@ -4503,6 +4515,11 @@ "sendLabel": "Send message", "emptyHint": "Ask questions about your latest audit — issues, crawl pages, Core Web Vitals, keywords, or Search Console metrics.", "thinking": "Thinking…", + "thinkingStep": "Thinking — step {step} of {total}…", + "planningStep": "Planning next step…", + "toolsProgress": "{done} of {total} tools complete", + "expandToolActivity": "Show tool details", + "collapseToolActivity": "Hide tool details", "suggestedTitle": "Suggested questions", "suggestedPrompts": [ "What are the top critical issues?", @@ -4546,6 +4563,9 @@ "assistantAppearanceLink": "Assistant appearance", "sessionError": "Could not create chat session", "agentError": "The assistant encountered an error", + "apiKeyMissingTitle": "API key required", + "apiKeyMissingHint": "Add your {provider} API key on the Secrets page and click Save. Keys are stored in the database and work across browsers and restarts.", + "openSecrets": "Open Secrets", "ollamaLabel": "Ollama", "ollamaNoModel": "no model selected", "cloudNoModel": "no model selected", @@ -4608,6 +4628,7 @@ "queryingData": "Querying audit data…", "sending": "Sending your message…", "writing": "Writing response…", + "writingSummary": "Writing summary…", "synthesizing": "Summarizing insights…", "synthesizingRetry": "Retrying summary…", "narrativeFailed": "Could not generate a summary. Tool results are shown below.", diff --git a/web/src/types/api.ts b/web/src/types/api.ts index 14b55e97..0aae4e39 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -63,7 +63,6 @@ export interface RunPostBody { command?: string | null | undefined; state?: PipelineConfigState; unknownKeys?: PipelineUnknownKey[]; - llmState?: LlmConfigState; python?: string; repoRoot?: string; propertyId?: number | null; diff --git a/web/src/types/chatNarrative.test.ts b/web/src/types/chatNarrative.test.ts index a9bc4b23..4b3ee114 100644 --- a/web/src/types/chatNarrative.test.ts +++ b/web/src/types/chatNarrative.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { isChatNarrative, narrativeFromToolResult } from '@/types/chatNarrative'; +import { isChatNarrative, narrativeFromToolResult, narrativeFromLegacyContent } from '@/types/chatNarrative'; describe('chatNarrative types', () => { it('validates narrative shape', () => { @@ -23,4 +23,14 @@ describe('chatNarrative types', () => { }); expect(narrative?.power_insights).toEqual(['x']); }); + + it('reads narrative from legacy content JSON', () => { + const narrative = narrativeFromLegacyContent( + JSON.stringify({ + power_insights: ['legacy insight'], + recommended_actions: ['legacy action'], + }), + ); + expect(narrative?.power_insights).toEqual(['legacy insight']); + }); }); diff --git a/web/src/types/chatNarrative.ts b/web/src/types/chatNarrative.ts index fe0ee87c..6fee71b2 100644 --- a/web/src/types/chatNarrative.ts +++ b/web/src/types/chatNarrative.ts @@ -6,14 +6,11 @@ export interface ChatNarrative { export function isChatNarrative(value: unknown): value is ChatNarrative { if (!value || typeof value !== 'object') return false; const v = value as Record; - const insights = v.power_insights; - const actions = v.recommended_actions; - if (!Array.isArray(insights) || !Array.isArray(actions)) return false; - return ( - insights.every((item) => typeof item === 'string' && item.trim().length > 0) && - actions.every((item) => typeof item === 'string' && item.trim().length > 0) && - (insights.length > 0 || actions.length > 0) - ); + const insights = Array.isArray(v.power_insights) ? v.power_insights : []; + const actions = Array.isArray(v.recommended_actions) ? v.recommended_actions : []; + const validInsight = insights.every((item) => typeof item === 'string' && item.trim().length > 0); + const validAction = actions.every((item) => typeof item === 'string' && item.trim().length > 0); + return validInsight && validAction && (insights.length > 0 || actions.length > 0); } export function narrativeFromToolResult( @@ -23,3 +20,20 @@ export function narrativeFromToolResult( const raw = toolResult.narrative; return isChatNarrative(raw) ? raw : undefined; } + +/** Legacy assistant rows stored narrative JSON in content instead of tool_result. */ +export function narrativeFromLegacyContent(content: string): ChatNarrative | undefined { + const trimmed = content.trim(); + if (!trimmed.startsWith('{')) return undefined; + try { + const parsed = JSON.parse(trimmed) as unknown; + if (isChatNarrative(parsed)) return parsed; + if (parsed && typeof parsed === 'object') { + const wrapped = (parsed as Record).narrative; + if (isChatNarrative(wrapped)) return wrapped; + } + } catch { + /* not JSON */ + } + return undefined; +} diff --git a/web/src/views/Chat.tsx b/web/src/views/Chat.tsx index 1c31cc27..a40c9da3 100644 --- a/web/src/views/Chat.tsx +++ b/web/src/views/Chat.tsx @@ -1,5 +1,5 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Link } from 'react-router-dom'; import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'; import { AlertCircle } from 'lucide-react'; @@ -9,6 +9,7 @@ import ChatSidebar from '@/components/chat/ChatSidebar'; import ChatMessageList, { agentErrorFromToolResult, narrativeFromToolResult, + narrativeFromLegacyContent, type ChatMessage, } from '@/components/chat/ChatMessageList'; import ChatComposer from '@/components/chat/ChatComposer'; @@ -16,12 +17,14 @@ import SuggestedPrompts from '@/components/chat/SuggestedPrompts'; import ChatModelPicker from '@/components/chat/ChatModelPicker'; import ChatProviderPicker from '@/components/chat/ChatProviderPicker'; import ChatUnlimitedToolsToggle from '@/components/chat/ChatUnlimitedToolsToggle'; +import ChatApiKeyBanner from '@/components/chat/ChatApiKeyBanner'; import ChatActivityBar from '@/components/chat/ChatActivityBar'; import { ChatFollowUpProvider } from '@/components/chat/ChatFollowUpContext'; import { usePipeline } from '@/context/PipelineContext'; import { apiUrl, apiFetch } from '@/lib/publicBase'; -import { format, strings } from '@/lib/strings'; -import { consumeChatSse } from '@/components/chat/parseChatSse'; +import { strings } from '@/lib/strings'; +import { statusFromSseEvent } from '@/components/chat/chatStatusLabels'; +import { consumeChatSse, resolveToolActivityIndex } from '@/components/chat/parseChatSse'; import { toolEventsToActivity } from '@/components/chat/deriveChatBlocks'; import type { ChatNarrative } from '@/types/chatNarrative'; import type { ToolActivityItem } from '@/components/chat/ChatToolActivity'; @@ -31,8 +34,18 @@ import { parseChatUrlContext, readChatComposerDraft, readStoredChatContext, + resolvePreferredChatSession, + normalizeChatSessionRow, + normalizeSessionId, + sessionIdsEqual, + upsertChatSession, + type ChatSessionRow, writeStoredChatContext, } from '@/lib/chatUrlState'; +import { + isLlmInsightsEnabled, + parseLlmBool, +} from '@/lib/llmConfigSchema'; import { normalizePropertyId, pickInitialPropertyId, @@ -47,25 +60,19 @@ interface PropertyOption { canonical_domain: string; } -interface SessionRow { - id: number; - property_id: number; - title: string; -} - export default function ChatPage() { const navigate = useNavigate(); const { pathname } = useLocation(); const [searchParams] = useSearchParams(); - const { configState, configLoaded, llmConfigState } = usePipeline(); + const { configState, configLoaded, llmConfigState, llmApiKeyConfigured } = usePipeline(); const initialUrlCtx = parseChatUrlContext(searchParams); const [properties, setProperties] = useState([]); const [propertyId, setPropertyId] = useState(initialUrlCtx.propertyId); - const [sessions, setSessions] = useState([]); + const [sessions, setSessions] = useState([]); const [sessionId, setSessionId] = useState(initialUrlCtx.sessionId); const [messages, setMessages] = useState([]); const [busy, setBusy] = useState(false); - const [loadingSessions, setLoadingSessions] = useState(false); + const [loadingSessions, setLoadingSessions] = useState(Boolean(initialUrlCtx.propertyId)); const [loadingMessages, setLoadingMessages] = useState(Boolean(initialUrlCtx.sessionId)); const [loadingProperties, setLoadingProperties] = useState(true); const [error, setError] = useState(''); @@ -99,16 +106,27 @@ export default function ChatPage() { setComposerDraft(prompt); }, []); - const llmEnabled = - llmConfigState.llm_enabled === true && - String(llmConfigState.llm_provider || 'none') !== 'none'; + const llmEnabled = isLlmInsightsEnabled(llmConfigState); + const llmProvider = String(llmConfigState.llm_provider || 'none'); + const needsApiKey = + llmEnabled && configLoaded && !llmApiKeyConfigured && llmProvider !== 'none' && llmProvider !== 'ollama'; - const crawlChatEnabled = llmConfigState.llm_chat_allow_crawl === true; + const crawlChatEnabled = parseLlmBool(llmConfigState.llm_chat_allow_crawl); const showConversation = Boolean(sessionId) || messages.length > 0 || busy || loadingMessages; const isHero = !showConversation; const activeProperty = properties.find((p) => propertyIdsEqual(p.id, propertyId)) ?? null; - const activeSession = sessions.find((s) => s.id === sessionId) ?? null; + const activeSession = sessions.find((s) => sessionIdsEqual(s.id, sessionId)) ?? null; + const contextSessionTitle = useMemo(() => { + if (activeSession?.title && activeSession.title !== 'New chat') { + return activeSession.title; + } + const firstUser = messages.find((m) => m.role === 'user' && m.content.trim()); + if (firstUser?.content.trim()) { + return firstUser.content.trim().slice(0, 80); + } + return activeSession?.title ?? null; + }, [activeSession, messages]); const loadProperties = useCallback(async () => { if (!configLoaded) return; @@ -143,7 +161,8 @@ export default function ChatPage() { }); setPropertyId((current) => { if (nextId != null) return nextId; - return current != null ? null : current; + if (urlCtx.propertyId != null) return urlCtx.propertyId; + return current; }); } catch { /* ignore */ @@ -156,12 +175,13 @@ export default function ChatPage() { try { const res = await apiFetch(apiUrl(`/chat/sessions/${sid}`)); if (!res.ok) return false; - const data = (await res.json()) as { session?: SessionRow }; - const session = data.session; + const data = (await res.json()) as { session?: ChatSessionRow }; + const session = data.session ? normalizeChatSessionRow(data.session) : null; if (!session) return false; - if (pid != null && session.property_id !== pid) { - setPropertyId(session.property_id); + if (pid != null && session.propertyId !== pid) { + setPropertyId(session.propertyId); } + setSessions((prev) => upsertChatSession(prev, session)); setSessionId(session.id); return true; } catch { @@ -174,8 +194,12 @@ export default function ChatPage() { try { const res = await apiFetch(apiUrl(`/chat/sessions?propertyId=${pid}`)); if (!res.ok) throw new Error('Failed to load sessions'); - const data = (await res.json()) as { sessions?: SessionRow[] }; - setSessions(data.sessions || []); + const data = (await res.json()) as { sessions?: ChatSessionRow[] }; + setSessions( + (data.sessions || []) + .map((row) => normalizeChatSessionRow(row)) + .filter((row): row is ChatSessionRow => row != null), + ); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { @@ -204,11 +228,13 @@ export default function ChatPage() { .map((m) => { const toolActivity = toolEventsToActivity(m.tool_result); const agentError = agentErrorFromToolResult(m.tool_result); - const narrative = narrativeFromToolResult(m.tool_result); + const narrative = + narrativeFromToolResult(m.tool_result) ?? + (m.role === 'assistant' ? narrativeFromLegacyContent(m.content) : undefined); return { id: m.id, role: m.role as 'user' | 'assistant', - content: m.content, + content: narrative ? '' : m.content, narrative, toolActivity: toolActivity.length ? toolActivity : undefined, partialError: Boolean(agentError && toolActivity.length > 0), @@ -239,13 +265,17 @@ export default function ChatPage() { } }, [searchParams]); - useEffect(() => { - if (propertyId) void loadSessions(propertyId); - }, [propertyId, loadSessions]); - useEffect(() => { setUrlSyncEnabled(false); - }, [propertyId]); + sessionRestoredForProperty.current = null; + if (!propertyId) { + setLoadingSessions(false); + setSessions([]); + return; + } + setLoadingSessions(true); + void loadSessions(propertyId); + }, [propertyId, loadSessions]); useEffect(() => { if (!propertyId || loadingSessions) return; @@ -253,7 +283,7 @@ export default function ChatPage() { const urlCtx = parseChatUrlContext(searchParams); const stored = readStoredChatContext(); - const preferredSession = urlCtx.sessionId ?? stored.sessionId; + const preferredSession = resolvePreferredChatSession(propertyId, urlCtx, stored, sessions); const finishRestore = () => { sessionRestoredForProperty.current = propertyId; @@ -265,16 +295,20 @@ export default function ChatPage() { return; } - if (sessions.some((s) => s.id === preferredSession)) { - setSessionId(preferredSession); + if (sessions.some((s) => sessionIdsEqual(s.id, preferredSession))) { + setSessionId(normalizeSessionId(preferredSession)); finishRestore(); return; } void resolveSessionFromUrl(preferredSession, propertyId).then((ok) => { - if (!ok && urlCtx.sessionId === preferredSession) { - setSessionId(null); - setMessages([]); + if (!ok) { + if (urlCtx.sessionId === preferredSession) { + setSessionId(null); + setMessages([]); + } else if (sessions.length > 0) { + setSessionId(sessions[0]!.id); + } } finishRestore(); }); @@ -291,6 +325,12 @@ export default function ChatPage() { else if (!sessionId) setMessages([]); }, [sessionId, propertyId, loadMessages, busy]); + useEffect(() => { + if (!sessionId || !propertyId) return; + if (sessions.some((s) => sessionIdsEqual(s.id, sessionId))) return; + void resolveSessionFromUrl(sessionId, propertyId); + }, [sessionId, propertyId, sessions, resolveSessionFromUrl]); + useEffect(() => { if (!busy || !startedAt) { setElapsedSec(0); @@ -376,6 +416,7 @@ export default function ChatPage() { let narrative: ChatNarrative | undefined; let streamError = ''; const tools: ToolActivityItem[] = []; + let lastProgressAt = 0; const patchAssistant = (patch: Partial) => { setMessages((prev) => @@ -385,16 +426,28 @@ export default function ChatPage() { await consumeChatSse(res, (evt) => { if (evt.type === 'status' && evt.detail) { - setActivityText(evt.detail); - patchAssistant({ statusText: evt.detail, streaming: true }); + const label = statusFromSseEvent(evt); + setActivityText(label); + patchAssistant({ statusText: label, streaming: true }); } else if (evt.type === 'token') { - content += evt.text; - setActivityText(c.writing); - patchAssistant({ content, streaming: true, statusText: c.writing, error: false }); + const label = statusFromSseEvent(evt); + setActivityText(label); + patchAssistant({ streaming: true, statusText: label, error: false }); + } else if (evt.type === 'narrative_partial') { + narrative = evt.narrative; + patchAssistant({ + narrative: evt.narrative, + streaming: true, + statusText: undefined, + error: false, + partialError: false, + }); } else if (evt.type === 'tool_start') { - setActivityText(format(c.toolStatus, { name: evt.name || 'tool' })); + const callId = evt.callId || `${evt.name}-${tools.length}`; + const label = statusFromSseEvent(evt); + setActivityText(label); tools.push({ - id: `${evt.name}-${tools.length}`, + id: callId, name: evt.name || 'tool', args: evt.args, status: 'running', @@ -402,10 +455,22 @@ export default function ChatPage() { patchAssistant({ toolActivity: [...tools], streaming: true, - statusText: format(c.toolStatus, { name: evt.name || 'tool' }), + statusText: label, + }); + } else if (evt.type === 'tool_progress' && evt.detail) { + const now = Date.now(); + if (now - lastProgressAt < 100) { + return; + } + lastProgressAt = now; + const label = statusFromSseEvent(evt); + setActivityText(label); + patchAssistant({ + streaming: true, + statusText: label, }); } else if (evt.type === 'tool_end') { - const idx = tools.findIndex((t) => t.name === evt.name && t.status === 'running'); + const idx = resolveToolActivityIndex(tools, evt); if (idx >= 0) { tools[idx] = { ...tools[idx], result: evt.result, status: 'done' }; } @@ -440,17 +505,23 @@ export default function ChatPage() { }); } else if (evt.type === 'error') { streamError = evt.message || c.agentError; - setError(streamError); const hasTools = tools.length > 0; + const hasNarrativeContent = Boolean( + narrative && + (narrative.power_insights.length > 0 || narrative.recommended_actions.length > 0), + ); + if (!hasNarrativeContent) { + setError(streamError); + } const fallbackContent = content.trim() || (hasTools ? c.partialToolsSaved : streamError); patchAssistant({ content: fallbackContent, narrative, streaming: false, - error: !hasTools, - partialError: hasTools, - agentError: streamError, + error: !hasTools && !hasNarrativeContent, + partialError: hasTools && !hasNarrativeContent, + agentError: hasNarrativeContent ? null : streamError, statusText: undefined, toolActivity: tools, }); @@ -520,12 +591,16 @@ export default function ChatPage() { const handleDeleteSession = async (id: number) => { if (!propertyId) return; if (sessionId === id) abortRef.current?.abort(); - await apiFetch(apiUrl(`/chat/sessions/${id}?propertyId=${propertyId}`), { method: 'DELETE' }); - if (sessionId === id) { - setSessionId(null); - setMessages([]); + try { + await apiFetch(apiUrl(`/chat/sessions/${id}?propertyId=${propertyId}`), { method: 'DELETE' }); + if (sessionId === id) { + setSessionId(null); + setMessages([]); + } + await loadSessions(propertyId); + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to delete session'); } - await loadSessions(propertyId); }; const modelPicker = llmEnabled ? ( @@ -546,7 +621,7 @@ export default function ChatPage() { const composer = ( void handleSend(msg)} trailing={modelPicker} @@ -556,6 +631,12 @@ export default function ChatPage() { /> ); + const apiKeyStrip = needsApiKey ? ( +
+ +
+ ) : null; + const errorStrip = error ? (
@@ -629,10 +710,13 @@ export default function ChatPage() {

{c.emptySubline}

-
{composer}
+
+ {apiKeyStrip} + {composer} +
void handleSend(p)} - disabled={busy || !propertyId} + disabled={busy || !propertyId || needsApiKey} crawlEnabled={crawlChatEnabled} />
@@ -648,6 +732,7 @@ export default function ChatPage() { )}
+ {apiKeyStrip} {errorStrip} {composer}
diff --git a/web/src/views/Issues.tsx b/web/src/views/Issues.tsx index c92557e2..6e436776 100644 --- a/web/src/views/Issues.tsx +++ b/web/src/views/Issues.tsx @@ -28,6 +28,7 @@ import { normalizePriority, type PriorityKey, } from '@/lib/issuePriority'; +import { issueDisplayMessage } from '@/lib/issueDisplayMessage'; registerChartJsBase(); @@ -66,7 +67,9 @@ function IssueCard({ item, vi, emDash }: IssueCardProps) { {categoryDisplayName(item.category)} -

{iss.message || emDash}

+

+ {issueDisplayMessage(iss.message) || emDash} +

{iss.url && (
; + onToggle: (domain: string, enabled: boolean) => void; + disabled?: boolean; +}) { + return ( +
+ {domains.map((domain) => { + const checked = enabledDomains.has(domain); + return ( + + ); + })} +
+ ); +} + // ─── Per-tool accordion ─────────────────────────────────────────────────────── function ToolDomainAccordion({ @@ -181,6 +224,7 @@ function ToolDomainAccordion({ disabledTools, onToggle, currentBundle, + enabledDomains, disabled, }: { domain: string; @@ -188,11 +232,19 @@ function ToolDomainAccordion({ disabledTools: Set; onToggle: (name: string, disabled: boolean) => void; currentBundle: string; + enabledDomains: Set; disabled?: boolean; }) { const [open, setOpen] = useState(false); - const bundleTools = tools.filter((t) => t.bundles.includes(currentBundle) || currentBundle === 'full'); + const inBundle = + currentBundle === 'full' + || tools.some((t) => t.bundles.includes(currentBundle)) + || (currentBundle === 'custom' && enabledDomains.has(domain)); + const bundleTools = tools.filter((t) => + currentBundle === 'full' + || t.bundles.includes(currentBundle) + || (currentBundle === 'custom' && enabledDomains.has(domain))); const enabledCount = tools.filter((t) => !disabledTools.has(t.name)).length; return ( @@ -224,11 +276,14 @@ function ToolDomainAccordion({
{tools.map((tool) => { const isDisabled = disabledTools.has(tool.name); - const inBundle = tool.bundles.includes(currentBundle) || currentBundle === 'full'; + const inToolBundle = + currentBundle === 'full' + || tool.bundles.includes(currentBundle) + || (currentBundle === 'custom' && enabledDomains.has(domain)); return (

{tool.name}

@@ -237,9 +292,9 @@ function ToolDomainAccordion({ {tool.description}

)} - {!inBundle && ( + {!inToolBundle && (

- Not in “{currentBundle}” bundle — switch to “full” to activate + Not in current bundle — enable the “{domain}” domain or switch bundle

)}
@@ -289,6 +344,8 @@ export default function RiskSettingsPage() { save, disabledTools, setToolDisabled, + enabledDomains, + setDomainEnabled, featureEnabled, setFeatureEnabled, llmState, @@ -397,6 +454,20 @@ export default function RiskSettingsPage() { ⚠️ Full mode exposes every tool to MCP clients — use a strong bearer token.

)} + {currentDomain === 'custom' && ( +
+

Enabled tool domains

+

+ Choose which audit tool groups are active for MCP and in-app chat. +

+ +
+ )}
{/* Per-tool toggles */} @@ -432,6 +503,7 @@ export default function RiskSettingsPage() { disabledTools={disabledTools} onToggle={setToolDisabled} currentBundle={currentDomain} + enabledDomains={enabledDomains} disabled={saving} /> ))} diff --git a/web/src/views/SearchPerformance.tsx b/web/src/views/SearchPerformance.tsx index a0b8956c..fe1ac6e1 100644 --- a/web/src/views/SearchPerformance.tsx +++ b/web/src/views/SearchPerformance.tsx @@ -34,6 +34,7 @@ import { } from '../components/searchPerformance/gscTableUtils'; import UrlGapListsPanel from '../components/google/UrlGapListsPanel'; import UrlInspectorButton from '@/components/UrlInspectorButton'; +import GoogleDataRefreshButton from '@/components/google/GoogleDataRefreshButton'; import { useSearchParams } from 'react-router-dom'; import { useUrlTab } from '@/hooks/useUrlTab'; @@ -281,6 +282,7 @@ export default function SearchPerformance() { {headerMeta} } + actions={} /> {errors.length > 0 && ( diff --git a/web/src/views/Traffic.tsx b/web/src/views/Traffic.tsx index fa8cc776..b398187e 100644 --- a/web/src/views/Traffic.tsx +++ b/web/src/views/Traffic.tsx @@ -36,6 +36,7 @@ import { } from '../components/traffic/ga4TableUtils'; import { syncChartJsDefaultsColor } from '../utils/chartJsDefaults'; import { buildLinksInspectHref } from '../lib/reportNav'; +import GoogleDataRefreshButton from '@/components/google/GoogleDataRefreshButton'; import { useSearchParams } from 'react-router-dom'; import { useUrlTab } from '@/hooks/useUrlTab'; @@ -249,6 +250,7 @@ export default function Traffic() { {headerMeta} } + actions={} /> {errors.length > 0 && (
@@ -214,7 +214,7 @@ export function LinksExplorerTableTab({ {key} + {vl.thJsErrors} {jsErrorsHint ? ( @@ -224,12 +224,23 @@ export function LinksExplorerTableTab({ ) : null} + {vl.thActions}
+ {searchQuery || filterValues.statusFilter !== sj.all || filterValues.inlinksFilter !== sj.all + || filterValues.rtFilter !== sj.all || filterValues.wcFilter !== sj.all + || filterValues.jsErrorFilter !== sj.all || advConditions.length > 0 + ? strings.components?.urlGapLists?.noResults ?? 'No URLs match your search or filters.' + : vl.noUrlData} +
@@ -291,19 +314,19 @@ export function LinksExplorerTableTab({ + {link.depth != null ? link.depth : sj.emDash} {formatMs(link.response_time_ms)} + {(link.word_count ?? 0) > 0 ? (link.word_count ?? 0).toLocaleString() : sj.emDash}