diff --git a/AGENT.md b/AGENT.md
index 390cfc7..31a5dab 100644
--- a/AGENT.md
+++ b/AGENT.md
@@ -4,7 +4,7 @@ Developer reference for agents and contributors. User-facing overview: [README.m
**What it is:** `python -m src` from repo root (`src/__main__.py` -> package **`website_profiling`**). Config: stored in **PostgreSQL** (`pipeline_config` table, `key/value/is_unknown/updated_at`). A shadow **`pipeline-config.txt`** is auto-written to `DATA_DIR` on every Save/Run. CLI loads DB first (`DATABASE_URL`), then shadow file; `--config` overrides with a file. Reference keys: `input.txt.example` and `pipeline-config.example.txt` (not auto-loaded).
-**LLM / AI:** Settings live in **`llm_config`** table in PostgreSQL. Providers: OpenAI, Google Gemini, Anthropic, Ollama (`web/src/lib/llmConfigSchema.ts`). Configure only via web UI **AI** tab (`GET/PUT /api/llm-config`, localhost). Never in `pipeline-config.txt` or `--config`.
+**LLM / AI:** Settings live in **`llm_config`** table in PostgreSQL. Providers: OpenAI, Google Gemini, Anthropic, Groq, Ollama (`web/src/lib/llmConfigSchema.ts`). Configure only via web UI **AI** tab (`GET/PUT /api/llm-config`, localhost). Never in `pipeline-config.txt` or `--config`.
**Frontend:** **`web/`** (Next.js) -- server reads PostgreSQL via `/api/report/*`.
diff --git a/README.md b/README.md
index ca1ad73..35a0e51 100644
--- a/README.md
+++ b/README.md
@@ -214,13 +214,14 @@ In Audit settings, set **Crawl rendering** to `javascript` (always headless Chro
### AI chat (optional)
-Ask questions about audit data at [http://localhost:3000/chat](http://localhost:3000/chat). Enable a provider under **Run audit → AI settings** (`llm_enabled`, provider, model). `./local-run setup` installs Python deps from `requirements.txt` (including `httpx`, OpenAI, and Anthropic SDKs; Gemini uses `httpx` via REST).
+Ask questions about audit data at [http://localhost:3000/chat](http://localhost:3000/chat). Enable a provider under **Run audit → AI settings** (`llm_enabled`, provider, model). `./local-run setup` installs Python deps from `requirements.txt` (including `httpx`, OpenAI, Anthropic, and Groq SDKs; Gemini uses `httpx` via REST).
| Provider | Notes |
|----------|-------|
| **Ollama** | Local daemon at `http://127.0.0.1:11434`. Chat UI lists installed models plus the live Ollama cloud catalog. Native tool calling when supported; ReAct fallback otherwise. |
| **OpenAI** / **Anthropic** | API key in AI settings or env (`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`); native tool calling with streaming. |
| **Google Gemini** | API key in AI settings or `GEMINI_API_KEY`; REST via `httpx`. |
+| **Groq** | API key in AI settings or `GROQ_API_KEY`; official Groq Python SDK; native tool calling with streaming. Default model `openai/gpt-oss-120b`. |
The agent uses the same **340 read-only audit tools** as the MCP server ([docs/MCP.md](docs/MCP.md)), with **dynamic routing** (~45 tools per turn). Responses stream over SSE (`POST /api/chat`). Sessions persist per property (`chat_sessions` / `chat_messages`).
diff --git a/docs/MCP.md b/docs/MCP.md
index 25e9f7b..420bef6 100644
--- a/docs/MCP.md
+++ b/docs/MCP.md
@@ -239,6 +239,7 @@ Responses stream over SSE via `POST /api/chat`. Sessions persist per property in
| **OpenAI** | Native with streaming | API key in AI settings or `OPENAI_API_KEY` |
| **Anthropic** | Native with streaming | API key in AI settings or `ANTHROPIC_API_KEY` |
| **Google Gemini** | Native with streaming | API key in AI settings or `GEMINI_API_KEY`; REST via `httpx` |
+| **Groq** | Native with streaming | API key in AI settings or `GROQ_API_KEY`; official Groq Python SDK; default model `openai/gpt-oss-120b` |
---
diff --git a/requirements.txt b/requirements.txt
index 002458f..ad01943 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -37,6 +37,7 @@ playwright==1.60.0
httpx==0.28.1
openai==2.41.0
anthropic==0.107.0
+groq==1.4.0
# Spell-check / HTML validation extras
pyspellchecker==0.9.0
diff --git a/src/website_profiling/crawl/crawler.py b/src/website_profiling/crawl/crawler.py
index c3b9076..1757e49 100644
--- a/src/website_profiling/crawl/crawler.py
+++ b/src/website_profiling/crawl/crawler.py
@@ -449,8 +449,15 @@ def crawl(
continue
futures.append(ex.submit(self.worker, url))
- if futures and self.queue.empty():
+ can_submit_more = (
+ not self.queue.empty()
+ and len(futures) < self.concurrency
+ and (len(self.results) + len(futures)) < self.max_pages
+ )
+ if futures and not can_submit_more:
# Block until at least one future completes instead of busy-polling.
+ # Covers both an empty frontier and a saturated worker pool; wait()
+ # returns immediately if a future is already done.
wait(futures, return_when=FIRST_COMPLETED)
remaining = []
diff --git a/src/website_profiling/crawl/fetchers/factory.py b/src/website_profiling/crawl/fetchers/factory.py
index 5f0bb8d..ff4f1d0 100644
--- a/src/website_profiling/crawl/fetchers/factory.py
+++ b/src/website_profiling/crawl/fetchers/factory.py
@@ -29,8 +29,10 @@ def _browser_auth_from_session(
headers[str(key)] = str(value)
credentials: Optional[dict[str, str]] = None
auth = getattr(session, "auth", None)
- if auth and auth[0]:
- credentials = {"username": str(auth[0]), "password": str(auth[1] or "")}
+ # requests also allows a callable auth handler; only basic (user, pass) tuples map here.
+ if isinstance(auth, (tuple, list)) and len(auth) >= 1 and auth[0]:
+ password = str(auth[1] or "") if len(auth) > 1 else ""
+ credentials = {"username": str(auth[0]), "password": password}
return headers, credentials
diff --git a/src/website_profiling/integrations/google/keyword_enrich.py b/src/website_profiling/integrations/google/keyword_enrich.py
index f592051..49a2310 100644
--- a/src/website_profiling/integrations/google/keyword_enrich.py
+++ b/src/website_profiling/integrations/google/keyword_enrich.py
@@ -73,7 +73,8 @@ def _normalize_kw(kw: str) -> str:
# ── Intent ────────────────────────────────────────────────────────────────────
def classify_intent(kw: str, brand_name: str = "") -> str:
- if brand_name and brand_name.lower().split()[0] in kw.lower():
+ brand_tokens = brand_name.lower().split()
+ if brand_tokens and brand_tokens[0] in kw.lower():
return "navigational"
if QUESTION_STARTS.match(kw):
return "informational"
diff --git a/src/website_profiling/llm/agent.py b/src/website_profiling/llm/agent.py
index fc8cbbf..e9b0a1b 100644
--- a/src/website_profiling/llm/agent.py
+++ b/src/website_profiling/llm/agent.py
@@ -2,13 +2,19 @@
from __future__ import annotations
import json
+import os
from typing import Any, Callable
from ..concurrency import map_parallel, tool_concurrency
from ..llm_config import llm_is_enabled, load_llm_config_from_db
from ..text_sanitize import sanitize_unicode_deep, strip_surrogates
from ..tools.audit_tools import AuditToolContext
-from ..tools.audit_tools.registry import TOOL_DEFINITIONS, dispatch_tool, openai_tools_schema
+from ..tools.audit_tools.registry import (
+ TOOL_DEFINITIONS,
+ _normalize_tool_args,
+ dispatch_tool,
+ openai_tools_schema,
+)
from ..tools.audit_tools.tool_selector import (
apply_tool_cap,
chat_tool_mode,
@@ -16,8 +22,37 @@
select_tools_for_turn,
)
from .base import ChatResult, ToolCall, get_llm_client
+from .chat_narrative import ChatNarrativeError, synthesize_chat_narrative
+
+MAX_TOOL_ROUNDS_DEFAULT = 10
+MAX_TOOL_ROUNDS_EXTENDED = 100
+# Back-compat for tests and imports
+MAX_TOOL_ROUNDS = MAX_TOOL_ROUNDS_DEFAULT
+
+
+def _truthy_cfg(cfg: dict[str, str], key: str) -> bool:
+ return str(cfg.get(key, "")).lower() in ("true", "1", "yes")
+
+
+def _max_tool_rounds(cfg: dict[str, str]) -> int:
+ """Resolve per-turn tool loop cap from llm_config and optional env overrides."""
+ if _truthy_cfg(cfg, "llm_chat_unlimited_tool_rounds"):
+ raw = (os.environ.get("CHAT_MAX_TOOL_ROUNDS_EXTENDED") or "").strip()
+ if raw:
+ try:
+ return max(1, int(raw))
+ except ValueError:
+ pass
+ return MAX_TOOL_ROUNDS_EXTENDED
+ raw = (os.environ.get("CHAT_MAX_TOOL_ROUNDS") or "").strip()
+ if raw:
+ try:
+ return max(1, int(raw))
+ except ValueError:
+ pass
+ return MAX_TOOL_ROUNDS_DEFAULT
-MAX_TOOL_ROUNDS = 10
+NARRATIVE_FAILED_MSG = "Could not generate a summary. Tool results are shown below."
SYSTEM_PROMPT = """You are Site Audit AI, a technical SEO assistant for a self-hosted site audit platform.
You help users understand crawl results, audit issues, Lighthouse scores, keywords, and Search Console data.
@@ -31,7 +66,7 @@
- Use get_data_coverage_report when tools return empty or missing data.
Image playbook:
-- Overview: get_image_audit_summary first — the UI renders summary cards, page preview lists (alt/lazy/OG/dimensions), and Lighthouse image findings. Write only ### Power Insights and ### Recommended actions (interpretation). Never repeat counts, URL lists, or markdown tables of pages.
+- Overview: get_image_audit_summary first — the UI renders summary cards, page preview lists (alt/lazy/OG/dimensions), and Lighthouse image findings. Call tools only; the app generates user-facing narrative separately.
- Missing alt / lazy / OG / dimensions: get_image_audit_summary includes previews; call list_pages_* only if the user wants the full exportable list
- All image URLs: list_site_image_urls (optional kind filter)
- Lighthouse image issues: list_lighthouse_image_opportunities
@@ -63,10 +98,14 @@
- When citing issues, include the URL when available.
- The chat UI automatically renders charts, gauges, and tables from tool results. Never tell the user you cannot show graphs or charts, and never send them to other app pages for data you can fetch with tools.
- For visual or chart requests, always call the appropriate tools first, then give a short interpretation (2–4 sentences) with recommendations.
-- When tools return issue lists, scores, or breakdowns, keep the narrative short. Do not re-list every issue or duplicate data in markdown tables—the UI renders structured blocks from tool data.
-- Use markdown headings and bullets for structure. Do not emit fake chart JSON or custom visualization blocks.
+- When tools return issue lists, scores, or breakdowns, do not re-list them in prose—the UI renders structured blocks from tool data.
+- Do not emit markdown headings, bullet lists, or pipe tables for the user. The app synthesizes the final narrative from tool results.
+- After gathering enough data via tools, stop calling tools. A brief internal acknowledgment is enough; user-facing text is generated separately.
+- Do not repeat health scores, URL counts, success rates, category scores, priority counts, or URL lists when the UI already shows them in cards or tables.
+- Never mention internal tool names (e.g. run_technical_workflow, export_audit_report) in user-facing text.
- You are read-only: you cannot run crawls or change settings.
-- If data is missing, say what integration or crawl step is needed.
+- Do not pass property_id or report_id in tool calls — they are injected from the active chat property.
+- If data is missing, say what integration or crawl step is needed (briefly; narrative will be expanded separately).
"""
REACT_PROMPT_SUFFIX = """
@@ -111,8 +150,6 @@ def _react_step(
tool_calls=[ToolCall(id="react-0", name=name, arguments=args)],
)
text = str(data.get("text") or data.get("answer") or data.get("content") or "")
- if on_token and text:
- on_token(text)
return ChatResult(content=text)
@@ -173,6 +210,41 @@ def _build_openai_messages(history: list[dict[str, str]]) -> list[dict[str, Any]
return out
+def _finish_with_narrative(
+ cfg: dict[str, str],
+ user_message: str,
+ tool_events: list[dict[str, Any]],
+ on_event: Callable[[dict], None] | None,
+ *,
+ partial_note: str | None = None,
+) -> dict[str, Any]:
+ if partial_note:
+ _emit(on_event, {"type": "partial_done", "message": partial_note})
+
+ def on_status(phase: str) -> None:
+ detail = "Retrying summary…" if phase == "retrying" else "Summarizing insights…"
+ _emit(on_event, {"type": "status", "phase": "synthesizing", "detail": detail})
+
+ try:
+ narrative = synthesize_chat_narrative(
+ cfg,
+ user_message,
+ tool_events,
+ on_status=on_status,
+ )
+ except ChatNarrativeError:
+ _emit(on_event, {"type": "error", "message": NARRATIVE_FAILED_MSG})
+ return {
+ "ok": False,
+ "error": NARRATIVE_FAILED_MSG,
+ "tool_events": tool_events,
+ }
+
+ _emit(on_event, {"type": "narrative", "narrative": narrative})
+ _emit(on_event, {"type": "done"})
+ return {"ok": True, "tool_events": tool_events, "narrative": narrative}
+
+
def run_agent_turn(
messages: list[dict[str, str]],
context: AuditToolContext,
@@ -181,7 +253,7 @@ def run_agent_turn(
) -> dict[str, Any]:
"""
Run the agent loop. Emits NDJSON-style events via on_event.
- Returns final result dict with ok, message, tool_events.
+ Returns final result dict with ok, tool_events, and narrative on success.
"""
cfg = load_llm_config_from_db()
if not llm_is_enabled(cfg):
@@ -199,33 +271,37 @@ def run_agent_turn(
openai_messages = _build_openai_messages(messages)
last_user = _last_user_message(messages)
active_names = select_tools_for_turn(last_user, messages)
- tools = openai_tools_schema(active_names)
+ tools = openai_tools_schema(active_names, context_scoped=True)
tool_events: list[dict[str, Any]] = []
- final_message = ""
+ max_rounds = _max_tool_rounds(cfg)
+ partial_note: str | None = None
- def on_token(text: str) -> None:
- _emit(on_event, {"type": "token", "text": strip_surrogates(text)})
-
- for _round in range(MAX_TOOL_ROUNDS):
+ for _round in range(max_rounds):
_emit(on_event, {
"type": "status",
"phase": "model",
- "detail": f"Thinking (step {_round + 1}/{MAX_TOOL_ROUNDS})…",
+ "detail": f"Thinking (step {_round + 1}/{max_rounds})…",
})
try:
llm_messages = sanitize_unicode_deep(openai_messages)
if _supports_native_tools(client):
- result = client.chat_with_tools(llm_messages, tools, on_token=on_token)
+ result = client.chat_with_tools(llm_messages, tools, on_token=None)
else:
result = _react_step(
client,
llm_messages,
_tools_description(names=active_names, compact=True),
- on_token,
+ None,
)
except Exception as e:
msg = str(e).strip() or type(e).__name__
- if "httpx" in msg.lower() or "requirements.txt" in msg.lower():
+ if "Connection error" in msg and (cfg.get("llm_provider") or "").strip().lower() == "groq":
+ msg = (
+ "Could not reach Groq. Check your Groq API key on the Secrets page and "
+ "that outbound HTTPS to api.groq.com is allowed. "
+ f"Details: {msg}"
+ )
+ elif "httpx" in msg.lower() or "requirements.txt" in msg.lower():
msg = (
"LLM dependencies are missing. Run: pip install -r requirements.txt "
f"(or restart with ./local-run setup). Details: {msg}"
@@ -282,9 +358,10 @@ def _run_tool(tc: ToolCall) -> dict[str, Any]:
"error": f"tool not loaded this turn: {tc.name}",
"hint": "Call search_audit_tools to load specialized tools, or rephrase your request.",
}
+ tool_args = _normalize_tool_args(tc.arguments)
try:
return sanitize_unicode_deep(
- dispatch_tool(tc.name, tc.arguments, context=context),
+ dispatch_tool(tc.name, tool_args, context=context),
)
except Exception as e: # noqa: BLE001 - isolate one tool's failure from the batch
return {"error": str(e).strip() or type(e).__name__}
@@ -313,16 +390,21 @@ def _run_tool(tc: ToolCall) -> dict[str, Any]:
})
if gated:
- tools = openai_tools_schema(active_names)
+ tools = openai_tools_schema(active_names, context_scoped=True)
continue
- final_message = strip_surrogates(result.content).strip()
- if final_message:
- _emit(on_event, {"type": "done", "message": final_message})
- return {"ok": True, "message": final_message, "tool_events": tool_events}
-
break
+ else:
+ if tool_events:
+ partial_note = (
+ f"The agent completed {len(tool_events)} tool step(s) but did not finish "
+ "all planned steps. Tool results are preserved below."
+ )
- err = "Agent stopped after maximum tool rounds without a final answer."
- _emit(on_event, {"type": "error", "message": err})
- return {"ok": False, "error": err, "tool_events": tool_events}
+ return _finish_with_narrative(
+ cfg,
+ last_user,
+ tool_events,
+ on_event,
+ partial_note=partial_note,
+ )
diff --git a/src/website_profiling/llm/base.py b/src/website_profiling/llm/base.py
index d200af4..32ee099 100644
--- a/src/website_profiling/llm/base.py
+++ b/src/website_profiling/llm/base.py
@@ -23,6 +23,27 @@ class ChatResult:
TokenCallback = Callable[[str], None]
+OLLAMA_DEFAULT_BASES = frozenset({
+ "http://127.0.0.1:11434",
+ "http://localhost:11434",
+})
+
+
+def is_ollama_base_url(url: str) -> bool:
+ """True when llm_base_url points at a local Ollama daemon (not a cloud proxy)."""
+ normalized = (url or "").strip().rstrip("/").lower()
+ if normalized in OLLAMA_DEFAULT_BASES:
+ return True
+ return normalized.endswith(":11434")
+
+
+def optional_cloud_base_url(cfg: dict[str, str]) -> str | None:
+ """Custom OpenAI-compatible base URL; excludes Ollama's local default."""
+ base = (cfg.get("llm_base_url") or "").strip().rstrip("/")
+ if not base or is_ollama_base_url(base):
+ return None
+ return base
+
class LLMClient(Protocol):
def complete_json(self, system: str, user: str) -> dict[str, Any]: ...
@@ -69,6 +90,10 @@ def get_llm_client(cfg: dict[str, str]) -> LLMClient:
from .providers.gemini import GeminiClient
return GeminiClient(cfg)
+ if provider == "groq":
+ from .providers.groq import GroqClient
+
+ return GroqClient(cfg)
if provider == "ollama":
from .providers.ollama import OllamaClient
diff --git a/src/website_profiling/llm/chat_narrative.py b/src/website_profiling/llm/chat_narrative.py
new file mode 100644
index 0000000..f813fa5
--- /dev/null
+++ b/src/website_profiling/llm/chat_narrative.py
@@ -0,0 +1,163 @@
+"""Structured narrative synthesis for chat turns."""
+from __future__ import annotations
+
+import json
+from typing import Any, Callable
+
+from .base import get_llm_client, parse_json_response
+from .prompts import CHAT_NARRATIVE_REPAIR_SYSTEM, CHAT_NARRATIVE_SYSTEM
+
+MAX_ITEMS = 5
+MAX_PAYLOAD_CHARS = 10000
+MAX_PREVIOUS_RESPONSE_CHARS = 4000
+
+NarrativeStatusCallback = Callable[[str], None]
+
+
+class ChatNarrativeError(Exception):
+ def __init__(self, message: str, errors: list[str] | None = None) -> None:
+ super().__init__(message)
+ self.errors = errors or []
+
+
+def build_synthesis_payload(
+ user_message: str,
+ tool_events: list[dict[str, Any]],
+ *,
+ conversation_snippet: str | None = None,
+) -> str:
+ compact_events = [
+ {
+ "name": ev.get("name"),
+ "args": ev.get("args"),
+ "result": ev.get("result"),
+ }
+ for ev in tool_events
+ ]
+ payload: dict[str, Any] = {
+ "user_question": user_message,
+ "tool_results": compact_events,
+ }
+ if conversation_snippet:
+ payload["conversation_context"] = conversation_snippet
+ raw = json.dumps(payload, indent=2, default=str)
+ if len(raw) > MAX_PAYLOAD_CHARS:
+ return raw[:MAX_PAYLOAD_CHARS] + "\n…(truncated)"
+ return raw
+
+
+def _normalize_string_list(value: Any, field: str, errors: list[str]) -> list[str]:
+ if value is None:
+ errors.append(f"missing key {field}")
+ return []
+ if not isinstance(value, list):
+ errors.append(f"{field} must be an array")
+ return []
+ if len(value) > MAX_ITEMS:
+ errors.append(f"{field} has more than {MAX_ITEMS} items")
+ out: list[str] = []
+ for i, item in enumerate(value):
+ if not isinstance(item, str):
+ errors.append(f"{field}[{i}] must be a string")
+ continue
+ text = item.strip()
+ if not text:
+ errors.append(f"{field}[{i}] is empty")
+ continue
+ out.append(text)
+ if len(out) >= MAX_ITEMS:
+ break
+ return out
+
+
+def validate_chat_narrative(raw: dict[str, Any]) -> tuple[dict[str, list[str]], list[str]]:
+ errors: list[str] = []
+ if not isinstance(raw, dict):
+ return {"power_insights": [], "recommended_actions": []}, ["response must be a JSON object"]
+
+ insights = _normalize_string_list(raw.get("power_insights"), "power_insights", errors)
+ actions = _normalize_string_list(raw.get("recommended_actions"), "recommended_actions", errors)
+
+ if not insights and not actions:
+ errors.append("both power_insights and recommended_actions are empty after normalization")
+
+ return {"power_insights": insights, "recommended_actions": actions}, errors
+
+
+def _coerce_attempt(raw: Any) -> tuple[dict[str, Any], str]:
+ if isinstance(raw, dict):
+ return raw, json.dumps(raw, default=str)
+ text = str(raw or "").strip()
+ return parse_json_response(text), text
+
+
+def _attempt_synthesis(
+ client: Any,
+ system: str,
+ user: str,
+) -> tuple[dict[str, list[str]] | None, list[str], str]:
+ errors: list[str] = []
+ raw_text = ""
+ try:
+ raw = client.complete_json(system, user)
+ parsed, raw_text = _coerce_attempt(raw)
+ narrative, errors = validate_chat_narrative(parsed)
+ if not errors:
+ return narrative, [], raw_text
+ except Exception as e: # noqa: BLE001 - convert to validation errors for repair pass
+ errors = [str(e).strip() or type(e).__name__]
+ if not raw_text:
+ raw_text = errors[0]
+ return None, errors, raw_text
+
+
+def synthesize_chat_narrative(
+ cfg: dict[str, str],
+ user_message: str,
+ tool_events: list[dict[str, Any]],
+ *,
+ on_status: NarrativeStatusCallback | None = None,
+) -> dict[str, list[str]]:
+ """Synthesize narrative JSON; retries once with repair prompt before raising."""
+ client = get_llm_client(cfg)
+ payload = build_synthesis_payload(user_message, tool_events)
+
+ if on_status:
+ on_status("synthesizing")
+
+ narrative, errors, previous = _attempt_synthesis(client, CHAT_NARRATIVE_SYSTEM, payload)
+ if narrative is not None:
+ return narrative
+
+ if on_status:
+ on_status("retrying")
+
+ try:
+ original_data = json.loads(payload)
+ except json.JSONDecodeError:
+ original_data = payload
+
+ repair_payload = json.dumps(
+ {
+ "original_data": original_data,
+ "previous_response": (previous or "")[:MAX_PREVIOUS_RESPONSE_CHARS],
+ "errors": errors,
+ "required_schema": {
+ "power_insights": ["string"],
+ "recommended_actions": ["string"],
+ },
+ },
+ indent=2,
+ default=str,
+ )
+
+ narrative2, errors2, _ = _attempt_synthesis(
+ client, CHAT_NARRATIVE_REPAIR_SYSTEM, repair_payload,
+ )
+ if narrative2 is not None:
+ return narrative2
+
+ raise ChatNarrativeError(
+ "Chat narrative synthesis failed after repair attempt.",
+ errors=errors + errors2,
+ )
diff --git a/src/website_profiling/llm/enrich.py b/src/website_profiling/llm/enrich.py
index b2f068b..1b0d4d8 100644
--- a/src/website_profiling/llm/enrich.py
+++ b/src/website_profiling/llm/enrich.py
@@ -12,6 +12,7 @@
import pandas as pd
from ..analysis.text import normalize_fingerprint_text
+from ..console_io import console_print
from ..llm_config import llm_is_enabled
from .base import get_llm_client
from .prompts import (
@@ -147,8 +148,11 @@ def _one(item: tuple[str, dict[str, Any]]) -> tuple[str, dict[str, Any], dict[st
if workers <= 1 or len(pending) <= 1:
for item in pending:
- _, payload, result = _one(item)
- apply_batch(payload, result)
+ try:
+ _, payload, result = _one(item)
+ apply_batch(payload, result)
+ except Exception as exc: # noqa: BLE001 - one batch failing must not abort the rest
+ console_print(f" LLM enrichment batch failed: {exc}", flush=True)
return
with ThreadPoolExecutor(max_workers=workers) as pool:
@@ -157,8 +161,8 @@ def _one(item: tuple[str, dict[str, Any]]) -> tuple[str, dict[str, Any], dict[st
try:
_, payload, result = future.result()
apply_batch(payload, result)
- except Exception:
- pass
+ except Exception as exc: # noqa: BLE001 - one batch failing must not abort the rest
+ console_print(f" LLM enrichment batch failed: {exc}", flush=True)
def _call_cached(
diff --git a/src/website_profiling/llm/prompts.py b/src/website_profiling/llm/prompts.py
index 55dec85..6408d45 100644
--- a/src/website_profiling/llm/prompts.py
+++ b/src/website_profiling/llm/prompts.py
@@ -87,3 +87,15 @@
AUDIT_EXECUTIVE_SYSTEM = """You write a short executive summary for a site audit report for agency clients.
Use ONLY the scores and issues provided. Be direct and prioritize by traffic impact.
Return JSON: {"summary": "3-5 sentences in plain language", "priorities": ["bullet 1", "bullet 2", "bullet 3"]}"""
+
+CHAT_NARRATIVE_SYSTEM = """You write the user-facing narrative for a site-audit chat turn.
+Use ONLY the user question and tool results provided. Do not invent metrics, URLs, or scores.
+The chat UI already renders charts, tables, and score cards from tool data — do not repeat those numbers.
+Return JSON only: {"power_insights": ["..."], "recommended_actions": ["..."]}
+Max 5 items per array. Plain language. No internal tool names. No emoji."""
+
+CHAT_NARRATIVE_REPAIR_SYSTEM = """Your previous response was not valid JSON matching the required schema.
+Return ONLY a JSON object with exactly these keys:
+{"power_insights": ["string", ...], "recommended_actions": ["string", ...]}
+Each value must be a non-empty array of non-empty strings (max 5 each).
+Use ONLY the original user question and tool data provided. Do not invent metrics."""
diff --git a/src/website_profiling/llm/providers/groq.py b/src/website_profiling/llm/providers/groq.py
new file mode 100644
index 0000000..9c0abb2
--- /dev/null
+++ b/src/website_profiling/llm/providers/groq.py
@@ -0,0 +1,143 @@
+"""Groq chat completions via official Python SDK."""
+from __future__ import annotations
+
+import json
+from typing import Any
+
+from ..base import ChatResult, TokenCallback, ToolCall, optional_cloud_base_url, parse_json_response
+
+DEFAULT_MODEL = "openai/gpt-oss-120b"
+_MISSING_KEY_MSG = "Groq API key missing. Set it in the AI tab or GROQ_API_KEY."
+
+
+class GroqClient:
+ def __init__(self, cfg: dict[str, str]) -> None:
+ self._model = (cfg.get("llm_model") or DEFAULT_MODEL).strip()
+ self._timeout = float(cfg.get("llm_timeout_s") or 120)
+ self._api_key = (cfg.get("llm_api_key") or "").strip()
+ self._base_url = optional_cloud_base_url(cfg)
+
+ def _client(self) -> Any:
+ if not self._api_key:
+ raise RuntimeError(_MISSING_KEY_MSG)
+ try:
+ from groq import Groq
+ except ImportError as e:
+ raise ImportError("pip install -r requirements.txt") from e
+
+ kwargs: dict[str, Any] = {"api_key": self._api_key, "timeout": self._timeout}
+ if self._base_url:
+ kwargs["base_url"] = self._base_url
+ return Groq(**kwargs)
+
+ def complete_json(self, system: str, user: str) -> dict[str, Any]:
+ client = self._client()
+ completion = client.chat.completions.create(
+ model=self._model,
+ messages=[
+ {"role": "system", "content": system},
+ {"role": "user", "content": user},
+ ],
+ response_format={"type": "json_object"},
+ temperature=0.2,
+ )
+ choice = (completion.choices or [None])[0]
+ if choice is None:
+ raise RuntimeError("Groq response contained no choices.")
+ content = choice.message.content
+ if content is None:
+ raise RuntimeError("Groq response contained no content.")
+ return parse_json_response(content if isinstance(content, str) else json.dumps(content))
+
+ def chat_with_tools(
+ self,
+ messages: list[dict[str, Any]],
+ tools: list[dict[str, Any]],
+ *,
+ on_token: TokenCallback | None = None,
+ ) -> ChatResult:
+ client = self._client()
+ kwargs: dict[str, Any] = {
+ "model": self._model,
+ "messages": messages,
+ "tools": tools,
+ "tool_choice": "auto",
+ "temperature": 0.2,
+ }
+ if on_token:
+ return self._stream_chat(client, kwargs, on_token)
+
+ completion = client.chat.completions.create(**kwargs)
+ choice = (completion.choices or [None])[0]
+ if choice is None:
+ return ChatResult()
+ return self._parse_message(choice.message, finish_reason=str(choice.finish_reason or "stop"))
+
+ def _parse_message(self, msg: Any, *, finish_reason: str = "stop") -> ChatResult:
+ content = str(getattr(msg, "content", None) or "")
+ tool_calls: list[ToolCall] = []
+ for tc in getattr(msg, "tool_calls", None) or []:
+ fn = getattr(tc, "function", None)
+ raw_args = getattr(fn, "arguments", None) or "{}"
+ try:
+ args = json.loads(raw_args) if isinstance(raw_args, str) else dict(raw_args)
+ except json.JSONDecodeError:
+ args = {}
+ tool_calls.append(
+ ToolCall(
+ id=str(getattr(tc, "id", None) or ""),
+ name=str(getattr(fn, "name", None) or ""),
+ arguments=args if isinstance(args, dict) else {},
+ ),
+ )
+ return ChatResult(content=content, tool_calls=tool_calls, finish_reason=finish_reason)
+
+ def _stream_chat(
+ self,
+ client: Any,
+ kwargs: dict[str, Any],
+ on_token: TokenCallback,
+ ) -> ChatResult:
+ content_parts: list[str] = []
+ tool_calls_acc: dict[int, dict[str, Any]] = {}
+
+ stream = client.chat.completions.create(**kwargs, stream=True)
+ for chunk in stream:
+ choice = (chunk.choices or [None])[0]
+ if choice is None:
+ continue
+ delta = choice.delta
+ if getattr(delta, "content", None):
+ text = str(delta.content)
+ content_parts.append(text)
+ on_token(text)
+ for tc in getattr(delta, "tool_calls", None) or []:
+ idx = int(getattr(tc, "index", None) or 0)
+ acc = tool_calls_acc.setdefault(
+ idx,
+ {"id": "", "name": "", "arguments": ""},
+ )
+ if getattr(tc, "id", None):
+ acc["id"] = tc.id
+ fn = getattr(tc, "function", None)
+ if fn is not None:
+ if getattr(fn, "name", None):
+ acc["name"] = fn.name
+ if getattr(fn, "arguments", None):
+ acc["arguments"] += fn.arguments
+
+ tool_calls: list[ToolCall] = []
+ for acc in tool_calls_acc.values():
+ raw_args = acc.get("arguments") or "{}"
+ try:
+ args = json.loads(raw_args) if isinstance(raw_args, str) else dict(raw_args)
+ except json.JSONDecodeError:
+ args = {}
+ tool_calls.append(
+ ToolCall(
+ id=str(acc.get("id") or ""),
+ name=str(acc.get("name") or ""),
+ arguments=args if isinstance(args, dict) else {},
+ ),
+ )
+ return ChatResult(content="".join(content_parts), tool_calls=tool_calls, finish_reason="stop")
diff --git a/src/website_profiling/llm/providers/openai.py b/src/website_profiling/llm/providers/openai.py
index 29170eb..dfa4af4 100644
--- a/src/website_profiling/llm/providers/openai.py
+++ b/src/website_profiling/llm/providers/openai.py
@@ -4,7 +4,7 @@
import json
from typing import Any
-from ..base import ChatResult, TokenCallback, ToolCall, parse_json_response
+from ..base import ChatResult, TokenCallback, ToolCall, optional_cloud_base_url, parse_json_response
class OpenAIClient:
@@ -13,7 +13,8 @@ def __init__(self, cfg: dict[str, str]) -> None:
self._model = (cfg.get("llm_model") or "gpt-4o-mini").strip()
self._timeout = float(cfg.get("llm_timeout_s") or 120)
self._api_key = (cfg.get("llm_api_key") or "").strip()
- self._base = (cfg.get("llm_base_url") or "https://api.openai.com/v1").strip().rstrip("/")
+ custom_base = optional_cloud_base_url(cfg)
+ self._base = custom_base or "https://api.openai.com/v1"
def complete_json(self, system: str, user: str) -> dict[str, Any]:
if not self._api_key:
@@ -38,7 +39,10 @@ def complete_json(self, system: str, user: str) -> dict[str, Any]:
r = client.post(url, headers=headers, json=payload)
r.raise_for_status()
data = r.json()
- content = data["choices"][0]["message"]["content"]
+ choice = (data.get("choices") or [{}])[0]
+ content = (choice.get("message") or {}).get("content")
+ if content is None:
+ raise RuntimeError("OpenAI response contained no content.")
return parse_json_response(content if isinstance(content, str) else json.dumps(content))
def chat_with_tools(
diff --git a/src/website_profiling/llm_config.py b/src/website_profiling/llm_config.py
index 4ca55da..97e475c 100644
--- a/src/website_profiling/llm_config.py
+++ b/src/website_profiling/llm_config.py
@@ -7,13 +7,29 @@
import os
from typing import Optional
+_LLM_CLOUD_PROVIDERS = ("openai", "gemini", "anthropic", "groq")
+
_ENV_KEY_BY_PROVIDER = {
"openai": "OPENAI_API_KEY",
"gemini": "GEMINI_API_KEY",
"anthropic": "ANTHROPIC_API_KEY",
+ "groq": "GROQ_API_KEY",
+}
+
+_PROVIDER_API_KEY_FIELDS = {
+ provider: f"llm_api_key_{provider}" for provider in _LLM_CLOUD_PROVIDERS
}
+def _resolve_llm_api_key(cfg: dict[str, str]) -> str:
+ provider = (cfg.get("llm_provider") or "none").strip().lower()
+ if provider in _PROVIDER_API_KEY_FIELDS:
+ per_provider = (cfg.get(_PROVIDER_API_KEY_FIELDS[provider]) or "").strip()
+ if per_provider:
+ return per_provider
+ return (cfg.get("llm_api_key") or "").strip()
+
+
def load_llm_config_from_db() -> dict[str, str]:
try:
from .db import db_session
@@ -28,15 +44,18 @@ def load_llm_config_from_db() -> dict[str, str]:
return {}
provider = (cfg.get("llm_provider") or "none").strip().lower()
- if provider and provider != "none":
- if not (cfg.get("llm_api_key") or "").strip():
- env_var = _ENV_KEY_BY_PROVIDER.get(provider)
- if env_var:
- env_val = (os.environ.get(env_var) or "").strip()
- if env_val:
- cfg = dict(cfg)
- cfg["llm_api_key"] = env_val
- cfg["_llm_api_key_source"] = "env"
+ resolved = _resolve_llm_api_key(cfg)
+ if resolved:
+ cfg = dict(cfg)
+ cfg["llm_api_key"] = resolved
+ elif provider and provider != "none":
+ env_var = _ENV_KEY_BY_PROVIDER.get(provider)
+ if env_var:
+ env_val = (os.environ.get(env_var) or "").strip()
+ if env_val:
+ cfg = dict(cfg)
+ cfg["llm_api_key"] = env_val
+ cfg["_llm_api_key_source"] = "env"
return cfg
diff --git a/src/website_profiling/tools/audit_tools/registry.py b/src/website_profiling/tools/audit_tools/registry.py
index c1b472f..6729156 100644
--- a/src/website_profiling/tools/audit_tools/registry.py
+++ b/src/website_profiling/tools/audit_tools/registry.py
@@ -1,6 +1,7 @@
"""Tool registry and dispatch for MCP and chat agent."""
from __future__ import annotations
+import copy
from typing import Any, Callable
from psycopg import Connection
@@ -760,6 +761,31 @@
}
+_CONTEXT_SCOPED_PARAMS = frozenset({"property_id", "report_id"})
+
+
+def _schema_for_llm(input_schema: dict[str, Any], *, context_scoped: bool) -> dict[str, Any]:
+ """Drop session-scoped IDs from LLM tool schemas (chat injects them from AuditToolContext)."""
+ if not context_scoped:
+ return input_schema
+ schema = copy.deepcopy(input_schema)
+ props = dict(schema.get("properties") or {})
+ for key in _CONTEXT_SCOPED_PARAMS:
+ props.pop(key, None)
+ schema["properties"] = props
+ schema["required"] = [
+ key for key in (schema.get("required") or []) if key not in _CONTEXT_SCOPED_PARAMS
+ ]
+ return schema
+
+
+def _normalize_tool_args(args: dict[str, Any] | None) -> dict[str, Any]:
+ """Remove explicit nulls so strict providers do not reject tool-call JSON."""
+ if not isinstance(args, dict):
+ return {}
+ return {key: value for key, value in args.items() if value is not None}
+
+
def dispatch_tool(
name: str,
args: dict[str, Any] | None,
@@ -773,7 +799,7 @@ def dispatch_tool(
return {"error": f"unknown tool: {name}"}
ctx = context or AuditToolContext()
- payload_args = dict(args or {})
+ payload_args = _normalize_tool_args(args)
merged_ctx = ctx.with_args(payload_args)
if conn is not None:
@@ -856,18 +882,23 @@ def search_tools(query: str, limit: int = 10) -> list[dict[str, Any]]:
return [row for _, _, row in scored[:cap]]
-def openai_tools_schema(names: set[str] | None = None) -> list[dict[str, Any]]:
+def openai_tools_schema(
+ names: set[str] | None = None,
+ *,
+ context_scoped: bool = False,
+) -> list[dict[str, Any]]:
"""Convert TOOL_DEFINITIONS to OpenAI function-calling format (optional name filter)."""
out: list[dict[str, Any]] = []
for tool in TOOL_DEFINITIONS:
if names is not None and tool["name"] not in names:
continue
+ input_schema = tool.get("inputSchema", {"type": "object", "properties": {}})
out.append({
"type": "function",
"function": {
"name": tool["name"],
"description": tool.get("description", ""),
- "parameters": tool.get("inputSchema", {"type": "object", "properties": {}}),
+ "parameters": _schema_for_llm(input_schema, context_scoped=context_scoped),
},
})
return out
diff --git a/tests/test_browser_auth_from_session.py b/tests/test_browser_auth_from_session.py
new file mode 100644
index 0000000..7215e23
--- /dev/null
+++ b/tests/test_browser_auth_from_session.py
@@ -0,0 +1,39 @@
+"""Regression tests for mapping a requests Session's auth onto browser context options.
+
+`_browser_auth_from_session` must not assume `session.auth` is a 2-tuple — requests
+also allows a callable auth handler and 1-element credentials.
+"""
+from __future__ import annotations
+
+import requests
+
+from website_profiling.crawl.fetchers.factory import _browser_auth_from_session
+
+
+def test_none_session_returns_empty_options() -> None:
+ assert _browser_auth_from_session(None) == ({}, None)
+
+
+def test_basic_two_tuple_auth_maps_to_credentials() -> None:
+ session = requests.Session()
+ session.headers["X-Custom"] = "1"
+ session.auth = ("user", "secret")
+ headers, credentials = _browser_auth_from_session(session)
+ assert credentials == {"username": "user", "password": "secret"}
+ assert headers.get("X-Custom") == "1"
+ # User-Agent is intentionally filtered out (the browser sets its own).
+ assert "User-Agent" not in headers
+
+
+def test_single_element_auth_defaults_password_to_empty() -> None:
+ session = requests.Session()
+ session.auth = ("user-only",)
+ _, credentials = _browser_auth_from_session(session)
+ assert credentials == {"username": "user-only", "password": ""}
+
+
+def test_callable_auth_handler_is_ignored_without_raising() -> None:
+ session = requests.Session()
+ session.auth = lambda request: request # e.g. HTTPDigestAuth / custom handler
+ _, credentials = _browser_auth_from_session(session)
+ assert credentials is None
diff --git a/tests/test_chat_agent.py b/tests/test_chat_agent.py
index 908e2a2..e9d27c0 100644
--- a/tests/test_chat_agent.py
+++ b/tests/test_chat_agent.py
@@ -1,12 +1,23 @@
"""Tests for chat agent loop."""
from __future__ import annotations
-from unittest.mock import MagicMock, patch
-
-from website_profiling.llm.agent import MAX_TOOL_ROUNDS, run_agent_turn
+from unittest.mock import patch
+
+from website_profiling.llm.agent import (
+ MAX_TOOL_ROUNDS,
+ MAX_TOOL_ROUNDS_EXTENDED,
+ NARRATIVE_FAILED_MSG,
+ _max_tool_rounds,
+ run_agent_turn,
+)
from website_profiling.llm.base import ChatResult, ToolCall
from website_profiling.tools.audit_tools import AuditToolContext
+VALID_NARRATIVE = {
+ "power_insights": ["Crawl health is solid overall."],
+ "recommended_actions": ["Address critical issues first."],
+}
+
class FakeToolClient:
def __init__(self, steps: list[ChatResult]) -> None:
@@ -16,15 +27,16 @@ def __init__(self, steps: list[ChatResult]) -> None:
def chat_with_tools(self, messages, tools, *, on_token=None):
result = self._steps[min(self._calls, len(self._steps) - 1)]
self._calls += 1
- if on_token and result.content:
- on_token(result.content)
return result
+ def complete_json(self, system, user):
+ return VALID_NARRATIVE
+
def test_agent_tool_then_answer() -> None:
client = FakeToolClient([
ChatResult(tool_calls=[ToolCall(id="tc1", name="list_issues", arguments={"limit": 5})]),
- ChatResult(content="Found 3 critical issues."),
+ ChatResult(content="ignored internal stop"),
])
events: list[dict] = []
ctx = AuditToolContext(property_id=1)
@@ -33,23 +45,26 @@ def test_agent_tool_then_answer() -> None:
"llm_enabled": True, "llm_provider": "openai", "llm_api_key": "sk-test",
}):
with patch("website_profiling.llm.agent.get_llm_client", return_value=client):
- with patch(
- "website_profiling.llm.agent.dispatch_tool",
- return_value={"issues": [], "total": 0},
- ) as mock_dispatch:
- result = run_agent_turn(
- [{"role": "user", "content": "What are the top issues?"}],
- ctx,
- on_event=events.append,
- )
+ with patch("website_profiling.llm.chat_narrative.get_llm_client", return_value=client):
+ with patch(
+ "website_profiling.llm.agent.dispatch_tool",
+ return_value={"issues": [], "total": 0},
+ ) as mock_dispatch:
+ result = run_agent_turn(
+ [{"role": "user", "content": "What are the top issues?"}],
+ ctx,
+ on_event=events.append,
+ )
assert result["ok"] is True
- assert "critical" in result["message"].lower()
+ assert result["narrative"] == VALID_NARRATIVE
mock_dispatch.assert_called_once()
types = [e["type"] for e in events]
assert "tool_start" in types
assert "tool_end" in types
+ assert "narrative" in types
assert "done" in types
+ assert "token" not in types
def test_agent_runs_multiple_tool_calls_in_one_turn() -> None:
@@ -60,7 +75,7 @@ def test_agent_runs_multiple_tool_calls_in_one_turn() -> None:
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."),
+ ChatResult(content="stop"),
])
events: list[dict] = []
ctx = AuditToolContext(property_id=1, report_id=1)
@@ -74,39 +89,31 @@ def fake_dispatch(name, args, *, context=None):
"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,
- )
+ with patch("website_profiling.llm.chat_narrative.get_llm_client", return_value=client):
+ with patch("website_profiling.llm.agent.chat_tool_mode", return_value="full"):
+ with patch(
+ "website_profiling.llm.agent.dispatch_tool",
+ side_effect=fake_dispatch,
+ ):
+ result = run_agent_turn(
+ [{"role": "user", "content": "give me a full audit overview"}],
+ ctx,
+ on_event=events.append,
+ )
assert result["ok"] is True
- # 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."),
+ ChatResult(content="stop"),
])
ctx = AuditToolContext(property_id=1)
@@ -114,15 +121,16 @@ def test_agent_isolates_tool_exception() -> None:
"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,
- )
+ with patch("website_profiling.llm.chat_narrative.get_llm_client", return_value=client):
+ with patch("website_profiling.llm.agent.chat_tool_mode", return_value="full"):
+ with patch(
+ "website_profiling.llm.agent.dispatch_tool",
+ side_effect=RuntimeError("db exploded"),
+ ):
+ result = run_agent_turn(
+ [{"role": "user", "content": "list issues"}],
+ ctx,
+ )
assert result["ok"] is True
assert result["tool_events"][0]["result"]["error"] == "db exploded"
@@ -142,12 +150,45 @@ def test_agent_disabled_llm() -> None:
assert events[-1]["type"] == "error"
-def test_max_tool_rounds() -> None:
+def test_max_tool_rounds_still_synthesizes() -> None:
always_tool = ChatResult(
tool_calls=[ToolCall(id="x", name="list_properties", arguments={})],
)
client = FakeToolClient([always_tool] * (MAX_TOOL_ROUNDS + 1))
ctx = AuditToolContext()
+ events: list[dict] = []
+
+ with patch("website_profiling.llm.agent.load_llm_config_from_db", return_value={
+ "llm_enabled": True, "llm_provider": "openai", "llm_api_key": "sk-test",
+ "llm_chat_unlimited_tool_rounds": "false",
+ }):
+ with patch("website_profiling.llm.agent.get_llm_client", return_value=client):
+ with patch("website_profiling.llm.chat_narrative.get_llm_client", return_value=client):
+ with patch(
+ "website_profiling.llm.agent.dispatch_tool",
+ return_value={"properties": []},
+ ):
+ result = run_agent_turn(
+ [{"role": "user", "content": "List properties"}],
+ ctx,
+ on_event=events.append,
+ )
+
+ assert result["ok"] is True
+ assert result["narrative"] == VALID_NARRATIVE
+ assert any(e["type"] == "partial_done" for e in events)
+ assert any(e["type"] == "narrative" for e in events)
+
+
+def test_narrative_failure_emits_error_and_preserves_tools() -> None:
+ from website_profiling.llm.chat_narrative import ChatNarrativeError
+
+ client = FakeToolClient([
+ ChatResult(tool_calls=[ToolCall(id="tc1", name="list_issues", arguments={})]),
+ ChatResult(content="stop"),
+ ])
+ events: list[dict] = []
+ ctx = AuditToolContext(property_id=1)
with patch("website_profiling.llm.agent.load_llm_config_from_db", return_value={
"llm_enabled": True, "llm_provider": "openai", "llm_api_key": "sk-test",
@@ -155,12 +196,32 @@ def test_max_tool_rounds() -> None:
with patch("website_profiling.llm.agent.get_llm_client", return_value=client):
with patch(
"website_profiling.llm.agent.dispatch_tool",
- return_value={"properties": []},
+ return_value={"issues": [{"url": "/a"}]},
):
- result = run_agent_turn(
- [{"role": "user", "content": "List properties"}],
- ctx,
- )
+ with patch(
+ "website_profiling.llm.agent.synthesize_chat_narrative",
+ side_effect=ChatNarrativeError("failed"),
+ ):
+ result = run_agent_turn(
+ [{"role": "user", "content": "issues"}],
+ ctx,
+ on_event=events.append,
+ )
assert result["ok"] is False
- assert "maximum tool rounds" in result["error"].lower()
+ assert result["error"] == NARRATIVE_FAILED_MSG
+ assert len(result["tool_events"]) == 1
+ assert events[-1]["type"] == "error"
+
+
+def test_max_tool_rounds_extended_when_unlimited_enabled() -> None:
+ assert _max_tool_rounds({"llm_chat_unlimited_tool_rounds": "true"}) == MAX_TOOL_ROUNDS_EXTENDED
+ assert _max_tool_rounds({"llm_chat_unlimited_tool_rounds": "false"}) == MAX_TOOL_ROUNDS
+
+
+def test_system_prompt_does_not_require_markdown_template() -> None:
+ from website_profiling.llm.agent import SYSTEM_PROMPT
+
+ assert "### Power Insights" not in SYSTEM_PROMPT
+ assert "### Recommended actions" not in SYSTEM_PROMPT
+ assert "generated separately" in SYSTEM_PROMPT.lower()
diff --git a/tests/test_chat_narrative.py b/tests/test_chat_narrative.py
new file mode 100644
index 0000000..5b76414
--- /dev/null
+++ b/tests/test_chat_narrative.py
@@ -0,0 +1,97 @@
+"""Tests for structured chat narrative synthesis."""
+from __future__ import annotations
+
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from website_profiling.llm.chat_narrative import (
+ ChatNarrativeError,
+ build_synthesis_payload,
+ synthesize_chat_narrative,
+ validate_chat_narrative,
+)
+
+
+def test_validate_chat_narrative_accepts_valid_payload() -> None:
+ narrative, errors = validate_chat_narrative({
+ "power_insights": [" Strong crawl health "],
+ "recommended_actions": ["Fix broken links"],
+ })
+ assert not errors
+ assert narrative["power_insights"] == ["Strong crawl health"]
+ assert narrative["recommended_actions"] == ["Fix broken links"]
+
+
+def test_validate_chat_narrative_rejects_empty_arrays() -> None:
+ _, errors = validate_chat_narrative({
+ "power_insights": [],
+ "recommended_actions": [],
+ })
+ assert any("empty" in e for e in errors)
+
+
+def test_validate_chat_narrative_caps_items() -> None:
+ items = [f"item {i}" for i in range(8)]
+ narrative, errors = validate_chat_narrative({
+ "power_insights": items,
+ "recommended_actions": ["one"],
+ })
+ assert any("more than" in e for e in errors)
+ assert len(narrative["power_insights"]) == 5
+
+
+def test_build_synthesis_payload_truncates_large_tool_results() -> None:
+ huge = {"blob": "x" * 20000}
+ payload = build_synthesis_payload(
+ "overview?",
+ [{"name": "get_report_summary", "args": {}, "result": huge}],
+ )
+ assert len(payload) <= 10020
+ assert "truncated" in payload
+
+
+def test_synthesize_chat_narrative_success_first_attempt() -> None:
+ client = MagicMock()
+ client.complete_json.return_value = {
+ "power_insights": ["Insight"],
+ "recommended_actions": ["Action"],
+ }
+ with patch("website_profiling.llm.chat_narrative.get_llm_client", return_value=client):
+ result = synthesize_chat_narrative(
+ {"llm_provider": "openai"},
+ "What is site health?",
+ [{"name": "get_report_summary", "result": {"health": 80}}],
+ )
+ assert result["power_insights"] == ["Insight"]
+ client.complete_json.assert_called_once()
+
+
+def test_synthesize_chat_narrative_retries_on_invalid_first_attempt() -> None:
+ client = MagicMock()
+ client.complete_json.side_effect = [
+ {"power_insights": []},
+ {"power_insights": ["Fixed"], "recommended_actions": ["Do it"]},
+ ]
+ statuses: list[str] = []
+ with patch("website_profiling.llm.chat_narrative.get_llm_client", return_value=client):
+ result = synthesize_chat_narrative(
+ {"llm_provider": "openai"},
+ "overview",
+ [],
+ on_status=statuses.append,
+ )
+ assert result["power_insights"] == ["Fixed"]
+ assert client.complete_json.call_count == 2
+ assert statuses == ["synthesizing", "retrying"]
+
+
+def test_synthesize_chat_narrative_raises_after_two_failures() -> None:
+ client = MagicMock()
+ client.complete_json.side_effect = [
+ "not json",
+ {"recommended_actions": ["only actions"]},
+ ]
+ with patch("website_profiling.llm.chat_narrative.get_llm_client", return_value=client):
+ with pytest.raises(ChatNarrativeError):
+ synthesize_chat_narrative({"llm_provider": "openai"}, "hi", [])
diff --git a/tests/test_crawler_deep.py b/tests/test_crawler_deep.py
index 1f8c58e..6ad95a4 100644
--- a/tests/test_crawler_deep.py
+++ b/tests/test_crawler_deep.py
@@ -180,6 +180,48 @@ def _slow_worker(url):
assert len(df) == 2
+def test_crawl_waits_when_pool_saturated_instead_of_busy_spinning(monkeypatch):
+ # Regression: when every worker slot is busy AND the frontier still has URLs,
+ # the loop must block on wait() rather than busy-spin. We record the queue size
+ # at each wait() call; on the buggy version wait() only fired once the queue was
+ # empty (qsize == 0), so observing a wait() with a non-empty queue proves the fix.
+ import time
+ import website_profiling.crawl.crawler as mod
+
+ monkeypatch.setattr(
+ "website_profiling.crawl.sitemap.discover_sitemap_urls",
+ lambda *_a, **_k: [],
+ )
+ c = mod.Crawler(
+ start_url="https://site.com",
+ ignore_robots=True,
+ use_wappalyzer=False,
+ concurrency=2,
+ max_pages=4,
+ )
+ for path in ("/a", "/b", "/c"):
+ c.queue.put(f"https://site.com{path}")
+
+ qsizes_at_wait: list[int] = []
+ real_wait = mod.wait
+
+ def _recording_wait(fs, **kwargs):
+ qsizes_at_wait.append(c.queue.qsize())
+ return real_wait(fs, **kwargs)
+
+ monkeypatch.setattr(mod, "wait", _recording_wait)
+
+ def _slow_worker(url):
+ time.sleep(0.02)
+ return {"url": url, "status": 200, "content_type": "text/html", "title": "ok", "outlinks": 0}
+
+ monkeypatch.setattr(c, "worker", _slow_worker)
+ df = c.crawl(show_progress=False)
+
+ assert len(df) == 4
+ assert any(q > 0 for q in qsizes_at_wait)
+
+
def test_crawl_runs_and_handles_done_futures(monkeypatch):
import website_profiling.crawl.crawler as mod
diff --git a/tests/test_keyword_intent.py b/tests/test_keyword_intent.py
new file mode 100644
index 0000000..7b220b8
--- /dev/null
+++ b/tests/test_keyword_intent.py
@@ -0,0 +1,25 @@
+"""Regression tests for keyword intent classification.
+
+`classify_intent` must not raise when the brand name is empty or whitespace-only.
+"""
+from __future__ import annotations
+
+import pytest
+
+from website_profiling.integrations.google.keyword_enrich import classify_intent
+
+_VALID = {"navigational", "informational", "transactional", "commercial"}
+
+
+@pytest.mark.parametrize("brand", ["", " ", "\t\n"])
+def test_blank_or_whitespace_brand_does_not_raise(brand: str) -> None:
+ # Whitespace-only brand is truthy but splits to [], which used to IndexError.
+ assert classify_intent("some search query", brand_name=brand) in _VALID
+
+
+def test_brand_token_match_is_navigational() -> None:
+ assert classify_intent("acme login", brand_name="Acme") == "navigational"
+
+
+def test_no_brand_falls_through_to_other_intents() -> None:
+ assert classify_intent("how to bake bread") == "informational"
diff --git a/tests/test_llm_config.py b/tests/test_llm_config.py
index 78ec833..5299323 100644
--- a/tests/test_llm_config.py
+++ b/tests/test_llm_config.py
@@ -35,3 +35,21 @@ def test_load_llm_config_from_db(require_database_url):
def test_llm_disabled_by_default():
assert not llm_is_enabled({})
assert not llm_is_enabled({"llm_enabled": "false", "llm_provider": "openai"})
+
+
+def test_resolve_llm_api_key_per_provider():
+ from website_profiling.llm_config import _resolve_llm_api_key
+
+ cfg = {
+ "llm_provider": "groq",
+ "llm_api_key_groq": "gsk-test",
+ "llm_api_key_openai": "sk-openai",
+ "llm_api_key": "legacy",
+ }
+ assert _resolve_llm_api_key(cfg) == "gsk-test"
+
+ cfg["llm_provider"] = "openai"
+ assert _resolve_llm_api_key(cfg) == "sk-openai"
+
+ del cfg["llm_api_key_openai"]
+ assert _resolve_llm_api_key(cfg) == "legacy"
diff --git a/tests/test_llm_enrich_batches.py b/tests/test_llm_enrich_batches.py
new file mode 100644
index 0000000..8b885df
--- /dev/null
+++ b/tests/test_llm_enrich_batches.py
@@ -0,0 +1,54 @@
+"""Regression tests for `_run_llm_batches` failure handling.
+
+A failing batch must not abort the run, and the failure must be logged (observable)
+in BOTH the sequential and the concurrent code paths — they used to diverge
+(sequential propagated; concurrent silently swallowed).
+"""
+from __future__ import annotations
+
+import pytest
+
+from website_profiling.llm import enrich
+
+
+class _BoomClient:
+ """LLM client whose every call fails."""
+
+ def complete_json(self, system: str, user: str) -> dict:
+ raise RuntimeError("api unavailable")
+
+
+def test_sequential_path_logs_and_continues_on_failure(
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
+) -> None:
+ monkeypatch.setattr(enrich, "_llm_concurrency", lambda _cfg: 1)
+ applied: list = []
+ enrich._run_llm_batches(
+ _BoomClient(),
+ "task",
+ "system",
+ [{"k": 1}],
+ {},
+ lambda payload, result: applied.append(result),
+ )
+ out = capsys.readouterr().out
+ assert "LLM enrichment batch failed" in out
+ assert applied == [] # nothing applied, but no exception escaped
+
+
+def test_concurrent_path_logs_and_continues_on_failure(
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
+) -> None:
+ monkeypatch.setattr(enrich, "_llm_concurrency", lambda _cfg: 4)
+ applied: list = []
+ enrich._run_llm_batches(
+ _BoomClient(),
+ "task",
+ "system",
+ [{"k": 1}, {"k": 2}], # >1 batch + workers>1 -> concurrent path
+ {},
+ lambda payload, result: applied.append(result),
+ )
+ out = capsys.readouterr().out
+ assert out.count("LLM enrichment batch failed") >= 1
+ assert applied == []
diff --git a/tests/test_llm_provider_groq.py b/tests/test_llm_provider_groq.py
new file mode 100644
index 0000000..5f3c4d3
--- /dev/null
+++ b/tests/test_llm_provider_groq.py
@@ -0,0 +1,162 @@
+"""Tests for Groq LLM provider (official Python SDK)."""
+from __future__ import annotations
+
+import sys
+import types
+from types import SimpleNamespace
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from website_profiling.llm.base import ChatResult, get_llm_client
+from website_profiling.llm.providers.groq import DEFAULT_MODEL, GroqClient
+
+
+def _install_fake_groq(monkeypatch: pytest.MonkeyPatch, client: MagicMock) -> MagicMock:
+ mock_cls = MagicMock(return_value=client)
+ fake = types.ModuleType("groq")
+ fake.Groq = mock_cls
+ monkeypatch.setitem(sys.modules, "groq", fake)
+ return mock_cls
+
+
+def test_get_llm_client_routes_groq() -> None:
+ client = get_llm_client({"llm_provider": "groq", "llm_api_key": "gsk-test"})
+ assert isinstance(client, GroqClient)
+
+
+def test_default_model() -> None:
+ client = GroqClient({"llm_provider": "groq", "llm_api_key": "gsk-test"})
+ assert client._model == DEFAULT_MODEL
+
+
+def test_explicit_model_and_base_url() -> None:
+ client = GroqClient({
+ "llm_api_key": "gsk-test",
+ "llm_model": "llama-3.1-8b-instant",
+ "llm_base_url": "https://custom.example/v1",
+ })
+ assert client._model == "llama-3.1-8b-instant"
+ assert client._base_url == "https://custom.example/v1"
+
+
+def test_ignores_ollama_base_url() -> None:
+ client = GroqClient({
+ "llm_api_key": "gsk-test",
+ "llm_base_url": "http://127.0.0.1:11434",
+ })
+ assert client._base_url is None
+
+
+def test_complete_json_missing_key_raises_groq_error() -> None:
+ client = GroqClient({"llm_provider": "groq"})
+ with pytest.raises(RuntimeError, match="Groq API key"):
+ client.complete_json("system", "user")
+
+
+def test_chat_with_tools_missing_key_raises_groq_error() -> None:
+ client = GroqClient({"llm_provider": "groq"})
+ with pytest.raises(RuntimeError, match="Groq API key"):
+ client.chat_with_tools([], [])
+
+
+def test_complete_json_uses_sdk(monkeypatch: pytest.MonkeyPatch) -> None:
+ mock_create = MagicMock(
+ return_value=SimpleNamespace(
+ choices=[SimpleNamespace(message=SimpleNamespace(content='{"ok": true}'))],
+ ),
+ )
+ sdk_client = MagicMock()
+ sdk_client.chat.completions.create = mock_create
+ mock_cls = _install_fake_groq(monkeypatch, sdk_client)
+
+ client = GroqClient({"llm_api_key": "gsk-test"})
+ assert client.complete_json("system", "user") == {"ok": True}
+ mock_cls.assert_called_once_with(api_key="gsk-test", timeout=120.0)
+ mock_create.assert_called_once()
+
+
+def test_chat_with_tools_non_streaming(monkeypatch: pytest.MonkeyPatch) -> None:
+ mock_create = MagicMock(
+ return_value=SimpleNamespace(
+ choices=[
+ SimpleNamespace(
+ finish_reason="stop",
+ message=SimpleNamespace(
+ content="hello",
+ tool_calls=[
+ SimpleNamespace(
+ id="tc1",
+ function=SimpleNamespace(
+ name="list_issues",
+ arguments='{"limit": 5}',
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ )
+ sdk_client = MagicMock()
+ sdk_client.chat.completions.create = mock_create
+ _install_fake_groq(monkeypatch, sdk_client)
+
+ client = GroqClient({"llm_api_key": "gsk-test"})
+ result = client.chat_with_tools([{"role": "user", "content": "hi"}], [])
+ assert result.content == "hello"
+ assert len(result.tool_calls) == 1
+ assert result.tool_calls[0].name == "list_issues"
+ assert result.tool_calls[0].arguments == {"limit": 5}
+ mock_create.assert_called_once_with(
+ model=DEFAULT_MODEL,
+ messages=[{"role": "user", "content": "hi"}],
+ tools=[],
+ tool_choice="auto",
+ temperature=0.2,
+ )
+
+
+def test_chat_with_tools_streaming(monkeypatch: pytest.MonkeyPatch) -> None:
+ chunks = [
+ SimpleNamespace(
+ choices=[SimpleNamespace(delta=SimpleNamespace(content="hel", tool_calls=None))],
+ ),
+ SimpleNamespace(
+ choices=[SimpleNamespace(delta=SimpleNamespace(content="lo", tool_calls=None))],
+ ),
+ ]
+ mock_create = MagicMock(return_value=iter(chunks))
+ sdk_client = MagicMock()
+ sdk_client.chat.completions.create = mock_create
+ _install_fake_groq(monkeypatch, sdk_client)
+
+ tokens: list[str] = []
+ client = GroqClient({"llm_api_key": "gsk-test"})
+ result = client.chat_with_tools(
+ [{"role": "user", "content": "hi"}],
+ [],
+ on_token=tokens.append,
+ )
+ assert result.content == "hello"
+ assert tokens == ["hel", "lo"]
+ mock_create.assert_called_once()
+ assert mock_create.call_args.kwargs["stream"] is True
+
+
+def test_load_llm_config_from_db_groq_env_fallback(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ monkeypatch.setenv("GROQ_API_KEY", "gsk-from-env")
+ with patch("website_profiling.db.db_session") as mock_session:
+ with patch(
+ "website_profiling.db.storage.read_llm_config",
+ return_value={"llm_provider": "groq", "llm_enabled": "true"},
+ ):
+ mock_session.return_value.__enter__ = MagicMock(return_value=MagicMock())
+ mock_session.return_value.__exit__ = MagicMock(return_value=False)
+ from website_profiling.llm_config import load_llm_config_from_db
+
+ cfg = load_llm_config_from_db()
+ assert cfg.get("llm_api_key") == "gsk-from-env"
+ assert cfg.get("_llm_api_key_source") == "env"
diff --git a/tests/test_llm_provider_openai.py b/tests/test_llm_provider_openai.py
new file mode 100644
index 0000000..e46e91f
--- /dev/null
+++ b/tests/test_llm_provider_openai.py
@@ -0,0 +1,63 @@
+"""Regression tests for the OpenAI JSON-completion client.
+
+`complete_json` must defensively handle a 200 response whose body lacks the
+expected ``choices``/``message`` structure instead of raising KeyError/IndexError.
+"""
+from __future__ import annotations
+
+import sys
+import types
+
+import pytest
+
+from website_profiling.llm.providers.openai import OpenAIClient
+
+
+class _FakeResponse:
+ def __init__(self, payload: dict) -> None:
+ self._payload = payload
+
+ def raise_for_status(self) -> None:
+ return None
+
+ def json(self) -> dict:
+ return self._payload
+
+
+class _FakeClient:
+ def __init__(self, payload: dict) -> None:
+ self._payload = payload
+
+ def __call__(self, *args, **kwargs): # httpx.Client(...) constructor
+ return self
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *args) -> bool:
+ return False
+
+ def post(self, *args, **kwargs) -> _FakeResponse:
+ return _FakeResponse(self._payload)
+
+
+def _install_fake_httpx(monkeypatch: pytest.MonkeyPatch, payload: dict) -> None:
+ fake = types.ModuleType("httpx")
+ fake.Client = _FakeClient(payload)
+ monkeypatch.setitem(sys.modules, "httpx", fake)
+
+
+def test_complete_json_missing_choices_raises_clean_error(monkeypatch: pytest.MonkeyPatch) -> None:
+ _install_fake_httpx(monkeypatch, {}) # no "choices"
+ client = OpenAIClient({"llm_api_key": "sk-test"})
+ with pytest.raises(RuntimeError, match="no content"):
+ client.complete_json("system", "user")
+
+
+def test_complete_json_parses_content_on_well_formed_response(monkeypatch: pytest.MonkeyPatch) -> None:
+ _install_fake_httpx(
+ monkeypatch,
+ {"choices": [{"message": {"content": '{"ok": true}'}}]},
+ )
+ client = OpenAIClient({"llm_api_key": "sk-test"})
+ assert client.complete_json("system", "user") == {"ok": True}
diff --git a/tests/test_text_sanitize.py b/tests/test_text_sanitize.py
index a73d18e..bb6f71f 100644
--- a/tests/test_text_sanitize.py
+++ b/tests/test_text_sanitize.py
@@ -9,6 +9,11 @@
from website_profiling.text_sanitize import sanitize_unicode_deep, strip_surrogates
from website_profiling.tools.audit_tools import AuditToolContext
+VALID_NARRATIVE = {
+ "power_insights": ["High-priority issues were found in the audit."],
+ "recommended_actions": ["Review the listed URLs and fix sitemap coverage gaps."],
+}
+
def test_strip_surrogates_replaces_lone_surrogate() -> None:
bad = "URL issue\udc9d here"
@@ -62,6 +67,9 @@ def chat_with_tools(self, messages, tools, *, on_token=None):
tool_calls=[ToolCall(id="tc1", name="list_issues", arguments={"priority": "High"})],
)
+ def complete_json(self, system, user):
+ return VALID_NARRATIVE
+
client = RecordingClient()
events: list[dict] = []
@@ -69,15 +77,16 @@ def chat_with_tools(self, messages, tools, *, on_token=None):
"llm_enabled": True, "llm_provider": "openai", "llm_api_key": "sk-test",
}):
with patch("website_profiling.llm.agent.get_llm_client", return_value=client):
- with patch(
- "website_profiling.llm.agent.dispatch_tool",
- return_value=tool_payload,
- ):
- result = run_agent_turn(
- [{"role": "user", "content": "high risk audit issues"}],
- AuditToolContext(property_id=1),
- on_event=events.append,
- )
+ with patch("website_profiling.llm.chat_narrative.get_llm_client", return_value=client):
+ with patch(
+ "website_profiling.llm.agent.dispatch_tool",
+ return_value=tool_payload,
+ ):
+ result = run_agent_turn(
+ [{"role": "user", "content": "high risk audit issues"}],
+ AuditToolContext(property_id=1),
+ on_event=events.append,
+ )
assert result["ok"] is True
assert client.last_messages is not None
diff --git a/tests/tools/test_audit_tools.py b/tests/tools/test_audit_tools.py
index b203e67..7f9da2e 100644
--- a/tests/tools/test_audit_tools.py
+++ b/tests/tools/test_audit_tools.py
@@ -73,6 +73,15 @@ def test_openai_tools_schema() -> None:
assert schema[0]["type"] == "function"
+def test_openai_tools_schema_context_scoped_strips_property_id() -> None:
+ schema = openai_tools_schema({"run_technical_workflow"}, context_scoped=True)
+ assert len(schema) == 1
+ params = schema[0]["function"]["parameters"]
+ assert "property_id" not in params.get("properties", {})
+ assert "report_id" not in params.get("properties", {})
+ assert "property_id" not in (params.get("required") or [])
+
+
def test_dispatch_unknown_tool() -> None:
conn = MagicMock()
result = dispatch_tool("nonexistent", {}, conn=conn)
diff --git a/tests/tools/test_tools_gate100_coverage.py b/tests/tools/test_tools_gate100_coverage.py
index c547f88..91850cf 100644
--- a/tests/tools/test_tools_gate100_coverage.py
+++ b/tests/tools/test_tools_gate100_coverage.py
@@ -450,6 +450,9 @@ def test_registry_helpers_and_validation_errors() -> None:
assert registry.list_domains_catalog()
assert registry.search_tools("") == []
assert registry.search_tools("get_report_summary", limit=5)[0]["name"] == "get_report_summary"
+ assert registry._normalize_tool_args(None) == {}
+ assert registry._normalize_tool_args("not-a-dict") == {}
+ assert registry._normalize_tool_args({"keep": 1, "drop": None}) == {"keep": 1}
filtered = registry.openai_tools_schema({"get_report_summary"})
assert len(filtered) == 1
diff --git a/web/app/api/chat/route.ts b/web/app/api/chat/route.ts
index 89d60f8..ffed8cb 100644
--- a/web/app/api/chat/route.ts
+++ b/web/app/api/chat/route.ts
@@ -13,6 +13,7 @@ import {
} from '@/server/chatDb';
import { loadLlmConfig } from '@/server/llmConfig';
import type { ApiRouteHandler } from '@/types/api';
+import type { ChatNarrative } from '@/types/chatNarrative';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
@@ -46,6 +47,32 @@ function sseLine(event: string, data: Record
+ {hasClientId ? strings.secrets.googleConfigured : strings.secrets.googleNotConfigured} +
++ {strings.secrets.googleCredentialsHint}{' '} + + {strings.secrets.pageTitle} + +
+ {label} +
+{value}
+ {delta ? {delta} : null} ++ {title} +
+ {children} +{c.proseStrippedNote}
+ ) : null} + + {showPartialNote ? ( +{c.partialResponseNote}
+ ) : null} + + {showNarrative && narrative ? ( +{prose}
+ ) : ( +{msg.content}
+{msg.content}
- ) : msg.streaming ? ( -{msg.content}
- ) : ( -{c.ollamaUnreachable}
) ) : ( -- {model || provider} -
+{effectiveModel || c.cloudNoModel}
)}{saveError}
: null} +{label}
+{block.toolName}
+{block.message}
+ {block.hint ?{block.hint}
: null} ++ {format(c.truncatedToolNote, { + tool: block.toolName, + shown: block.shown, + total: block.total, + })} +
+ ); +} diff --git a/web/src/components/chat/chatSectionTitles.ts b/web/src/components/chat/chatSectionTitles.ts new file mode 100644 index 0000000..eec0c8c --- /dev/null +++ b/web/src/components/chat/chatSectionTitles.ts @@ -0,0 +1,56 @@ +/** Shared section title patterns for preprocess and strip pipelines. */ + +export const CHAT_SECTION_TITLES = [ + 'Power Insights', + 'Key takeaways', + 'Executive summary', + 'What the data shows', + 'Overall health', + 'Site health', + 'Critical blockers', + 'Top priorities', + 'Issues to fix', + 'Recommended actions', + 'Next steps', + 'Quick wins', + 'Priority fixes', + 'What looks good', + "What's working", + 'Strengths', + 'What needs attention', + 'Areas to improve', + 'Recommendations', + 'Issue details', + 'Image audit', + 'Lighthouse', + 'Search Console', + 'GSC summary', + 'Compare', + 'Security findings', +] as const; + +/** Sections expanded by default in ChatInsightSections. */ +export const CHAT_EXPANDED_SECTIONS = new Set( + [ + 'power insights', + 'key takeaways', + 'executive summary', + 'recommended actions', + 'quick wins', + 'next steps', + 'priority fixes', + 'recommendations', + ].map((s) => s.toLowerCase()), +); + +export function isExpandedSectionTitle(title: string): boolean { + const normalized = title.replace(/^[\p{Emoji_Presentation}\p{Extended_Pictographic}\s]+/u, '').trim(); + return CHAT_EXPANDED_SECTIONS.has(normalized.toLowerCase()); +} + +export function stripEmojiFromTitle(title: string): string { + return title + .replace(/^[\p{Emoji_Presentation}\p{Extended_Pictographic}\s🔎📸💡⚠️✅]+/u, '') + .replace(/\s+for\s+[\w.-]+\.[a-z]{2,}\s*$/i, '') + .trim(); +} diff --git a/web/src/components/chat/deriveChatBlocks.ts b/web/src/components/chat/deriveChatBlocks.ts index 27f597f..f2b683b 100644 --- a/web/src/components/chat/deriveChatBlocks.ts +++ b/web/src/components/chat/deriveChatBlocks.ts @@ -134,6 +134,19 @@ export type ChatBlock = }[]; total?: number; truncated?: boolean; + } + | { + type: 'tool_status'; + variant: 'error' | 'empty' | 'missing_data'; + toolName: string; + message: string; + hint?: string; + } + | { + type: 'tool_truncated'; + toolName: string; + shown: number; + total: number; }; const SUMMARY_TOOLS = new Set(['get_report_summary', 'get_executive_summary']); @@ -210,6 +223,10 @@ export function blockKey(block: ChatBlock): string { return `image_attention:${block.title}`; case 'image_lighthouse_list': return 'image_lighthouse'; + case 'tool_status': + return `tool_status:${block.toolName}:${block.variant}`; + case 'tool_truncated': + return `tool_truncated:${block.toolName}`; default: return block.type; } diff --git a/web/src/components/chat/deriveFallbackBlocks.test.ts b/web/src/components/chat/deriveFallbackBlocks.test.ts new file mode 100644 index 0000000..64defaa --- /dev/null +++ b/web/src/components/chat/deriveFallbackBlocks.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { deriveFallbackBlocks } from './deriveFallbackBlocks'; +import type { ToolActivityItem } from './ChatToolActivity'; + +function doneTool(name: string, result: Record