diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..cc51403 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,12 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "web", + "runtimeExecutable": "npm", + "runtimeArgs": ["--prefix", "web", "run", "dev"], + "port": 3000, + "autoPort": true + } + ] +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 134057f..cf5abd8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,29 +38,12 @@ jobs: run: pytest tests/ -q -m "not browser" - name: Pytest (reporting coverage gate) run: | - pytest tests/test_categories_roadmap.py tests/test_report_categories_golden.py \ - tests/test_categories_coverage.py tests/test_contrast_issues.py \ - tests/test_indexation_coverage.py tests/test_crawl_segments.py \ - tests/test_terminology.py tests/test_compare_payload.py \ - tests/test_optional_audits.py tests/test_property_profile.py tests/test_reporting_gaps.py \ - tests/test_text_content_analysis.py tests/test_builder_image_buckets.py \ - tests/test_pipeline_report_pool_unit.py tests/test_reporting_builder_modules.py \ + pytest tests/reporting/ \ --cov=website_profiling.reporting --cov-config=.coveragerc.reporting \ --cov-report=term-missing --cov-fail-under=100 -q -o addopts= - name: Pytest (tools coverage gate) run: | - pytest tests/test_alert_checker.py tests/test_schedule_runner.py tests/test_export_audit.py \ - tests/test_export_audit_coverage.py tests/test_audit_tools.py tests/test_audit_tools_expanded.py \ - tests/test_audit_tools_coverage.py tests/test_audit_tools_dispatch_coverage.py \ - tests/test_audit_tools_links_extras.py tests/test_audit_tools_expansion.py \ - tests/test_audit_tools_expansion_coverage.py tests/test_audit_tools_batch100_coverage.py tests/test_export_custom_coverage.py \ - tests/test_export_artifacts_coverage.py tests/test_export_compare_coverage.py \ - tests/test_export_tools_coverage.py tests/test_image_tools.py tests/test_export_custom.py \ - tests/test_export_artifacts.py tests/test_export_compare.py tests/test_export_workbook.py \ - tests/test_export_sitemap.py tests/test_mcp_registry.py tests/test_mcp_resources.py \ - tests/test_router_tools.py tests/test_tool_selector.py \ - tests/test_tools_gate100_coverage.py \ - tests/test_tools_branch_coverage.py \ + pytest tests/tools/ \ --cov=website_profiling.tools --cov-config=.coveragerc.tools \ --cov-report=term-missing --cov-fail-under=100 -q -o addopts= - name: CLI smoke diff --git a/AGENT.md b/AGENT.md index a137531..390cfc7 100644 --- a/AGENT.md +++ b/AGENT.md @@ -92,12 +92,12 @@ These recur when adding features. Verify explicitly — do not assume tests caug | Gate | Config | Source | Threshold | Test scope | |------|--------|--------|-----------|------------| | Core | `.coveragerc` | all packages **except** `tools/` and `reporting/` | 100% | `pytest tests/ -m "not browser"` | - | Reporting | `.coveragerc.reporting` | `website_profiling.reporting` | 100% | fixed test file list | - | Tools | `.coveragerc.tools` | `website_profiling.tools` | 100% | fixed test file list | + | Reporting | `.coveragerc.reporting` | `website_profiling.reporting` | 100% | `pytest tests/reporting/` | + | Tools | `.coveragerc.tools` | `website_profiling.tools` | 100% | `pytest tests/tools/` | - **Symptom:** `./local-test` or core pytest passes at 100%, but CI fails on tools/reporting (e.g. 84% tools). - - **Causes:** (a) only ran core pytest, not reporting/tools gates; (b) added tests under `tests/test__coverage.py` but did not add the file to the tools gate list in **both** `scripts/local-test.sh`, `scripts/local-test.ps1`, and `.github/workflows/ci.yml`; (c) changed code under `website_profiling/tools/` without tests that hit those lines in the tools gate subset. - - **Do:** Run full `./local-test` before push. When adding tools coverage tests, name them `tests/test__coverage.py` (repo convention) and register the file in all three places above. Keep bash and PowerShell local-test scripts in sync. - - **Don't:** Assume `pytest tests/` alone matches CI. Don't rely on a single mega `test_tools_coverage_gaps.py` — split by module. + - **Causes:** (a) only ran core pytest, not reporting/tools gates; (b) added reporting/tools tests outside `tests/reporting/` or `tests/tools/`; (c) changed code under `website_profiling/tools/` without tests that hit those lines in the tools gate subset. + - **Do:** Run full `./local-test` before push. Put reporting coverage tests in `tests/reporting/` and tools coverage tests in `tests/tools/` (one module per file, e.g. `test__coverage.py`). Keep bash and PowerShell local-test scripts in sync. + - **Don't:** Assume `pytest tests/` alone matches CI. Don't maintain long per-file lists in CI — use the directory gates above. 5. **Python — `runpy.run_module` / `__main__` guard tests** - Tests that execute a module as `__main__` via `runpy.run_module(..., run_name="__main__")` emit: diff --git a/alembic/versions/017_content_drafts.py b/alembic/versions/017_content_drafts.py new file mode 100644 index 0000000..d1d078c --- /dev/null +++ b/alembic/versions/017_content_drafts.py @@ -0,0 +1,42 @@ +"""Property-scoped content drafts for Content Studio. + +Revision ID: 017_content_drafts +Revises: 016_competitor_keyword_gap +""" +from __future__ import annotations + +from alembic import op + +revision = "017_content_drafts" +down_revision = "016_competitor_keyword_gap" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute(""" + CREATE TABLE content_drafts ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + property_id BIGINT NOT NULL REFERENCES properties(id) ON DELETE CASCADE, + title TEXT NOT NULL DEFAULT 'Untitled draft', + target_keyword TEXT NOT NULL DEFAULT '', + landing_url TEXT, + status TEXT NOT NULL DEFAULT 'draft' + CHECK (status IN ('draft', 'ready', 'archived')), + body_html TEXT NOT NULL DEFAULT '', + title_tag TEXT NOT NULL DEFAULT '', + meta_description TEXT NOT NULL DEFAULT '', + grade_score SMALLINT, + grade_snapshot JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + """) + op.execute(""" + CREATE INDEX content_drafts_property_updated_idx + ON content_drafts(property_id, updated_at DESC) + """) + + +def downgrade() -> None: + op.execute("DROP TABLE IF EXISTS content_drafts") diff --git a/docs/OPS.md b/docs/OPS.md index c283ed9..765ffd0 100644 --- a/docs/OPS.md +++ b/docs/OPS.md @@ -65,7 +65,24 @@ POST /api/alerts/check?propertyId={id} ### Behavior -Evaluates health-score changes and stale GSC Links imports for the specified property. When `alert_webhook_url` is configured on the property, sends a POST notification to that URL. +Evaluates health-score changes and stale GSC Links imports for the specified property. When `alert_webhook_url` is configured on the property, sends a POST notification to that URL. When `alert_email` is set and SMTP is configured on the server, sends a plain-text email summary. + +Response JSON includes `alerts`, `webhook_sent`, and `email_sent`. + +### SMTP (optional, for alert email) + +Set on the host running the web app (Docker: web service environment): + +| Variable | Required | Default | Purpose | +|----------|----------|---------|---------| +| `SMTP_HOST` | Yes (with `SMTP_FROM`) | — | SMTP server hostname | +| `SMTP_FROM` | Yes (with `SMTP_HOST`) | — | From address | +| `SMTP_PORT` | No | `587` | SMTP port | +| `SMTP_USER` | No | — | Login user (if auth required) | +| `SMTP_PASS` | No | — | Login password | +| `SMTP_USE_TLS` | No | `true` | Use STARTTLS | + +If SMTP is not configured, alert checks still succeed; `email_sent` is `false`. ### Example diff --git a/scripts/local-test.ps1 b/scripts/local-test.ps1 index adc865d..7d2d782 100644 --- a/scripts/local-test.ps1 +++ b/scripts/local-test.ps1 @@ -226,21 +226,7 @@ function Invoke-PytestCore { function Invoke-PytestReporting { Write-Log "Pytest (reporting coverage gate, 100%)" & $VENV_PYTEST ` - tests/test_categories_roadmap.py ` - tests/test_report_categories_golden.py ` - tests/test_categories_coverage.py ` - tests/test_contrast_issues.py ` - tests/test_indexation_coverage.py ` - tests/test_crawl_segments.py ` - tests/test_terminology.py ` - tests/test_compare_payload.py ` - tests/test_optional_audits.py ` - tests/test_property_profile.py ` - tests/test_reporting_gaps.py ` - tests/test_text_content_analysis.py ` - tests/test_builder_image_buckets.py ` - tests/test_pipeline_report_pool_unit.py ` - tests/test_reporting_builder_modules.py ` + tests/reporting/ ` --cov=website_profiling.reporting ` --cov-config=.coveragerc.reporting ` --cov-report=term-missing ` @@ -253,31 +239,7 @@ function Invoke-PytestReporting { function Invoke-PytestTools { Write-Log "Pytest (tools coverage gate, 100%)" & $VENV_PYTEST ` - tests/test_alert_checker.py ` - tests/test_schedule_runner.py ` - tests/test_export_audit.py ` - tests/test_export_audit_coverage.py ` - tests/test_audit_tools.py ` - tests/test_audit_tools_expanded.py ` - tests/test_audit_tools_coverage.py ` - tests/test_audit_tools_dispatch_coverage.py ` - tests/test_audit_tools_links_extras.py ` - tests/test_audit_tools_expansion.py ` - tests/test_audit_tools_expansion_coverage.py ` - tests/test_export_custom_coverage.py ` - tests/test_export_artifacts_coverage.py ` - tests/test_export_compare_coverage.py ` - tests/test_export_tools_coverage.py ` - tests/test_image_tools.py ` - tests/test_export_custom.py ` - tests/test_export_artifacts.py ` - tests/test_export_compare.py ` - tests/test_export_workbook.py ` - tests/test_export_sitemap.py ` - tests/test_mcp_registry.py ` - tests/test_mcp_resources.py ` - tests/test_tools_gate100_coverage.py ` - tests/test_tools_branch_coverage.py ` + tests/tools/ ` --cov=website_profiling.tools ` --cov-config=.coveragerc.tools ` --cov-report=term-missing ` diff --git a/scripts/local-test.sh b/scripts/local-test.sh index 357f1d5..174d572 100755 --- a/scripts/local-test.sh +++ b/scripts/local-test.sh @@ -115,21 +115,7 @@ run_pytest_reporting() { [[ "$PYTEST_NO_COV" -eq 1 ]] && return 0 log "Pytest (reporting coverage gate, 100%)" "$VENV/bin/pytest" \ - tests/test_categories_roadmap.py \ - tests/test_report_categories_golden.py \ - tests/test_categories_coverage.py \ - tests/test_contrast_issues.py \ - tests/test_indexation_coverage.py \ - tests/test_crawl_segments.py \ - tests/test_terminology.py \ - tests/test_compare_payload.py \ - tests/test_optional_audits.py \ - tests/test_property_profile.py \ - tests/test_reporting_gaps.py \ - tests/test_text_content_analysis.py \ - tests/test_builder_image_buckets.py \ - tests/test_pipeline_report_pool_unit.py \ - tests/test_reporting_builder_modules.py \ + tests/reporting/ \ --cov=website_profiling.reporting \ --cov-config=.coveragerc.reporting \ --cov-report=term-missing \ @@ -142,34 +128,7 @@ run_pytest_tools() { [[ "$PYTEST_NO_COV" -eq 1 ]] && return 0 log "Pytest (tools coverage gate, 100%)" "$VENV/bin/pytest" \ - tests/test_alert_checker.py \ - tests/test_schedule_runner.py \ - tests/test_export_audit.py \ - tests/test_export_audit_coverage.py \ - tests/test_audit_tools.py \ - tests/test_audit_tools_expanded.py \ - tests/test_audit_tools_coverage.py \ - tests/test_audit_tools_dispatch_coverage.py \ - tests/test_audit_tools_links_extras.py \ - tests/test_audit_tools_expansion.py \ - tests/test_audit_tools_expansion_coverage.py \ - tests/test_audit_tools_batch100_coverage.py \ - tests/test_export_custom_coverage.py \ - tests/test_export_artifacts_coverage.py \ - tests/test_export_compare_coverage.py \ - tests/test_export_tools_coverage.py \ - tests/test_image_tools.py \ - tests/test_export_custom.py \ - tests/test_export_artifacts.py \ - tests/test_export_compare.py \ - tests/test_export_workbook.py \ - tests/test_export_sitemap.py \ - tests/test_mcp_registry.py \ - tests/test_mcp_resources.py \ - tests/test_router_tools.py \ - tests/test_tool_selector.py \ - tests/test_tools_gate100_coverage.py \ - tests/test_tools_branch_coverage.py \ + tests/tools/ \ --cov=website_profiling.tools \ --cov-config=.coveragerc.tools \ --cov-report=term-missing \ diff --git a/src/website_profiling/concurrency.py b/src/website_profiling/concurrency.py new file mode 100644 index 0000000..8129f86 --- /dev/null +++ b/src/website_profiling/concurrency.py @@ -0,0 +1,56 @@ +"""Bounded parallel execution helpers shared by the agent loops and workflows. + +When a model returns several independent, read-only tool calls in one turn we run +them concurrently with a bounded worker pool instead of one at a time — the same +"parallel tool execution" pattern Claude Code uses. Mirrors the ``ThreadPoolExecutor`` +usage already in ``crawl/crawler.py``, ``llm/enrich.py`` and ``analysis/image_probe.py``, +and the env-int parsing in ``db/pool.py``. +""" +from __future__ import annotations + +import os +from concurrent.futures import ThreadPoolExecutor +from typing import Callable, Sequence, TypeVar + +T = TypeVar("T") +R = TypeVar("R") + +DEFAULT_TOOL_CONCURRENCY = 6 + + +def tool_concurrency(default: int = DEFAULT_TOOL_CONCURRENCY) -> int: + """Max number of tool calls to dispatch concurrently within one agent turn. + + Override with the ``WP_TOOL_CONCURRENCY`` env var; a value of ``1`` disables + parallelism (every dispatch runs sequentially). Empty or non-integer values fall + back to ``default``. The result is floored at 1. + """ + raw = (os.environ.get("WP_TOOL_CONCURRENCY") or "").strip() + if not raw: + return default + try: + return max(1, int(raw)) + except ValueError: + return default + + +def map_parallel( + items: Sequence[T], + fn: Callable[[T], R], + *, + max_workers: int, +) -> list[R]: + """Apply ``fn`` to each item, returning results in input order. + + Runs sequentially when ``max_workers <= 1`` or there is at most one item; otherwise + uses a bounded thread pool. ``fn`` MUST NOT raise — callers wrap their work in + try/except and return an error value so one failure never sinks the batch. + """ + count = len(items) + if count == 0: + return [] + workers = max(1, min(max_workers, count)) + if workers == 1 or count == 1: + return [fn(item) for item in items] + with ThreadPoolExecutor(max_workers=workers) as pool: + return list(pool.map(fn, items)) diff --git a/src/website_profiling/content_studio/__init__.py b/src/website_profiling/content_studio/__init__.py new file mode 100644 index 0000000..93d7ce5 --- /dev/null +++ b/src/website_profiling/content_studio/__init__.py @@ -0,0 +1,7 @@ +"""Content Studio — draft writing and SEO scoring.""" +from __future__ import annotations + +from .score import score_content_draft +from .ai_suggest import analyze_content_draft + +__all__ = ["score_content_draft", "analyze_content_draft"] diff --git a/src/website_profiling/content_studio/agent.py b/src/website_profiling/content_studio/agent.py new file mode 100644 index 0000000..5aa0254 --- /dev/null +++ b/src/website_profiling/content_studio/agent.py @@ -0,0 +1,261 @@ +"""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, +) -> None: + for name in sorted(REQUIRED_CONTENT_STUDIO_TOOLS - called): + result = sanitize_unicode_deep(dispatch_content_studio_tool(name, ctx)) + called.add(name) + 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: + _inject_missing_tools(openai_messages, ctx, called, ollama_format) + for name in sorted(REQUIRED_CONTENT_STUDIO_TOOLS): + if any(e["name"] == name for e in tool_events): + continue + tool_events.append({ + "name": name, + "args": {}, + "result": dispatch_content_studio_tool(name, ctx), + }) + 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 new file mode 100644 index 0000000..b60c8dc --- /dev/null +++ b/src/website_profiling/content_studio/ai_suggest.py @@ -0,0 +1,222 @@ +"""Content Studio analyze: SEO score + rule-based and optional LLM suggestions.""" +from __future__ import annotations + +import hashlib +import json +import re +from typing import Any + +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 +from .context import ContentStudioContext +from .score import score_content_draft +from .tools import run_all_content_studio_tools + + +def _rule_suggestions(score: dict[str, Any]) -> list[dict[str, Any]]: + items: list[dict[str, Any]] = [] + for term in score.get("terms") or []: + if not isinstance(term, dict): + continue + if term.get("status") == "missing": + items.append({ + "text": f"Work the term “{term.get('term')}” into a heading or paragraph.", + "priority": term.get("importance") or "medium", + "type": "term", + "source": "rule", + }) + elif term.get("status") == "partial": + items.append({ + "text": f"Use the full phrase “{term.get('term')}” (related words appear but not the exact query).", + "priority": "medium", + "type": "term", + "source": "rule", + }) + for check in score.get("checks") or []: + if isinstance(check, dict) and not check.get("pass"): + items.append({ + "text": str(check.get("hint") or "Fix an on-page check."), + "priority": "high", + "type": "seo", + "source": "rule", + }) + wc = int(score.get("word_count") or 0) + if wc > 0 and wc < 400: + items.append({ + "text": "Expand the body with examples, FAQs, or subsections to reach a competitive word count.", + "priority": "medium", + "type": "structure", + "source": "rule", + }) + return items[:15] + + +def _merge_suggestions( + rule: list[dict[str, Any]], + ai: list[dict[str, Any]], +) -> list[dict[str, Any]]: + seen: set[str] = set() + merged: list[dict[str, Any]] = [] + for item in [*ai, *rule]: + if not isinstance(item, dict): + continue + text = re.sub(r"\s+", " ", str(item.get("text") or "").strip().lower()) + if not text or text in seen: + continue + seen.add(text) + merged.append({ + "text": str(item.get("text") or "").strip(), + "priority": str(item.get("priority") or "medium"), + "type": str(item.get("type") or "seo"), + "source": str(item.get("source") or "ai"), + }) + return merged[:20] + + +def _cfg_content_studio_ai(cfg: dict[str, str]) -> bool: + v = str(cfg.get("llm_enable_content_studio", "true")).lower() + return v in ("true", "1", "yes") + + +def analyze_content_draft( + property_id: int | None, + keyword: str, + body_html: str, + title_tag: str = "", + meta_description: str = "", + landing_url: str | None = None, + *, + use_ai: bool = False, + refresh: bool = False, + title: str = "", +) -> dict[str, Any]: + """Full analyze: score + suggestions (rules always; LLM when use_ai and configured).""" + score = score_content_draft( + property_id, + keyword, + body_html, + title_tag, + meta_description, + landing_url, + ) + rule = _rule_suggestions(score) + result: dict[str, Any] = { + "ok": True, + "score": score, + "suggestions": rule, + "summary": _default_summary(score, keyword), + "outline": [], + "title_ideas": [], + "ai_used": False, + "tools_used": [], + "tool_events": [], + "provenance": score.get("provenance", "Search Console + on-site heuristics"), + } + + if not use_ai: + result["provenance"] = f"{result['provenance']} · Rule-based tips" + ctx = ContentStudioContext( + property_id=property_id, + keyword=keyword, + body_html=body_html, + title_tag=title_tag, + meta_description=meta_description, + landing_url=landing_url, + title=title, + ) + tool_events = run_all_content_studio_tools(ctx) + result["tools_used"] = [e["name"] for e in tool_events] + result["tool_events"] = tool_events + return result + + cfg = load_llm_config_from_db() + if not llm_is_enabled(cfg) or not _cfg_content_studio_ai(cfg): + result["provenance"] = f"{result['provenance']} · AI off (enable in Run audit → AI settings)" + result["ai_error"] = "AI insights disabled in settings." + return result + + ctx = ContentStudioContext( + property_id=property_id, + keyword=keyword, + body_html=body_html, + title_tag=title_tag, + meta_description=meta_description, + landing_url=landing_url, + title=title, + ) + model = (cfg.get("llm_model") or cfg.get("llm_provider") or "unknown").strip() + cache_payload = { + "keyword": keyword, + "title": title, + "title_tag": title_tag, + "meta_description": meta_description, + "landing_url": landing_url, + "grade_score": score.get("grade_score"), + "body_hash": hashlib.sha256((body_html or "").encode()).hexdigest()[:16], + } + cache_key = hashlib.sha256( + f"content_studio:v2-tools:{PROMPT_VERSION}:{model}:{json.dumps(cache_payload, sort_keys=True)}".encode() + ).hexdigest() + + ai_block: dict[str, Any] = {} + tool_events: list[dict[str, Any]] = [] + if not refresh: + cached = _read_cache(cache_key) + if isinstance(cached, dict) and cached.get("ai_block"): + ai_block = cached["ai_block"] + 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) + 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 + + if not agent_result.get("ok"): + result["ai_error"] = str(agent_result.get("error") or "AI analyze failed") + result["provenance"] = f"{result['provenance']} · Rule-based tips (AI failed)" + return result + + ai_block = agent_result.get("ai_block") if isinstance(agent_result.get("ai_block"), dict) else {} + if ai_block: + _write_cache(cache_key, {"ai_block": ai_block, "tool_events": tool_events}) + else: + result["tools_used"] = [str(e.get("name") or "") for e in tool_events if e.get("name")] + result["tool_events"] = tool_events + + if not ai_block: + result["ai_error"] = "No structured output from analyze agent." + result["provenance"] = f"{result['provenance']} · Rule-based tips (AI failed)" + return result + + ai_suggestions = ai_block.get("suggestions") if isinstance(ai_block.get("suggestions"), list) else [] + for s in ai_suggestions: + if isinstance(s, dict): + s["source"] = "ai" + + result["suggestions"] = _merge_suggestions(rule, ai_suggestions) + if ai_block.get("summary"): + result["summary"] = str(ai_block["summary"]) + if isinstance(ai_block.get("outline"), list): + result["outline"] = [str(x) for x in ai_block["outline"][:8]] + if isinstance(ai_block.get("title_ideas"), list): + result["title_ideas"] = [str(x) for x in ai_block["title_ideas"][:5]] + result["ai_used"] = True + result["provenance"] = "Tool-based AI analyze + Search Console heuristics" + return result + + +def _default_summary(score: dict[str, Any], keyword: str) -> str: + grade = score.get("grade_label") or "?" + pts = score.get("grade_score") + kw = (keyword or "").strip() or "your target keyword" + missing = sum( + 1 for t in (score.get("terms") or []) + if isinstance(t, dict) and t.get("status") == "missing" + ) + return ( + f"Draft scores {grade} ({pts}/100) for “{kw}”. " + f"{missing} priority term(s) still missing from the body." + ) diff --git a/src/website_profiling/content_studio/context.py b/src/website_profiling/content_studio/context.py new file mode 100644 index 0000000..e3732a3 --- /dev/null +++ b/src/website_profiling/content_studio/context.py @@ -0,0 +1,15 @@ +"""Execution context for Content Studio analyze tools.""" +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass +class ContentStudioContext: + property_id: int | None + keyword: str + body_html: str + title_tag: str = "" + meta_description: str = "" + landing_url: str | None = None + title: str = "" diff --git a/src/website_profiling/content_studio/score.py b/src/website_profiling/content_studio/score.py new file mode 100644 index 0000000..8e80ec1 --- /dev/null +++ b/src/website_profiling/content_studio/score.py @@ -0,0 +1,262 @@ +"""Content Studio scoring from GSC keywords and on-page heuristics.""" +from __future__ import annotations + +import re +from typing import Any + +from bs4 import BeautifulSoup + +from ..content_analysis.reading_level import flesch_kincaid_grade +from ..content_analysis.text_extract import extract_text +from ..content_analysis.tokenize import count_words, tokenize_words +from ..db import db_session +from ..integrations.google.keyword_store import read_latest_keyword_data + +PROVENANCE = "Search Console + on-site heuristics" + +_WORD_COUNT_MIN = 600 +_WORD_COUNT_MAX = 2500 + + +def _grade_label(score: int) -> str: + if score >= 90: + return "A" + if score >= 80: + return "B" + if score >= 70: + return "C" + if score >= 60: + return "D" + return "F" + + +def _normalize_url(url: str) -> str: + return (url or "").strip().lower().rstrip("/") + + +def _html_to_text(html: str) -> str: + if not html or not html.strip(): + return "" + soup = BeautifulSoup(html, "html.parser") + return extract_text(soup) + + +def _count_h1(html: str) -> int: + if not html: + return 0 + soup = BeautifulSoup(html, "html.parser") + return len(soup.find_all("h1")) + + +def _term_in_corpus(term: str, corpus: str) -> str: + """Return included | partial | missing for a term against corpus text.""" + t = (term or "").strip().lower() + if not t: + return "missing" + c = (corpus or "").lower() + if t in c: + return "included" + words = [w for w in re.split(r"\W+", t) if len(w) >= 3] + if words and all(w in c for w in words): + return "partial" + return "missing" + + +def _title_check(title_tag: str) -> dict[str, Any]: + n = len((title_tag or "").strip()) + if n == 0: + return {"id": "title_length", "pass": False, "hint": "Add a title tag (aim for 50–60 characters)."} + if n < 30: + return {"id": "title_length", "pass": False, "hint": f"Title is short ({n} chars); aim for 50–60."} + if n > 70: + return {"id": "title_length", "pass": False, "hint": f"Title is long ({n} chars); aim for 50–60."} + return {"id": "title_length", "pass": True, "hint": f"Title length OK ({n} chars)."} + + +def _meta_check(meta: str) -> dict[str, Any]: + n = len((meta or "").strip()) + if n == 0: + return {"id": "meta_length", "pass": False, "hint": "Add a meta description (aim for 120–160 characters)."} + if n < 70: + return {"id": "meta_length", "pass": False, "hint": f"Meta description is short ({n} chars); aim for 120–160."} + if n > 170: + return {"id": "meta_length", "pass": False, "hint": f"Meta description is long ({n} chars); aim for 120–160."} + return {"id": "meta_length", "pass": True, "hint": f"Meta description length OK ({n} chars)."} + + +def _h1_check(html: str) -> dict[str, Any]: + n = _count_h1(html) + if n == 0: + return {"id": "h1_single", "pass": False, "hint": "Add exactly one H1 in the body."} + if n > 1: + return {"id": "h1_single", "pass": False, "hint": f"Found {n} H1 tags; use exactly one."} + return {"id": "h1_single", "pass": True, "hint": "Single H1 present."} + + +def _word_count_check(word_count: int) -> dict[str, Any]: + if word_count < _WORD_COUNT_MIN: + return { + "id": "word_count", + "pass": False, + "hint": f"Content is thin ({word_count} words); aim for {_WORD_COUNT_MIN}–{_WORD_COUNT_MAX}.", + } + if word_count > _WORD_COUNT_MAX: + return { + "id": "word_count", + "pass": False, + "hint": f"Content is very long ({word_count} words); consider tightening.", + } + return {"id": "word_count", "pass": True, "hint": f"Word count in range ({word_count} words)."} + + +def _collect_gsc_terms( + keyword: str, + landing_url: str | None, + rows: list[dict[str, Any]], + *, + cap: int = 25, +) -> list[dict[str, Any]]: + """Build ranked term list from GSC keyword rows.""" + kw_lower = (keyword or "").strip().lower() + landing_norm = _normalize_url(landing_url or "") + seen: set[str] = set() + terms: list[dict[str, Any]] = [] + + def add(term: str, importance: str, source: str, impressions: int = 0) -> None: + t = (term or "").strip() + if not t or len(t) < 2: + return + key = t.lower() + if key in seen: + return + seen.add(key) + terms.append({ + "term": t, + "importance": importance, + "source": source, + "_impressions": impressions, + }) + + if kw_lower: + add(keyword.strip(), "high", "keyword", 10_000) + + scored_rows: list[tuple[int, dict[str, Any]]] = [] + for row in rows: + if not isinstance(row, dict): + continue + q = str(row.get("keyword") or "").strip() + if not q: + continue + imps = int(row.get("gsc_impressions") or 0) + gsc_url = _normalize_url(str(row.get("gsc_url") or "")) + q_lower = q.lower() + related = ( + kw_lower in q_lower + or q_lower in kw_lower + or (landing_norm and landing_norm in gsc_url) + ) + if related: + scored_rows.append((imps, row)) + + scored_rows.sort(key=lambda x: -x[0]) + for imps, row in scored_rows[:cap]: + q = str(row.get("keyword") or "").strip() + importance = "high" if imps >= 100 or q.lower() == kw_lower else "medium" + add(q, importance, "gsc", imps) + + terms.sort(key=lambda t: (-(2 if t["importance"] == "high" else 1), -t["_impressions"])) + for t in terms: + t.pop("_impressions", None) + return terms[:cap] + + +def _term_coverage_score(terms: list[dict[str, Any]]) -> float: + if not terms: + return 0.5 + total_weight = 0.0 + earned = 0.0 + for t in terms: + w = 2.0 if t.get("importance") == "high" else 1.0 + total_weight += w + status = t.get("status") or "missing" + if status == "included": + earned += w + elif status == "partial": + earned += w * 0.5 + return earned / total_weight if total_weight else 0.5 + + +def _checks_pass_rate(checks: list[dict[str, Any]]) -> float: + if not checks: + return 0.0 + passed = sum(1 for c in checks if c.get("pass")) + return passed / len(checks) + + +def _word_count_band_score(word_count: int) -> float: + if _WORD_COUNT_MIN <= word_count <= _WORD_COUNT_MAX: + return 1.0 + if word_count < _WORD_COUNT_MIN: + return max(0.0, word_count / _WORD_COUNT_MIN) + excess = word_count - _WORD_COUNT_MAX + return max(0.0, 1.0 - excess / _WORD_COUNT_MAX) + + +def score_content_draft( + property_id: int | None, + keyword: str, + body_html: str, + title_tag: str = "", + meta_description: str = "", + landing_url: str | None = None, + *, + keyword_rows: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + """ + Score draft content. Pass keyword_rows in tests; otherwise loads from DB. + """ + body_text = _html_to_text(body_html) + tokens = tokenize_words(body_text) + word_count = count_words(tokens) + reading_level = flesch_kincaid_grade(tokens, body_text) if tokens else 0.0 + + corpus = f"{title_tag} {body_text}".lower() + + rows = keyword_rows + if rows is None and property_id is not None: + with db_session() as conn: + data = read_latest_keyword_data(conn, property_id) + rows = (data or {}).get("rows") or [] + if not isinstance(rows, list): + rows = [] + rows = rows or [] + + raw_terms = _collect_gsc_terms(keyword, landing_url, rows) + terms: list[dict[str, Any]] = [] + for t in raw_terms: + status = _term_in_corpus(str(t["term"]), corpus) + terms.append({**t, "status": status}) + + checks = [ + _title_check(title_tag), + _meta_check(meta_description), + _h1_check(body_html), + _word_count_check(word_count), + ] + + term_cov = _term_coverage_score(terms) + check_rate = _checks_pass_rate(checks) + wc_band = _word_count_band_score(word_count) + + raw_grade = term_cov * 0.6 + check_rate * 0.25 + wc_band * 0.15 + grade_score = max(0, min(100, round(raw_grade * 100))) + + return { + "grade_score": grade_score, + "grade_label": _grade_label(grade_score), + "word_count": word_count, + "reading_level": round(reading_level, 1), + "terms": terms, + "checks": checks, + "provenance": PROVENANCE, + } diff --git a/src/website_profiling/content_studio/tools.py b/src/website_profiling/content_studio/tools.py new file mode 100644 index 0000000..d475aa9 --- /dev/null +++ b/src/website_profiling/content_studio/tools.py @@ -0,0 +1,252 @@ +"""Deterministic tools for Content Studio analyze (tool-calling agent).""" +from __future__ import annotations + +import re +from typing import Any, Callable + +from bs4 import BeautifulSoup + +from ..db import db_session +from ..integrations.google.keyword_store import read_latest_keyword_data +from .context import ContentStudioContext +from .score import score_content_draft + +ToolHandler = Callable[[ContentStudioContext], dict[str, Any]] + + +def _headings_outline(html: str, *, cap: int = 12) -> list[dict[str, str]]: + if not html or not html.strip(): + return [] + soup = BeautifulSoup(html, "html.parser") + out: list[dict[str, str]] = [] + for tag in soup.find_all(re.compile(r"^h[1-3]$", re.I)): + text = tag.get_text(separator=" ", strip=True) + if text: + out.append({"level": tag.name.lower(), "text": text[:200]}) + if len(out) >= cap: + break + return out + + +def tool_get_draft_seo_score(ctx: ContentStudioContext) -> dict[str, Any]: + score = score_content_draft( + ctx.property_id, + ctx.keyword, + ctx.body_html, + ctx.title_tag, + ctx.meta_description, + ctx.landing_url, + ) + return { + "grade_score": score.get("grade_score"), + "grade_label": score.get("grade_label"), + "word_count": score.get("word_count"), + "reading_level": score.get("reading_level"), + "provenance": score.get("provenance"), + } + + +def tool_get_term_coverage(ctx: ContentStudioContext) -> dict[str, Any]: + score = score_content_draft( + ctx.property_id, + ctx.keyword, + ctx.body_html, + ctx.title_tag, + ctx.meta_description, + ctx.landing_url, + ) + terms = score.get("terms") or [] + grouped: dict[str, list[str]] = {"missing": [], "partial": [], "included": []} + for t in terms: + if not isinstance(t, dict): + continue + status = str(t.get("status") or "missing") + term = str(t.get("term") or "") + if term and status in grouped: + grouped[status].append(term) + return { + "target_keyword": ctx.keyword.strip(), + "missing": grouped["missing"][:12], + "partial": grouped["partial"][:12], + "included": grouped["included"][:12], + "missing_high_priority": [ + str(t.get("term") or "") + for t in terms + if isinstance(t, dict) + and t.get("status") == "missing" + and t.get("importance") == "high" + ][:8], + } + + +def tool_get_onpage_checks(ctx: ContentStudioContext) -> dict[str, Any]: + score = score_content_draft( + ctx.property_id, + ctx.keyword, + ctx.body_html, + ctx.title_tag, + ctx.meta_description, + ctx.landing_url, + ) + checks = score.get("checks") or [] + failed = [ + {"id": c.get("id"), "hint": c.get("hint")} + for c in checks + if isinstance(c, dict) and not c.get("pass") + ] + passed = [ + {"id": c.get("id"), "hint": c.get("hint")} + for c in checks + if isinstance(c, dict) and c.get("pass") + ] + return { + "title_tag": ctx.title_tag, + "meta_description_length": len((ctx.meta_description or "").strip()), + "failed": failed, + "passed": passed, + } + + +def tool_get_keyword_gsc_context(ctx: ContentStudioContext) -> dict[str, Any]: + kw = (ctx.keyword or "").strip().lower() + if not kw: + return {"queries": [], "note": "No target keyword set."} + + rows: list[dict[str, Any]] = [] + if ctx.property_id: + try: + with db_session() as conn: + data = read_latest_keyword_data(conn, ctx.property_id) + if isinstance(data, dict): + raw = data.get("rows") or [] + rows = [r for r in raw if isinstance(r, dict)] + except Exception: + rows = [] + + landing_norm = (ctx.landing_url or "").strip().lower().rstrip("/") + related: list[dict[str, Any]] = [] + for row in rows: + q = str(row.get("keyword") or "").strip() + if not q: + continue + q_lower = q.lower() + gsc_url = str(row.get("gsc_url") or "").strip().lower().rstrip("/") + if ( + kw in q_lower + or q_lower in kw + or (landing_norm and landing_norm in gsc_url) + ): + related.append({ + "keyword": q, + "impressions": int(row.get("gsc_impressions") or 0), + "clicks": int(row.get("gsc_clicks") or 0), + "position": row.get("gsc_position"), + "url": row.get("gsc_url"), + }) + + related.sort(key=lambda r: -(int(r.get("impressions") or 0))) + return { + "target_keyword": ctx.keyword.strip(), + "landing_url": ctx.landing_url, + "queries": related[:15], + "total_related": len(related), + } + + +def tool_get_draft_structure(ctx: ContentStudioContext) -> dict[str, Any]: + score = score_content_draft( + ctx.property_id, + ctx.keyword, + ctx.body_html, + ctx.title_tag, + ctx.meta_description, + ctx.landing_url, + ) + return { + "draft_title": ctx.title, + "headings": _headings_outline(ctx.body_html), + "word_count": score.get("word_count"), + "reading_level": score.get("reading_level"), + "body_preview": BeautifulSoup(ctx.body_html or "", "html.parser").get_text( + separator=" ", strip=True + )[:1200], + } + + +CONTENT_STUDIO_TOOL_DEFINITIONS: list[dict[str, Any]] = [ + { + "name": "get_draft_seo_score", + "description": "Overall SEO grade, word count, and reading level for the draft.", + "parameters": {"type": "object", "properties": {}, "required": []}, + }, + { + "name": "get_term_coverage", + "description": "GSC-related terms and whether they are missing, partial, or included in the draft.", + "parameters": {"type": "object", "properties": {}, "required": []}, + }, + { + "name": "get_onpage_checks", + "description": "Title tag, meta description, H1, and word-count checks with pass/fail hints.", + "parameters": {"type": "object", "properties": {}, "required": []}, + }, + { + "name": "get_keyword_gsc_context", + "description": "Related Search Console queries for the target keyword and landing URL.", + "parameters": {"type": "object", "properties": {}, "required": []}, + }, + { + "name": "get_draft_structure", + "description": "Heading outline, body preview, and structure metrics for the draft HTML.", + "parameters": {"type": "object", "properties": {}, "required": []}, + }, +] + +CONTENT_STUDIO_TOOL_HANDLERS: dict[str, ToolHandler] = { + "get_draft_seo_score": tool_get_draft_seo_score, + "get_term_coverage": tool_get_term_coverage, + "get_onpage_checks": tool_get_onpage_checks, + "get_keyword_gsc_context": tool_get_keyword_gsc_context, + "get_draft_structure": tool_get_draft_structure, +} + +REQUIRED_CONTENT_STUDIO_TOOLS = frozenset(CONTENT_STUDIO_TOOL_HANDLERS.keys()) + + +def openai_tools_schema() -> list[dict[str, Any]]: + return [ + { + "type": "function", + "function": { + "name": t["name"], + "description": t["description"], + "parameters": t["parameters"], + }, + } + for t in CONTENT_STUDIO_TOOL_DEFINITIONS + ] + + +def dispatch_content_studio_tool(name: str, ctx: ContentStudioContext) -> dict[str, Any]: + handler = CONTENT_STUDIO_TOOL_HANDLERS.get(name) + if handler is None: + return {"error": f"unknown tool: {name}"} + try: + return handler(ctx) + except Exception as e: + return {"error": str(e)} + + +def run_all_content_studio_tools(ctx: ContentStudioContext) -> list[dict[str, Any]]: + """Deterministic fallback: execute every analyze tool, in declaration order.""" + from ..concurrency import map_parallel, tool_concurrency + + names = [str(t["name"]) for t in CONTENT_STUDIO_TOOL_DEFINITIONS] + results = map_parallel( + names, + lambda name: dispatch_content_studio_tool(name, ctx), + max_workers=tool_concurrency(), + ) + return [ + {"name": name, "args": {}, "result": result} + for name, result in zip(names, results) + ] diff --git a/src/website_profiling/llm/agent.py b/src/website_profiling/llm/agent.py index ecdcaa9..fc8cbbf 100644 --- a/src/website_profiling/llm/agent.py +++ b/src/website_profiling/llm/agent.py @@ -4,6 +4,7 @@ import json 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 @@ -264,23 +265,38 @@ def on_token(text: str) -> None: "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}) - if chat_tool_mode() != "full" and tc.name not in active_names: - tool_result = { + + 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.", } - else: - tool_result = sanitize_unicode_deep( + try: + return sanitize_unicode_deep( dispatch_tool(tc.name, tc.arguments, 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) - if chat_tool_mode() != "full": - tools = openai_tools_schema(active_names) tool_content = json.dumps(tool_result, default=str) if ollama_format: @@ -295,6 +311,9 @@ def on_token(text: str) -> None: "tool_call_id": tc.id, "content": tool_content, }) + + if gated: + tools = openai_tools_schema(active_names) continue final_message = strip_surrogates(result.content).strip() diff --git a/src/website_profiling/llm/prompts.py b/src/website_profiling/llm/prompts.py index 7ab6e72..55dec85 100644 --- a/src/website_profiling/llm/prompts.py +++ b/src/website_profiling/llm/prompts.py @@ -31,6 +31,17 @@ } 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"}""" diff --git a/src/website_profiling/tools/alert_checker.py b/src/website_profiling/tools/alert_checker.py index 50df913..e08dac5 100644 --- a/src/website_profiling/tools/alert_checker.py +++ b/src/website_profiling/tools/alert_checker.py @@ -2,8 +2,81 @@ from __future__ import annotations import json +import logging +import os +import smtplib +from email.message import EmailMessage from typing import Any +logger = logging.getLogger(__name__) + + +def _env_bool(name: str, default: bool) -> bool: + raw = os.environ.get(name) + if raw is None or str(raw).strip() == "": + return default + return str(raw).strip().lower() in ("1", "true", "yes", "on") + + +def smtp_configured() -> bool: + host = (os.environ.get("SMTP_HOST") or "").strip() + from_addr = (os.environ.get("SMTP_FROM") or "").strip() + return bool(host and from_addr) + + +def _format_alert_email_body(payload: dict[str, Any]) -> str: + lines = ["Site Audit property alerts", ""] + prop_id = payload.get("property_id") + if prop_id is not None: + lines.append(f"Property ID: {prop_id}") + lines.append("") + alerts = payload.get("alerts") or [] + if not alerts: + lines.append("No alerts.") + return "\n".join(lines) + for i, alert in enumerate(alerts, start=1): + if not isinstance(alert, dict): + continue + severity = alert.get("severity") or "info" + msg = alert.get("message") or alert.get("type") or "Alert" + lines.append(f"{i}. [{severity}] {msg}") + return "\n".join(lines) + + +def dispatch_email(to: str, payload: dict[str, Any]) -> bool: + """Send alert summary via SMTP. Returns False when unconfigured or on failure.""" + recipient = (to or "").strip() + if not recipient: + return False + if not smtp_configured(): + logger.info("SMTP not configured (SMTP_HOST and SMTP_FROM required); skipping alert email") + return False + + host = os.environ.get("SMTP_HOST", "").strip() + port = int(os.environ.get("SMTP_PORT") or "587") + user = (os.environ.get("SMTP_USER") or "").strip() + password = os.environ.get("SMTP_PASS") or "" + from_addr = os.environ.get("SMTP_FROM", "").strip() + use_tls = _env_bool("SMTP_USE_TLS", True) + + msg = EmailMessage() + msg["Subject"] = "Site Audit alerts" + msg["From"] = from_addr + msg["To"] = recipient + msg.set_content(_format_alert_email_body(payload)) + + try: + with smtplib.SMTP(host, port, timeout=20) as smtp: + if use_tls: + smtp.starttls() + if user: + smtp.login(user, password) + smtp.send_message(msg) + return True + except Exception: + logger.exception("Failed to send alert email to %s", recipient) + return False + def check_health_alerts(property_id: int, threshold_drop: int = 10) -> list[dict[str, Any]]: from ..db.storage import db_session diff --git a/src/website_profiling/tools/audit_tools/router_tools.py b/src/website_profiling/tools/audit_tools/router_tools.py index 6eb0a94..fffd630 100644 --- a/src/website_profiling/tools/audit_tools/router_tools.py +++ b/src/website_profiling/tools/audit_tools/router_tools.py @@ -37,68 +37,85 @@ def list_tool_domains(_conn: Connection, _ctx: AuditToolContext, _args: dict[str } -def _dispatch(name: str, conn: Connection, ctx: AuditToolContext, args: dict[str, Any]) -> dict[str, Any]: +def _dispatch(name: str, ctx: AuditToolContext, args: dict[str, Any]) -> dict[str, Any]: + """Dispatch one workflow step on its own pooled connection (safe to run in parallel).""" from .registry import dispatch_tool - return dispatch_tool(name, args, context=ctx, conn=conn) + try: + return dispatch_tool(name, args, context=ctx) + except Exception as e: # noqa: BLE001 - one failed step must not sink the whole workflow + return {"error": str(e)} -def run_insight_workflow(conn: Connection, ctx: AuditToolContext, args: dict[str, Any]) -> dict[str, Any]: +def _run_steps( + ctx: AuditToolContext, + plan: list[tuple[str, dict[str, Any]]], +) -> list[dict[str, Any]]: + """Dispatch workflow steps concurrently (bounded), preserving plan order. + + Each step opens its own pooled connection via ``_dispatch`` (psycopg connections + are not safe to share across threads), so independent read-only steps run in + parallel like Claude Code's parallel tool calls. + """ + from ...concurrency import map_parallel, tool_concurrency + + return map_parallel( + plan, + lambda step: {"tool": step[0], "result": _dispatch(step[0], ctx, step[1])}, + max_workers=tool_concurrency(), + ) + + +def run_insight_workflow(_conn: Connection, ctx: AuditToolContext, args: dict[str, Any]) -> dict[str, Any]: scoped = ctx.with_args(args) wf_type = str(args.get("type") or "priorities").strip().lower() base = {"property_id": scoped.property_id, "report_id": scoped.report_id} - steps: list[dict[str, Any]] = [] if wf_type in ("traffic", "health"): - r = _dispatch("get_traffic_health_check", conn, scoped, base) - steps.append({"tool": "get_traffic_health_check", "result": r}) + plan = [("get_traffic_health_check", base)] elif wf_type in ("landing_pages", "landing"): - r = _dispatch("get_landing_page_blended_table", conn, scoped, {**base, "limit": args.get("limit") or 30}) - steps.append({"tool": "get_landing_page_blended_table", "result": r}) - r2 = _dispatch("get_opportunity_matrix", conn, scoped, {**base, "limit": args.get("limit") or 30}) - steps.append({"tool": "get_opportunity_matrix", "result": r2}) + limit = args.get("limit") or 30 + plan = [ + ("get_landing_page_blended_table", {**base, "limit": limit}), + ("get_opportunity_matrix", {**base, "limit": limit}), + ] else: - r = _dispatch("get_opportunity_matrix", conn, scoped, {**base, "limit": args.get("limit") or 30}) - steps.append({"tool": "get_opportunity_matrix", "result": r}) - r2 = _dispatch("get_issue_to_traffic_map", conn, scoped, {**base, "limit": args.get("limit") or 20}) - steps.append({"tool": "get_issue_to_traffic_map", "result": r2}) + plan = [ + ("get_opportunity_matrix", {**base, "limit": args.get("limit") or 30}), + ("get_issue_to_traffic_map", {**base, "limit": args.get("limit") or 20}), + ] - return {"workflow": "insight", "type": wf_type, "steps": steps} + return {"workflow": "insight", "type": wf_type, "steps": _run_steps(scoped, plan)} -def run_technical_workflow(conn: Connection, ctx: AuditToolContext, args: dict[str, Any]) -> dict[str, Any]: +def run_technical_workflow(_conn: Connection, ctx: AuditToolContext, args: dict[str, Any]) -> dict[str, Any]: scoped = ctx.with_args(args) base = {"property_id": scoped.property_id, "report_id": scoped.report_id} - steps = [ - {"tool": "get_report_summary", "result": _dispatch("get_report_summary", conn, scoped, base)}, - {"tool": "get_critical_issues", "result": _dispatch("get_critical_issues", conn, scoped, base)}, - {"tool": "get_issue_priority_breakdown", "result": _dispatch("get_issue_priority_breakdown", conn, scoped, base)}, + plan = [ + ("get_report_summary", base), + ("get_critical_issues", base), + ("get_issue_priority_breakdown", base), ] baseline = args.get("baseline_report_id") if baseline is not None: - steps.append({ - "tool": "compare_issue_deltas", - "result": _dispatch("compare_issue_deltas", conn, scoped, { - **base, - "baseline_report_id": baseline, - }), - }) - return {"workflow": "technical", "steps": steps} + plan.append(("compare_issue_deltas", {**base, "baseline_report_id": baseline})) + return {"workflow": "technical", "steps": _run_steps(scoped, plan)} -def run_keyword_workflow(conn: Connection, ctx: AuditToolContext, args: dict[str, Any]) -> dict[str, Any]: +def run_keyword_workflow(_conn: Connection, ctx: AuditToolContext, args: dict[str, Any]) -> dict[str, Any]: scoped = ctx.with_args(args) if scoped.property_id is None: return {"error": "property_id is required"} base = {"property_id": scoped.property_id, "limit": args.get("limit") or 20} - steps = [] - for tool_name in ("get_brand_keyword_split", "get_striking_distance_keywords", "list_keywords_ctr_opportunity"): - steps.append({"tool": tool_name, "result": _dispatch(tool_name, conn, scoped, base)}) - return {"workflow": "keyword", "steps": steps} + plan = [ + (name, base) + for name in ("get_brand_keyword_split", "get_striking_distance_keywords", "list_keywords_ctr_opportunity") + ] + return {"workflow": "keyword", "steps": _run_steps(scoped, plan)} -def run_domain_agent(conn: Connection, ctx: AuditToolContext, args: dict[str, Any]) -> dict[str, Any]: - """Run a short scripted sequence of tools within one domain (subagent-style).""" +def run_domain_agent(_conn: Connection, ctx: AuditToolContext, args: dict[str, Any]) -> dict[str, Any]: + """Run a short scripted sequence of tools within one domain (subagent-style), in parallel.""" from .registry import search_tools, tool_names_for_domain, tool_meta scoped = ctx.with_args(args) @@ -135,9 +152,7 @@ def run_domain_agent(conn: Connection, ctx: AuditToolContext, args: dict[str, An picked = tool_names_for_domain(domain)[:max_steps] base = {"property_id": scoped.property_id, "report_id": scoped.report_id, "limit": 20} - steps = [] - for name in picked: - steps.append({"tool": name, "result": _dispatch(name, conn, scoped, base)}) + steps = _run_steps(scoped, [(name, base) for name in picked]) return { "task": task, diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..664a432 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,32 @@ +# Python tests layout + +Pytest discovers all `test_*.py` files under `tests/`. Shared helpers live at the package root (`conftest.py`, `db_test_fakes.py`, `fixtures/`). + +## Directory gates (mirror source packages) + +| Directory | Source package | Coverage config | CI / local gate | +|-----------|----------------|-----------------|-----------------| +| `tests/reporting/` | `website_profiling.reporting` | `.coveragerc.reporting` | `pytest tests/reporting/` | +| `tests/tools/` | `website_profiling.tools` | `.coveragerc.tools` | `pytest tests/tools/` | +| `tests/content_studio/` | `website_profiling.content_studio` | `.coveragerc` (core) | included in `pytest tests/` | + +Add new reporting or tools tests inside the matching directory — no need to edit file lists in `ci.yml` or `local-test.sh`. + +**Fixtures:** shared files live in `tests/fixtures/`. Subpackage tests should import via `from tests.conftest import FIXTURES` (not `Path(__file__).parent / "fixtures"`). + +## Content Studio + +``` +tests/content_studio/ + fakes.py # shared LLM client doubles + test_agent.py # tool-calling analyze loop + test_ai_suggest.py # rule/AI suggestions orchestration + test_score.py # GSC + on-page scoring + test_tools.py # deterministic analyze tools +``` + +## Core (everything else) + +Remaining `tests/test_*.py` files cover the core gate (100% on all packages except `reporting/`, `tools/`, and other omits in `.coveragerc`). + +Browser integration tests stay at the top level (`test_crawl_fetchers.py`, `test_crawler_browser_e2e.py`) and run via `@pytest.mark.browser`. diff --git a/tests/conftest.py b/tests/conftest.py index 372ecf8..2415fb0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,6 +14,7 @@ sys.path.insert(0, str(SRC)) FIXTURES = Path(__file__).resolve().parent / "fixtures" +TESTS_DIR = Path(__file__).resolve().parent class _FixtureHandler(SimpleHTTPRequestHandler): diff --git a/tests/content_studio/__init__.py b/tests/content_studio/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/content_studio/fakes.py b/tests/content_studio/fakes.py new file mode 100644 index 0000000..ad9e218 --- /dev/null +++ b/tests/content_studio/fakes.py @@ -0,0 +1,39 @@ +"""Shared test doubles for Content Studio agent tests.""" +from __future__ import annotations + +from website_profiling.content_studio.context import ContentStudioContext +from website_profiling.llm.base import ChatResult + + +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 + + +class ReactClient: + def __init__(self, steps: list[dict]) -> None: + self._steps = list(steps) + self._calls = 0 + + def complete_json(self, system, user): + payload = self._steps[min(self._calls, len(self._steps) - 1)] + self._calls += 1 + return payload + + +class OllamaClient(FakeToolClient): + """Stand-in for provider client; name must match _uses_ollama_tool_format check.""" + + +def sample_ctx() -> ContentStudioContext: + return ContentStudioContext( + property_id=None, + keyword="best crm", + body_html="

Best CRM

best crm overview

", + ) diff --git a/tests/content_studio/test_agent.py b/tests/content_studio/test_agent.py new file mode 100644 index 0000000..5a23caa --- /dev/null +++ b/tests/content_studio/test_agent.py @@ -0,0 +1,179 @@ +"""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"} + _inject_missing_tools(messages, ctx, called, ollama_format=False) + assert any(m.get("role") == "tool" for m in messages) + assert called == REQUIRED_CONTENT_STUDIO_TOOLS + messages_ollama: list[dict] = [] + called_ollama = {"get_draft_seo_score"} + _inject_missing_tools(messages_ollama, ctx, called_ollama, ollama_format=True) + 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 new file mode 100644 index 0000000..3ed1d71 --- /dev/null +++ b/tests/content_studio/test_ai_suggest.py @@ -0,0 +1,189 @@ +"""Tests for Content Studio analyze suggestions and AI orchestration.""" +from __future__ import annotations + +import re +from unittest.mock import patch + +from website_profiling.content_studio.ai_suggest import ( + _cfg_content_studio_ai, + _merge_suggestions, + _rule_suggestions, + analyze_content_draft, +) +from website_profiling.content_studio.tools import REQUIRED_CONTENT_STUDIO_TOOLS + + +def test_rule_suggestions_missing_terms_and_checks() -> None: + score = { + "terms": [ + {"term": "best crm", "status": "missing", "importance": "high"}, + {"term": "crm software", "status": "partial", "importance": "medium"}, + {"term": "sales pipeline", "status": "included", "importance": "medium"}, + ], + "checks": [ + {"id": "title", "pass": False, "hint": "Add a title tag between 30–60 characters."}, + {"id": "meta", "pass": True, "hint": "Meta description length is good."}, + ], + "word_count": 120, + } + items = _rule_suggestions(score) + texts = [i["text"] for i in items] + assert any("best crm" in t for t in texts) + assert any("crm software" in t for t in texts) + assert any("title tag" in t.lower() for t in texts) + assert all(i["source"] == "rule" for i in items) + + +def test_analyze_without_ai_runs_all_tools() -> None: + result = analyze_content_draft( + None, + "best crm", + "

Best CRM

Short draft.

", + title_tag="", + meta_description="", + use_ai=False, + ) + assert result["ok"] is True + assert result["ai_used"] is False + assert len(result["tools_used"]) == len(REQUIRED_CONTENT_STUDIO_TOOLS) + assert len(result["tool_events"]) == len(REQUIRED_CONTENT_STUDIO_TOOLS) + assert "Rule-based" in result["provenance"] + + +def test_rule_suggestions_skips_non_dict_terms() -> None: + score = { + "terms": ["bad", {"term": "crm", "status": "missing", "importance": "high"}], + "checks": [], + "word_count": 500, + } + items = _rule_suggestions(score) + assert len(items) == 1 + assert "crm" in items[0]["text"] + + +def test_merge_suggestions_dedupes_and_normalizes() -> None: + merged = _merge_suggestions( + [{"text": "Fix title", "priority": "high", "type": "seo", "source": "rule"}], + [ + {"text": " fix title ", "priority": "low", "type": "seo"}, + "not-a-dict", + {"text": "", "priority": "low"}, + {"text": "Add keyword", "priority": "medium", "type": "term"}, + ], + ) + assert len(merged) == 2 + normalized = {re.sub(r"\s+", " ", m["text"].strip().lower()) for m in merged} + assert normalized == {"fix title", "add keyword"} + + +def test_cfg_content_studio_ai_toggle() -> None: + assert _cfg_content_studio_ai({"llm_enable_content_studio": "true"}) is True + assert _cfg_content_studio_ai({"llm_enable_content_studio": "0"}) is False + + +def test_analyze_with_ai_disabled_in_settings() -> None: + cfg = {"llm_enabled": True, "llm_provider": "openai", "llm_enable_content_studio": "false"} + with patch("website_profiling.content_studio.ai_suggest.load_llm_config_from_db", return_value=cfg): + result = analyze_content_draft( + None, + "best crm", + "

Best CRM

best crm

", + use_ai=True, + ) + assert result["ai_used"] is False + assert result["ai_error"] == "AI insights disabled in settings." + + +def test_analyze_with_ai_llm_disabled() -> None: + with patch( + "website_profiling.content_studio.ai_suggest.load_llm_config_from_db", + return_value={"llm_enabled": False, "llm_provider": "none"}, + ): + result = analyze_content_draft(None, "best crm", "

best crm

", use_ai=True) + assert result["ai_used"] is False + assert "AI off" in result["provenance"] + + +def test_analyze_with_ai_uses_cache() -> None: + cached = { + "ai_block": { + "summary": "Cached summary", + "suggestions": [{"text": "Cached tip", "priority": "high", "type": "seo"}], + "outline": ["H2 idea"], + "title_ideas": ["Title A"], + }, + "tool_events": [{"name": "get_draft_seo_score", "args": {}, "result": {}}], + } + cfg = {"llm_enabled": True, "llm_provider": "openai", "llm_api_key": "sk-test"} + 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: + result = analyze_content_draft(None, "best crm", "

best crm

", use_ai=True) + mock_agent.assert_not_called() + assert result["ai_used"] is True + assert result["summary"] == "Cached summary" + assert result["outline"] == ["H2 idea"] + assert result["title_ideas"] == ["Title A"] + assert any(s["source"] == "ai" for s in result["suggestions"]) + + +def test_analyze_with_ai_agent_success() -> None: + cfg = {"llm_enabled": True, "llm_provider": "openai", "llm_api_key": "sk-test"} + agent_payload = { + "ok": True, + "ai_block": { + "summary": "Agent summary", + "suggestions": [{"text": "Agent tip", "priority": "medium", "type": "structure"}], + "outline": ["Section"], + "title_ideas": ["Better title"], + }, + "tool_events": [{"name": "get_draft_seo_score", "args": {}, "result": {"grade_score": 50}}], + } + 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.run_content_studio_analyze", + return_value=agent_payload, + ), patch("website_profiling.content_studio.ai_suggest._write_cache") as mock_write: + result = analyze_content_draft( + None, + "best crm", + "

Best CRM

short

", + title_tag="", + use_ai=True, + refresh=True, + ) + mock_write.assert_called_once() + assert result["ai_used"] is True + assert result["summary"] == "Agent summary" + assert result["tools_used"] == ["get_draft_seo_score"] + + +def test_analyze_with_ai_agent_failure() -> None: + cfg = {"llm_enabled": True, "llm_provider": "openai", "llm_api_key": "sk-test"} + 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.run_content_studio_analyze", + return_value={"ok": False, "error": "model timeout", "tool_events": []}, + ): + result = analyze_content_draft(None, "best crm", "

best crm

", use_ai=True, refresh=True) + assert result["ai_used"] is False + assert result["ai_error"] == "model timeout" + assert "AI failed" in result["provenance"] + + +def test_analyze_with_ai_empty_block() -> None: + cfg = {"llm_enabled": True, "llm_provider": "openai", "llm_api_key": "sk-test"} + 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.run_content_studio_analyze", + return_value={"ok": True, "ai_block": {}, "tool_events": []}, + ): + result = analyze_content_draft(None, "best crm", "

best crm

", use_ai=True, refresh=True) + assert result["ai_error"] == "No structured output from analyze agent." diff --git a/tests/content_studio/test_score.py b/tests/content_studio/test_score.py new file mode 100644 index 0000000..a879f23 --- /dev/null +++ b/tests/content_studio/test_score.py @@ -0,0 +1,193 @@ +from __future__ import annotations + +from website_profiling.content_studio.score import score_content_draft + + +def test_term_in_corpus_included() -> None: + from website_profiling.content_studio.score import _term_in_corpus + + assert _term_in_corpus("crm software", "our crm software guide") == "included" + + +def test_term_in_corpus_partial() -> None: + from website_profiling.content_studio.score import _term_in_corpus + + assert _term_in_corpus("sales pipeline", "building a pipeline for sales") == "partial" + + +def test_term_in_corpus_missing() -> None: + from website_profiling.content_studio.score import _term_in_corpus + + assert _term_in_corpus("enterprise crm", "small business tips") == "missing" + + +def test_score_empty_body_low_grade() -> None: + result = score_content_draft( + None, + "best crm", + "", + title_tag="", + meta_description="", + keyword_rows=[ + {"keyword": "best crm", "gsc_impressions": 500, "gsc_url": "https://ex.com/crm"}, + {"keyword": "crm software", "gsc_impressions": 200, "gsc_url": "https://ex.com/crm"}, + ], + ) + assert 0 <= result["grade_score"] <= 100 + assert result["word_count"] == 0 + assert result["grade_label"] in ("A", "B", "C", "D", "F") + assert result["provenance"] == "Search Console + on-site heuristics" + assert any(t["term"] == "best crm" for t in result["terms"]) + + +def test_score_rich_content_higher() -> None: + body = """ +

Best CRM for Startups

+

Choosing the best crm and crm software for your sales pipeline matters. + This guide covers crm software options for startups.

+ """ + sparse = score_content_draft( + None, + "best crm", + "

hi

", + title_tag="Best CRM Guide", + meta_description="A" * 130, + keyword_rows=[{"keyword": "best crm", "gsc_impressions": 1000}], + ) + rich = score_content_draft( + None, + "best crm", + body, + title_tag="Best CRM for Startups — Complete Guide", + meta_description="A" * 130, + keyword_rows=[ + {"keyword": "best crm", "gsc_impressions": 1000}, + {"keyword": "crm software", "gsc_impressions": 500}, + {"keyword": "sales pipeline", "gsc_impressions": 300}, + ], + ) + assert rich["grade_score"] >= sparse["grade_score"] + assert rich["checks"][2]["id"] == "h1_single" + assert rich["checks"][2]["pass"] is True + + +def test_meta_title_checks() -> None: + from website_profiling.content_studio.score import _meta_check, _title_check + + assert _title_check("")["pass"] is False + assert _title_check("x" * 55)["pass"] is True + assert _meta_check("x" * 140)["pass"] is True + assert _meta_check("short")["pass"] is False + + +def test_grade_label_bounds() -> None: + from website_profiling.content_studio.score import _grade_label + + assert _grade_label(95) == "A" + assert _grade_label(85) == "B" + assert _grade_label(75) == "C" + assert _grade_label(65) == "D" + assert _grade_label(40) == "F" + + +def test_term_in_corpus_empty_term() -> None: + from website_profiling.content_studio.score import _term_in_corpus + + assert _term_in_corpus("", "some corpus") == "missing" + assert _term_in_corpus(" ", "some corpus") == "missing" + + +def test_title_and_meta_length_edges() -> None: + from website_profiling.content_studio.score import _meta_check, _title_check + + long_title = _title_check("x" * 75) + assert long_title["pass"] is False + assert "long" in long_title["hint"].lower() + + long_meta = _meta_check("x" * 180) + assert long_meta["pass"] is False + assert "long" in long_meta["hint"].lower() + + +def test_h1_multiple_fails() -> None: + from website_profiling.content_studio.score import _h1_check + + result = _h1_check("

A

B

") + assert result["pass"] is False + assert "2" in result["hint"] + + +def test_word_count_check_edges() -> None: + from website_profiling.content_studio.score import _word_count_band_score, _word_count_check + + thin = _word_count_check(100) + assert thin["pass"] is False + long_body = _word_count_check(3000) + assert long_body["pass"] is False + ok = _word_count_check(800) + assert ok["pass"] is True + assert _word_count_band_score(800) == 1.0 + assert _word_count_band_score(3000) < 1.0 + + +def test_collect_gsc_terms_skips_invalid_rows() -> None: + from website_profiling.content_studio.score import _collect_gsc_terms + + terms = _collect_gsc_terms( + "best crm", + "https://ex.com/crm", + [ + "not-a-dict", + {"keyword": "", "gsc_impressions": 50}, + {"keyword": "best crm software", "gsc_impressions": 200, "gsc_url": "https://ex.com/crm"}, + ], + ) + assert any(t["term"] == "best crm" for t in terms) + assert any(t["term"] == "best crm software" for t in terms) + + +def test_term_coverage_score_empty_and_partial() -> None: + from website_profiling.content_studio.score import _checks_pass_rate, _term_coverage_score + + assert _term_coverage_score([]) == 0.5 + assert _checks_pass_rate([]) == 0.0 + partial = _term_coverage_score([ + {"importance": "high", "status": "partial"}, + {"importance": "medium", "status": "missing"}, + ]) + assert 0.0 < partial < 1.0 + + +def test_score_loads_keyword_rows_from_db() -> None: + from unittest.mock import MagicMock, patch + + rows = [{"keyword": "best crm", "gsc_impressions": 500, "gsc_url": "https://ex.com/crm"}] + conn = MagicMock() + with patch("website_profiling.content_studio.score.db_session") as mock_sess: + mock_sess.return_value.__enter__.return_value = conn + with patch( + "website_profiling.content_studio.score.read_latest_keyword_data", + return_value={"rows": rows}, + ): + result = score_content_draft( + 1, + "best crm", + "

Best CRM

best crm overview

", + title_tag="Best CRM Guide", + meta_description="x" * 130, + ) + assert any(t["term"] == "best crm" for t in result["terms"]) + + +def test_score_db_returns_non_list_rows() -> None: + from unittest.mock import MagicMock, patch + + conn = MagicMock() + with patch("website_profiling.content_studio.score.db_session") as mock_sess: + mock_sess.return_value.__enter__.return_value = conn + with patch( + "website_profiling.content_studio.score.read_latest_keyword_data", + return_value={"rows": "bad"}, + ): + result = score_content_draft(1, "best crm", "

best crm

") + assert result["terms"] diff --git a/tests/content_studio/test_tools.py b/tests/content_studio/test_tools.py new file mode 100644 index 0000000..54c9b8e --- /dev/null +++ b/tests/content_studio/test_tools.py @@ -0,0 +1,105 @@ +"""Tests for Content Studio deterministic analyze tools.""" +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +from website_profiling.content_studio.context import ContentStudioContext +from website_profiling.content_studio.tools import ( + REQUIRED_CONTENT_STUDIO_TOOLS, + dispatch_content_studio_tool, + run_all_content_studio_tools, + tool_get_keyword_gsc_context, + tool_get_term_coverage, +) + + +def test_content_studio_tools_return_structured_data() -> None: + ctx = ContentStudioContext( + property_id=None, + keyword="best crm", + body_html="

Best CRM Guide

Our best crm overview.

", + title_tag="Best CRM Guide", + meta_description="x" * 130, + title="Draft title", + ) + score = dispatch_content_studio_tool("get_draft_seo_score", ctx) + terms = dispatch_content_studio_tool("get_term_coverage", ctx) + structure = dispatch_content_studio_tool("get_draft_structure", ctx) + + assert "grade_score" in score + assert terms["target_keyword"] == "best crm" + assert structure["headings"][0]["level"] == "h1" + assert len(run_all_content_studio_tools(ctx)) == len(REQUIRED_CONTENT_STUDIO_TOOLS) + + +def test_content_studio_tools_edge_cases() -> None: + empty_kw = tool_get_keyword_gsc_context( + ContentStudioContext(property_id=None, keyword="", body_html="

x

") + ) + assert empty_kw["queries"] == [] + + unknown = dispatch_content_studio_tool("missing_tool", ContentStudioContext( + property_id=None, keyword="x", body_html="

x

", + )) + assert "unknown tool" in unknown["error"] + + with patch( + "website_profiling.content_studio.tools.score_content_draft", + side_effect=RuntimeError("score failed"), + ): + err = dispatch_content_studio_tool( + "get_draft_seo_score", + ContentStudioContext(property_id=None, keyword="x", body_html="

x

"), + ) + assert err["error"] == "score failed" + + many_headings = "".join(f"

Section {i}

" for i in range(20)) + structure = dispatch_content_studio_tool( + "get_draft_structure", + ContentStudioContext(property_id=None, keyword="x", body_html=many_headings), + ) + assert len(structure["headings"]) <= 12 + + with patch("website_profiling.content_studio.tools.score_content_draft") as mock_score: + mock_score.return_value = { + "terms": ["bad", {"term": "crm", "status": "missing"}], + "checks": [], + } + terms = tool_get_term_coverage( + ContentStudioContext(property_id=None, keyword="crm", body_html="

x

"), + ) + assert "crm" in terms["missing"] + + rows = [ + {"keyword": "best crm", "gsc_impressions": 100, "gsc_url": "https://ex.com/crm"}, + {"keyword": "", "gsc_impressions": 10, "gsc_url": "https://ex.com/crm"}, + {"keyword": "unrelated", "gsc_impressions": 50, "gsc_url": "https://ex.com/other"}, + ] + conn = MagicMock() + with patch("website_profiling.content_studio.tools.db_session") as mock_sess: + mock_sess.return_value.__enter__.return_value = conn + with patch( + "website_profiling.content_studio.tools.read_latest_keyword_data", + return_value={"rows": rows}, + ): + gsc = tool_get_keyword_gsc_context(ContentStudioContext( + property_id=1, + keyword="best crm", + body_html="

x

", + landing_url="https://ex.com/crm", + )) + assert gsc["total_related"] == 1 + assert gsc["queries"][0]["keyword"] == "best crm" + + with patch("website_profiling.content_studio.tools.db_session") as mock_sess: + mock_sess.return_value.__enter__.side_effect = RuntimeError("db down") + gsc_err = tool_get_keyword_gsc_context(ContentStudioContext( + property_id=1, keyword="best crm", body_html="

x

", + )) + assert gsc_err["queries"] == [] + + empty_structure = dispatch_content_studio_tool( + "get_draft_structure", + ContentStudioContext(property_id=None, keyword="x", body_html=""), + ) + assert empty_structure["headings"] == [] diff --git a/tests/reporting/__init__.py b/tests/reporting/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_builder_image_buckets.py b/tests/reporting/test_builder_image_buckets.py similarity index 100% rename from tests/test_builder_image_buckets.py rename to tests/reporting/test_builder_image_buckets.py diff --git a/tests/test_categories_coverage.py b/tests/reporting/test_categories_coverage.py similarity index 100% rename from tests/test_categories_coverage.py rename to tests/reporting/test_categories_coverage.py diff --git a/tests/test_categories_roadmap.py b/tests/reporting/test_categories_roadmap.py similarity index 100% rename from tests/test_categories_roadmap.py rename to tests/reporting/test_categories_roadmap.py diff --git a/tests/test_compare_payload.py b/tests/reporting/test_compare_payload.py similarity index 100% rename from tests/test_compare_payload.py rename to tests/reporting/test_compare_payload.py diff --git a/tests/test_contrast_issues.py b/tests/reporting/test_contrast_issues.py similarity index 100% rename from tests/test_contrast_issues.py rename to tests/reporting/test_contrast_issues.py diff --git a/tests/test_crawl_segments.py b/tests/reporting/test_crawl_segments.py similarity index 100% rename from tests/test_crawl_segments.py rename to tests/reporting/test_crawl_segments.py diff --git a/tests/test_indexation_coverage.py b/tests/reporting/test_indexation_coverage.py similarity index 100% rename from tests/test_indexation_coverage.py rename to tests/reporting/test_indexation_coverage.py diff --git a/tests/test_optional_audits.py b/tests/reporting/test_optional_audits.py similarity index 100% rename from tests/test_optional_audits.py rename to tests/reporting/test_optional_audits.py diff --git a/tests/test_pipeline_report_pool_unit.py b/tests/reporting/test_pipeline_report_pool_unit.py similarity index 100% rename from tests/test_pipeline_report_pool_unit.py rename to tests/reporting/test_pipeline_report_pool_unit.py diff --git a/tests/test_property_profile.py b/tests/reporting/test_property_profile.py similarity index 100% rename from tests/test_property_profile.py rename to tests/reporting/test_property_profile.py diff --git a/tests/test_report_categories_golden.py b/tests/reporting/test_report_categories_golden.py similarity index 92% rename from tests/test_report_categories_golden.py rename to tests/reporting/test_report_categories_golden.py index c5347f0..d7e5a79 100644 --- a/tests/test_report_categories_golden.py +++ b/tests/reporting/test_report_categories_golden.py @@ -2,13 +2,14 @@ from __future__ import annotations import json -from pathlib import Path import pandas as pd from website_profiling.reporting.categories import build_categories, merge_indexation_issues -FIXTURES = Path(__file__).resolve().parent / "fixtures" / "report" +from tests.conftest import FIXTURES + +REPORT_FIXTURES = FIXTURES / "report" def _issue_fingerprints(categories: list[dict]) -> set[tuple[str, str, str]]: @@ -23,7 +24,7 @@ def _issue_fingerprints(categories: list[dict]) -> set[tuple[str, str, str]]: def test_build_categories_golden_fingerprints() -> None: - rows = json.loads((FIXTURES / "minimal_crawl.json").read_text(encoding="utf-8")) + rows = json.loads((REPORT_FIXTURES / "minimal_crawl.json").read_text(encoding="utf-8")) df = pd.DataFrame(rows) edges = [ ("https://example.com/", "https://example.com/thin"), @@ -69,7 +70,7 @@ def test_build_categories_golden_fingerprints() -> None: def test_build_categories_missing_site_files_low_issues() -> None: - rows = json.loads((FIXTURES / "minimal_crawl.json").read_text(encoding="utf-8")) + rows = json.loads((REPORT_FIXTURES / "minimal_crawl.json").read_text(encoding="utf-8")) df = pd.DataFrame(rows) site_level = { "robots_present": True, diff --git a/tests/test_reporting_builder_modules.py b/tests/reporting/test_reporting_builder_modules.py similarity index 100% rename from tests/test_reporting_builder_modules.py rename to tests/reporting/test_reporting_builder_modules.py diff --git a/tests/test_reporting_gaps.py b/tests/reporting/test_reporting_gaps.py similarity index 100% rename from tests/test_reporting_gaps.py rename to tests/reporting/test_reporting_gaps.py diff --git a/tests/test_terminology.py b/tests/reporting/test_terminology.py similarity index 100% rename from tests/test_terminology.py rename to tests/reporting/test_terminology.py diff --git a/tests/test_text_content_analysis.py b/tests/reporting/test_text_content_analysis.py similarity index 100% rename from tests/test_text_content_analysis.py rename to tests/reporting/test_text_content_analysis.py diff --git a/tests/test_chat_agent.py b/tests/test_chat_agent.py index a15c15e..908e2a2 100644 --- a/tests/test_chat_agent.py +++ b/tests/test_chat_agent.py @@ -52,6 +52,82 @@ def test_agent_tool_then_answer() -> None: assert "done" 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="Here is your overview."), + ]) + 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.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 + # every tool was dispatched... + assert sorted(dispatched) == [ + "get_critical_issues", "get_issue_priority_breakdown", "get_report_summary", + ] + # ...and results were applied back in request order + assert [e["name"] for e in result["tool_events"]] == [ + "get_report_summary", "get_critical_issues", "get_issue_priority_breakdown", + ] + starts = [e["name"] for e in events if e["type"] == "tool_start"] + ends = [e["name"] for e in events if e["type"] == "tool_end"] + assert starts == [ + "get_report_summary", "get_critical_issues", "get_issue_priority_breakdown", + ] + assert sorted(ends) == sorted(starts) + + +def test_agent_isolates_tool_exception() -> None: + """A handler raising mid-turn becomes an error result instead of crashing the turn.""" + client = FakeToolClient([ + ChatResult(tool_calls=[ToolCall(id="x", name="list_issues", arguments={})]), + ChatResult(content="Recovered."), + ]) + 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.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={ diff --git a/tests/test_concurrency_coverage.py b/tests/test_concurrency_coverage.py new file mode 100644 index 0000000..a6bb16a --- /dev/null +++ b/tests/test_concurrency_coverage.py @@ -0,0 +1,45 @@ +"""Coverage for the bounded parallel execution helpers.""" +from __future__ import annotations + +from website_profiling.concurrency import ( + DEFAULT_TOOL_CONCURRENCY, + map_parallel, + tool_concurrency, +) + + +def test_tool_concurrency_default_when_unset(monkeypatch) -> None: + monkeypatch.delenv("WP_TOOL_CONCURRENCY", raising=False) + assert tool_concurrency() == DEFAULT_TOOL_CONCURRENCY + assert tool_concurrency(3) == 3 + + +def test_tool_concurrency_reads_env(monkeypatch) -> None: + monkeypatch.setenv("WP_TOOL_CONCURRENCY", "4") + assert tool_concurrency() == 4 + + +def test_tool_concurrency_floor_and_bad_value(monkeypatch) -> None: + monkeypatch.setenv("WP_TOOL_CONCURRENCY", "0") + assert tool_concurrency() == 1 + monkeypatch.setenv("WP_TOOL_CONCURRENCY", "-5") + assert tool_concurrency() == 1 + monkeypatch.setenv("WP_TOOL_CONCURRENCY", "not-an-int") + assert tool_concurrency(7) == 7 + + +def test_map_parallel_empty() -> None: + assert map_parallel([], lambda x: x, max_workers=4) == [] + + +def test_map_parallel_single_and_sequential() -> None: + # single item → sequential branch + assert map_parallel([5], lambda x: x * 2, max_workers=4) == [10] + # max_workers == 1 → sequential branch even with many items + assert map_parallel([1, 2, 3], lambda x: x + 1, max_workers=1) == [2, 3, 4] + + +def test_map_parallel_preserves_order_when_parallel() -> None: + items = list(range(10)) + result = map_parallel(items, lambda x: x * x, max_workers=4) + assert result == [x * x for x in items] diff --git a/tests/test_router_tools.py b/tests/test_router_tools.py deleted file mode 100644 index 61c4187..0000000 --- a/tests/test_router_tools.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Tests for run_domain_agent fallback behavior.""" -from __future__ import annotations - -from unittest.mock import MagicMock, patch - -from website_profiling.tools.audit_tools import AuditToolContext -from website_profiling.tools.audit_tools.router_tools import run_domain_agent - - -def test_run_domain_agent_falls_back_to_global_search() -> None: - conn = MagicMock() - ctx = AuditToolContext(property_id=1) - fake_matches = [ - {"name": "list_broken_links", "description": "", "domain": "links", "tier": 1}, - {"name": "get_schema_coverage", "description": "", "domain": "schema", "tier": 1}, - ] - with patch( - "website_profiling.tools.audit_tools.registry.search_tools", - return_value=fake_matches, - ): - with patch( - "website_profiling.tools.audit_tools.registry.tool_names_for_domain", - return_value=["get_unrelated_tool"], - ): - result = run_domain_agent(conn, ctx, { - "task": "broken links audit", - "domain": "schema", - "max_steps": 2, - }) - - assert result["tools_used"] == ["list_broken_links", "get_schema_coverage"] diff --git a/tests/tools/__init__.py b/tests/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_alert_checker.py b/tests/tools/test_alert_checker.py similarity index 62% rename from tests/test_alert_checker.py rename to tests/tools/test_alert_checker.py index a262eaf..b237691 100644 --- a/tests/test_alert_checker.py +++ b/tests/tools/test_alert_checker.py @@ -10,7 +10,9 @@ check_all_alerts, check_gsc_links_stale_alerts, check_health_alerts, + dispatch_email, dispatch_webhook, + smtp_configured, ) @@ -105,6 +107,76 @@ def test_dispatch_webhook_empty_url() -> None: assert dispatch_webhook(" ", {"alerts": []}) is False +def test_smtp_configured_requires_host_and_from(monkeypatch) -> None: + monkeypatch.delenv("SMTP_HOST", raising=False) + monkeypatch.delenv("SMTP_FROM", raising=False) + assert smtp_configured() is False + monkeypatch.setenv("SMTP_HOST", "smtp.example.com") + monkeypatch.setenv("SMTP_FROM", "alerts@example.com") + assert smtp_configured() is True + + +def test_dispatch_email_empty_recipient() -> None: + assert dispatch_email(" ", {"alerts": []}) is False + + +def test_dispatch_email_skips_when_smtp_not_configured(monkeypatch) -> None: + monkeypatch.delenv("SMTP_HOST", raising=False) + monkeypatch.delenv("SMTP_FROM", raising=False) + assert dispatch_email("ops@example.com", {"alerts": [{"message": "x"}]}) is False + + +@patch("website_profiling.tools.alert_checker.smtplib.SMTP") +def test_dispatch_email_success(mock_smtp_cls, monkeypatch) -> None: + monkeypatch.setenv("SMTP_HOST", "smtp.example.com") + monkeypatch.setenv("SMTP_FROM", "alerts@example.com") + monkeypatch.setenv("SMTP_PORT", "587") + monkeypatch.setenv("SMTP_USE_TLS", "true") + smtp = MagicMock() + mock_smtp_cls.return_value.__enter__.return_value = smtp + payload = {"property_id": 1, "alerts": [{"severity": "high", "message": "Health drop"}]} + assert dispatch_email("ops@example.com", payload) is True + smtp.starttls.assert_called_once() + smtp.send_message.assert_called_once() + + +@patch("website_profiling.tools.alert_checker.smtplib.SMTP") +def test_dispatch_email_with_auth(mock_smtp_cls, monkeypatch) -> None: + monkeypatch.setenv("SMTP_HOST", "smtp.example.com") + monkeypatch.setenv("SMTP_FROM", "alerts@example.com") + monkeypatch.setenv("SMTP_USER", "alerts@example.com") + monkeypatch.setenv("SMTP_PASS", "secret") + monkeypatch.setenv("SMTP_USE_TLS", "false") + smtp = MagicMock() + mock_smtp_cls.return_value.__enter__.return_value = smtp + assert dispatch_email("ops@example.com", {"alerts": [{"message": "x"}]}) is True + smtp.login.assert_called_once_with("alerts@example.com", "secret") + smtp.starttls.assert_not_called() + + +def test_format_alert_email_body_empty_alerts() -> None: + from website_profiling.tools.alert_checker import _format_alert_email_body + + body = _format_alert_email_body({"property_id": 5, "alerts": []}) + assert "No alerts." in body + assert "Property ID: 5" in body + + +def test_format_alert_email_body_skips_non_dict_alerts() -> None: + from website_profiling.tools.alert_checker import _format_alert_email_body + + body = _format_alert_email_body({"alerts": ["bad", {"severity": "low", "message": "ok"}]}) + assert "2. [low] ok" in body + assert "bad" not in body + + +@patch("website_profiling.tools.alert_checker.smtplib.SMTP", side_effect=OSError("smtp down")) +def test_dispatch_email_failure(_mock_smtp, monkeypatch) -> None: + monkeypatch.setenv("SMTP_HOST", "smtp.example.com") + monkeypatch.setenv("SMTP_FROM", "alerts@example.com") + assert dispatch_email("ops@example.com", {"alerts": [{"message": "x"}]}) is False + + @pytest.fixture def property_id(): if not (os.environ.get("DATABASE_URL") or "").strip(): diff --git a/tests/test_audit_tools.py b/tests/tools/test_audit_tools.py similarity index 100% rename from tests/test_audit_tools.py rename to tests/tools/test_audit_tools.py diff --git a/tests/test_audit_tools_batch100_coverage.py b/tests/tools/test_audit_tools_batch100_coverage.py similarity index 100% rename from tests/test_audit_tools_batch100_coverage.py rename to tests/tools/test_audit_tools_batch100_coverage.py diff --git a/tests/test_audit_tools_coverage.py b/tests/tools/test_audit_tools_coverage.py similarity index 100% rename from tests/test_audit_tools_coverage.py rename to tests/tools/test_audit_tools_coverage.py diff --git a/tests/test_audit_tools_dispatch_coverage.py b/tests/tools/test_audit_tools_dispatch_coverage.py similarity index 100% rename from tests/test_audit_tools_dispatch_coverage.py rename to tests/tools/test_audit_tools_dispatch_coverage.py diff --git a/tests/test_audit_tools_expanded.py b/tests/tools/test_audit_tools_expanded.py similarity index 100% rename from tests/test_audit_tools_expanded.py rename to tests/tools/test_audit_tools_expanded.py diff --git a/tests/test_audit_tools_expansion.py b/tests/tools/test_audit_tools_expansion.py similarity index 100% rename from tests/test_audit_tools_expansion.py rename to tests/tools/test_audit_tools_expansion.py diff --git a/tests/test_audit_tools_expansion_coverage.py b/tests/tools/test_audit_tools_expansion_coverage.py similarity index 100% rename from tests/test_audit_tools_expansion_coverage.py rename to tests/tools/test_audit_tools_expansion_coverage.py diff --git a/tests/test_audit_tools_links_extras.py b/tests/tools/test_audit_tools_links_extras.py similarity index 100% rename from tests/test_audit_tools_links_extras.py rename to tests/tools/test_audit_tools_links_extras.py diff --git a/tests/test_export_artifacts.py b/tests/tools/test_export_artifacts.py similarity index 100% rename from tests/test_export_artifacts.py rename to tests/tools/test_export_artifacts.py diff --git a/tests/test_export_artifacts_coverage.py b/tests/tools/test_export_artifacts_coverage.py similarity index 100% rename from tests/test_export_artifacts_coverage.py rename to tests/tools/test_export_artifacts_coverage.py diff --git a/tests/test_export_audit.py b/tests/tools/test_export_audit.py similarity index 100% rename from tests/test_export_audit.py rename to tests/tools/test_export_audit.py diff --git a/tests/test_export_audit_coverage.py b/tests/tools/test_export_audit_coverage.py similarity index 100% rename from tests/test_export_audit_coverage.py rename to tests/tools/test_export_audit_coverage.py diff --git a/tests/test_export_compare.py b/tests/tools/test_export_compare.py similarity index 100% rename from tests/test_export_compare.py rename to tests/tools/test_export_compare.py diff --git a/tests/test_export_compare_coverage.py b/tests/tools/test_export_compare_coverage.py similarity index 100% rename from tests/test_export_compare_coverage.py rename to tests/tools/test_export_compare_coverage.py diff --git a/tests/test_export_custom.py b/tests/tools/test_export_custom.py similarity index 100% rename from tests/test_export_custom.py rename to tests/tools/test_export_custom.py diff --git a/tests/test_export_custom_coverage.py b/tests/tools/test_export_custom_coverage.py similarity index 100% rename from tests/test_export_custom_coverage.py rename to tests/tools/test_export_custom_coverage.py diff --git a/tests/test_export_sitemap.py b/tests/tools/test_export_sitemap.py similarity index 100% rename from tests/test_export_sitemap.py rename to tests/tools/test_export_sitemap.py diff --git a/tests/test_export_tools_coverage.py b/tests/tools/test_export_tools_coverage.py similarity index 100% rename from tests/test_export_tools_coverage.py rename to tests/tools/test_export_tools_coverage.py diff --git a/tests/test_export_workbook.py b/tests/tools/test_export_workbook.py similarity index 100% rename from tests/test_export_workbook.py rename to tests/tools/test_export_workbook.py diff --git a/tests/test_image_tools.py b/tests/tools/test_image_tools.py similarity index 100% rename from tests/test_image_tools.py rename to tests/tools/test_image_tools.py diff --git a/tests/test_mcp_registry.py b/tests/tools/test_mcp_registry.py similarity index 100% rename from tests/test_mcp_registry.py rename to tests/tools/test_mcp_registry.py diff --git a/tests/test_mcp_resources.py b/tests/tools/test_mcp_resources.py similarity index 68% rename from tests/test_mcp_resources.py rename to tests/tools/test_mcp_resources.py index 55abd6f..3fbebaf 100644 --- a/tests/test_mcp_resources.py +++ b/tests/tools/test_mcp_resources.py @@ -3,17 +3,24 @@ from unittest.mock import patch -from website_profiling.mcp import server as mcp_server + +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: - with patch("website_profiling.mcp.server.dispatch_tool", return_value={"count": 0, "properties": []}): + 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: - with patch("website_profiling.mcp.server.db_session") as mock_db, patch.object( + 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() @@ -22,6 +29,7 @@ def test_resolve_report_latest_missing_payload() -> None: 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") @@ -29,14 +37,15 @@ def test_resolve_glossary_and_tools() -> None: def test_resolve_property_and_report() -> None: - with patch("website_profiling.mcp.server.dispatch_tool", side_effect=[ + 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("website_profiling.mcp.server.db_session") as mock_db, patch.object( + 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() diff --git a/tests/tools/test_router_tools.py b/tests/tools/test_router_tools.py new file mode 100644 index 0000000..08129cf --- /dev/null +++ b/tests/tools/test_router_tools.py @@ -0,0 +1,59 @@ +"""Tests for run_domain_agent fallback behavior and parallel step dispatch.""" +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +from website_profiling.tools.audit_tools import AuditToolContext +from website_profiling.tools.audit_tools import router_tools as router_mod +from website_profiling.tools.audit_tools.router_tools import run_domain_agent + + +def test_run_domain_agent_falls_back_to_global_search() -> None: + conn = MagicMock() + ctx = AuditToolContext(property_id=1) + fake_matches = [ + {"name": "list_broken_links", "description": "", "domain": "links", "tier": 1}, + {"name": "get_schema_coverage", "description": "", "domain": "schema", "tier": 1}, + ] + with patch( + "website_profiling.tools.audit_tools.registry.search_tools", + return_value=fake_matches, + ), patch( + "website_profiling.tools.audit_tools.registry.tool_names_for_domain", + return_value=["get_unrelated_tool"], + ), patch.object(router_mod, "_dispatch", return_value={"ok": True}): + result = run_domain_agent(conn, ctx, { + "task": "broken links audit", + "domain": "schema", + "max_steps": 2, + }) + + assert result["tools_used"] == ["list_broken_links", "get_schema_coverage"] + # steps are returned in plan order even though they run concurrently + assert [s["tool"] for s in result["steps"]] == ["list_broken_links", "get_schema_coverage"] + + +def test_dispatch_runs_each_step_on_its_own_connection() -> None: + ctx = AuditToolContext(property_id=1) + with patch( + "website_profiling.tools.audit_tools.registry.dispatch_tool", + return_value={"ok": 1}, + ) as dispatched: + out = router_mod._dispatch("get_report_summary", ctx, {"limit": 5}) + + assert out == {"ok": 1} + # No explicit conn is threaded through → each step checks out its own pooled session. + _, kwargs = dispatched.call_args + assert "conn" not in kwargs + assert kwargs.get("context") is ctx + + +def test_dispatch_isolates_step_errors() -> None: + ctx = AuditToolContext(property_id=1) + with patch( + "website_profiling.tools.audit_tools.registry.dispatch_tool", + side_effect=RuntimeError("boom"), + ): + out = router_mod._dispatch("get_report_summary", ctx, {}) + + assert out == {"error": "boom"} diff --git a/tests/test_schedule_runner.py b/tests/tools/test_schedule_runner.py similarity index 100% rename from tests/test_schedule_runner.py rename to tests/tools/test_schedule_runner.py diff --git a/tests/test_tool_selector.py b/tests/tools/test_tool_selector.py similarity index 100% rename from tests/test_tool_selector.py rename to tests/tools/test_tool_selector.py diff --git a/tests/test_tools_branch_coverage.py b/tests/tools/test_tools_branch_coverage.py similarity index 100% rename from tests/test_tools_branch_coverage.py rename to tests/tools/test_tools_branch_coverage.py diff --git a/tests/test_tools_gate100_coverage.py b/tests/tools/test_tools_gate100_coverage.py similarity index 100% rename from tests/test_tools_gate100_coverage.py rename to tests/tools/test_tools_gate100_coverage.py diff --git a/web/app/api/alerts/check/route.ts b/web/app/api/alerts/check/route.ts index 0d3d86e..8a7428e 100644 --- a/web/app/api/alerts/check/route.ts +++ b/web/app/api/alerts/check/route.ts @@ -25,22 +25,28 @@ export const POST: ApiRouteHandler = async (request: NextRequest): Promise((resolve) => { diff --git a/web/app/api/content-drafts/[id]/route.ts b/web/app/api/content-drafts/[id]/route.ts new file mode 100644 index 0000000..8e20da2 --- /dev/null +++ b/web/app/api/content-drafts/[id]/route.ts @@ -0,0 +1,100 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { forbiddenIfNotLocal } from '@/server/localOnly'; +import { requireApiAuth } from '@/server/auth'; +import { + deleteContentDraft, + getContentDraft, + updateContentDraft, + type UpdateContentDraftInput, +} from '@/server/contentDraftDb'; +import type { ApiRouteHandler } from '@/types/api'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +/** GET /api/content-drafts/[id] */ +export const GET: ApiRouteHandler = async ( + request: NextRequest, + context?: { params?: Promise<{ id: string }> }, +): Promise => { + const params = context?.params ? await context.params : { id: '' }; + const draftId = Number(params.id || '0'); + if (!draftId) { + return NextResponse.json({ error: 'invalid draft id' }, { status: 400 }); + } + + try { + const draft = await getContentDraft(draftId); + if (!draft) { + return NextResponse.json({ error: 'draft not found' }, { status: 404 }); + } + return NextResponse.json({ draft }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + return NextResponse.json({ error: msg }, { status: 500 }); + } +}; + +/** PATCH /api/content-drafts/[id] */ +export const PATCH: ApiRouteHandler = async ( + request: NextRequest, + context?: { params?: Promise<{ id: string }> }, +): Promise => { + const denied = forbiddenIfNotLocal(request); + if (denied) return denied; + const authDenied = requireApiAuth(request); + if (authDenied) return authDenied; + + const params = context?.params ? await context.params : { id: '' }; + const draftId = Number(params.id || '0'); + if (!draftId) { + return NextResponse.json({ error: 'invalid draft id' }, { status: 400 }); + } + + let body: UpdateContentDraftInput; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + } + + try { + const existing = await getContentDraft(draftId); + if (!existing) { + return NextResponse.json({ error: 'draft not found' }, { status: 404 }); + } + const draft = await updateContentDraft(draftId, body); + return NextResponse.json({ draft }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + return NextResponse.json({ error: msg }, { status: 500 }); + } +}; + +/** DELETE /api/content-drafts/[id] */ +export const DELETE: ApiRouteHandler = async ( + request: NextRequest, + context?: { params?: Promise<{ id: string }> }, +): Promise => { + const denied = forbiddenIfNotLocal(request); + if (denied) return denied; + const authDenied = requireApiAuth(request); + if (authDenied) return authDenied; + + const params = context?.params ? await context.params : { id: '' }; + const draftId = Number(params.id || '0'); + if (!draftId) { + return NextResponse.json({ error: 'invalid draft id' }, { status: 400 }); + } + + try { + const ok = await deleteContentDraft(draftId); + if (!ok) { + return NextResponse.json({ error: 'draft not found' }, { status: 404 }); + } + return NextResponse.json({ ok: true }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + return NextResponse.json({ error: msg }, { status: 500 }); + } +}; diff --git a/web/app/api/content-drafts/route.ts b/web/app/api/content-drafts/route.ts new file mode 100644 index 0000000..f16f10a --- /dev/null +++ b/web/app/api/content-drafts/route.ts @@ -0,0 +1,55 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { forbiddenIfNotLocal } from '@/server/localOnly'; +import { requireApiAuth } from '@/server/auth'; +import { + createContentDraft, + listContentDrafts, + type CreateContentDraftInput, +} from '@/server/contentDraftDb'; +import type { ApiRouteHandler } from '@/types/api'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +/** GET /api/content-drafts?propertyId= — list drafts for a property. */ +export const GET: ApiRouteHandler = async (request: NextRequest): Promise => { + const propertyId = Number(request.nextUrl.searchParams.get('propertyId') || '0'); + if (!propertyId) { + return NextResponse.json({ error: 'propertyId required' }, { status: 400 }); + } + try { + const drafts = await listContentDrafts(propertyId); + return NextResponse.json({ drafts }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + return NextResponse.json({ error: msg }, { status: 500 }); + } +}; + +/** POST /api/content-drafts — create a new draft. */ +export const POST: ApiRouteHandler = async (request: NextRequest): Promise => { + const denied = forbiddenIfNotLocal(request); + if (denied) return denied; + const authDenied = requireApiAuth(request); + if (authDenied) return authDenied; + + let body: CreateContentDraftInput & { propertyId?: number }; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + } + + const propertyId = Number(body.propertyId || 0); + if (!propertyId) { + return NextResponse.json({ error: 'propertyId required' }, { status: 400 }); + } + + try { + const id = await createContentDraft(propertyId, body); + return NextResponse.json({ id, propertyId }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + return NextResponse.json({ error: msg }, { status: 500 }); + } +}; diff --git a/web/app/api/content/analyze/route.ts b/web/app/api/content/analyze/route.ts new file mode 100644 index 0000000..4c6861e --- /dev/null +++ b/web/app/api/content/analyze/route.ts @@ -0,0 +1,104 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { spawn } from 'child_process'; +import { forbiddenIfNotLocal } from '@/server/localOnly'; +import { requireApiAuth } from '@/server/auth'; +import { getRepoRoot, getPipelineSpawnEnv } from '@/server/pipelineSpawnEnv'; +import { resolvePythonExecutable, parsePythonJsonStdout } from '@/server/resolvePython'; +import type { ApiRouteHandler } from '@/types/api'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +/** + * POST /api/content/analyze — SEO score + rule/AI suggestions (one-click analyzer). + */ +export const POST: ApiRouteHandler = async (request: NextRequest): Promise => { + const denied = forbiddenIfNotLocal(request); + if (denied) return denied; + const authDenied = requireApiAuth(request); + if (authDenied) return authDenied; + + let body: { + propertyId?: number; + keyword?: string; + bodyHtml?: string; + titleTag?: string; + metaDescription?: string; + landingUrl?: string; + title?: string; + useAi?: boolean; + refresh?: boolean; + }; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + } + + const keyword = String(body.keyword || '').trim(); + if (!keyword) { + return NextResponse.json({ error: 'keyword required' }, { status: 400 }); + } + + const propertyId = Number(body.propertyId || 0) || null; + const repoRoot = getRepoRoot(); + const pythonExe = resolvePythonExecutable(null, repoRoot); + const script = ` +import json, sys +from website_profiling.content_studio.ai_suggest import analyze_content_draft +payload = json.load(sys.stdin) +pid = payload.get("propertyId") +print(json.dumps(analyze_content_draft( + int(pid) if pid else None, + payload.get("keyword", ""), + payload.get("bodyHtml", ""), + payload.get("titleTag", ""), + payload.get("metaDescription", ""), + payload.get("landingUrl"), + use_ai=bool(payload.get("useAi")), + refresh=bool(payload.get("refresh")), + title=payload.get("title", ""), +))) +`; + + return new Promise((resolve) => { + const proc = spawn(pythonExe, ['-c', script], { + cwd: repoRoot, + env: getPipelineSpawnEnv(repoRoot), + shell: false, + }); + let stdout = ''; + proc.stdout?.on('data', (c: Buffer | string) => { stdout += c.toString(); }); + proc.stdin?.write( + JSON.stringify({ + propertyId, + keyword, + bodyHtml: body.bodyHtml || '', + titleTag: body.titleTag || '', + metaDescription: body.metaDescription || '', + landingUrl: body.landingUrl || null, + title: body.title || '', + useAi: body.useAi === true, + refresh: body.refresh === true, + }), + ); + proc.stdin?.end(); + proc.on('error', () => { + clearTimeout(timer); + resolve(NextResponse.json({ error: 'Analyze failed: could not start Python' }, { status: 500 })); + }); + proc.on('close', (code) => { + clearTimeout(timer); + const parsed = parsePythonJsonStdout(stdout); + if (code === 0 && parsed) { + resolve(NextResponse.json({ analysis: parsed })); + return; + } + resolve(NextResponse.json({ error: 'Content analyze failed' }, { status: 500 })); + }); + const timer = setTimeout(() => { + try { proc.kill(); } catch { /* ignore */ } + resolve(NextResponse.json({ error: 'Analyze timed out after 90s' }, { status: 504 })); + }, 90_000); + }); +}; diff --git a/web/app/api/content/score/route.ts b/web/app/api/content/score/route.ts new file mode 100644 index 0000000..22ad407 --- /dev/null +++ b/web/app/api/content/score/route.ts @@ -0,0 +1,89 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { spawn } from 'child_process'; +import { getRepoRoot, getPipelineSpawnEnv } from '@/server/pipelineSpawnEnv'; +import { resolvePythonExecutable, parsePythonJsonStdout } from '@/server/resolvePython'; +import type { ApiRouteHandler } from '@/types/api'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +/** + * POST /api/content/score + */ +export const POST: ApiRouteHandler = async (request: NextRequest): Promise => { + let body: { + propertyId?: number; + keyword?: string; + bodyHtml?: string; + titleTag?: string; + metaDescription?: string; + landingUrl?: string; + }; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + } + + const keyword = String(body.keyword || '').trim(); + if (!keyword) { + return NextResponse.json({ error: 'keyword required' }, { status: 400 }); + } + + const propertyId = Number(body.propertyId || 0) || null; + + const repoRoot = getRepoRoot(); + const pythonExe = resolvePythonExecutable(null, repoRoot); + const script = ` +import json, sys +from website_profiling.content_studio.score import score_content_draft +payload = json.load(sys.stdin) +pid = payload.get("propertyId") +print(json.dumps(score_content_draft( + int(pid) if pid else None, + payload.get("keyword", ""), + payload.get("bodyHtml", ""), + payload.get("titleTag", ""), + payload.get("metaDescription", ""), + payload.get("landingUrl"), +))) +`; + + return new Promise((resolve) => { + const proc = spawn(pythonExe, ['-c', script], { + cwd: repoRoot, + env: getPipelineSpawnEnv(repoRoot), + shell: false, + }); + let stdout = ''; + proc.stdout?.on('data', (c: Buffer | string) => { stdout += c.toString(); }); + proc.stdin?.write( + JSON.stringify({ + propertyId, + keyword, + bodyHtml: body.bodyHtml || '', + titleTag: body.titleTag || '', + metaDescription: body.metaDescription || '', + landingUrl: body.landingUrl || null, + }), + ); + proc.stdin?.end(); + proc.on('error', () => { + clearTimeout(timer); + resolve(NextResponse.json({ error: 'Content score failed: could not start Python process' }, { status: 500 })); + }); + proc.on('close', (code) => { + clearTimeout(timer); + const parsed = parsePythonJsonStdout(stdout); + if (code === 0 && parsed) { + resolve(NextResponse.json({ score: parsed })); + return; + } + resolve(NextResponse.json({ error: 'Content score failed' }, { status: 500 })); + }); + const timer = setTimeout(() => { + try { proc.kill(); } catch { /* ignore */ } + resolve(NextResponse.json({ error: 'Content score timed out after 30s' }, { status: 504 })); + }, 30_000); + }); +}; diff --git a/web/app/api/report/audit-tool/route.ts b/web/app/api/report/audit-tool/route.ts new file mode 100644 index 0000000..769a04f --- /dev/null +++ b/web/app/api/report/audit-tool/route.ts @@ -0,0 +1,45 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { forbiddenIfNotLocal } from '@/server/localOnly'; +import { spawnAuditTool } from '@/server/spawnAuditTool'; +import type { ApiRouteHandler } from '@/types/api'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +/** + * POST /api/report/audit-tool — dispatch allowlisted read-only audit tools for report UI. + */ +export const POST: ApiRouteHandler = async (request: NextRequest): Promise => { + const denied = forbiddenIfNotLocal(request); + if (denied) return denied; + + let body: { + toolName?: string; + propertyId?: number; + reportId?: number; + args?: Record; + }; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + } + + const toolName = String(body.toolName || '').trim(); + const propertyId = Number(body.propertyId || 0); + if (!toolName || !propertyId) { + return NextResponse.json({ error: 'toolName and propertyId required' }, { status: 400 }); + } + + const result = await spawnAuditTool({ + toolName, + propertyId, + reportId: body.reportId, + args: body.args, + }); + + if (!result.ok) { + return NextResponse.json({ error: result.error, ...result.data }, { status: result.status }); + } + return NextResponse.json(result.data); +}; diff --git a/web/app/api/report/custom/compose/route.ts b/web/app/api/report/custom/compose/route.ts new file mode 100644 index 0000000..ed27113 --- /dev/null +++ b/web/app/api/report/custom/compose/route.ts @@ -0,0 +1,45 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { forbiddenIfNotLocal } from '@/server/localOnly'; +import { composeCustomReport } from '@/server/spawnCustomReport'; +import type { ApiRouteHandler } from '@/types/api'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export const POST: ApiRouteHandler = async (request: NextRequest): Promise => { + const denied = forbiddenIfNotLocal(request); + if (denied) return denied; + + let body: { + title?: string; + sections?: Array>; + propertyId?: number; + reportId?: number; + }; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + } + + const title = String(body.title || '').trim(); + const propertyId = Number(body.propertyId || 0); + const sections = body.sections; + if (!title || !propertyId || !Array.isArray(sections) || sections.length === 0) { + return NextResponse.json({ error: 'title, propertyId, and sections required' }, { status: 400 }); + } + if (sections.length > 12) { + return NextResponse.json({ error: 'sections max 12' }, { status: 400 }); + } + + const result = await composeCustomReport({ + title, + sections, + propertyId, + reportId: body.reportId, + }); + if (!result.ok) { + return NextResponse.json({ error: result.error, ...result.data }, { status: result.status }); + } + return NextResponse.json(result.data); +}; diff --git a/web/app/api/report/custom/export/route.ts b/web/app/api/report/custom/export/route.ts new file mode 100644 index 0000000..4100abe --- /dev/null +++ b/web/app/api/report/custom/export/route.ts @@ -0,0 +1,51 @@ +import { NextResponse, type NextRequest } from 'next/server'; +import { forbiddenIfNotLocal } from '@/server/localOnly'; +import { exportCustomReportArtifact } from '@/server/spawnCustomReport'; +import type { ApiRouteHandler } from '@/types/api'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export const GET: ApiRouteHandler = async (request: NextRequest): Promise => { + const denied = forbiddenIfNotLocal(request); + if (denied) return denied; + + const params = request.nextUrl.searchParams; + const reportSpecId = String(params.get('specId') || '').trim(); + const format = (params.get('format') || 'html').toLowerCase(); + const propertyId = Number(params.get('propertyId') || '0'); + const reportIdRaw = params.get('reportId'); + const reportId = reportIdRaw && /^\d+$/.test(reportIdRaw) ? Number(reportIdRaw) : null; + + if (!reportSpecId || !propertyId) { + return NextResponse.json({ error: 'specId and propertyId required' }, { status: 400 }); + } + if (format !== 'html' && format !== 'pdf') { + return NextResponse.json({ error: 'format must be html or pdf' }, { status: 400 }); + } + + const result = await exportCustomReportArtifact({ + reportSpecId, + format, + propertyId, + reportId, + }); + if (!result.ok) { + return NextResponse.json({ error: result.error, ...result.data }, { status: result.status }); + } + + const filename = String(result.data.filename || `custom-report.${format}`); + const mimeType = String(result.data.mime_type || (format === 'pdf' ? 'application/pdf' : 'text/html')); + const b64 = String(result.data.data_b64 || ''); + const buf = Buffer.from(b64, 'base64'); + const dispositionParam = params.get('disposition'); + const inline = dispositionParam === 'inline'; + + return new NextResponse(buf, { + status: 200, + headers: { + 'Content-Type': mimeType, + 'Content-Disposition': `${inline ? 'inline' : 'attachment'}; filename="${filename}"`, + }, + }); +}; diff --git a/web/app/api/report/payload/route.ts b/web/app/api/report/payload/route.ts index 8206e7c..d330531 100644 --- a/web/app/api/report/payload/route.ts +++ b/web/app/api/report/payload/route.ts @@ -1,6 +1,7 @@ import { NextResponse, type NextRequest } from 'next/server'; import { withReportDb } from '@/server/reportDb'; -import { readReportPayloadFromDatabase } from '@/lib/loadReportDb'; +import { readReportPayloadFromDatabase, readReportSectionFromDatabase } from '@/lib/loadReportDb'; +import { SECTION_KEYS, type SectionKey } from '@/lib/reportSections'; import type { ApiRouteHandler } from '@/types/api'; export const dynamic = 'force-dynamic'; @@ -9,10 +10,25 @@ export const GET: ApiRouteHandler = async (request: NextRequest): Promise).includes(sectionParam)) { + return NextResponse.json({ error: 'Invalid section' }, { status: 400 }); + } + try { + if (sectionParam != null) { + const section = sectionParam as SectionKey; + const payload = await withReportDb((db) => + readReportSectionFromDatabase(db, section, reportId, domain), + ); + return NextResponse.json({ payload, section }); + } + const payload = await withReportDb((db) => readReportPayloadFromDatabase(db, reportId, domain), ); diff --git a/web/app/api/report/portfolio/route.ts b/web/app/api/report/portfolio/route.ts index a6ec6e9..7218a51 100644 --- a/web/app/api/report/portfolio/route.ts +++ b/web/app/api/report/portfolio/route.ts @@ -3,23 +3,77 @@ import { withReportDb } from '@/server/reportDb'; import { listReportsFromDatabase, readReportPayloadFromDatabase, + readReportSectionFromDatabase, getCrawlRunsRows, getCrawlRunSummaries, } from '@/lib/loadReportDb'; import { computeDomainGroups, computeCrawlOnlyGroups, + computePortfolioSummary, mergePortfolioGroups, + buildPortfolioCard, } from '@/lib/homePortfolio'; import { buildCrawlHistoryByDomain } from '@/lib/portfolioCrawlHistory'; import { strings } from '@/lib/strings'; import type { ApiRouteHandler } from '@/types/api'; import type { StringsCatalog } from '@/types/strings'; +import type { PoolClient } from 'pg'; export const dynamic = 'force-dynamic'; const catalog = strings as StringsCatalog; +const WIDGETS = ['full', 'groups', 'card', 'summary'] as const; +type PortfolioWidget = (typeof WIDGETS)[number]; + +async function loadPortfolioMaps(client: PoolClient) { + const crawlRows = await getCrawlRunsRows(client); + const startUrlByRunId = new Map(crawlRows.map((cr) => [cr.id, cr.start_url])); + const runCreatedAtByRunId = new Map(crawlRows.map((cr) => [cr.id, cr.created_at])); + const runMetaByRunId = new Map( + crawlRows.map((cr) => [ + cr.id, + { render_mode: cr.render_mode, discovery_mode: cr.discovery_mode }, + ]), + ); + const crawlSummaries = await getCrawlRunSummaries(client); + return { startUrlByRunId, runCreatedAtByRunId, runMetaByRunId, crawlSummaries }; +} + +async function buildGroupsBundle( + client: PoolClient, + reportList: Awaited>, + lite: boolean, +) { + const { startUrlByRunId, runCreatedAtByRunId, runMetaByRunId, crawlSummaries } = + await loadPortfolioMaps(client); + const unknownBrand = catalog.views.home.unknownBrand; + const emDash = catalog.common.emDash; + const getPayload = lite + ? (id: number) => readReportSectionFromDatabase(client, 'core', id) + : (id: number) => readReportPayloadFromDatabase(client, id); + + const reportGroups = await computeDomainGroups( + reportList, + startUrlByRunId, + runCreatedAtByRunId, + unknownBrand, + emDash, + getPayload, + runMetaByRunId, + ); + const crawlOnlyGroups = computeCrawlOnlyGroups( + crawlSummaries, + reportGroups, + unknownBrand, + emDash, + ); + const groups = mergePortfolioGroups(reportGroups, crawlOnlyGroups); + const crawlHistoryByDomain = buildCrawlHistoryByDomain(crawlSummaries); + return { groups, crawlHistoryByDomain, crawlSummaries, startUrlByRunId, runCreatedAtByRunId, runMetaByRunId }; +} + export const GET: ApiRouteHandler = async (request: NextRequest): Promise => { const idsParam = request.nextUrl.searchParams.get('ids'); const ids = idsParam @@ -29,41 +83,57 @@ export const GET: ApiRouteHandler = async (request: NextRequest): Promise Number.isFinite(n) && n > 0) : []; + const widgetParam = request.nextUrl.searchParams.get('widget') || 'full'; + if (!WIDGETS.includes(widgetParam as PortfolioWidget)) { + return NextResponse.json({ error: 'Invalid widget' }, { status: 400 }); + } + const widget = widgetParam as PortfolioWidget; + + const reportIdParam = request.nextUrl.searchParams.get('reportId'); + const crawlRunIdParam = request.nextUrl.searchParams.get('crawlRunId'); + const reportId = + reportIdParam != null && reportIdParam !== '' ? Number(reportIdParam) : undefined; + const crawlRunId = + crawlRunIdParam != null && crawlRunIdParam !== '' ? Number(crawlRunIdParam) : undefined; + + if (widget === 'card' && reportId == null && crawlRunId == null) { + return NextResponse.json({ error: 'reportId or crawlRunId required for card widget' }, { status: 400 }); + } + try { - const portfolio = await withReportDb(async (client) => { + const result = await withReportDb(async (client) => { const all = await listReportsFromDatabase(client); const idSet = new Set(ids); const reportList = ids.length ? all.filter((r) => idSet.has(r.id)) : all; - const crawlRows = await getCrawlRunsRows(client); - const startUrlByRunId = new Map(crawlRows.map((cr) => [cr.id, cr.start_url])); - const runCreatedAtByRunId = new Map(crawlRows.map((cr) => [cr.id, cr.created_at])); - const runMetaByRunId = new Map( - crawlRows.map((cr) => [ - cr.id, - { render_mode: cr.render_mode, discovery_mode: cr.discovery_mode }, - ]), - ); - const reportGroups = await computeDomainGroups( - reportList, - startUrlByRunId, - runCreatedAtByRunId, - catalog.views.home.unknownBrand, - catalog.common.emDash, - (id: number) => readReportPayloadFromDatabase(client, id), - runMetaByRunId, - ); - const crawlSummaries = await getCrawlRunSummaries(client); - const crawlOnlyGroups = computeCrawlOnlyGroups( - crawlSummaries, - reportGroups, - catalog.views.home.unknownBrand, - catalog.common.emDash, - ); - const groups = mergePortfolioGroups(reportGroups, crawlOnlyGroups); - const crawlHistoryByDomain = buildCrawlHistoryByDomain(crawlSummaries); - return { groups, crawlHistoryByDomain }; + + if (widget === 'card') { + const maps = await loadPortfolioMaps(client); + const group = await buildPortfolioCard( + reportList, + maps.startUrlByRunId, + maps.runCreatedAtByRunId, + maps.runMetaByRunId, + maps.crawlSummaries, + catalog.views.home.unknownBrand, + catalog.common.emDash, + (id: number) => readReportPayloadFromDatabase(client, id), + { reportId, crawlRunId }, + ); + if (!group) return { group: null }; + return { group }; + } + + const lite = widget === 'groups' || widget === 'summary'; + const bundle = await buildGroupsBundle(client, reportList, lite); + + if (widget === 'summary') { + return { ...computePortfolioSummary(bundle.groups) }; + } + + return { groups: bundle.groups, crawlHistoryByDomain: bundle.crawlHistoryByDomain }; }); - return NextResponse.json(portfolio); + + return NextResponse.json(result); } catch (e) { const msg = e instanceof Error ? e.message : String(e); return NextResponse.json({ error: msg, groups: [], crawlHistoryByDomain: {} }, { status: 500 }); diff --git a/web/app/globals.css b/web/app/globals.css index fa11f18..dbfc915 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -104,11 +104,11 @@ html.dark { --app-link: #60a5fa; --app-link-soft: #93c5fd; - --chat-bg: #131314; - --chat-surface: #1e1f20; - --chat-surface-hover: #28292a; - --chat-glow: rgba(66, 97, 255, 0.12); - --chat-glow-secondary: rgba(138, 79, 255, 0.06); + --chat-bg: var(--app-bg); + --chat-surface: var(--app-bg-elevated); + --chat-surface-hover: var(--app-bg-muted); + --chat-glow: rgba(37, 99, 235, 0.06); + --chat-glow-secondary: rgba(37, 99, 235, 0.03); /* Warm secondary accent — brighter on dark surfaces */ --accent-warm: #fb923c; @@ -296,8 +296,8 @@ code { flex-direction: column; align-items: center; gap: 0.25rem; - border-right: 1px solid color-mix(in srgb, var(--app-border-muted) 70%, transparent); - background: var(--chat-surface); + border-right: 1px solid var(--app-border-muted); + background: var(--app-bg-elevated); padding: 0.75rem 0; } @@ -316,8 +316,8 @@ code { width: 17.5rem; flex-shrink: 0; flex-direction: column; - border-right: 1px solid color-mix(in srgb, var(--app-border-muted) 70%, transparent); - background: var(--chat-surface); + border-right: 1px solid var(--app-border-muted); + background: var(--app-bg-elevated); } @media (max-width: 767px) { @@ -384,8 +384,8 @@ code { } .chat-composer-dock { - border-top: 1px solid color-mix(in srgb, var(--app-border-muted) 55%, transparent); - background: color-mix(in srgb, var(--chat-bg) 96%, transparent); + border-top: 1px solid var(--app-border-muted); + background: color-mix(in srgb, var(--app-bg) 92%, transparent); backdrop-filter: blur(8px); } @@ -401,14 +401,14 @@ code { .chat-hero-input { box-shadow: - 0 0 0 1px rgba(255, 255, 255, 0.06), - 0 8px 32px rgba(0, 0, 0, 0.35); + 0 0 0 1px var(--app-border), + var(--elevation-2); } .chat-hero-input:focus-within { box-shadow: - 0 0 0 1px rgba(255, 255, 255, 0.1), - 0 8px 40px rgba(0, 0, 0, 0.45); + 0 0 0 1px var(--accent-border), + var(--elevation-3); } /* Chat markdown prose */ diff --git a/web/app/write/page.tsx b/web/app/write/page.tsx new file mode 100644 index 0000000..145e65f --- /dev/null +++ b/web/app/write/page.tsx @@ -0,0 +1,18 @@ +import { Suspense } from 'react'; +import WriteStudio from '@/views/WriteStudio'; + +export const dynamic = 'force-dynamic'; + +export default function WritePage() { + return ( + + Loading… + + } + > + + + ); +} diff --git a/web/package-lock.json b/web/package-lock.json index 6ffb80b..24cf709 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -7,8 +7,23 @@ "": { "name": "web", "version": "0.1.0", + "license": "MIT", "dependencies": { "@tanstack/react-virtual": "^3.13.23", + "@tiptap/extension-image": "^3.26.1", + "@tiptap/extension-link": "^3.26.1", + "@tiptap/extension-table": "^3.26.1", + "@tiptap/extension-table-cell": "^3.26.1", + "@tiptap/extension-table-header": "^3.26.1", + "@tiptap/extension-table-row": "^3.26.1", + "@tiptap/extension-task-item": "^3.26.1", + "@tiptap/extension-task-list": "^3.26.1", + "@tiptap/extension-underline": "^3.26.1", + "@tiptap/extensions": "^3.26.1", + "@tiptap/markdown": "^3.26.1", + "@tiptap/pm": "^3.26.1", + "@tiptap/react": "^3.26.1", + "@tiptap/starter-kit": "^3.26.1", "3d-force-graph": "^1.79.1", "chart.js": "^4.5.1", "lucide-react": "^0.577.0", @@ -677,6 +692,34 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT", + "optional": true + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -2166,6 +2209,544 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@tiptap/core": { + "version": "3.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.26.1.tgz", + "integrity": "sha512-TX9PyPqBoix0qDLjtok/bddtdSy54QhzLVha405C07V+WySOpH3s/pWYkywehZQY0SQtcrcY4MNSCeQjCbA28A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/pm": "3.26.1" + } + }, + "node_modules/@tiptap/extension-blockquote": { + "version": "3.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.26.1.tgz", + "integrity": "sha512-WaKjKmUaadgvZDDBk9JOn/oidlOFr6booqJIWHGL5S0aUUTKHS19oGfKQq/l9Z1y1niaRePk0Y4fy/jxCnfKPA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.26.1" + } + }, + "node_modules/@tiptap/extension-bold": { + "version": "3.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.26.1.tgz", + "integrity": "sha512-VIlF2sAiV6K009pcIDotfY8mvsPaq90dxeG9Q0ZIqfMD958TUCqjHw4MGYZf0/FgP12xksBfmcR7W312xgUf9Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.26.1" + } + }, + "node_modules/@tiptap/extension-bubble-menu": { + "version": "3.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.26.1.tgz", + "integrity": "sha512-Y3R9wFKP/U9M04JG+0PM/yW3OV+MSbUp6YBKQWZmUu8x6y7TbcNvDsaJ6QEFZt5aRMS6qH1ksYPTOz47JdjcfA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@floating-ui/dom": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.26.1", + "@tiptap/pm": "3.26.1" + } + }, + "node_modules/@tiptap/extension-bullet-list": { + "version": "3.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.26.1.tgz", + "integrity": "sha512-JB6bEJJHxXNAXEXTIAN3/j70p1ARHdeMfhzshGZswWKUWtDibTCrspIp7p1VNeiuVtJ/HB6PpFkGi7yWtQ3RTg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.26.1" + } + }, + "node_modules/@tiptap/extension-code": { + "version": "3.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.26.1.tgz", + "integrity": "sha512-t9/VR5k3rGPyhcGau9YvVgaAQ+nP9R9WzS996bQQ7GIrMOTSXb0FWwoQFBiYl83V6VA16Tlj/oScC7SFlA8lvA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.26.1" + } + }, + "node_modules/@tiptap/extension-code-block": { + "version": "3.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.26.1.tgz", + "integrity": "sha512-NY7SYqcrqDVYTSWyaNGdSfCims6pOHoRQ2Rh4DEFb/rb8gLVkqbLZhcHzQCVfinlPqgV3xWF6cYMORwmnlBkXQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.26.1", + "@tiptap/pm": "3.26.1" + } + }, + "node_modules/@tiptap/extension-document": { + "version": "3.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.26.1.tgz", + "integrity": "sha512-6W2vZjvi0Mv+4xEtwMDGhWwo7FotWR6eKfmntmduvehWevFpMxOKcTtyotjLigfZv738y50YWmvbaPuAPJG3BA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.26.1" + } + }, + "node_modules/@tiptap/extension-dropcursor": { + "version": "3.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.26.1.tgz", + "integrity": "sha512-eVq3BvFIa3YD+pBIlj1i72vYEixlegGVKHnSYiVF2ovkQOSAH9sca7pkq6WgV1sMTCyWCU8e+WznTqtydvHUWA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "3.26.1" + } + }, + "node_modules/@tiptap/extension-floating-menu": { + "version": "3.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.26.1.tgz", + "integrity": "sha512-xn0g4m/q2bjG+hULPwp6Aqb/6wpzUtc65jOhgJsG/S3Ey3kLJGUvZBuhozwNFu8FcugxM1fMUpNhkJkodCCGFw==", + "license": "MIT", + "optional": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@floating-ui/dom": "^1.0.0", + "@tiptap/core": "3.26.1", + "@tiptap/pm": "3.26.1" + } + }, + "node_modules/@tiptap/extension-gapcursor": { + "version": "3.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.26.1.tgz", + "integrity": "sha512-BWW1yMQQA4TbEU0LLK+4cd9ebLTuZG5KjHwFMBRD/bGiRW9V1gTWFsCqThBbczcANoQiZK9pn5/4Ad/rGM3HUg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "3.26.1" + } + }, + "node_modules/@tiptap/extension-hard-break": { + "version": "3.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.26.1.tgz", + "integrity": "sha512-gzNb1e/fK6HN+ko1axsrasjK7F1q0Bnm0G4ZY/0eq7pV7s1wZuwoCiGbvUx/9LCFKRV6+94FTqlb0A3NbYN36g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.26.1" + } + }, + "node_modules/@tiptap/extension-heading": { + "version": "3.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.26.1.tgz", + "integrity": "sha512-eRlv9XxzUL8FobKAiF1WjP35CT2QpbcxxeyYFF7BmGEONvKI7r5g7JGwyGli4Cvclh70h8w6JuoXSmGUVEU65A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.26.1" + } + }, + "node_modules/@tiptap/extension-horizontal-rule": { + "version": "3.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.26.1.tgz", + "integrity": "sha512-l9lPZYeSmY90y/2GkQcKaICFD5Atr8sx2SzJGkQzpNC9tRxZXyAHnfJE3OjBkspuGzjWIN0DimxBj4ibz58sKw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.26.1", + "@tiptap/pm": "3.26.1" + } + }, + "node_modules/@tiptap/extension-image": { + "version": "3.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-3.26.1.tgz", + "integrity": "sha512-IjoT+kRK4a1sTImvUz257yfk5l9kMxXxfxCfix5AUKdiWyn8SGUjJZapLICcZVY05UDqXmwsBvBK9lHkKX5ERg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.26.1" + } + }, + "node_modules/@tiptap/extension-italic": { + "version": "3.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.26.1.tgz", + "integrity": "sha512-cLKYvOLToWEkJkAPspgIZ/PYDzAxacLm1VWcAq1tO1QDQCDe2Kw+y/zsGlyYEq/aKsAgpp4JNopBwAXRXxt2/A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.26.1" + } + }, + "node_modules/@tiptap/extension-link": { + "version": "3.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.26.1.tgz", + "integrity": "sha512-aLLGLgikuhLFHRbjfUC6D4gRg+NUty4uhW7YkyVl8AxxPME47dPbCOX4H6uLCjEZcn3WnfNuCTr6HCTl0KEmGA==", + "license": "MIT", + "dependencies": { + "linkifyjs": "^4.3.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.26.1", + "@tiptap/pm": "3.26.1" + } + }, + "node_modules/@tiptap/extension-list": { + "version": "3.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.26.1.tgz", + "integrity": "sha512-06nOjnyXpzMO8Ys5k3IbYsDsKib1mv2OtaxBYX1/1uvRyOKwUX5tqDLb/qigic0LIANNL73lkNC8Z8XPeG4Tkg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.26.1", + "@tiptap/pm": "3.26.1" + } + }, + "node_modules/@tiptap/extension-list-item": { + "version": "3.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.26.1.tgz", + "integrity": "sha512-5gLXJUiP763NA6i4HgrtcwUDXPP8820hsaBQyF1Y1VsXNi02uW9FVLe3RZK8jF0NZUNh9CqD0gogYJCbKOUU8A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.26.1" + } + }, + "node_modules/@tiptap/extension-list-keymap": { + "version": "3.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.26.1.tgz", + "integrity": "sha512-EReSayePO6SIxtRbxx+7KfBQreWHvoZmMb3O/RemfT8W6J0hCG5N/Rh8Z12+YZOnCDRXJ4RzFpAikYka3E54jQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.26.1" + } + }, + "node_modules/@tiptap/extension-ordered-list": { + "version": "3.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.26.1.tgz", + "integrity": "sha512-LeFPeFwb7ylkQVuuaHj+niu7WhWHpjDOi1GKZJE/ohOa2lgt7P221HMqhUzPiDlXOExN72oWTNmXUlT0ymCTkw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.26.1" + } + }, + "node_modules/@tiptap/extension-paragraph": { + "version": "3.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.26.1.tgz", + "integrity": "sha512-OkBeYUNM3eTzjm3z6IcC3NHryOX8g3eGNI86P/B+tFoFQSRuzLsKZU50ARCfIiLLg812NjcqujeJ1eX3BKDZrw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.26.1" + } + }, + "node_modules/@tiptap/extension-strike": { + "version": "3.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.26.1.tgz", + "integrity": "sha512-7hmQ2mBsA+75GRrJIKYxb+10H23mblEQSGGsv9Ptl7JLaGmj+8sv2HGQGSUT9QBiBVprxaYTqyWFXQC9akfLWg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.26.1" + } + }, + "node_modules/@tiptap/extension-table": { + "version": "3.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-table/-/extension-table-3.26.1.tgz", + "integrity": "sha512-epxUhc5ecxsH39lzNejc2WxFPXAXWGs9g2ofKDrIaoSlZlfFHf89/sEGSz048a46E5Sb+fYCtzUvRUUx+aG4xw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.26.1", + "@tiptap/pm": "3.26.1" + } + }, + "node_modules/@tiptap/extension-table-cell": { + "version": "3.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-table-cell/-/extension-table-cell-3.26.1.tgz", + "integrity": "sha512-eCGgHrzIUPHZpz/z3F4O8yk+SM/HBcLVvAWTHl8P+4/GC2+6oVFH+9ixBDIMKiJugSOnuOY8uLm30+Ld/MtyTw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-table": "3.26.1" + } + }, + "node_modules/@tiptap/extension-table-header": { + "version": "3.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-table-header/-/extension-table-header-3.26.1.tgz", + "integrity": "sha512-idVDYdhVpTL4hnzuf/MbE74HHjqqqIRCVwzfbTy/d5JnTnJ1LXpJZKz2oFWNOk5NaAq0kPhkwkz5lSBUgd2DbQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-table": "3.26.1" + } + }, + "node_modules/@tiptap/extension-table-row": { + "version": "3.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-table-row/-/extension-table-row-3.26.1.tgz", + "integrity": "sha512-zAr7bQcUHoBpeysvbzxW8JchMduUn0wGwA2UeEgoE1K+gep74wRHs9LE8NRd70hARbZLzgUMRXcpT+W1pdoMMw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-table": "3.26.1" + } + }, + "node_modules/@tiptap/extension-task-item": { + "version": "3.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-task-item/-/extension-task-item-3.26.1.tgz", + "integrity": "sha512-VjwGkI7MJIswrfkArO4yWS9eAJq2KrCVsNIH96yUGkKve4cHrpDHfbCboLaj8453N98AGVtgY9GdVnAL6ypSAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.26.1" + } + }, + "node_modules/@tiptap/extension-task-list": { + "version": "3.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-task-list/-/extension-task-list-3.26.1.tgz", + "integrity": "sha512-d5eO6Ae6WqSZHd0lt16snD8u2PlZIjmKuvDQBjPYvzl5MUOmplcuOyaAsaphBJK5tr4u9pP9XDNVGB7Xjp0gHQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.26.1" + } + }, + "node_modules/@tiptap/extension-text": { + "version": "3.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.26.1.tgz", + "integrity": "sha512-Gocui5WvcCCJJIX17gdOVCSdYi5H4fDwaR0qkMAUZPq5kJCdrfl+vNpt8BTt53Bk+/QumiUW21fhQ184w7RoeQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.26.1" + } + }, + "node_modules/@tiptap/extension-underline": { + "version": "3.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.26.1.tgz", + "integrity": "sha512-HUHtQ+DRWDM0opW7Nk3YQwrLzw876hMU7cr1X/ZTG+8Bp+AKHihlwU+bqrPgG5St0mqASyUEhHQ/vK5PlnUYOQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.26.1" + } + }, + "node_modules/@tiptap/extensions": { + "version": "3.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.26.1.tgz", + "integrity": "sha512-PmRaoe6bebTgz/ZQrjmzwZMST1d9js9ZTiKnUXeXl3Fm+V5U/c3TbbKDfqmL63qPQdjtShDMHi9tYuv+c77OFQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.26.1", + "@tiptap/pm": "3.26.1" + } + }, + "node_modules/@tiptap/markdown": { + "version": "3.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/markdown/-/markdown-3.26.1.tgz", + "integrity": "sha512-PpAi3hZqZnb7IsCiRnD6rZfauj8O19fvSzRRdx99Uwx14VnhznbO3WKpUMyleuLz5KjClidqqtKMQWDM6Wt0dA==", + "license": "MIT", + "dependencies": { + "marked": "^17.0.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.26.1", + "@tiptap/pm": "3.26.1" + } + }, + "node_modules/@tiptap/pm": { + "version": "3.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.26.1.tgz", + "integrity": "sha512-48cJQRbvr9Ux0+IgM1BR5vOLU5hkC+n+uerdQy2JjrIRKpYE/huU8fQFm6PoRppoKYfilklzb29elsQ+n2TA+g==", + "license": "MIT", + "dependencies": { + "prosemirror-changeset": "^2.3.0", + "prosemirror-commands": "^1.6.2", + "prosemirror-dropcursor": "^1.8.1", + "prosemirror-gapcursor": "^1.3.2", + "prosemirror-history": "^1.4.1", + "prosemirror-inputrules": "^1.4.0", + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.7", + "prosemirror-schema-list": "^1.5.0", + "prosemirror-state": "^1.4.4", + "prosemirror-tables": "^1.8.0", + "prosemirror-transform": "^1.12.0", + "prosemirror-view": "^1.41.8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@tiptap/react": { + "version": "3.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.26.1.tgz", + "integrity": "sha512-Gl7AhTJM7pjQ2WFwdIwD736oQeqUcw3GVaXYmCKtwTSO3F9PszLgeKEp6DvM+CmctTNYhu/apRfzkH3vU0h0uA==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "fast-equals": "^5.3.3", + "use-sync-external-store": "^1.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "optionalDependencies": { + "@tiptap/extension-bubble-menu": "^3.26.1", + "@tiptap/extension-floating-menu": "^3.26.1" + }, + "peerDependencies": { + "@tiptap/core": "3.26.1", + "@tiptap/pm": "3.26.1", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tiptap/starter-kit": { + "version": "3.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.26.1.tgz", + "integrity": "sha512-A0zsvwGU9exLND34F8e8KqUXFSfs835tNN+VC+ZT3yNeaO/WXnlh/Cgal1F6pHHbcxy7RV2CRwJU5S3cWLPxrA==", + "license": "MIT", + "dependencies": { + "@tiptap/core": "^3.26.1", + "@tiptap/extension-blockquote": "^3.26.1", + "@tiptap/extension-bold": "^3.26.1", + "@tiptap/extension-bullet-list": "^3.26.1", + "@tiptap/extension-code": "^3.26.1", + "@tiptap/extension-code-block": "^3.26.1", + "@tiptap/extension-document": "^3.26.1", + "@tiptap/extension-dropcursor": "^3.26.1", + "@tiptap/extension-gapcursor": "^3.26.1", + "@tiptap/extension-hard-break": "^3.26.1", + "@tiptap/extension-heading": "^3.26.1", + "@tiptap/extension-horizontal-rule": "^3.26.1", + "@tiptap/extension-italic": "^3.26.1", + "@tiptap/extension-link": "^3.26.1", + "@tiptap/extension-list": "^3.26.1", + "@tiptap/extension-list-item": "^3.26.1", + "@tiptap/extension-list-keymap": "^3.26.1", + "@tiptap/extension-ordered-list": "^3.26.1", + "@tiptap/extension-paragraph": "^3.26.1", + "@tiptap/extension-strike": "^3.26.1", + "@tiptap/extension-text": "^3.26.1", + "@tiptap/extension-underline": "^3.26.1", + "@tiptap/extensions": "^3.26.1", + "@tiptap/pm": "^3.26.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, "node_modules/@tweenjs/tween.js": { "version": "25.0.0", "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-25.0.0.tgz", @@ -2304,7 +2885,6 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -2316,6 +2896,12 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.60.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.1.tgz", @@ -4720,6 +5306,15 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-equals": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", + "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/fast-glob": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", @@ -6231,6 +6826,12 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/linkifyjs": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz", + "integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==", + "license": "MIT" + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -6333,6 +6934,18 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/marked": { + "version": "17.0.6", + "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.6.tgz", + "integrity": "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -7584,6 +8197,12 @@ "node": ">= 0.8.0" } }, + "node_modules/orderedmap": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", + "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", + "license": "MIT" + }, "node_modules/own-keys": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", @@ -7965,6 +8584,145 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/prosemirror-changeset": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz", + "integrity": "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==", + "license": "MIT", + "dependencies": { + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-commands": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz", + "integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.10.2" + } + }, + "node_modules/prosemirror-dropcursor": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz", + "integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0", + "prosemirror-view": "^1.1.0" + } + }, + "node_modules/prosemirror-gapcursor": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz", + "integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.0.0", + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-view": "^1.0.0" + } + }, + "node_modules/prosemirror-history": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz", + "integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.2.2", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.31.0", + "rope-sequence": "^1.3.0" + } + }, + "node_modules/prosemirror-inputrules": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz", + "integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-keymap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz", + "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "w3c-keyname": "^2.2.0" + } + }, + "node_modules/prosemirror-model": { + "version": "1.25.9", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.9.tgz", + "integrity": "sha512-pRTklkDDMMRopyoAcrr9wV/8g/RYgrLHBuJAb5hlEuYZRdm5yqmPjWId83fpBwPpSFqEdja0H7Dfd7z1X/npcA==", + "license": "MIT", + "dependencies": { + "orderedmap": "^2.0.0" + } + }, + "node_modules/prosemirror-schema-list": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz", + "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.7.3" + } + }, + "node_modules/prosemirror-state": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", + "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.27.0" + } + }, + "node_modules/prosemirror-tables": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz", + "integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.4", + "prosemirror-state": "^1.4.4", + "prosemirror-transform": "^1.10.5", + "prosemirror-view": "^1.41.4" + } + }, + "node_modules/prosemirror-transform": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz", + "integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.21.0" + } + }, + "node_modules/prosemirror-view": { + "version": "1.41.9", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.9.tgz", + "integrity": "sha512-clTunTX+eaLbr87L1V1QPheRlEQJyTlL3gXe9x3jQIk3rL0RVWxviDGz8tFaydwIVm+hKhYCyr+R/zBtWr9s6A==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.25.8", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -8319,6 +9077,12 @@ "fsevents": "~2.3.2" } }, + "node_modules/rope-sequence": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", + "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", + "license": "MIT" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -9413,6 +10177,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", @@ -9656,6 +10429,12 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/web/package.json b/web/package.json index 9be2cf4..0c9a88d 100644 --- a/web/package.json +++ b/web/package.json @@ -14,6 +14,20 @@ }, "dependencies": { "@tanstack/react-virtual": "^3.13.23", + "@tiptap/extension-image": "^3.26.1", + "@tiptap/extension-link": "^3.26.1", + "@tiptap/extension-table": "^3.26.1", + "@tiptap/extension-table-cell": "^3.26.1", + "@tiptap/extension-table-header": "^3.26.1", + "@tiptap/extension-table-row": "^3.26.1", + "@tiptap/extension-task-item": "^3.26.1", + "@tiptap/extension-task-list": "^3.26.1", + "@tiptap/extension-underline": "^3.26.1", + "@tiptap/extensions": "^3.26.1", + "@tiptap/markdown": "^3.26.1", + "@tiptap/pm": "^3.26.1", + "@tiptap/react": "^3.26.1", + "@tiptap/starter-kit": "^3.26.1", "3d-force-graph": "^1.79.1", "chart.js": "^4.5.1", "lucide-react": "^0.577.0", diff --git a/web/src/ReportShell.tsx b/web/src/ReportShell.tsx index a4f955c..5983f44 100644 --- a/web/src/ReportShell.tsx +++ b/web/src/ReportShell.tsx @@ -13,6 +13,8 @@ import { FileText, ShieldAlert, Bug, + Accessibility, + Image, Gauge, Share2, BarChart2, @@ -29,6 +31,7 @@ import { Globe2, Contact2, TextSearch, + PenLine, } from 'lucide-react'; import { UrlInspectorProvider } from './context/UrlInspectorContext'; import AppShell from './components/AppShell'; @@ -39,6 +42,8 @@ import { pathSlugToViewId, viewIdToPathSlug, type ViewId } from './routes'; import { dispatchOpenIntegrations } from './lib/pipelineJobEvents'; import ReportShellSkeleton from './components/ReportShellSkeleton'; import { ReportProvider as ReportProviderBase } from './context/ReportContext'; +import { PortfolioProvider } from './context/PortfolioContext'; +import ViewSectionLoader from './components/ViewSectionLoader'; import type { ReportPayload } from '@/types'; function viewLoading(label = 'Loading view…') { return ( @@ -60,6 +65,9 @@ const Redirects = dynamic(() => import('./views/Redirects'), { loading: () => vi const Content = dynamic(() => import('./views/Content'), { loading: () => viewLoading() }); const Security = dynamic(() => import('./views/Security'), { loading: () => viewLoading() }); const JavaScriptErrors = dynamic(() => import('./views/JavaScriptErrors'), { loading: () => viewLoading() }); +const AccessibilityView = dynamic(() => import('./views/Accessibility'), { loading: () => viewLoading() }); +const ImageSeo = dynamic(() => import('./views/ImageSeo'), { loading: () => viewLoading() }); +const GeoReadiness = dynamic(() => import('./views/GeoReadiness'), { loading: () => viewLoading() }); const Lighthouse = dynamic(() => import('./views/Lighthouse'), { loading: () => viewLoading() }); const Network = dynamic(() => import('./views/Network'), { ssr: false, @@ -74,6 +82,7 @@ const Indexation = dynamic(() => import('./views/Indexation'), { loading: () => const Backlinks = dynamic(() => import('./views/Backlinks'), { loading: () => viewLoading() }); const Traffic = dynamic(() => import('./views/Traffic'), { loading: () => viewLoading() }); const KeywordsExplorer = dynamic(() => import('./views/KeywordsExplorer'), { loading: () => viewLoading() }); +const ContentStudio = dynamic(() => import('./views/ContentStudio'), { loading: () => viewLoading() }); const ExportReport = dynamic(() => import('./views/ExportReport'), { loading: () => viewLoading() }); const LogAnalyzer = dynamic(() => import('./views/LogAnalyzer'), { loading: () => viewLoading() }); const Subdomains = dynamic(() => import('./views/Subdomains'), { loading: () => viewLoading() }); @@ -122,6 +131,9 @@ const VIEW_CONFIG: ViewConfigEntry[] = [ { id: 'lighthouse', component: Lighthouse as ComponentType, icon: Gauge }, { id: 'security', component: Security as ComponentType, icon: ShieldAlert }, { id: 'javascript-errors', component: JavaScriptErrors as ComponentType, icon: Bug }, + { id: 'accessibility', component: AccessibilityView as ComponentType, icon: Accessibility }, + { id: 'image-seo', component: ImageSeo as ComponentType, icon: Image }, + { id: 'geo-readiness', component: GeoReadiness as ComponentType, icon: Globe2 }, { id: 'content-analytics', component: ContentAnalytics as ComponentType, icon: BarChart2 }, { id: 'text-content-analysis', component: TextContentAnalysis as ComponentType, icon: TextSearch }, { id: 'tech-stack', component: TechStack as ComponentType, icon: Cpu }, @@ -134,6 +146,7 @@ const VIEW_CONFIG: ViewConfigEntry[] = [ { id: 'backlinks', component: Backlinks as ComponentType, icon: Link2 }, { id: 'traffic', component: Traffic as ComponentType, icon: BarChart2 }, { id: 'keywords-explorer', component: KeywordsExplorer as ComponentType, icon: Key }, + { id: 'content-studio', component: ContentStudio as ComponentType, icon: PenLine }, ]; const VIEWS = VIEW_CONFIG.map((v) => ({ @@ -182,7 +195,7 @@ function AppContent({ slug }: SlugProps): ReactNode { const router = useRouter(); const searchParams = useSearchParams(); const [searchQuery, setSearchQuery] = useState(''); - const { loading, error, setSelectedReportId } = useReport() as ReportShellReportContext; + const { loading, error, data, setSelectedReportId } = useReport() as ReportShellReportContext; const view = pathSlugToViewId(slug ?? ''); @@ -214,8 +227,8 @@ function AppContent({ slug }: SlugProps): ReactNode { const showSidebar = view !== 'home'; const showSearch = showSidebar && view !== 'export'; - if (loading) { - return ; + if (loading && view !== 'home' && !data) { + return ; } if (error && view !== 'home') { @@ -250,11 +263,21 @@ function AppContent({ slug }: SlugProps): ReactNode { searchQuery={searchQuery} onSearchChange={setSearchQuery} > - + {view === 'home' ? ( + + + + ) : ( + + )} ); } @@ -263,6 +286,7 @@ function RoutedShell({ slug }: SlugProps): ReactNode { return ( <> + ); diff --git a/web/src/components/AppShell.tsx b/web/src/components/AppShell.tsx index bbf8a6a..df54240 100644 --- a/web/src/components/AppShell.tsx +++ b/web/src/components/AppShell.tsx @@ -9,13 +9,11 @@ import { ExternalLink, Menu, Search, - Settings2, X, } from 'lucide-react'; import AppLogo from '@/components/AppLogo'; import IntegrationsModal from '@/components/IntegrationsModal'; -import { Badge, ReportSelector } from '@/components'; -import ThemeToggle from '@/components/ThemeToggle'; +import { Badge, Breadcrumb, ReportSelector } from '@/components'; import { useReport } from '@/context/useReport'; import { useSession } from '@/context/SessionContext'; import { strings, format } from '@/lib/strings'; @@ -142,11 +140,6 @@ export default function AppShell({ return () => window.removeEventListener(OPEN_INTEGRATIONS, onOpen); }, []); - const openIntegrations = () => { - setIntegrationsToast(null); - setIntegrationsOpen(true); - }; - const issueCount = (data?.categories as ReportCategoryWithIssues[] | undefined)?.reduce( (n: number, c: ReportCategoryWithIssues) => n + (c.issues?.length ?? 0), @@ -170,6 +163,8 @@ export default function AppShell({ ? format(strings.app.crawlCompletedSeconds, { seconds: crawlSummary.crawl_time_s }) : strings.app.crawlCompleted; + const activeNavItem = APP_NAV_ITEMS.find((item) => isNavItemActive(item, pathname)); + return (
{showSidebar && sidebarOpen ? ( @@ -247,12 +242,18 @@ export default function AppShell({ key={item.id} href={href} onClick={closeSidebar} - title={sidebarCollapsed ? item.label : undefined} + title={ + sidebarCollapsed + ? item.description + ? `${item.label} — ${item.description}` + : item.label + : undefined + } aria-label={sidebarCollapsed ? item.label : undefined} className={`nav-btn press relative w-full flex items-center rounded-lg text-sm font-medium transition-all ${ sidebarCollapsed ? 'gap-3 px-3 py-2.5 md:justify-center md:gap-0 md:px-0 md:py-2.5' - : 'gap-3 px-3 py-2.5' + : 'gap-3 px-3 py-2' } ${ isActive ? 'tab-active bg-blue-500/10 border border-blue-500/25 text-link' @@ -266,8 +267,19 @@ export default function AppShell({ /> ) : null} - - {item.label} + + {item.label} + {item.description ? ( + + {item.description} + + ) : null} {badgeCount > 0 && !sidebarCollapsed ? ( : }
+ {activeNavItem ? ( + + ) : null} {showSearch && onSearchChange ? (
@@ -382,16 +403,6 @@ export default function AppShell({ )}
{headerExtra} - -
diff --git a/web/src/components/Breadcrumb.tsx b/web/src/components/Breadcrumb.tsx new file mode 100644 index 0000000..17348de --- /dev/null +++ b/web/src/components/Breadcrumb.tsx @@ -0,0 +1,61 @@ +import Link from 'next/link'; +import { ChevronRight, type LucideIcon } from 'lucide-react'; + +export interface BreadcrumbItem { + label: string; + href?: string; + icon?: LucideIcon; +} + +export interface BreadcrumbProps { + items: BreadcrumbItem[]; + className?: string; +} + +/** + * Compact breadcrumb trail. The last item is treated as the current page. + * Shared by the global app header and the pipeline header for a consistent + * "you are here" affordance. + */ +export default function Breadcrumb({ items, className = '' }: BreadcrumbProps) { + return ( + + ); +} diff --git a/web/src/components/CountUp.tsx b/web/src/components/CountUp.tsx new file mode 100644 index 0000000..5d3fb57 --- /dev/null +++ b/web/src/components/CountUp.tsx @@ -0,0 +1,19 @@ +'use client'; + +import { useCountUp } from '@/hooks/useCountUp'; + +export interface CountUpProps { + value: number; + durationMs?: number; + /** Custom formatter for the (rounded) display value. Defaults to locale string. */ + format?: (n: number) => string; + className?: string; +} + +/** Renders a number that animates up to `value` (respects prefers-reduced-motion). */ +export default function CountUp({ value, durationMs, format, className }: CountUpProps) { + const animated = useCountUp(value, durationMs); + const rounded = Math.round(animated); + const display = format ? format(rounded) : rounded.toLocaleString(); + return {display}; +} diff --git a/web/src/components/SectionLoadingGate.tsx b/web/src/components/SectionLoadingGate.tsx new file mode 100644 index 0000000..2f72c94 --- /dev/null +++ b/web/src/components/SectionLoadingGate.tsx @@ -0,0 +1,31 @@ +'use client'; + +import type { ReactNode } from 'react'; +import type { SectionKey } from '@/lib/reportSections'; +import { isSectionPending } from '@/lib/reportViewSections'; +import { useTabSections } from '@/hooks/useTabSections'; + +export interface SectionLoadingGateProps { + sections: readonly SectionKey[]; + enabled?: boolean; + fallback: ReactNode; + children: ReactNode; +} + +/** Renders fallback shimmer until all listed sections are loaded. */ +export function SectionLoadingGate({ + sections, + enabled = true, + fallback, + children, +}: SectionLoadingGateProps) { + const statusMap = useTabSections(sections, enabled); + if (isSectionPending(sections, statusMap)) return <>{fallback}; + return <>{children}; +} + +export function isSectionStatusPending( + status: 'idle' | 'loading' | 'loaded' | 'error' | undefined, +): boolean { + return status === 'idle' || status === 'loading' || status === 'error'; +} diff --git a/web/src/components/SectionWidgetSkeleton.tsx b/web/src/components/SectionWidgetSkeleton.tsx new file mode 100644 index 0000000..a890faa --- /dev/null +++ b/web/src/components/SectionWidgetSkeleton.tsx @@ -0,0 +1,93 @@ +import { Skeleton } from '@/components/Skeleton'; + +export function StatRowSkeleton({ count = 4 }: { count?: number }) { + return ( +
+ {Array.from({ length: count }, (_, i) => ( +
+ + +
+ ))} +
+ ); +} + +export function ChartBlockSkeleton() { + return ( +
+ + +
+ ); +} + +export function CardBlockSkeleton({ lines = 4 }: { lines?: number }) { + return ( +
+ + {Array.from({ length: lines }, (_, i) => ( + + ))} +
+ ); +} + +export function TableBlockSkeleton({ rows = 6 }: { rows?: number }) { + return ( +
+ + {Array.from({ length: rows }, (_, i) => ( + + ))} +
+ ); +} + +/** Default page body placeholder while a report section loads (tabs, filters, table). */ +export function ViewPageSkeleton() { + return ( +
+
+ + +
+ +
+ + +
+ +
+ {Array.from({ length: 5 }, (_, i) => ( + + ))} +
+ +
+
+ + + + + +
+
+ {Array.from({ length: 10 }, (_, i) => ( +
+ + + + +
+ ))} +
+
+ + + +
+
+
+ ); +} diff --git a/web/src/components/UrlInspectorDrawer.tsx b/web/src/components/UrlInspectorDrawer.tsx index caf702f..f4dbebb 100644 --- a/web/src/components/UrlInspectorDrawer.tsx +++ b/web/src/components/UrlInspectorDrawer.tsx @@ -1,9 +1,13 @@ 'use client'; -import { useMemo } from 'react'; -import { X } from 'lucide-react'; +import { Fragment, useMemo } from 'react'; +import { ChevronLeft, ChevronRight, X } from 'lucide-react'; import { useReport } from '@/context/useReport'; +import { useUrlInspector } from '@/context/UrlInspectorContext'; +import { useSectionData } from '@/hooks/useSectionData'; import InspectorTabs from '@/components/links/InspectorTabs'; +import { shortPath } from '@/lib/linkGraph'; +import { strings } from '@/lib/strings'; import type { InspectorDetails, LinkDetail, ReportLink } from '@/types/report'; interface UrlInspectorDrawerProps { @@ -60,6 +64,11 @@ function buildInspectorDetails(data: NonNullable['d export default function UrlInspectorDrawer({ url, onClose }: UrlInspectorDrawerProps) { const { data } = useReport(); + const { trail, back, forward, goTo, canGoBack, canGoForward } = useUrlInspector(); + const ui = strings.components.urlInspector; + // Ensure link-graph data (link_edges, inlink_anchor_matrix) is loaded while the + // inspector is open, even if it was launched from a view that didn't need it. + useSectionData('links', Boolean(url)); const links = (data?.links || []) as ReportLink[]; const link = useMemo((): LinkDetail | null => { @@ -77,12 +86,68 @@ export default function UrlInspectorDrawer({ url, onClose }: UrlInspectorDrawerP if (!url || !link) return null; return ( -
- + +
+ +
diff --git a/web/src/components/ViewSectionLoader.tsx b/web/src/components/ViewSectionLoader.tsx new file mode 100644 index 0000000..ae534e9 --- /dev/null +++ b/web/src/components/ViewSectionLoader.tsx @@ -0,0 +1,11 @@ +'use client'; + +import { pathSlugToViewId } from '@/routes'; +import { useViewSections } from '@/hooks/useViewSections'; + +/** Triggers on-demand section fetches for the active report route. */ +export default function ViewSectionLoader({ slug }: { slug: string }): null { + const viewId = pathSlugToViewId(slug); + useViewSections(viewId, viewId != null && viewId !== 'home'); + return null; +} diff --git a/web/src/components/ViewSectionLoading.tsx b/web/src/components/ViewSectionLoading.tsx new file mode 100644 index 0000000..36d41bf --- /dev/null +++ b/web/src/components/ViewSectionLoading.tsx @@ -0,0 +1,32 @@ +'use client'; + +import { PageLayout, PageHeader } from '@/components'; +import { Skeleton } from '@/components/Skeleton'; +import { ViewPageSkeleton } from '@/components/SectionWidgetSkeleton'; +import { strings } from '@/lib/strings'; + +export function ViewSectionLoading({ title }: { title: string }) { + return ( + + +
+ {strings.app.loading} + +
+
+ ); +} + +export function OverviewHeaderSkeleton() { + return ( +
+ + +
+ {[0, 1, 2, 3].map((i) => ( + + ))} +
+
+ ); +} diff --git a/web/src/components/ViewTabs.tsx b/web/src/components/ViewTabs.tsx index 786012e..ab21f3c 100644 --- a/web/src/components/ViewTabs.tsx +++ b/web/src/components/ViewTabs.tsx @@ -29,7 +29,7 @@ export default function ViewTabs({ }: ViewTabsProps) { return (
diff --git a/web/src/components/chat/ChatSidebar.tsx b/web/src/components/chat/ChatSidebar.tsx index 2b5a5b5..c287ee5 100644 --- a/web/src/components/chat/ChatSidebar.tsx +++ b/web/src/components/chat/ChatSidebar.tsx @@ -9,6 +9,7 @@ import { Link as LinkIcon, MessageSquarePlus, PanelLeft, + PenLine, Settings, Terminal, Trash2, @@ -50,6 +51,7 @@ const NAV_LINKS = [ { href: '/search-performance', label: c.navGsc, icon: TrendingUp }, { href: '/links', label: c.navLinks, icon: LinkIcon }, { href: '/pipeline', label: c.navPipeline, icon: Terminal }, + { href: '/write', label: strings.nav.write.label, icon: PenLine }, ] as const; function RailButton({ diff --git a/web/src/components/chat/blocks/ChatImageAuditBlock.tsx b/web/src/components/chat/blocks/ChatImageAuditBlock.tsx index c1cdbf9..f115b6a 100644 --- a/web/src/components/chat/blocks/ChatImageAuditBlock.tsx +++ b/web/src/components/chat/blocks/ChatImageAuditBlock.tsx @@ -1,126 +1,25 @@ 'use client'; -import { ImageIcon } from 'lucide-react'; -import { SimpleBarChart } from '@/components/charts/SimpleBarChart'; import type { ChatBlock } from '@/components/chat/deriveChatBlocks'; -import { strings } from '@/lib/strings'; +import ImageAuditSummaryCards from '@/components/imageSeo/ImageAuditSummaryCards'; type Block = Extract; -const ib = strings.components.chat.blocks.imageAudit; - -function StatCard({ - label, - value, - tone, -}: { - label: string; - value: number; - tone: 'ok' | 'warn' | 'neutral'; -}) { - const toneClass = - tone === 'ok' - ? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-200' - : tone === 'warn' - ? 'border-amber-500/30 bg-amber-500/10 text-amber-100' - : 'border-default bg-brand-800/40 text-foreground'; - - return ( -
-

{label}

-

{value.toLocaleString()}

-
- ); -} export default function ChatImageAuditBlock({ block }: { block: Block }) { - const chartItems = [ - { label: ib.missingAlt, value: block.pagesMissingAlt }, - { label: ib.noLazyLoad, value: block.pagesWithoutLazy }, - { label: ib.missingDimensions, value: block.pagesMissingDimensions }, - { label: ib.lighthouseIssues, value: block.lighthouseImageDiagnostics }, - ].filter((i) => i.value > 0); - return ( -
-
-
- -
-
-

{ib.title}

-

{ib.subtitle}

-
-
-

- {block.imagesTotal.toLocaleString()} -

-

{ib.totalImages}

-
-
- -
- 0 ? 'warn' : 'ok'} - /> - 0 ? 'warn' : 'ok'} - /> - 0 ? 'warn' : 'ok'} - /> - 0 ? 'warn' : 'ok'} - /> -
- -
- {block.ogCoveragePct != null ? ( - - {ib.ogCoverage}:{' '} - - {block.ogCoveragePct % 1 === 0 - ? block.ogCoveragePct - : block.ogCoveragePct.toFixed(1)} - % - - {block.ogMissingCount != null && block.ogMissingCount > 0 - ? ` · ${block.ogMissingCount} missing` - : ''} - - ) : null} - - {ib.sizeProbe}:{' '} - - {block.inventoryAvailable ? ib.probeOn : ib.probeOff} - - {block.inventoryAvailable && block.inventoryProbed != null - ? ` · ${block.inventoryProbed} URLs` - : ''} - -
- - {chartItems.length > 0 ? ( -
-

{ib.issueBreakdown}

- i.label)} - values={chartItems.map((i) => i.value)} - ariaLabel={ib.issueBreakdown} - /> -
- ) : null} -
+ ); } diff --git a/web/src/components/contentStudio/AiSuggestionsPanel.tsx b/web/src/components/contentStudio/AiSuggestionsPanel.tsx new file mode 100644 index 0000000..a469732 --- /dev/null +++ b/web/src/components/contentStudio/AiSuggestionsPanel.tsx @@ -0,0 +1,115 @@ +'use client'; + +import { Loader2, Sparkles } from 'lucide-react'; +import { strings } from '@/lib/strings'; +import type { ContentAnalyzeResult } from '@/types/contentStudio'; + +interface AiSuggestionsPanelProps { + analysis: ContentAnalyzeResult | null; + loading: boolean; + error: string | null; + visible: boolean; +} + +function priorityClass(p: string): string { + const v = p.toLowerCase(); + if (v === 'high') return 'border-l-red-500/60'; + if (v === 'low') return 'border-l-muted-foreground/40'; + return 'border-l-amber-500/60'; +} + +export default function AiSuggestionsPanel({ + analysis, + loading, + error, + visible, +}: AiSuggestionsPanelProps) { + const s = strings.views.contentStudio.ai; + + if (!visible) { + return ( +
+ {s.disabledHint} +
+ ); + } + + if (loading) { + return ( +
+ + {s.analyzing} +
+ ); + } + + if (error) { + return

{error}

; + } + + if (!analysis) { + return ( +

{s.clickAnalyze}

+ ); + } + + return ( +
+
+ + {s.title} +
+ {analysis.summary ? ( +

{analysis.summary}

+ ) : null} + {analysis.provenance ? ( +

{analysis.provenance}

+ ) : null} + {analysis.tools_used && analysis.tools_used.length > 0 ? ( +

+ {s.toolsUsed}: {analysis.tools_used.join(' → ')} +

+ ) : null} + {analysis.ai_error ? ( +

{analysis.ai_error}

+ ) : null} + + {analysis.suggestions.length > 0 ? ( +
    + {analysis.suggestions.map((item, i) => ( +
  • + {item.text} +
  • + ))} +
+ ) : ( +

{s.noSuggestions}

+ )} + + {analysis.outline.length > 0 ? ( +
+

{s.outlineTitle}

+
    + {analysis.outline.map((line) => ( +
  • {line}
  • + ))} +
+
+ ) : null} + + {analysis.title_ideas.length > 0 ? ( +
+

{s.titleIdeas}

+
    + {analysis.title_ideas.map((t) => ( +
  • {t}
  • + ))} +
+
+ ) : null} +
+ ); +} diff --git a/web/src/components/contentStudio/AnalyzerSidebar.tsx b/web/src/components/contentStudio/AnalyzerSidebar.tsx new file mode 100644 index 0000000..879c7a1 --- /dev/null +++ b/web/src/components/contentStudio/AnalyzerSidebar.tsx @@ -0,0 +1,39 @@ +'use client'; + +import SeoScoreSidebar from './SeoScoreSidebar'; +import AiSuggestionsPanel from './AiSuggestionsPanel'; +import type { ContentAnalyzeResult, ContentScoreResult } from '@/types/contentStudio'; + +interface AnalyzerSidebarProps { + score: ContentScoreResult | null; + scoreLoading: boolean; + scoreError: string | null; + keyword: string; + analysis: ContentAnalyzeResult | null; + analyzeLoading: boolean; + analyzeError: string | null; + aiVisible: boolean; +} + +export default function AnalyzerSidebar({ + score, + scoreLoading, + scoreError, + keyword, + analysis, + analyzeLoading, + analyzeError, + aiVisible, +}: AnalyzerSidebarProps) { + return ( +
+ + +
+ ); +} diff --git a/web/src/components/contentStudio/ContentEditor.tsx b/web/src/components/contentStudio/ContentEditor.tsx new file mode 100644 index 0000000..c353e5c --- /dev/null +++ b/web/src/components/contentStudio/ContentEditor.tsx @@ -0,0 +1,397 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import dynamic from 'next/dynamic'; +import { Save, ScanSearch, Sparkles } from 'lucide-react'; +import { apiUrl } from '@/lib/publicBase'; +import { strings } from '@/lib/strings'; +import { Button } from '@/components'; +import SeoScoreSidebar from './SeoScoreSidebar'; +import AiSuggestionsPanel from './AiSuggestionsPanel'; +import { useContentScore } from './useContentScore'; +import type { ContentAnalyzeResult, ContentDraftDetail, ContentScoreResult } from '@/types/contentStudio'; + +const RichTextEditor = dynamic(() => import('./RichTextEditor'), { + ssr: false, + loading: () => ( +
+ ), +}); + +export interface ContentEditorProps { + draft: ContentDraftDetail; + propertyId: number; + readOnly: boolean; + saving: boolean; + layout?: 'embedded' | 'page'; + siteLabel?: string; + onBack?: () => void; + aiSuggestionsEnabled?: boolean; + onAiSuggestionsEnabledChange?: (enabled: boolean) => void; + onScoreChange?: (score: ContentScoreResult | null) => void; + onAnalysisChange?: (analysis: ContentAnalyzeResult | null) => void; + onAnalyzeLoading?: (loading: boolean) => void; + onAnalyzeError?: (error: string | null) => void; + analysis?: ContentAnalyzeResult | null; + analyzeLoading?: boolean; + analyzeError?: string | null; + onSave: (patch: { + title: string; + target_keyword: string; + landing_url: string | null; + title_tag: string; + meta_description: string; + body_html: string; + grade_score: number | null; + grade_snapshot: ContentScoreResult | null; + }) => void; +} + +export default function ContentEditor({ + draft, + propertyId, + readOnly, + saving, + layout = 'embedded', + siteLabel, + onBack, + aiSuggestionsEnabled = true, + onAiSuggestionsEnabledChange, + onScoreChange, + onAnalysisChange, + onAnalyzeLoading, + onAnalyzeError, + analysis = null, + analyzeLoading = false, + analyzeError = null, + onSave, +}: ContentEditorProps) { + const s = strings.views.contentStudio.editor; + const ai = strings.views.contentStudio.ai; + const isPage = layout === 'page'; + + const [title, setTitle] = useState(draft.title); + const [keyword, setKeyword] = useState(draft.target_keyword); + const [landingUrl, setLandingUrl] = useState(draft.landing_url || ''); + const [titleTag, setTitleTag] = useState(draft.title_tag); + const [metaDescription, setMetaDescription] = useState(draft.meta_description); + const [bodyHtml, setBodyHtml] = useState(draft.body_html); + const [seoOpen, setSeoOpen] = useState(false); + const [analyzing, setAnalyzing] = useState(false); + + const { score, loading: scoreLoading, error: scoreError } = useContentScore({ + propertyId, + keyword, + bodyHtml, + titleTag, + metaDescription, + landingUrl: landingUrl || null, + enabled: !readOnly, + }); + + useEffect(() => { + onScoreChange?.(score); + }, [score, onScoreChange]); + + const runAnalyze = useCallback(async (refresh = false) => { + if (!keyword.trim()) return; + setAnalyzing(true); + onAnalyzeLoading?.(true); + onAnalyzeError?.(null); + try { + const res = await fetch(apiUrl('/content/analyze'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + propertyId, + keyword, + bodyHtml, + titleTag, + metaDescription, + landingUrl: landingUrl.trim() || null, + title, + useAi: aiSuggestionsEnabled, + refresh, + }), + }); + const payload = await res.json(); + if (!res.ok) throw new Error(payload.error || ai.analyzeFailed); + const analysis = (payload.analysis || null) as ContentAnalyzeResult | null; + if (analysis?.score) onScoreChange?.(analysis.score); + onAnalysisChange?.(analysis); + } catch (e) { + const msg = e instanceof Error ? e.message : ai.analyzeFailed; + onAnalyzeError?.(msg); + onAnalysisChange?.(null); + } finally { + setAnalyzing(false); + onAnalyzeLoading?.(false); + } + }, [ + propertyId, + keyword, + bodyHtml, + titleTag, + metaDescription, + landingUrl, + title, + aiSuggestionsEnabled, + ai.analyzeFailed, + onScoreChange, + onAnalysisChange, + onAnalyzeLoading, + onAnalyzeError, + ]); + + const handleSave = () => { + onSave({ + title, + target_keyword: keyword, + landing_url: landingUrl.trim() || null, + title_tag: titleTag, + meta_description: metaDescription, + body_html: bodyHtml, + grade_score: score?.grade_score ?? null, + grade_snapshot: score, + }); + }; + + const gradeBadge = + score != null ? ( + + {score.grade_label} · {score.grade_score} + + ) : null; + + if (isPage) { + return ( +
+
+
+
+ {siteLabel ? ( + + {siteLabel} + + ) : null} + setTitle(e.target.value)} + disabled={readOnly} + placeholder={s.draftTitlePlaceholder} + className="min-w-0 flex-1 bg-transparent text-base font-semibold text-foreground placeholder:text-muted-foreground focus:outline-none disabled:opacity-60 sm:text-lg" + /> +
+ + {!readOnly ? ( + + ) : null} + {gradeBadge} + {!readOnly ? ( + + ) : null} +
+ +
+ setKeyword(e.target.value)} + disabled={readOnly} + placeholder={s.targetKeyword} + className="min-w-[7rem] flex-1 rounded-md border border-default bg-[var(--chat-surface)] px-2 py-1 text-xs text-foreground focus:border-blue-500 focus:outline-none disabled:opacity-60" + /> + setLandingUrl(e.target.value)} + disabled={readOnly} + placeholder={s.landingUrl} + className="min-w-[9rem] flex-[2] rounded-md border border-default bg-[var(--chat-surface)] px-2 py-1 text-xs text-foreground focus:border-blue-500 focus:outline-none disabled:opacity-60" + /> + +
+ + {seoOpen ? ( +
+ setTitleTag(e.target.value)} + disabled={readOnly} + placeholder={s.titleTag} + className="rounded-md border border-default bg-[var(--chat-surface)] px-2 py-1 text-xs text-foreground focus:border-blue-500 focus:outline-none disabled:opacity-60" + /> + setMetaDescription(e.target.value)} + disabled={readOnly} + placeholder={s.metaDescription} + className="rounded-md border border-default bg-[var(--chat-surface)] px-2 py-1 text-xs text-foreground focus:border-blue-500 focus:outline-none disabled:opacity-60" + /> +
+ ) : null} +
+ +
+ +
+ +
+ + +
+
+ ); + } + + return ( +
+
+ {onBack ? ( + + ) : null} + {!readOnly ? ( + + ) : null} +
+ +
+
+ +
+ + +
+ +