dakshtaneja Claude Opus 5 commited on
Commit
4ec29a9
·
1 Parent(s): ef1f687

citations: space before the link, and a hover preview card

Browse files

The model writes the marker straight onto the preceding word, so
linking it produced "Self-Defence Forcesen.wikipedia.org". A space is
now inserted unless the marker already follows whitespace or an
opening bracket.

The annotations carry a title for each source, which was being dropped
along with everything but the URL. It now rides along as the markdown
link title, so no schema change was needed to get it to the client --
react-markdown hands it straight to the anchor. A custom `a` component
renders it as a small rounded card above the link on hover: source
title in white, host in orange, pointer-events-none so it can't
swallow the click. Ordinary links (no title) render exactly as before.

Where several citations share a host, the one with a title wins, since
a titleless match would render a card with nothing in it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

backend/app/llm.py CHANGED
@@ -17,52 +17,80 @@ class LLMError(Exception):
17
  pass
18
 
19
 
20
- def _citation_urls(annotations: list | None) -> list[str]:
21
- """Pull the URLs out of OpenRouter's url_citation annotations."""
22
- out: list[str] = []
 
 
 
 
 
23
  for a in annotations or []:
24
  if not isinstance(a, dict):
25
  continue
26
  cite = a.get("url_citation") or {}
27
  url = cite.get("url") or ""
28
- if url.startswith("http") and url not in out:
29
- out.append(url)
 
 
30
  return out
31
 
32
 
 
 
 
 
33
  # The web plugin writes citations as a bare domain in fullwidth brackets —
34
  # 【pmindia.gov.in】 — which renders as text that looks like a link but isn't.
35
  # The real URLs come back in message.annotations, so the two can be matched up.
36
  _CITE_MARKER = re.compile(r"【\s*([^【】\s]+?)\s*】")
37
 
38
 
39
- def link_citations(text: str, urls: list[str]) -> str:
40
  """Turn 【domain】 markers into real markdown links.
41
 
42
  Matches each marker to the annotation whose host it belongs to, so the
43
  link lands on the actual cited page rather than the site's front door.
44
  Markers with no matching annotation are left exactly as they are — a
45
  plain-domain link would be a guess, and a wrong link is worse than none.
 
 
 
 
46
  """
47
- if not text or not urls:
48
  return text
49
  hosts = []
50
- for u in urls:
 
51
  try:
52
- host = urlparse(u).netloc.lower()
53
- except ValueError:
54
  continue
55
- hosts.append((host[4:] if host.startswith("www.") else host, u))
56
 
57
  def repl(m: re.Match) -> str:
58
  label = m.group(1)
59
  # removeprefix, not lstrip: lstrip strips any of "w"/"." from the
60
  # front, turning "wikipedia.org" into "ikipedia.org".
61
  key = label.lower().removeprefix("www.")
62
- for host, url in hosts:
63
- if host == key or host.endswith("." + key) or key.endswith(host):
64
- return f"[{label}]({url})"
65
- return m.group(0)
 
 
 
 
 
 
 
 
 
 
 
 
66
 
67
  return _CITE_MARKER.sub(repl, text)
68
 
@@ -183,7 +211,7 @@ async def chat(model: ModelSpec, system: str, user: str,
183
  content = message["content"] or ""
184
  except (KeyError, IndexError) as e:
185
  raise LLMError(f"{model.openrouter_id}: malformed response: {e}")
186
- content = link_citations(content, _citation_urls(message.get("annotations")))
187
  usage = data.get("usage") or {}
188
  return LLMResponse(
189
  content=content,
@@ -213,7 +241,7 @@ async def chat_stream(model: ModelSpec, system: str, user: str,
213
 
214
  start = time.monotonic()
215
  parts: list[str] = []
216
- citation_urls: list[str] = []
217
  tokens_in = tokens_out = 0
218
  served = model.openrouter_id
219
 
@@ -254,9 +282,9 @@ async def chat_stream(model: ModelSpec, system: str, user: str,
254
  # message); collected here so the completed answer can be
255
  # rewritten with real links below.
256
  for src in (delta, choices[0].get("message") or {}):
257
- for u in _citation_urls(src.get("annotations")):
258
- if u not in citation_urls:
259
- citation_urls.append(u)
260
  thinking = delta.get("reasoning") or ""
261
  if thinking:
262
  # Reasoning summaries stream before content on
@@ -273,7 +301,7 @@ async def chat_stream(model: ModelSpec, system: str, user: str,
273
  "response": LLMResponse(
274
  # Deltas streamed raw; the finished answer gets real links. The
275
  # client replaces the streamed text with this on `done`.
276
- content=link_citations("".join(parts), citation_urls),
277
  tokens_in=tokens_in,
278
  tokens_out=tokens_out,
279
  latency_ms=int((time.monotonic() - start) * 1000),
 
17
  pass
18
 
19
 
20
+ def _citation_items(annotations: list | None) -> list[dict]:
21
+ """Pull {url, title} out of OpenRouter's url_citation annotations.
22
+
23
+ The title is what the hover preview shows, so it's carried alongside the
24
+ URL rather than thrown away with the rest of the annotation.
25
+ """
26
+ out: list[dict] = []
27
+ seen: set[str] = set()
28
  for a in annotations or []:
29
  if not isinstance(a, dict):
30
  continue
31
  cite = a.get("url_citation") or {}
32
  url = cite.get("url") or ""
33
+ if not url.startswith("http") or url in seen:
34
+ continue
35
+ seen.add(url)
36
+ out.append({"url": url, "title": str(cite.get("title") or "")[:160]})
37
  return out
38
 
39
 
40
+ def _citation_urls(annotations: list | None) -> list[str]:
41
+ return [c["url"] for c in _citation_items(annotations)]
42
+
43
+
44
  # The web plugin writes citations as a bare domain in fullwidth brackets —
45
  # 【pmindia.gov.in】 — which renders as text that looks like a link but isn't.
46
  # The real URLs come back in message.annotations, so the two can be matched up.
47
  _CITE_MARKER = re.compile(r"【\s*([^【】\s]+?)\s*】")
48
 
49
 
50
+ def link_citations(text: str, citations: list) -> str:
51
  """Turn 【domain】 markers into real markdown links.
52
 
53
  Matches each marker to the annotation whose host it belongs to, so the
54
  link lands on the actual cited page rather than the site's front door.
55
  Markers with no matching annotation are left exactly as they are — a
56
  plain-domain link would be a guess, and a wrong link is worse than none.
57
+
58
+ The annotation title rides along as the link's markdown title, which is
59
+ what the frontend's hover preview reads. Accepts either {url, title}
60
+ dicts or bare URL strings.
61
  """
62
+ if not text or not citations:
63
  return text
64
  hosts = []
65
+ for c in citations:
66
+ item = {"url": c, "title": ""} if isinstance(c, str) else c
67
  try:
68
+ host = urlparse(item["url"]).netloc.lower()
69
+ except (ValueError, KeyError, TypeError):
70
  continue
71
+ hosts.append((host.removeprefix("www."), item))
72
 
73
  def repl(m: re.Match) -> str:
74
  label = m.group(1)
75
  # removeprefix, not lstrip: lstrip strips any of "w"/"." from the
76
  # front, turning "wikipedia.org" into "ikipedia.org".
77
  key = label.lower().removeprefix("www.")
78
+ matches = [item for host, item in hosts
79
+ if host == key or host.endswith("." + key)
80
+ or key.endswith(host)]
81
+ if not matches:
82
+ return m.group(0)
83
+ # Several citations can share a host; prefer one that carries a title,
84
+ # since that's what the hover preview has to show.
85
+ item = next((i for i in matches if i.get("title")), matches[0])
86
+ title = (item.get("title") or "").replace('"', "'")
87
+ suffix = f' "{title}"' if title else ""
88
+ # The model glues the marker straight onto the preceding word
89
+ # ("Self-Defence Forcesen.wikipedia.org"), so give the link room
90
+ # unless it already follows whitespace or an opening bracket.
91
+ start = m.start()
92
+ lead = "" if start == 0 or text[start - 1] in " \t\n([" else " "
93
+ return f"{lead}[{label}]({item['url']}{suffix})"
94
 
95
  return _CITE_MARKER.sub(repl, text)
96
 
 
211
  content = message["content"] or ""
212
  except (KeyError, IndexError) as e:
213
  raise LLMError(f"{model.openrouter_id}: malformed response: {e}")
214
+ content = link_citations(content, _citation_items(message.get("annotations")))
215
  usage = data.get("usage") or {}
216
  return LLMResponse(
217
  content=content,
 
241
 
242
  start = time.monotonic()
243
  parts: list[str] = []
244
+ citations: list[dict] = []
245
  tokens_in = tokens_out = 0
246
  served = model.openrouter_id
247
 
 
282
  # message); collected here so the completed answer can be
283
  # rewritten with real links below.
284
  for src in (delta, choices[0].get("message") or {}):
285
+ for c in _citation_items(src.get("annotations")):
286
+ if all(c["url"] != e["url"] for e in citations):
287
+ citations.append(c)
288
  thinking = delta.get("reasoning") or ""
289
  if thinking:
290
  # Reasoning summaries stream before content on
 
301
  "response": LLMResponse(
302
  # Deltas streamed raw; the finished answer gets real links. The
303
  # client replaces the streamed text with this on `done`.
304
+ content=link_citations("".join(parts), citations),
305
  tokens_in=tokens_in,
306
  tokens_out=tokens_out,
307
  latency_ms=int((time.monotonic() - start) * 1000),
backend/tests/test_citations.py CHANGED
@@ -16,8 +16,32 @@ def test_marker_becomes_a_link_to_the_deep_url():
16
  out = link_citations(
17
  "Narendra Modi is the Prime Minister of India【pmindia.gov.in】",
18
  [PM])
 
19
  assert out == (
20
- f"Narendra Modi is the Prime Minister of India[pmindia.gov.in]({PM})")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
 
22
 
23
  def test_multiple_markers_each_match_their_own_host():
 
16
  out = link_citations(
17
  "Narendra Modi is the Prime Minister of India【pmindia.gov.in】",
18
  [PM])
19
+ # note the inserted space: the model glues the marker onto the last word
20
  assert out == (
21
+ f"Narendra Modi is the Prime Minister of India [pmindia.gov.in]({PM})")
22
+
23
+
24
+ def test_space_inserted_only_when_needed():
25
+ # "Forcesen.wikipedia.org" was the reported symptom
26
+ glued = link_citations("the Self-Defence Forces【wikipedia.org】", [WIKI])
27
+ assert "Forces [wikipedia.org]" in glued
28
+ # already spaced -> no double space
29
+ spaced = link_citations("the Self-Defence Forces 【wikipedia.org】", [WIKI])
30
+ assert "Forces [" not in spaced
31
+ assert "Forces [wikipedia.org]" in spaced
32
+
33
+
34
+ def test_title_rides_along_for_the_hover_card():
35
+ out = link_citations("x【pmindia.gov.in】",
36
+ [{"url": PM, "title": "Know the PM"}])
37
+ assert out == f'x [pmindia.gov.in]({PM} "Know the PM")'
38
+
39
+
40
+ def test_quotes_in_title_are_neutralised():
41
+ # a raw double quote would terminate the markdown title early
42
+ out = link_citations("x【pmindia.gov.in】",
43
+ [{"url": PM, "title": 'The "PM" profile'}])
44
+ assert '"The \'PM\' profile"' in out
45
 
46
 
47
  def test_multiple_markers_each_match_their_own_host():
frontend/components/Markdown.tsx CHANGED
Binary files a/frontend/components/Markdown.tsx and b/frontend/components/Markdown.tsx differ