Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions frontend/snapshots.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5996,6 +5996,18 @@ snapshots:
hash: v1.k794b7964.e21450a1b2054a36fab2a183fd4485190e7161c07830a04008b734f83ecabb1c.Hj2ygumZlfd2zAIz7tC6m7XoM-G15hY_amDmI_m-cJo
replay-vision-analysis-nudge--default--light:
hash: v1.k794b7964.7eff340ad050dbd7cf07813207e989e33fccb29ef9767e38f89d16b1d567017c.Kr8YaJd8YlDg3tPWTMYWt4vAxTb_HKQuhGUe4IKACDU
replay-vision-cited-markdown--hostile-links--dark:
hash: v1.k794b7964.d98b1851e8c4c695b87856379b85fd225d554c843086706f8d51fe5c060ad2d9.Nu73wfxltRa0Loo3i2L0DYIY_5NgIZwuP5RMTMyQGts
replay-vision-cited-markdown--hostile-links--light:
hash: v1.k794b7964.362d39ed28755489dbf7a698e8115a98141f601b8db1364bc44c8558d488ee1c.AIbcwFSazoZiB1_qtsq7cOH1rV6XPryL3zckEZWetEA
replay-vision-cited-markdown--plain-prose--dark:
hash: v1.k794b7964.c31ceb73ee19d59911337cca2b583f30b7bfdc69349088088811998d357c87f3.y2qdje-cpHJgB9yhNVGjvi8_bsTMVyuR98LWJrnT9Lk
replay-vision-cited-markdown--plain-prose--light:
hash: v1.k794b7964.1a196e580088555e900e84c0162ae4c523feef638540838b541827654ae507ca.beJnsabMlBBuFJexvb0HtCu-cO0W1Md_3b2ucWGA72I
replay-vision-cited-markdown--structured--dark:
hash: v1.k794b7964.b8136fc7ff4b2794158921d793938bdd536bccbc0a66eeadf2d988386e27a2f0.5BXQa3xxRK-9zihaQbWZ4rHtM0lmb0uFKZaNcLbKiX8
replay-vision-cited-markdown--structured--light:
hash: v1.k794b7964.380bf20693c1029dce9ff64afc117eb5fd831449e81d541f4463ade2ec32b1e0.BBhrMSTk8RqH29BEuHYQeMRRlEos0ZQxgB1wPgvSMxQ
scenes-app-ai-observability-clustercard--default--dark:
hash: v1.k794b7964.745c8ac41b251f329b890d56d7b07fb31b4939a440a40eea7932f1081d3de702.ESnQVAOfySBfRJWfJsURYu-prgU3EUvQZCD62MyLlX4
scenes-app-ai-observability-clustercard--default--light:
Expand Down
77 changes: 59 additions & 18 deletions frontend/src/lib/lemon-ui/LemonMarkdown/LemonMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,17 @@ function chartRefId(href: unknown): string | null {
return typeof href === 'string' ? (CHART_REF_TARGET.exec(href)?.[1] ?? null) : null
}

/** Link target for a moment in a recording, e.g. `[01:32](t:92000)`, the offset in milliseconds. */
export const TIMESTAMP_REF_PREFIX = 't:'

// Bounded digits, so an overlong target stays an ordinary link and keeps the usual URL sanitizing.
const TIMESTAMP_REF_TARGET = new RegExp(`^${TIMESTAMP_REF_PREFIX}(\\d{1,12})$`)

function timestampRefMs(href: unknown): number | null {
const digits = typeof href === 'string' ? TIMESTAMP_REF_TARGET.exec(href)?.[1] : undefined
return digits === undefined ? null : parseInt(digits, 10)
}

/** Whether a paragraph's child node is a chart reference. */
function isChartRefNode(node: any): boolean {
return node?.tagName === 'a' && chartRefId(node.properties?.href) !== null
Expand Down Expand Up @@ -70,8 +81,16 @@ export interface LemonMarkdownProps {
* URL — leaking the viewer's IP, acting as a tracking pixel, or probing internal addresses.
* When set, only images served from PostHog (same-origin or a `posthog.com` host) render inline
* as <img>; every other image is rendered as a plain click-to-open link instead.
* `'all'` emits no <img> at all: a same-origin `src` is still a credentialed GET on this host.
*/
disableImages?: boolean
disableImages?: boolean | 'all'
/** Whether `@member:<id>` / `@role:<id>` stay plain text instead of chips naming a real colleague. */
disableMentions?: boolean
/**
* Whether every link renders as its label rather than an anchor. Preferred over stripping link syntax
* from the source, which has to out-guess the inline, reference, autolink and image-fallback forms.
*/
disableLinks?: boolean
className?: string
wrapCode?: boolean
/**
Expand All @@ -97,6 +116,8 @@ export interface LemonMarkdownProps {
* is a scheme no browser can follow.
*/
renderChartRef?: (chartId: string, sourceOffset?: number) => React.ReactNode
/** Without it, or on a nullish return, a `t:<ms>` target renders as its label. */
renderTimestampRef?: (timestampMs: number) => React.ReactNode
}

const HEADING_TAGS = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] as const
Expand Down Expand Up @@ -132,15 +153,22 @@ const LemonMarkdownRenderer = memo(function LemonMarkdownRenderer({
lowKeyHeadings = false,
disableDocsRedirect = false,
disableImages = false,
disableLinks = false,
disableMentions = false,
wrapCode = false,
codeMaxLines,
generateHeadingIds = false,
renderMermaid,
renderChartRef,
renderTimestampRef,
}: LemonMarkdownProps): JSX.Element {
const components = useMemo(
() => ({
a: ({ href, children, node }: any): JSX.Element => {
const timestampMs = timestampRefMs(href)
if (timestampMs !== null) {
return <>{renderTimestampRef?.(timestampMs) ?? children}</>
}
const chartId = chartRefId(href)
if (chartId !== null) {
// Falls back to its own label as plain text: for a consumer that can't draw
Expand All @@ -149,6 +177,9 @@ const LemonMarkdownRenderer = memo(function LemonMarkdownRenderer({
// instead would point the reader at a scheme no browser can follow.
return <>{renderChartRef?.(chartId, node?.position?.start?.offset) ?? children}</>
}
if (disableLinks) {
return <>{children}</>
}
return (
<Link to={href} target="_blank" targetBlankIcon disableDocsPanel={disableDocsRedirect}>
{children}
Expand Down Expand Up @@ -226,14 +257,19 @@ const LemonMarkdownRenderer = memo(function LemonMarkdownRenderer({
},
...(disableImages
? {
img: ({ src, alt }: any): JSX.Element =>
isTrustedPostHogUrl(src) ? (
<img src={src} alt={alt} loading="lazy" />
img: ({ src, alt }: any): JSX.Element => {
if (disableImages !== 'all' && isTrustedPostHogUrl(src)) {
return <img src={src} alt={alt} loading="lazy" />
}
// The click-to-open fallback is an anchor, so it answers to `disableLinks` too.
return disableLinks ? (
<>{alt || src}</>
) : (
<Link to={src} target="_blank" targetBlankIcon disableDocsPanel>
{alt || src}
</Link>
),
)
},
}
: {}),
li: ({ children, node }: any): JSX.Element => {
Expand Down Expand Up @@ -282,39 +318,38 @@ const LemonMarkdownRenderer = memo(function LemonMarkdownRenderer({
[
disableDocsRedirect,
disableImages,
disableLinks,
lowKeyHeadings,
wrapCode,
codeMaxLines,
generateHeadingIds,
renderMermaid,
renderChartRef,
renderTimestampRef,
]
)

// `chart:` is not a protocol the default URL sanitizer knows, so it strips the href before the
// `a` override ever sees it — and an emptied href is what `Link` turns into a focusable
// target-blank stub that goes nowhere. Let the scheme through whether or not this caller can
// draw charts, so the `a` override can recognize the reference and render its label as text.
// Only on `href`: the same transform runs for an image `src`, where nothing downstream reads a
// chart reference, so `![x](chart:y)` would carry an unsanitized scheme into a broken image.
// The default sanitizer strips both schemes, so let them through, on `href` only.
const urlTransform = useMemo(
() =>
(url: string, key: string): string =>
key === 'href' && chartRefId(url) !== null ? url : defaultUrlTransform(url),
key === 'href' && (chartRefId(url) !== null || timestampRefMs(url) !== null)
? url
: defaultUrlTransform(url),
[]
)

// remark-breaks: a single newline becomes a line break, so prose authored without the arcane
// two-trailing-spaces hard-break rule (e.g. agent-written report summaries) renders with the
// line breaks the author intended.
const remarkPlugins = useMemo(
() => (disableMentions ? [remarkGfm, remarkBreaks] : [remarkGfm, remarkBreaks, remarkMentions]),
[disableMentions]
)

return (
/* eslint-disable-next-line react/forbid-elements */
<ReactMarkdown
components={components}
remarkPlugins={[remarkGfm, remarkBreaks, remarkMentions]}
urlTransform={urlTransform}
skipHtml
>
<ReactMarkdown components={components} remarkPlugins={remarkPlugins} urlTransform={urlTransform} skipHtml>
{children}
</ReactMarkdown>
)
Expand All @@ -326,11 +361,14 @@ function LemonMarkdownComponent({
lowKeyHeadings = false,
disableDocsRedirect = false,
disableImages = false,
disableLinks = false,
disableMentions = false,
wrapCode = false,
codeMaxLines,
generateHeadingIds = false,
renderMermaid,
renderChartRef,
renderTimestampRef,
className,
}: LemonMarkdownProps): JSX.Element {
return (
Expand All @@ -339,11 +377,14 @@ function LemonMarkdownComponent({
lowKeyHeadings={lowKeyHeadings}
disableDocsRedirect={disableDocsRedirect}
disableImages={disableImages}
disableLinks={disableLinks}
disableMentions={disableMentions}
wrapCode={wrapCode}
codeMaxLines={codeMaxLines}
generateHeadingIds={generateHeadingIds}
renderMermaid={renderMermaid}
renderChartRef={renderChartRef}
renderTimestampRef={renderTimestampRef}
>
{children}
</LemonMarkdownRenderer>
Expand Down
8 changes: 7 additions & 1 deletion frontend/src/lib/lemon-ui/LemonMarkdown/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
export { CHART_REF_PREFIX, LemonMarkdown, type LemonMarkdownProps, slugifyHeading } from './LemonMarkdown'
export {
CHART_REF_PREFIX,
LemonMarkdown,
type LemonMarkdownProps,
TIMESTAMP_REF_PREFIX,
slugifyHeading,
} from './LemonMarkdown'
// LemonMarkdownWithMermaid is deliberately not re-exported: this barrel is imported by lib
// components shipped in the toolbar bundle, and the mermaid variant must stay out of that graph.
// Import it from 'lib/lemon-ui/LemonMarkdown/LemonMarkdownWithMermaid' directly.
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import { urls } from 'scenes/urls'

import type { ReplayObservationApi } from 'products/replay_vision/frontend/generated/api.schemas'
import { flattenMarkdownToLine } from 'products/replay_vision/frontend/utils/markdown'
import {
readErrorMessage,
readReasoning,
Expand All @@ -28,7 +29,7 @@
failed: 'the scan could not be started',
}

export function ReplayVisionScanWidget({ scanId, sessionIds, skipped }: ReplayVisionScanWidgetProps): JSX.Element {

Check warning on line 32 in products/posthog_ai/frontend/components/tool/widgets/ReplayVisionScanWidget.tsx

View workflow job for this annotation

GitHub Actions / Frontend formatting

lint:complexity

`ReplayVisionScanWidget` has cyclomatic complexity 12 (warn >10)
const { gaveUp, latestPerSession, pendingCount } = useValues(replayVisionScanWidgetLogic({ scanId, sessionIds }))
const skippedByReason = Object.entries(
skipped.reduce<Record<string, number>>((counts, entry) => {
Expand Down Expand Up @@ -98,9 +99,10 @@
)
}

// Summarizers write a title and a body; a monitor scan on the same widget has reasoning instead.
// Summarizers write a title and a body; a monitor scan has reasoning, which can carry markdown.
const title = readTitle(observation)
const body = readSummary(observation) ?? readReasoning(observation)
const reasoning = readReasoning(observation)
const body = readSummary(observation) ?? (reasoning ? flattenMarkdownToLine(reasoning) : null)

return (
<div className="px-3 py-2 text-sm">
Expand Down
72 changes: 70 additions & 2 deletions products/replay_vision/backend/observation_formatting.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,73 @@

SEARCH_SNIPPET_LIMIT = 600

_FENCE_RE = re.compile(r"^ {0,3}(?:```|~~~)")
_RULE_RE = re.compile(r"^ {0,3}(?:[-*_]\s*){3,}$")
_HEADING_RE = re.compile(r"^ {0,3}#{1,6}\s+")
_QUOTE_RE = re.compile(r"^ {0,3}>\s?")
_BULLET_RE = re.compile(r"^\s*(?:[-*+]|\d{1,9}[.)])\s+")
_IMAGE_RE = re.compile(r"!\[([^\]]*)\]\([^)]*\)")
_LINK_RE = re.compile(r"\[([^\]]*)\]\([^)]*\)")
_LINK_DEFINITION_RE = re.compile(r"^ {0,3}\[[^\]]+\]:\s*\S+(?:\s+(?:\"[^\"]*\"|'[^']*'|\([^)]*\)))?\s*$")
_REFERENCE_RE = re.compile(r"!?\[([^\]]*)\]\[[^\]]*\]")
_CODE_SPAN_RE = re.compile(r"`+([^`]*)`+")
_STRONG_RE = re.compile(r"(\*\*|__)(?=\S)(.+?)(?<=\S)\1")
_STRIKE_RE = re.compile(r"~~(?=\S)(.+?)(?<=\S)~~")
_EMPHASIS_RE = re.compile(r"(?<![\w*_])([*_])(?=\S)(.+?)(?<=\S)\1(?![\w*_])")
_ESCAPE_RE = re.compile(r"\\([\\`*_{}\[\]()#+\-.!>~|])")
_SENTENCE_ENDINGS = ".!?:;,"


def flatten_markdown(text: str) -> str:
"""Markdown-bearing model text as plain prose, keeping the line structure.

Scanner reasoning can carry a bold lead-in, a heading, or bullets. Every consumer outside the UI reads
it as prose, where the syntax is noise at best (`**` and `##` inside a search snippet) and a forged list
row at worst. Flattening here rather than at each consumer keeps the stored field the one canonical
copy. `plain_snippet` is what folds the result onto a single line.

The regexes above read a line the way a markdown parser does: block markers only at the start of a
line and only space-indented, inline markers anywhere. Three carry a reason worth knowing:
`_LINK_DEFINITION_RE` has to match a whole line, or it eats a sentence that merely opens the same way
("[Save]: clicked twice before it took"); bare `[text]` is deliberately left alone, being far more
often a real bracket in prose than a shortcut reference; and `_EMPHASIS_RE`'s lookarounds keep
`snake_case` and `2 * 3 * 4` intact. The frontend's `flattenMarkdownToLine`
(products/replay_vision/frontend/utils/markdown.ts) is the same table and has to stay in step, or a
snippet flattens differently in a search result and on the seekbar.
"""
lines: list[str] = []
for raw in text.split("\n"):
if _FENCE_RE.match(raw) or _RULE_RE.match(raw) or _LINK_DEFINITION_RE.match(raw):
continue
line = _BULLET_RE.sub("", _QUOTE_RE.sub("", _HEADING_RE.sub("", raw)))
line = _IMAGE_RE.sub(r"\1", line)
line = _LINK_RE.sub(r"\1", line)
line = _REFERENCE_RE.sub(r"\1", line)
line = _CODE_SPAN_RE.sub(r"\1", line)
line = _STRONG_RE.sub(r"\2", line)
line = _STRIKE_RE.sub(r"\1", line)
line = _EMPHASIS_RE.sub(r"\2", line)
lines.append(_ESCAPE_RE.sub(r"\1", line).rstrip())
return re.sub(r"\n{3,}", "\n\n", "\n".join(lines)).strip()


def plain_snippet(text: str, *, limit: int | None = SEARCH_SNIPPET_LIMIT) -> str:
"""One line of readable plain text from a model's free-text field.

The single line is a safety property, not tidiness. These snippets go into markdown someone else reads
as structure — the search-result bullet list inside a Max tool's untrusted-data fence — where a newline
lets recording-derived text forge a row or a header. Flattening runs first so a markdown bullet cannot
survive as a literal `-` at the start of the folded line either. Blocks are joined with a sentence
break so a flattened list still reads as prose rather than one run-on clause.
"""
flat = flatten_markdown(EVENT_ID_CITATION_RE.sub("", text))
out = ""
for block in (b for line in flat.split("\n") if (b := " ".join(line.split()))):
if out:
out += " " if out[-1] in _SENTENCE_ENDINGS else ". "
out += block
return out if limit is None else out[:limit]


def read_output(obs: "ReplayObservation") -> dict[str, Any] | None:
scanner_result = obs.scanner_result if isinstance(obs.scanner_result, dict) else None
Expand All @@ -36,11 +103,12 @@ def describe_output(output: dict[str, Any]) -> str | None:

def explanation_text(output: dict[str, Any]) -> str:
"""The free text a scanner wrote about the session: monitors, scorers and classifiers reason, summarizers
summarize. Event-id citations are for the UI, so they are stripped here."""
summarize. Event-id citations are for the UI, so they are stripped here, and any markdown the model wrote
is flattened: every caller reads this as one line of prose."""
explanation = output.get("reasoning") or output.get("summary")
if not isinstance(explanation, str):
return ""
return re.sub(r"\s+", " ", EVENT_ID_CITATION_RE.sub("", explanation)).strip()
return plain_snippet(explanation, limit=None)


def format_line(obs: "ReplayObservation", output: dict[str, Any], *, show_scanner: bool) -> str:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
EMBEDDING_PRODUCT,
OBSERVATION_EMBEDDING_MODEL,
)
from products.replay_vision.backend.observation_formatting import flatten_markdown
from products.replay_vision.backend.tags import slugify_tag
from products.replay_vision.backend.temporal.constants import KAFKA_DELIVERY_TIMEOUT_S
from products.replay_vision.backend.temporal.decorators import track_activity
Expand All @@ -39,8 +40,13 @@


def _renderings_for(model_output: AnyScannerOutput) -> list[tuple[str, str]]:
"""The `(rendering, content)` pairs to embed for one scanner output, or none when it has nothing to say.

Flattened first: markdown syntax the model wrote is not meaning, and embedding `**` and `##` tokens
dilutes the vector that semantic search ranks on.
"""
document = model_output.embedding_document()
return [(document.rendering, document.text)] if document else []
return [(document.rendering, flatten_markdown(document.text))] if document else []


def _result_metadata(model_output: AnyScannerOutput) -> dict[str, Any]:
Expand Down
Loading
Loading