Chat Agent: render markdown tables properly, show web-search usage to admins, fix pricing leak
Browse files- Render GFM pipe tables as real <table> markup (with horizontal scroll) in the
Test Chat panel, Conversations transcript, and the embeddable widget, instead
of dumping raw "| a | b |" text into a narrow chat bubble.
- Track whether a reply actually used the search_the_web tool and surface a
"Used live web search" badge to admins (Test Chat + Conversations), so a
live-web-sourced answer is never mistaken for a knowledge-base answer.
- Fix a real pricing leak: a visitor comparison question ("are you cheaper than
X") caused the agent to recite actual EZOFIS pricing pulled from the tenant's
own indexed content, because the comparison-table instruction pushed the
model to fill every cell from retrieval, overriding the separate "never
disclose pricing" rule. Added a hard, code-level backstop that strips
dollar-amount/discount patterns out of retrieved chunk text before it ever
reaches a prompt, plus reinforced the comparison-table instruction itself.
- Make the Gemini fast-crawl reindex path surface the model's actual response
when it fails to return valid JSON, instead of a generic unhelpful error.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- SKILLS-SECURITY.md +40 -3
- backend/app/chat_agent_chat.py +19 -4
- backend/app/chat_agent_common.py +1 -1
- backend/app/chat_agent_gemini_crawler.py +50 -15
- backend/app/chat_agent_retrieval.py +25 -1
- backend/app/chat_agent_routes.py +123 -13
- backend/app/database.py +11 -0
- frontend/src/components/settings/ChatAgentPanel.jsx +130 -9
|
@@ -4,11 +4,13 @@ The Chat Agent's Skills feature follows Anthropic's Agent Skills anatomy: each s
|
|
| 4 |
SKILL.md-format file (real YAML frontmatter) with `name` + `description` + `instructions` (the
|
| 5 |
"brain," always injected into the agent's system prompt), plus optional `references/` (domain
|
| 6 |
knowledge), `assets/` (output snippets), `templates/` (reusable output templates), `scripts/`
|
| 7 |
-
(mechanical helpers), and `evals/` (test cases). This document covers the
|
| 8 |
authoring skills should understand before relying on them: **the live chat agent never executes
|
| 9 |
scripts**, **an admin can run scripts explicitly, in a sandboxed subprocess, with real but
|
| 10 |
-
limited protection**, **references/assets/templates are visitor-searchable**,
|
| 11 |
-
search is opt-in, off by default, and still genuinely unverified external content**
|
|
|
|
|
|
|
| 12 |
|
| 13 |
## The live chat agent never executes scripts
|
| 14 |
|
|
@@ -129,6 +131,12 @@ prompt template Gemini receives), and stay true regardless of what a skill's ins
|
|
| 129 |
prefixed with that framing before the model ever sees it (`_format_web_search_results`).
|
| 130 |
- **Never reachable without both code-level gates**, same admin-controlled pattern as every other
|
| 131 |
opt-in feature in this project (Gemini fast indexing, the Gemini chat provider).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
|
| 133 |
**What is NOT protected — the residual risk that's genuinely still there, and now wider:**
|
| 134 |
- **The `query` argument is attacker-influenced, and now open-ended.** It comes from the model,
|
|
@@ -148,6 +156,35 @@ prompt template Gemini receives), and stay true regardless of what a skill's ins
|
|
| 148 |
leave the toggle off — `search_knowledge_base`-only answers, with "Requires confirmation" for
|
| 149 |
anything the knowledge base doesn't cover, still work fine without it.
|
| 150 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
## Summary
|
| 152 |
|
| 153 |
| Skill section | Injected into every reply? | Visitor-searchable via the KB? | Executed? |
|
|
|
|
| 4 |
SKILL.md-format file (real YAML frontmatter) with `name` + `description` + `instructions` (the
|
| 5 |
"brain," always injected into the agent's system prompt), plus optional `references/` (domain
|
| 6 |
knowledge), `assets/` (output snippets), `templates/` (reusable output templates), `scripts/`
|
| 7 |
+
(mechanical helpers), and `evals/` (test cases). This document covers the five things an admin
|
| 8 |
authoring skills should understand before relying on them: **the live chat agent never executes
|
| 9 |
scripts**, **an admin can run scripts explicitly, in a sandboxed subprocess, with real but
|
| 10 |
+
limited protection**, **references/assets/templates are visitor-searchable**, **live web
|
| 11 |
+
search is opt-in, off by default, and still genuinely unverified external content**, and
|
| 12 |
+
**pricing mentions are stripped out of retrieved knowledge-base content before the model ever
|
| 13 |
+
sees them, as a hard backstop behind the "never disclose pricing" instruction**.
|
| 14 |
|
| 15 |
## The live chat agent never executes scripts
|
| 16 |
|
|
|
|
| 131 |
prefixed with that framing before the model ever sees it (`_format_web_search_results`).
|
| 132 |
- **Never reachable without both code-level gates**, same admin-controlled pattern as every other
|
| 133 |
opt-in feature in this project (Gemini fast indexing, the Gemini chat provider).
|
| 134 |
+
- **Every use is visible to admins, per reply.** `ChatAgentMessage.used_web_search` records whether
|
| 135 |
+
a given assistant reply actually invoked the tool, surfaced as a "Used live web search" badge in
|
| 136 |
+
both the Test Chat panel and the Conversations transcript — an admin reviewing a conversation can
|
| 137 |
+
always tell a live-web-sourced answer apart from a knowledge-base answer, rather than having to
|
| 138 |
+
guess or trust the model's own framing. Deliberately not shown to visitors on the public widget —
|
| 139 |
+
it's an admin auditing signal, not visitor-facing UX.
|
| 140 |
|
| 141 |
**What is NOT protected — the residual risk that's genuinely still there, and now wider:**
|
| 142 |
- **The `query` argument is attacker-influenced, and now open-ended.** It comes from the model,
|
|
|
|
| 156 |
leave the toggle off — `search_knowledge_base`-only answers, with "Requires confirmation" for
|
| 157 |
anything the knowledge base doesn't cover, still work fine without it.
|
| 158 |
|
| 159 |
+
## Pricing is redacted at retrieval, not just prohibited in the prompt
|
| 160 |
+
|
| 161 |
+
The Non-Negotiable Rules in the default skill's `instructions` say never to disclose EZOFIS's own
|
| 162 |
+
pricing — but a prompt instruction alone isn't a hard guarantee if the tenant's own indexed
|
| 163 |
+
Drive/website content happens to contain real prices (a pricing page got crawled, a proposal
|
| 164 |
+
template lists a rate, etc.). A model asked to fill in a comparison table under pressure can end
|
| 165 |
+
up reciting whatever facts `search_knowledge_base` handed it, instruction or not — this happened
|
| 166 |
+
in practice: a visitor asking "are you cheaper than \<competitor\>" got back a table with real
|
| 167 |
+
per-user-per-month figures pulled straight from indexed content.
|
| 168 |
+
|
| 169 |
+
The fix is a hard, code-level backstop rather than a stronger-worded reminder:
|
| 170 |
+
`chat_agent_retrieval.py`'s `retrieve_relevant_chunks` — the single function both the agentic
|
| 171 |
+
`search_knowledge_base` tool and the Gemini single-shot path call — strips dollar-amount and
|
| 172 |
+
percentage-discount patterns out of chunk text with `_redact_pricing_mentions` *before* that text
|
| 173 |
+
is ever assembled into a prompt. The model never receives the figures, so it has nothing to leak
|
| 174 |
+
regardless of how a question is phrased or which instruction it weighs more heavily. This is
|
| 175 |
+
deliberately broad (any `$<amount>`, not only ones followed by `/user` or `/month`) — the
|
| 176 |
+
Non-Negotiable Rule covers "any subscription price, per-user price, discount, implementation fee,
|
| 177 |
+
or contract value," and an over-redacted non-pricing dollar mention is a far safer failure mode
|
| 178 |
+
than a leaked real price. The always-injected comparison-table instruction was also updated to
|
| 179 |
+
explicitly say a pricing row gets "Not disclosed via chat," not a redaction placeholder repeated
|
| 180 |
+
verbatim, so the reply still reads naturally.
|
| 181 |
+
|
| 182 |
+
**What is NOT protected:** this only redacts content retrieved via `search_knowledge_base` (the
|
| 183 |
+
tenant's own indexed Drive/website/skill content). It cannot redact a price the model might
|
| 184 |
+
otherwise generate on its own without any retrieval backing it, though that's a hallucination risk
|
| 185 |
+
covered by the general "never fabricate" instruction, not a leak of real data. It also does not
|
| 186 |
+
retroactively fix anything already sent in a *past* reply before this change shipped.
|
| 187 |
+
|
| 188 |
## Summary
|
| 189 |
|
| 190 |
| Skill section | Injected into every reply? | Visitor-searchable via the KB? | Executed? |
|
|
@@ -182,8 +182,11 @@ def build_system_prompt(
|
|
| 182 |
"below has no confirmed facts about the other product — mark cells you can't confirm as "
|
| 183 |
"'Requires confirmation' rather than skipping the comparison entirely or giving only a "
|
| 184 |
"vague capability summary. Never state a specific claim about the other product unless it "
|
| 185 |
-
"came from the context below.
|
| 186 |
-
"
|
|
|
|
|
|
|
|
|
|
| 187 |
"- If the knowledge base doesn't cover something, and it's not a comparison request (see "
|
| 188 |
"the rule above for those), your ENTIRE answer is: say so honestly, in one short sentence, "
|
| 189 |
"and offer to have the team follow up. Do not add a 'however, based on general "
|
|
@@ -284,8 +287,11 @@ def build_agentic_system_prompt(
|
|
| 284 |
"have no confirmed facts about the other product — mark cells you can't confirm as "
|
| 285 |
"'Requires confirmation' rather than skipping the comparison entirely or giving only a "
|
| 286 |
"vague capability summary. Never state a specific claim about the other product unless it "
|
| 287 |
-
"came from an actual search_knowledge_base result.
|
| 288 |
-
"
|
|
|
|
|
|
|
|
|
|
| 289 |
"- If the knowledge base still doesn't cover something after searching, and it's not a "
|
| 290 |
"comparison request (see the rule above for those), your ENTIRE answer is: say so "
|
| 291 |
"honestly, in one short sentence, and offer to have the team follow up. Do not add a "
|
|
@@ -463,6 +469,7 @@ def stream_agentic_reply_openai(
|
|
| 463 |
tenant_id: int,
|
| 464 |
db: Any,
|
| 465 |
web_search_enabled: bool = False,
|
|
|
|
| 466 |
) -> Iterator[str]:
|
| 467 |
"""
|
| 468 |
Agentic RAG: the model decides per-turn whether to call search_knowledge_base, can chain up
|
|
@@ -473,6 +480,12 @@ def stream_agentic_reply_openai(
|
|
| 473 |
|
| 474 |
`web_search_enabled` offers a second tool, search_the_web, ONLY when the tenant has opted in
|
| 475 |
(ChatAgentConfig.enable_web_search) — see SKILLS-SECURITY.md.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 476 |
"""
|
| 477 |
from .chat_agent_retrieval import retrieve_relevant_chunks # local import: avoids a cycle
|
| 478 |
# (chat_agent_retrieval -> chat_agent_ingest -> chat_agent_drive/crawler; none import this module)
|
|
@@ -547,6 +560,8 @@ def stream_agentic_reply_openai(
|
|
| 547 |
args = {}
|
| 548 |
|
| 549 |
if slot["name"] == "search_the_web" and web_search_enabled:
|
|
|
|
|
|
|
| 550 |
web_query = (args.get("query") or "").strip()
|
| 551 |
try:
|
| 552 |
from .chat_agent_web_research import search_web_via_gemini
|
|
|
|
| 182 |
"below has no confirmed facts about the other product — mark cells you can't confirm as "
|
| 183 |
"'Requires confirmation' rather than skipping the comparison entirely or giving only a "
|
| 184 |
"vague capability summary. Never state a specific claim about the other product unless it "
|
| 185 |
+
"came from the context below. This table is not an exception to the pricing rule below — "
|
| 186 |
+
"if a row would compare pricing, cost, or discounts, write 'Not disclosed via chat' in "
|
| 187 |
+
"the Our approach cell rather than any figure, even one that appears in the context below. "
|
| 188 |
+
"End with a recommendation for the single most useful next validation step (e.g. a "
|
| 189 |
+
"structured demo using the same use case).",
|
| 190 |
"- If the knowledge base doesn't cover something, and it's not a comparison request (see "
|
| 191 |
"the rule above for those), your ENTIRE answer is: say so honestly, in one short sentence, "
|
| 192 |
"and offer to have the team follow up. Do not add a 'however, based on general "
|
|
|
|
| 287 |
"have no confirmed facts about the other product — mark cells you can't confirm as "
|
| 288 |
"'Requires confirmation' rather than skipping the comparison entirely or giving only a "
|
| 289 |
"vague capability summary. Never state a specific claim about the other product unless it "
|
| 290 |
+
"came from an actual search_knowledge_base result. This table is not an exception to the "
|
| 291 |
+
"pricing rule below — if a row would compare pricing, cost, or discounts, write 'Not "
|
| 292 |
+
"disclosed via chat' in the Our approach cell rather than any figure, even one that came "
|
| 293 |
+
"back from search_knowledge_base. End with a recommendation for the single most useful "
|
| 294 |
+
"next validation step (e.g. a structured demo using the same use case).",
|
| 295 |
"- If the knowledge base still doesn't cover something after searching, and it's not a "
|
| 296 |
"comparison request (see the rule above for those), your ENTIRE answer is: say so "
|
| 297 |
"honestly, in one short sentence, and offer to have the team follow up. Do not add a "
|
|
|
|
| 469 |
tenant_id: int,
|
| 470 |
db: Any,
|
| 471 |
web_search_enabled: bool = False,
|
| 472 |
+
used_web_search: Optional[Dict[str, bool]] = None,
|
| 473 |
) -> Iterator[str]:
|
| 474 |
"""
|
| 475 |
Agentic RAG: the model decides per-turn whether to call search_knowledge_base, can chain up
|
|
|
|
| 480 |
|
| 481 |
`web_search_enabled` offers a second tool, search_the_web, ONLY when the tenant has opted in
|
| 482 |
(ChatAgentConfig.enable_web_search) — see SKILLS-SECURITY.md.
|
| 483 |
+
|
| 484 |
+
`used_web_search`, if given, is a mutable out-param — {"used": False} — flipped to True the
|
| 485 |
+
moment the search_the_web branch is attempted this turn. A generator can't return a value
|
| 486 |
+
alongside its yields, so the caller passes in a dict, reads it back once the generator is
|
| 487 |
+
exhausted, and persists it alongside the saved reply so admins can see which replies drew on
|
| 488 |
+
live web content instead of the knowledge base.
|
| 489 |
"""
|
| 490 |
from .chat_agent_retrieval import retrieve_relevant_chunks # local import: avoids a cycle
|
| 491 |
# (chat_agent_retrieval -> chat_agent_ingest -> chat_agent_drive/crawler; none import this module)
|
|
|
|
| 560 |
args = {}
|
| 561 |
|
| 562 |
if slot["name"] == "search_the_web" and web_search_enabled:
|
| 563 |
+
if used_web_search is not None:
|
| 564 |
+
used_web_search["used"] = True
|
| 565 |
web_query = (args.get("query") or "").strip()
|
| 566 |
try:
|
| 567 |
from .chat_agent_web_research import search_web_via_gemini
|
|
@@ -133,7 +133,7 @@ Escalate when the visitor requests pricing, a quotation, or contract terms; a ca
|
|
| 133 |
|
| 134 |
## 14. Live Web Search Guardrails (if enabled)
|
| 135 |
|
| 136 |
-
|
| 137 |
|
| 138 |
## 15. Final Behaviour Summary
|
| 139 |
|
|
|
|
| 133 |
|
| 134 |
## 14. Live Web Search Guardrails (if enabled)
|
| 135 |
|
| 136 |
+
Treat search_the_web as a last resort, not a default — reach for it only when it is genuinely required, meaning search_knowledge_base has already been tried for this question and did not return enough to give a detailed, useful answer. Do not use it merely because a question sounds like it needs current information, and do not use it on every turn of a conversation just because it is available. Good uses, once that bar is met: background on a named competitor or alternative product, general industry or technology context (e.g. what a technical term or standard means), or public information about an integration or partner ecosystem. Never use it to look up or estimate EZOFIS pricing, to find information about EZOFIS's own customers or confidential matters (use the knowledge base for anything about EZOFIS itself), or to look up private or personal information about any individual. Always present results from this tool as unverified public information, not confirmed fact, and note that vendor or source confirmation may be needed.
|
| 137 |
|
| 138 |
## 15. Final Behaviour Summary
|
| 139 |
|
|
@@ -11,7 +11,7 @@ from __future__ import annotations
|
|
| 11 |
import json
|
| 12 |
import logging
|
| 13 |
import os
|
| 14 |
-
from typing import Any, Dict, List
|
| 15 |
from urllib.parse import urlparse
|
| 16 |
|
| 17 |
from .chat_agent_crawler import _discover_sitemap_urls
|
|
@@ -19,7 +19,10 @@ from .chat_agent_crawler import _discover_sitemap_urls
|
|
| 19 |
logger = logging.getLogger(__name__)
|
| 20 |
|
| 21 |
DEFAULT_MAX_PAGES = 40
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
| 23 |
GEMINI_MODEL = os.getenv("CHAT_AGENT_GEMINI_MODEL") or "gemini-2.5-flash"
|
| 24 |
|
| 25 |
|
|
@@ -55,10 +58,33 @@ def _discover_urls(base_url: str, max_pages: int) -> List[str]:
|
|
| 55 |
return urls[:max_pages]
|
| 56 |
|
| 57 |
|
| 58 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
"""Fetch a batch of URLs via Gemini's url_context tool in one call, asking for a JSON array of
|
| 60 |
-
{url, title, markdown} back. Returns []
|
| 61 |
-
the caller just gets fewer pages from that batch, not a broken
|
|
|
|
|
|
|
| 62 |
from google.genai import types
|
| 63 |
|
| 64 |
urls_block = "\n".join(f"- {u}" for u in urls)
|
|
@@ -83,13 +109,12 @@ def _fetch_batch(client: Any, urls: List[str]) -> List[Dict[str, str]]:
|
|
| 83 |
if text.lower().startswith("json"):
|
| 84 |
text = text[4:]
|
| 85 |
text = text.strip()
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
return []
|
| 93 |
|
| 94 |
out: List[Dict[str, str]] = []
|
| 95 |
for item in data:
|
|
@@ -101,7 +126,11 @@ def _fetch_batch(client: Any, urls: List[str]) -> List[Dict[str, str]]:
|
|
| 101 |
continue
|
| 102 |
title = (item.get("title") or url).strip()
|
| 103 |
out.append({"url": url, "title": title, "markdown": markdown})
|
| 104 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
|
| 106 |
|
| 107 |
def fast_crawl_website_via_gemini(base_url: str, max_pages: int = DEFAULT_MAX_PAGES) -> List[Dict[str, str]]:
|
|
@@ -117,18 +146,24 @@ def fast_crawl_website_via_gemini(base_url: str, max_pages: int = DEFAULT_MAX_PA
|
|
| 117 |
raise RuntimeError(f"No pages discovered for '{base_url}'")
|
| 118 |
|
| 119 |
results: List[Dict[str, str]] = []
|
|
|
|
| 120 |
for i in range(0, len(urls), BATCH_SIZE):
|
| 121 |
batch = urls[i : i + BATCH_SIZE]
|
| 122 |
try:
|
| 123 |
-
|
|
|
|
|
|
|
|
|
|
| 124 |
except Exception as e:
|
| 125 |
logger.warning("Gemini fast-crawl batch failed for %s: %s", batch, e)
|
|
|
|
| 126 |
if len(results) >= max_pages:
|
| 127 |
break
|
| 128 |
|
| 129 |
if not results:
|
|
|
|
| 130 |
raise RuntimeError(
|
| 131 |
f"Gemini could not fetch any usable content from {base_url}. Try the standard reindex "
|
| 132 |
-
"instead, or check that the site is publicly reachable."
|
| 133 |
)
|
| 134 |
return results[:max_pages]
|
|
|
|
| 11 |
import json
|
| 12 |
import logging
|
| 13 |
import os
|
| 14 |
+
from typing import Any, Dict, List, Optional, Tuple
|
| 15 |
from urllib.parse import urlparse
|
| 16 |
|
| 17 |
from .chat_agent_crawler import _discover_sitemap_urls
|
|
|
|
| 19 |
logger = logging.getLogger(__name__)
|
| 20 |
|
| 21 |
DEFAULT_MAX_PAGES = 40
|
| 22 |
+
# Smaller batches ask the model to fetch/format less content per call, which in practice makes it
|
| 23 |
+
# much more likely to return strict JSON instead of drifting into prose (e.g. an explanation of
|
| 24 |
+
# why it couldn't format something) partway through a large batch.
|
| 25 |
+
BATCH_SIZE = 4
|
| 26 |
GEMINI_MODEL = os.getenv("CHAT_AGENT_GEMINI_MODEL") or "gemini-2.5-flash"
|
| 27 |
|
| 28 |
|
|
|
|
| 58 |
return urls[:max_pages]
|
| 59 |
|
| 60 |
|
| 61 |
+
def _extract_json_array(text: str) -> Optional[list]:
|
| 62 |
+
"""Parse a JSON array out of model output that's supposed to be JSON-only. Tries a direct
|
| 63 |
+
parse first, then falls back to pulling out the outermost [...] substring — the model
|
| 64 |
+
sometimes wraps the array in a sentence ("Here's the JSON: [...]") despite being told not
|
| 65 |
+
to. Returns None if neither approach yields a JSON array."""
|
| 66 |
+
try:
|
| 67 |
+
data = json.loads(text)
|
| 68 |
+
return data if isinstance(data, list) else None
|
| 69 |
+
except (json.JSONDecodeError, ValueError):
|
| 70 |
+
pass
|
| 71 |
+
start = text.find("[")
|
| 72 |
+
end = text.rfind("]")
|
| 73 |
+
if start == -1 or end == -1 or end <= start:
|
| 74 |
+
return None
|
| 75 |
+
try:
|
| 76 |
+
data = json.loads(text[start : end + 1])
|
| 77 |
+
return data if isinstance(data, list) else None
|
| 78 |
+
except (json.JSONDecodeError, ValueError):
|
| 79 |
+
return None
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def _fetch_batch(client: Any, urls: List[str]) -> Tuple[List[Dict[str, str]], Optional[str]]:
|
| 83 |
"""Fetch a batch of URLs via Gemini's url_context tool in one call, asking for a JSON array of
|
| 84 |
+
{url, title, markdown} back. Returns ([], error_snippet) — not a raised exception — on
|
| 85 |
+
malformed model output, so the caller just gets fewer pages from that batch, not a broken
|
| 86 |
+
reindex; error_snippet is a truncated view of what the model actually returned, so a total
|
| 87 |
+
failure can be diagnosed from the index log detail instead of guessing blind."""
|
| 88 |
from google.genai import types
|
| 89 |
|
| 90 |
urls_block = "\n".join(f"- {u}" for u in urls)
|
|
|
|
| 109 |
if text.lower().startswith("json"):
|
| 110 |
text = text[4:]
|
| 111 |
text = text.strip()
|
| 112 |
+
|
| 113 |
+
data = _extract_json_array(text)
|
| 114 |
+
if data is None:
|
| 115 |
+
snippet = text[:300]
|
| 116 |
+
logger.warning("Gemini fast-crawl batch returned non-JSON output (%d chars): %r", len(text), snippet)
|
| 117 |
+
return [], (snippet or "(empty response)")
|
|
|
|
| 118 |
|
| 119 |
out: List[Dict[str, str]] = []
|
| 120 |
for item in data:
|
|
|
|
| 126 |
continue
|
| 127 |
title = (item.get("title") or url).strip()
|
| 128 |
out.append({"url": url, "title": title, "markdown": markdown})
|
| 129 |
+
if not out:
|
| 130 |
+
# Valid JSON array, but every entry was skipped (missing url/markdown) — the model
|
| 131 |
+
# likely reported it couldn't fetch anything in this batch. Still worth surfacing why.
|
| 132 |
+
return [], f"model returned {len(data)} item(s) but none had usable url+markdown"
|
| 133 |
+
return out, None
|
| 134 |
|
| 135 |
|
| 136 |
def fast_crawl_website_via_gemini(base_url: str, max_pages: int = DEFAULT_MAX_PAGES) -> List[Dict[str, str]]:
|
|
|
|
| 146 |
raise RuntimeError(f"No pages discovered for '{base_url}'")
|
| 147 |
|
| 148 |
results: List[Dict[str, str]] = []
|
| 149 |
+
last_error: Optional[str] = None
|
| 150 |
for i in range(0, len(urls), BATCH_SIZE):
|
| 151 |
batch = urls[i : i + BATCH_SIZE]
|
| 152 |
try:
|
| 153 |
+
batch_results, batch_error = _fetch_batch(client, batch)
|
| 154 |
+
results.extend(batch_results)
|
| 155 |
+
if batch_error:
|
| 156 |
+
last_error = batch_error
|
| 157 |
except Exception as e:
|
| 158 |
logger.warning("Gemini fast-crawl batch failed for %s: %s", batch, e)
|
| 159 |
+
last_error = str(e)
|
| 160 |
if len(results) >= max_pages:
|
| 161 |
break
|
| 162 |
|
| 163 |
if not results:
|
| 164 |
+
detail = f" Gemini's response: {last_error!r}" if last_error else ""
|
| 165 |
raise RuntimeError(
|
| 166 |
f"Gemini could not fetch any usable content from {base_url}. Try the standard reindex "
|
| 167 |
+
f"instead, or check that the site is publicly reachable.{detail}"
|
| 168 |
)
|
| 169 |
return results[:max_pages]
|
|
@@ -2,6 +2,7 @@
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
|
|
| 5 |
from typing import Any, Dict, List, Optional
|
| 6 |
|
| 7 |
import numpy as np
|
|
@@ -10,6 +11,29 @@ from sqlalchemy.orm import Session
|
|
| 10 |
from .chat_agent_ingest import embed_texts
|
| 11 |
from .database import ChatAgentChunk, ChatAgentDocument
|
| 12 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
def retrieve_relevant_chunks(
|
| 15 |
db: Session,
|
|
@@ -54,7 +78,7 @@ def retrieve_relevant_chunks(
|
|
| 54 |
chunk, doc = valid_rows[int(idx)]
|
| 55 |
out.append(
|
| 56 |
{
|
| 57 |
-
"text": chunk.text,
|
| 58 |
"title": doc.title,
|
| 59 |
"source_type": doc.source_type,
|
| 60 |
"source_ref": doc.source_ref,
|
|
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
+
import re
|
| 6 |
from typing import Any, Dict, List, Optional
|
| 7 |
|
| 8 |
import numpy as np
|
|
|
|
| 11 |
from .chat_agent_ingest import embed_texts
|
| 12 |
from .database import ChatAgentChunk, ChatAgentDocument
|
| 13 |
|
| 14 |
+
# Hard, code-level backstop for the "never disclose pricing" rule — a prompt instruction alone
|
| 15 |
+
# isn't enough if the tenant's own indexed Drive/website content happens to contain a real price
|
| 16 |
+
# (e.g. a pricing page got crawled), because a model asked to fill in a comparison table under
|
| 17 |
+
# pressure can end up reciting whatever facts retrieval handed it, instruction or not. This strips
|
| 18 |
+
# dollar-amount and percentage-discount patterns out of retrieved chunk text BEFORE it ever reaches
|
| 19 |
+
# a prompt, so the model has nothing to leak regardless of what the system prompt says. Deliberately
|
| 20 |
+
# broad/aggressive (any "$<amount>", not just ones followed by "/user" or "/month") — the
|
| 21 |
+
# Non-Negotiable Rules cover "any subscription price, per-user price, discount, implementation fee,
|
| 22 |
+
# or contract value," and an over-redacted non-pricing dollar mention is a far safer failure mode
|
| 23 |
+
# than a leaked real price.
|
| 24 |
+
_PRICE_REDACTION_RE = re.compile(
|
| 25 |
+
r"\$\s?\d[\d,]*(?:\.\d{1,2})?(?:\s*(?:/|\bper\b)\s*\w+)?"
|
| 26 |
+
r"|\b\d{1,3}\s?%\s*(?:discount|off)\b"
|
| 27 |
+
r"|\bdiscount(?:s|ed)?\s*of\s*\d{1,3}\s?%",
|
| 28 |
+
re.IGNORECASE,
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _redact_pricing_mentions(text: str) -> str:
|
| 33 |
+
if not text:
|
| 34 |
+
return text
|
| 35 |
+
return _PRICE_REDACTION_RE.sub("[pricing — not disclosed via chat]", text)
|
| 36 |
+
|
| 37 |
|
| 38 |
def retrieve_relevant_chunks(
|
| 39 |
db: Session,
|
|
|
|
| 78 |
chunk, doc = valid_rows[int(idx)]
|
| 79 |
out.append(
|
| 80 |
{
|
| 81 |
+
"text": _redact_pricing_mentions(chunk.text),
|
| 82 |
"title": doc.title,
|
| 83 |
"source_type": doc.source_type,
|
| 84 |
"source_ref": doc.source_ref,
|
|
@@ -582,7 +582,12 @@ def get_conversation(conversation_id: int, tc: TenantContext = Depends(get_tenan
|
|
| 582 |
return {
|
| 583 |
**_conversation_to_dict(row),
|
| 584 |
"messages": [
|
| 585 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 586 |
for m in msgs
|
| 587 |
],
|
| 588 |
}
|
|
@@ -768,6 +773,11 @@ async def public_send_message(public_id: str, body: ChatAgentMessageRequest, db:
|
|
| 768 |
# happened (either via this exact instruction last time, or via the client-side greeting), so
|
| 769 |
# drop the instruction from the prompt entirely rather than trusting the model to self-limit.
|
| 770 |
already_asked_for_contact = any(h["role"] == "assistant" for h in history)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 771 |
|
| 772 |
if provider == "gemini":
|
| 773 |
# Secondary/opt-in provider: kept on the original single-shot RAG path (retrieve once,
|
|
@@ -799,7 +809,12 @@ async def public_send_message(public_id: str, body: ChatAgentMessageRequest, db:
|
|
| 799 |
|
| 800 |
def make_reply_iter():
|
| 801 |
return stream_agentic_reply_openai(
|
| 802 |
-
system_prompt,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 803 |
)
|
| 804 |
|
| 805 |
async def event_generator():
|
|
@@ -835,13 +850,22 @@ async def public_send_message(public_id: str, body: ChatAgentMessageRequest, db:
|
|
| 835 |
|
| 836 |
full_text = "".join(full_text_parts).strip()
|
| 837 |
if full_text:
|
| 838 |
-
db.add(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 839 |
convo.last_message_at = datetime.utcnow()
|
| 840 |
db_commit_with_retry(db)
|
| 841 |
# Capture/update the lead whenever we have an email, independent of whether the AI
|
| 842 |
# reply itself succeeded — a dropped LLM call shouldn't lose a volunteered email.
|
| 843 |
if convo.visitor_email:
|
| 844 |
_capture_or_update_lead(db, cfg, convo)
|
|
|
|
|
|
|
| 845 |
yield "data: [DONE]\n\n"
|
| 846 |
|
| 847 |
return StreamingResponse(event_generator(), media_type="text/event-stream", headers=_CORS_HEADERS)
|
|
@@ -866,6 +890,10 @@ _WIDGET_JS_TEMPLATE = r"""(function () {
|
|
| 866 |
'.ezofis-chat-msg{max-width:82%;padding:9px 12px;border-radius:12px;font-size:13.5px;line-height:1.45;white-space:pre-wrap;word-wrap:break-word}' +
|
| 867 |
'.ezofis-chat-msg.assistant{background:#fff;border:1px solid #e2e8f0;color:#1e293b;align-self:flex-start;border-bottom-left-radius:2px}' +
|
| 868 |
'.ezofis-chat-msg.user{background:#7c3aed;color:#fff;align-self:flex-end;border-bottom-right-radius:2px}' +
|
|
|
|
|
|
|
|
|
|
|
|
|
| 869 |
'.ezofis-chat-disclaimer{padding:6px 14px;font-size:11px;line-height:1.4;color:#94a3b8;background:#f8fafc;border-top:1px solid #eef2f7;text-align:center}' +
|
| 870 |
'.ezofis-chat-footer{padding:10px;border-top:1px solid #e2e8f0;display:flex;gap:8px;background:#fff}' +
|
| 871 |
'.ezofis-chat-footer textarea{flex:1;resize:none;border:1px solid #cbd5e1;border-radius:10px;padding:9px 10px;font-size:13.5px;font-family:inherit;max-height:80px;min-height:38px}' +
|
|
@@ -905,16 +933,16 @@ _WIDGET_JS_TEMPLATE = r"""(function () {
|
|
| 905 |
return el;
|
| 906 |
}
|
| 907 |
|
| 908 |
-
// Renders "Sources: [Title](https://...)" (and any inline markdown links) as real,
|
| 909 |
-
//
|
| 910 |
-
// http(s) URLs matched by the regex itself ever reach an href, so no HTML/script
|
| 911 |
-
|
| 912 |
-
|
| 913 |
var linkRe = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g;
|
| 914 |
var lastIndex = 0;
|
| 915 |
var m;
|
| 916 |
while ((m = linkRe.exec(text)) !== null) {
|
| 917 |
-
if (m.index > lastIndex)
|
| 918 |
var a = document.createElement('a');
|
| 919 |
a.href = m[2];
|
| 920 |
a.textContent = m[1];
|
|
@@ -922,10 +950,92 @@ _WIDGET_JS_TEMPLATE = r"""(function () {
|
|
| 922 |
a.rel = 'noopener noreferrer';
|
| 923 |
a.style.color = 'inherit';
|
| 924 |
a.style.textDecoration = 'underline';
|
| 925 |
-
|
| 926 |
lastIndex = linkRe.lastIndex;
|
| 927 |
}
|
| 928 |
-
if (lastIndex < text.length)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 929 |
}
|
| 930 |
|
| 931 |
function ensureSession() {
|
|
@@ -979,8 +1089,8 @@ _WIDGET_JS_TEMPLATE = r"""(function () {
|
|
| 979 |
if (payload === '[DONE]') return;
|
| 980 |
try {
|
| 981 |
var obj = JSON.parse(payload);
|
| 982 |
-
if (obj.delta) { acc += obj.delta;
|
| 983 |
-
if (obj.error) {
|
| 984 |
} catch (e) {}
|
| 985 |
});
|
| 986 |
return pump();
|
|
|
|
| 582 |
return {
|
| 583 |
**_conversation_to_dict(row),
|
| 584 |
"messages": [
|
| 585 |
+
{
|
| 586 |
+
"role": m.role,
|
| 587 |
+
"content": m.content,
|
| 588 |
+
"used_web_search": bool(m.used_web_search),
|
| 589 |
+
"created_at": m.created_at.isoformat() if m.created_at else None,
|
| 590 |
+
}
|
| 591 |
for m in msgs
|
| 592 |
],
|
| 593 |
}
|
|
|
|
| 773 |
# happened (either via this exact instruction last time, or via the client-side greeting), so
|
| 774 |
# drop the instruction from the prompt entirely rather than trusting the model to self-limit.
|
| 775 |
already_asked_for_contact = any(h["role"] == "assistant" for h in history)
|
| 776 |
+
# Mutable out-param the agentic loop flips to True if it actually invokes search_the_web this
|
| 777 |
+
# turn — read back after streaming finishes so the saved message (and the live SSE stream) can
|
| 778 |
+
# tell admins whether this specific reply drew on live web content. Always False on the Gemini
|
| 779 |
+
# provider path below, which doesn't offer this tool.
|
| 780 |
+
used_web_search_flag: Dict[str, bool] = {"used": False}
|
| 781 |
|
| 782 |
if provider == "gemini":
|
| 783 |
# Secondary/opt-in provider: kept on the original single-shot RAG path (retrieve once,
|
|
|
|
| 809 |
|
| 810 |
def make_reply_iter():
|
| 811 |
return stream_agentic_reply_openai(
|
| 812 |
+
system_prompt,
|
| 813 |
+
history,
|
| 814 |
+
tenant_id,
|
| 815 |
+
db,
|
| 816 |
+
web_search_enabled=web_search_enabled,
|
| 817 |
+
used_web_search=used_web_search_flag,
|
| 818 |
)
|
| 819 |
|
| 820 |
async def event_generator():
|
|
|
|
| 850 |
|
| 851 |
full_text = "".join(full_text_parts).strip()
|
| 852 |
if full_text:
|
| 853 |
+
db.add(
|
| 854 |
+
ChatAgentMessage(
|
| 855 |
+
conversation_id=convo.id,
|
| 856 |
+
role="assistant",
|
| 857 |
+
content=full_text,
|
| 858 |
+
used_web_search=1 if used_web_search_flag["used"] else 0,
|
| 859 |
+
)
|
| 860 |
+
)
|
| 861 |
convo.last_message_at = datetime.utcnow()
|
| 862 |
db_commit_with_retry(db)
|
| 863 |
# Capture/update the lead whenever we have an email, independent of whether the AI
|
| 864 |
# reply itself succeeded — a dropped LLM call shouldn't lose a volunteered email.
|
| 865 |
if convo.visitor_email:
|
| 866 |
_capture_or_update_lead(db, cfg, convo)
|
| 867 |
+
if used_web_search_flag["used"]:
|
| 868 |
+
yield f"data: {json.dumps({'meta': {'used_web_search': True}})}\n\n"
|
| 869 |
yield "data: [DONE]\n\n"
|
| 870 |
|
| 871 |
return StreamingResponse(event_generator(), media_type="text/event-stream", headers=_CORS_HEADERS)
|
|
|
|
| 890 |
'.ezofis-chat-msg{max-width:82%;padding:9px 12px;border-radius:12px;font-size:13.5px;line-height:1.45;white-space:pre-wrap;word-wrap:break-word}' +
|
| 891 |
'.ezofis-chat-msg.assistant{background:#fff;border:1px solid #e2e8f0;color:#1e293b;align-self:flex-start;border-bottom-left-radius:2px}' +
|
| 892 |
'.ezofis-chat-msg.user{background:#7c3aed;color:#fff;align-self:flex-end;border-bottom-right-radius:2px}' +
|
| 893 |
+
'.ezofis-chat-table-wrap{overflow-x:auto;margin:4px 0}' +
|
| 894 |
+
'.ezofis-chat-table{border-collapse:collapse;font-size:12px;white-space:normal}' +
|
| 895 |
+
'.ezofis-chat-table th,.ezofis-chat-table td{border:1px solid #e2e8f0;padding:4px 6px;text-align:left;vertical-align:top;white-space:nowrap}' +
|
| 896 |
+
'.ezofis-chat-table th{background:#f1f5f9;font-weight:600}' +
|
| 897 |
'.ezofis-chat-disclaimer{padding:6px 14px;font-size:11px;line-height:1.4;color:#94a3b8;background:#f8fafc;border-top:1px solid #eef2f7;text-align:center}' +
|
| 898 |
'.ezofis-chat-footer{padding:10px;border-top:1px solid #e2e8f0;display:flex;gap:8px;background:#fff}' +
|
| 899 |
'.ezofis-chat-footer textarea{flex:1;resize:none;border:1px solid #cbd5e1;border-radius:10px;padding:9px 10px;font-size:13.5px;font-family:inherit;max-height:80px;min-height:38px}' +
|
|
|
|
| 933 |
return el;
|
| 934 |
}
|
| 935 |
|
| 936 |
+
// Renders "Sources: [Title](https://...)" (and any inline markdown links) as real, safe <a>
|
| 937 |
+
// elements into the given container — text is inserted via createTextNode/textContent only,
|
| 938 |
+
// and only http(s) URLs matched by the regex itself ever reach an href, so no HTML/script
|
| 939 |
+
// injection is possible from model output.
|
| 940 |
+
function appendInlineLinks(container, text) {
|
| 941 |
var linkRe = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g;
|
| 942 |
var lastIndex = 0;
|
| 943 |
var m;
|
| 944 |
while ((m = linkRe.exec(text)) !== null) {
|
| 945 |
+
if (m.index > lastIndex) container.appendChild(document.createTextNode(text.slice(lastIndex, m.index)));
|
| 946 |
var a = document.createElement('a');
|
| 947 |
a.href = m[2];
|
| 948 |
a.textContent = m[1];
|
|
|
|
| 950 |
a.rel = 'noopener noreferrer';
|
| 951 |
a.style.color = 'inherit';
|
| 952 |
a.style.textDecoration = 'underline';
|
| 953 |
+
container.appendChild(a);
|
| 954 |
lastIndex = linkRe.lastIndex;
|
| 955 |
}
|
| 956 |
+
if (lastIndex < text.length) container.appendChild(document.createTextNode(text.slice(lastIndex)));
|
| 957 |
+
}
|
| 958 |
+
|
| 959 |
+
// A GFM pipe-table row: trimmed, starts and ends with "|" — the exact shape the agent is
|
| 960 |
+
// instructed to use for comparison tables.
|
| 961 |
+
function isTableRowLine(line) {
|
| 962 |
+
var t = line.trim();
|
| 963 |
+
return t.length >= 2 && t.charAt(0) === '|' && t.charAt(t.length - 1) === '|';
|
| 964 |
+
}
|
| 965 |
+
|
| 966 |
+
function isTableSeparatorLine(line) {
|
| 967 |
+
if (!isTableRowLine(line)) return false;
|
| 968 |
+
var t = line.trim();
|
| 969 |
+
var cells = t.slice(1, -1).split('|');
|
| 970 |
+
for (var i = 0; i < cells.length; i++) {
|
| 971 |
+
if (!/^:?-+:?$/.test(cells[i].trim())) return false;
|
| 972 |
+
}
|
| 973 |
+
return cells.length > 0;
|
| 974 |
+
}
|
| 975 |
+
|
| 976 |
+
function splitTableRow(line) {
|
| 977 |
+
var t = line.trim();
|
| 978 |
+
return t.slice(1, -1).split('|').map(function (c) { return c.trim(); });
|
| 979 |
+
}
|
| 980 |
+
|
| 981 |
+
// Splits message text into text/table blocks so a markdown pipe table renders as a real
|
| 982 |
+
// <table> instead of raw "| a | b |" text wrapped mid-word inside a narrow chat bubble.
|
| 983 |
+
function renderRichContent(el, text) {
|
| 984 |
+
el.textContent = '';
|
| 985 |
+
var lines = text.split('\n');
|
| 986 |
+
var textBuf = [];
|
| 987 |
+
function flushText() {
|
| 988 |
+
if (textBuf.length) {
|
| 989 |
+
appendInlineLinks(el, textBuf.join('\n'));
|
| 990 |
+
textBuf = [];
|
| 991 |
+
}
|
| 992 |
+
}
|
| 993 |
+
var i = 0;
|
| 994 |
+
while (i < lines.length) {
|
| 995 |
+
var line = lines[i];
|
| 996 |
+
var nextLine = i + 1 < lines.length ? lines[i + 1] : '';
|
| 997 |
+
if (isTableRowLine(line) && isTableSeparatorLine(nextLine)) {
|
| 998 |
+
flushText();
|
| 999 |
+
var header = splitTableRow(line);
|
| 1000 |
+
var j = i + 2;
|
| 1001 |
+
var rows = [];
|
| 1002 |
+
while (j < lines.length && isTableRowLine(lines[j])) {
|
| 1003 |
+
rows.push(splitTableRow(lines[j]));
|
| 1004 |
+
j++;
|
| 1005 |
+
}
|
| 1006 |
+
var wrap = document.createElement('div');
|
| 1007 |
+
wrap.className = 'ezofis-chat-table-wrap';
|
| 1008 |
+
var table = document.createElement('table');
|
| 1009 |
+
table.className = 'ezofis-chat-table';
|
| 1010 |
+
var thead = document.createElement('thead');
|
| 1011 |
+
var headTr = document.createElement('tr');
|
| 1012 |
+
header.forEach(function (cell) {
|
| 1013 |
+
var th = document.createElement('th');
|
| 1014 |
+
appendInlineLinks(th, cell);
|
| 1015 |
+
headTr.appendChild(th);
|
| 1016 |
+
});
|
| 1017 |
+
thead.appendChild(headTr);
|
| 1018 |
+
table.appendChild(thead);
|
| 1019 |
+
var tbody = document.createElement('tbody');
|
| 1020 |
+
rows.forEach(function (row) {
|
| 1021 |
+
var tr = document.createElement('tr');
|
| 1022 |
+
row.forEach(function (cell) {
|
| 1023 |
+
var td = document.createElement('td');
|
| 1024 |
+
appendInlineLinks(td, cell);
|
| 1025 |
+
tr.appendChild(td);
|
| 1026 |
+
});
|
| 1027 |
+
tbody.appendChild(tr);
|
| 1028 |
+
});
|
| 1029 |
+
table.appendChild(tbody);
|
| 1030 |
+
wrap.appendChild(table);
|
| 1031 |
+
el.appendChild(wrap);
|
| 1032 |
+
i = j;
|
| 1033 |
+
continue;
|
| 1034 |
+
}
|
| 1035 |
+
textBuf.push(line);
|
| 1036 |
+
i++;
|
| 1037 |
+
}
|
| 1038 |
+
flushText();
|
| 1039 |
}
|
| 1040 |
|
| 1041 |
function ensureSession() {
|
|
|
|
| 1089 |
if (payload === '[DONE]') return;
|
| 1090 |
try {
|
| 1091 |
var obj = JSON.parse(payload);
|
| 1092 |
+
if (obj.delta) { acc += obj.delta; renderRichContent(replyEl, acc); body.scrollTop = body.scrollHeight; }
|
| 1093 |
+
if (obj.error) { renderRichContent(replyEl, acc || 'Sorry, something went wrong.'); }
|
| 1094 |
} catch (e) {}
|
| 1095 |
});
|
| 1096 |
return pump();
|
|
@@ -538,6 +538,10 @@ class ChatAgentMessage(Base):
|
|
| 538 |
conversation_id = Column(Integer, ForeignKey("chat_agent_conversations.id"), nullable=False, index=True)
|
| 539 |
role = Column(String, nullable=False) # user | assistant
|
| 540 |
content = Column(Text, default="")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 541 |
created_at = Column(DateTime, default=datetime.utcnow)
|
| 542 |
|
| 543 |
|
|
@@ -850,6 +854,13 @@ def run_migrations(connection_engine):
|
|
| 850 |
text("ALTER TABLE chat_agent_configs ADD COLUMN enable_web_search INTEGER DEFAULT 0")
|
| 851 |
)
|
| 852 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 853 |
|
| 854 |
# Create tables then migrate legacy SQLite schemas
|
| 855 |
Base.metadata.create_all(bind=engine)
|
|
|
|
| 538 |
conversation_id = Column(Integer, ForeignKey("chat_agent_conversations.id"), nullable=False, index=True)
|
| 539 |
role = Column(String, nullable=False) # user | assistant
|
| 540 |
content = Column(Text, default="")
|
| 541 |
+
# True when this reply used the search_the_web tool — surfaced to admins in the Test Chat
|
| 542 |
+
# and Conversations views so they can tell live-web-sourced answers apart from knowledge-base
|
| 543 |
+
# answers at a glance.
|
| 544 |
+
used_web_search = Column(Integer, default=0)
|
| 545 |
created_at = Column(DateTime, default=datetime.utcnow)
|
| 546 |
|
| 547 |
|
|
|
|
| 854 |
text("ALTER TABLE chat_agent_configs ADD COLUMN enable_web_search INTEGER DEFAULT 0")
|
| 855 |
)
|
| 856 |
|
| 857 |
+
if insp.has_table("chat_agent_messages"):
|
| 858 |
+
msgcols = [c["name"] for c in insp.get_columns("chat_agent_messages")]
|
| 859 |
+
if "used_web_search" not in msgcols:
|
| 860 |
+
conn.execute(
|
| 861 |
+
text("ALTER TABLE chat_agent_messages ADD COLUMN used_web_search INTEGER DEFAULT 0")
|
| 862 |
+
)
|
| 863 |
+
|
| 864 |
|
| 865 |
# Create tables then migrate legacy SQLite schemas
|
| 866 |
Base.metadata.create_all(bind=engine)
|
|
@@ -4,6 +4,7 @@ import {
|
|
| 4 |
Bot,
|
| 5 |
ClipboardCopy,
|
| 6 |
ExternalLink,
|
|
|
|
| 7 |
Loader2,
|
| 8 |
Mail,
|
| 9 |
MessageSquare,
|
|
@@ -72,7 +73,7 @@ function CopyBlock({ label, code }) {
|
|
| 72 |
|
| 73 |
// Renders "[Title](https://...)" links safely — React escapes plain text nodes by default,
|
| 74 |
// and only http(s) URLs matched by the regex itself ever become an href.
|
| 75 |
-
function
|
| 76 |
const linkRe = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g;
|
| 77 |
const parts = [];
|
| 78 |
let lastIndex = 0;
|
|
@@ -80,11 +81,13 @@ function TestChatRichText({ text }) {
|
|
| 80 |
let key = 0;
|
| 81 |
while ((match = linkRe.exec(text)) !== null) {
|
| 82 |
if (match.index > lastIndex) {
|
| 83 |
-
parts.push(
|
|
|
|
|
|
|
| 84 |
}
|
| 85 |
parts.push(
|
| 86 |
<a
|
| 87 |
-
key={key++}
|
| 88 |
href={match[2]}
|
| 89 |
target="_blank"
|
| 90 |
rel="noopener noreferrer"
|
|
@@ -96,9 +99,108 @@ function TestChatRichText({ text }) {
|
|
| 96 |
lastIndex = linkRe.lastIndex;
|
| 97 |
}
|
| 98 |
if (lastIndex < text.length) {
|
| 99 |
-
parts.push(<React.Fragment key={key++}>{text.slice(lastIndex)}</React.Fragment>);
|
| 100 |
}
|
| 101 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
}
|
| 103 |
|
| 104 |
/** Talks to the SAME public session/message endpoints the embedded widget uses — a true
|
|
@@ -213,7 +315,7 @@ function TestChatPanel({ publicId, enabled }) {
|
|
| 213 |
const snapshot = acc;
|
| 214 |
setMessages((prev) => {
|
| 215 |
const next = [...prev];
|
| 216 |
-
next[next.length - 1] = { role: 'assistant', content: snapshot };
|
| 217 |
return next;
|
| 218 |
});
|
| 219 |
}
|
|
@@ -221,7 +323,14 @@ function TestChatPanel({ publicId, enabled }) {
|
|
| 221 |
const fallback = acc || 'Sorry, something went wrong.';
|
| 222 |
setMessages((prev) => {
|
| 223 |
const next = [...prev];
|
| 224 |
-
next[next.length - 1] = { role: 'assistant', content: fallback };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
return next;
|
| 226 |
});
|
| 227 |
}
|
|
@@ -289,7 +398,13 @@ function TestChatPanel({ publicId, enabled }) {
|
|
| 289 |
: 'bg-white border border-slate-200 text-slate-800'
|
| 290 |
)}
|
| 291 |
>
|
| 292 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 293 |
</div>
|
| 294 |
))
|
| 295 |
)}
|
|
@@ -899,7 +1014,13 @@ export default function ChatAgentPanel({ onBack }) {
|
|
| 899 |
: 'bg-slate-100 text-slate-800'
|
| 900 |
)}
|
| 901 |
>
|
| 902 |
-
{m.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 903 |
</div>
|
| 904 |
))}
|
| 905 |
</div>
|
|
|
|
| 4 |
Bot,
|
| 5 |
ClipboardCopy,
|
| 6 |
ExternalLink,
|
| 7 |
+
Globe,
|
| 8 |
Loader2,
|
| 9 |
Mail,
|
| 10 |
MessageSquare,
|
|
|
|
| 73 |
|
| 74 |
// Renders "[Title](https://...)" links safely — React escapes plain text nodes by default,
|
| 75 |
// and only http(s) URLs matched by the regex itself ever become an href.
|
| 76 |
+
function renderInlineLinks(text, keyPrefix) {
|
| 77 |
const linkRe = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g;
|
| 78 |
const parts = [];
|
| 79 |
let lastIndex = 0;
|
|
|
|
| 81 |
let key = 0;
|
| 82 |
while ((match = linkRe.exec(text)) !== null) {
|
| 83 |
if (match.index > lastIndex) {
|
| 84 |
+
parts.push(
|
| 85 |
+
<React.Fragment key={`${keyPrefix}-${key++}`}>{text.slice(lastIndex, match.index)}</React.Fragment>
|
| 86 |
+
);
|
| 87 |
}
|
| 88 |
parts.push(
|
| 89 |
<a
|
| 90 |
+
key={`${keyPrefix}-${key++}`}
|
| 91 |
href={match[2]}
|
| 92 |
target="_blank"
|
| 93 |
rel="noopener noreferrer"
|
|
|
|
| 99 |
lastIndex = linkRe.lastIndex;
|
| 100 |
}
|
| 101 |
if (lastIndex < text.length) {
|
| 102 |
+
parts.push(<React.Fragment key={`${keyPrefix}-${key++}`}>{text.slice(lastIndex)}</React.Fragment>);
|
| 103 |
}
|
| 104 |
+
return parts;
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
// A GFM pipe-table row: trimmed, starts and ends with "|" — matches the exact shape the model is
|
| 108 |
+
// instructed to produce for comparison tables (see chat_agent_chat.py's GROUNDING RULES).
|
| 109 |
+
function isTableRowLine(line) {
|
| 110 |
+
const t = line.trim();
|
| 111 |
+
return t.length >= 2 && t.startsWith('|') && t.endsWith('|');
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
// The "|---|:---:|---|" alignment row that follows a table header — every cell must be only
|
| 115 |
+
// dashes/colons, and at least one dash must be present so a lone "||" doesn't match.
|
| 116 |
+
function isTableSeparatorLine(line) {
|
| 117 |
+
if (!isTableRowLine(line)) return false;
|
| 118 |
+
const inner = line.trim().slice(1, -1);
|
| 119 |
+
const cells = inner.split('|');
|
| 120 |
+
return cells.length > 0 && cells.every((c) => /^:?-+:?$/.test(c.trim()));
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
function splitTableRow(line) {
|
| 124 |
+
const inner = line.trim().slice(1, -1);
|
| 125 |
+
return inner.split('|').map((c) => c.trim());
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
// Splits message text into alternating text/table blocks so a markdown pipe table (which the
|
| 129 |
+
// agent is told to use for comparisons) renders as a real, readable table instead of raw
|
| 130 |
+
// "| a | b |" text wrapped mid-word inside a narrow chat bubble.
|
| 131 |
+
function parseRichBlocks(text) {
|
| 132 |
+
const lines = text.split('\n');
|
| 133 |
+
const blocks = [];
|
| 134 |
+
let textBuf = [];
|
| 135 |
+
const flushText = () => {
|
| 136 |
+
if (textBuf.length) {
|
| 137 |
+
blocks.push({ type: 'text', content: textBuf.join('\n') });
|
| 138 |
+
textBuf = [];
|
| 139 |
+
}
|
| 140 |
+
};
|
| 141 |
+
let i = 0;
|
| 142 |
+
while (i < lines.length) {
|
| 143 |
+
const line = lines[i];
|
| 144 |
+
const nextLine = i + 1 < lines.length ? lines[i + 1] : '';
|
| 145 |
+
if (isTableRowLine(line) && isTableSeparatorLine(nextLine)) {
|
| 146 |
+
flushText();
|
| 147 |
+
const header = splitTableRow(line);
|
| 148 |
+
let j = i + 2;
|
| 149 |
+
const rows = [];
|
| 150 |
+
while (j < lines.length && isTableRowLine(lines[j])) {
|
| 151 |
+
rows.push(splitTableRow(lines[j]));
|
| 152 |
+
j++;
|
| 153 |
+
}
|
| 154 |
+
blocks.push({ type: 'table', header, rows });
|
| 155 |
+
i = j;
|
| 156 |
+
continue;
|
| 157 |
+
}
|
| 158 |
+
textBuf.push(line);
|
| 159 |
+
i++;
|
| 160 |
+
}
|
| 161 |
+
flushText();
|
| 162 |
+
return blocks;
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
function RichMessageContent({ text }) {
|
| 166 |
+
const blocks = useMemo(() => parseRichBlocks(text || ''), [text]);
|
| 167 |
+
return (
|
| 168 |
+
<>
|
| 169 |
+
{blocks.map((block, bi) =>
|
| 170 |
+
block.type === 'table' ? (
|
| 171 |
+
<div key={`b${bi}`} className="my-1.5 -mx-1 overflow-x-auto">
|
| 172 |
+
<table className="min-w-full border-collapse text-xs">
|
| 173 |
+
<thead>
|
| 174 |
+
<tr>
|
| 175 |
+
{block.header.map((cell, ci) => (
|
| 176 |
+
<th
|
| 177 |
+
key={ci}
|
| 178 |
+
className="border border-slate-200 bg-slate-100 px-2 py-1 text-left font-semibold whitespace-nowrap"
|
| 179 |
+
>
|
| 180 |
+
{renderInlineLinks(cell, `b${bi}h${ci}`)}
|
| 181 |
+
</th>
|
| 182 |
+
))}
|
| 183 |
+
</tr>
|
| 184 |
+
</thead>
|
| 185 |
+
<tbody>
|
| 186 |
+
{block.rows.map((row, ri) => (
|
| 187 |
+
<tr key={ri}>
|
| 188 |
+
{row.map((cell, ci) => (
|
| 189 |
+
<td key={ci} className="border border-slate-200 px-2 py-1 align-top">
|
| 190 |
+
{renderInlineLinks(cell, `b${bi}r${ri}c${ci}`)}
|
| 191 |
+
</td>
|
| 192 |
+
))}
|
| 193 |
+
</tr>
|
| 194 |
+
))}
|
| 195 |
+
</tbody>
|
| 196 |
+
</table>
|
| 197 |
+
</div>
|
| 198 |
+
) : (
|
| 199 |
+
<React.Fragment key={`b${bi}`}>{renderInlineLinks(block.content, `b${bi}`)}</React.Fragment>
|
| 200 |
+
)
|
| 201 |
+
)}
|
| 202 |
+
</>
|
| 203 |
+
);
|
| 204 |
}
|
| 205 |
|
| 206 |
/** Talks to the SAME public session/message endpoints the embedded widget uses — a true
|
|
|
|
| 315 |
const snapshot = acc;
|
| 316 |
setMessages((prev) => {
|
| 317 |
const next = [...prev];
|
| 318 |
+
next[next.length - 1] = { ...next[next.length - 1], role: 'assistant', content: snapshot };
|
| 319 |
return next;
|
| 320 |
});
|
| 321 |
}
|
|
|
|
| 323 |
const fallback = acc || 'Sorry, something went wrong.';
|
| 324 |
setMessages((prev) => {
|
| 325 |
const next = [...prev];
|
| 326 |
+
next[next.length - 1] = { ...next[next.length - 1], role: 'assistant', content: fallback };
|
| 327 |
+
return next;
|
| 328 |
+
});
|
| 329 |
+
}
|
| 330 |
+
if (obj.meta && obj.meta.used_web_search) {
|
| 331 |
+
setMessages((prev) => {
|
| 332 |
+
const next = [...prev];
|
| 333 |
+
next[next.length - 1] = { ...next[next.length - 1], usedWebSearch: true };
|
| 334 |
return next;
|
| 335 |
});
|
| 336 |
}
|
|
|
|
| 398 |
: 'bg-white border border-slate-200 text-slate-800'
|
| 399 |
)}
|
| 400 |
>
|
| 401 |
+
{m.usedWebSearch ? (
|
| 402 |
+
<span className="mb-1 flex w-fit items-center gap-1 rounded-full bg-sky-50 px-2 py-0.5 text-[10px] font-medium text-sky-700 ring-1 ring-sky-200">
|
| 403 |
+
<Globe className="h-3 w-3" />
|
| 404 |
+
Used live web search
|
| 405 |
+
</span>
|
| 406 |
+
) : null}
|
| 407 |
+
<RichMessageContent text={m.content} />
|
| 408 |
</div>
|
| 409 |
))
|
| 410 |
)}
|
|
|
|
| 1014 |
: 'bg-slate-100 text-slate-800'
|
| 1015 |
)}
|
| 1016 |
>
|
| 1017 |
+
{m.used_web_search ? (
|
| 1018 |
+
<span className="mb-1 flex w-fit items-center gap-1 rounded-full bg-sky-50 px-2 py-0.5 text-[10px] font-medium text-sky-700 ring-1 ring-sky-200">
|
| 1019 |
+
<Globe className="h-3 w-3" />
|
| 1020 |
+
Used live web search
|
| 1021 |
+
</span>
|
| 1022 |
+
) : null}
|
| 1023 |
+
<RichMessageContent text={m.content} />
|
| 1024 |
</div>
|
| 1025 |
))}
|
| 1026 |
</div>
|