File size: 4,302 Bytes
3fc8e60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
"use client";

import { Fragment, type ReactNode } from "react";
import { CitationChip } from "@/components/CitationChip";
import { segmentAnswer } from "@/lib/citations";
import type { CitationView } from "@/lib/api";

/**
 * Renders a generated answer: paragraphs, a small safe subset of inline markdown, and
 * citation chips.
 *
 * Everything is built as React elements — there is no `dangerouslySetInnerHTML` and no
 * markdown library. Model output is untrusted text that may quote a hostile document
 * verbatim, so the renderer is deliberately incapable of producing markup: the worst a
 * malicious passage can do is render as literal characters.
 *
 * The supported subset is what a grounded legal answer actually uses — bold for the
 * provision being named, inline code, numbered and bulleted clause lists — and nothing
 * more. Links are intentionally NOT rendered: the only trustworthy destination in this
 * product is a citation chip, which resolves through the verifier.
 */

const BOLD = /\*\*([^*]+)\*\*/g;
const CODE = /`([^`]+)`/g;

export function AnswerText({
  text,
  citations,
  onOpenEvidence,
}: {
  text: string;
  citations: CitationView[];
  onOpenEvidence: (chunkId: string) => void;
}) {
  const blocks = text.split(/\n{2,}/).filter((block) => block.trim().length > 0);

  return (
    <div className="statute max-w-prose space-y-3 text-[15px] text-ink/95">
      {blocks.map((block, blockIndex) => {
        const lines = block.split("\n").filter((line) => line.trim().length > 0);
        const isList = lines.length > 1 && lines.every((line) => /^\s*(?:[-*•]|\d+[.)])\s/.test(line));

        if (isList) {
          return (
            <ul key={blockIndex} className="ml-1 space-y-1.5">
              {lines.map((line, lineIndex) => (
                <li key={lineIndex} className="flex gap-2.5">
                  <span aria-hidden className="mt-[0.6em] size-1 shrink-0 rounded-full bg-cyan/60" />
                  <span>
                    <Inline
                      text={line.replace(/^\s*(?:[-*•]|\d+[.)])\s+/, "")}
                      citations={citations}
                      onOpenEvidence={onOpenEvidence}
                    />
                  </span>
                </li>
              ))}
            </ul>
          );
        }

        return (
          <p key={blockIndex}>
            {lines.map((line, lineIndex) => (
              <Fragment key={lineIndex}>
                {lineIndex > 0 ? <br /> : null}
                <Inline text={line} citations={citations} onOpenEvidence={onOpenEvidence} />
              </Fragment>
            ))}
          </p>
        );
      })}
    </div>
  );
}

function Inline({
  text,
  citations,
  onOpenEvidence,
}: {
  text: string;
  citations: CitationView[];
  onOpenEvidence: (chunkId: string) => void;
}) {
  return (
    <>
      {segmentAnswer(text, citations).map((segment, index) =>
        segment.kind === "citation" ? (
          <CitationChip
            key={index}
            label={segment.lawLabel}
            articleNo={segment.articleNo}
            citation={segment.citation}
            onOpen={onOpenEvidence}
          />
        ) : (
          <Fragment key={index}>{formatInline(segment.value)}</Fragment>
        ),
      )}
    </>
  );
}

/** Apply the supported inline marks, emitting elements rather than markup. */
function formatInline(value: string): ReactNode[] {
  const nodes: ReactNode[] = [];
  let cursor = 0;
  let key = 0;

  const pattern = new RegExp(`${BOLD.source}|${CODE.source}`, "g");
  let match = pattern.exec(value);
  while (match !== null) {
    if (match.index > cursor) nodes.push(value.slice(cursor, match.index));
    const [, boldBody, codeBody] = match;
    if (boldBody !== undefined) {
      nodes.push(
        <strong key={`b${key}`} className="font-semibold text-ink">
          {boldBody}
        </strong>,
      );
    } else if (codeBody !== undefined) {
      nodes.push(
        <code key={`c${key}`} className="rounded bg-surface-2 px-1 py-px font-mono text-[13px] text-cyan">
          {codeBody}
        </code>,
      );
    }
    key += 1;
    cursor = match.index + match[0].length;
    match = pattern.exec(value);
  }
  if (cursor < value.length) nodes.push(value.slice(cursor));
  return nodes;
}