Spaces:
Running
citations: space before the link, and a hover preview card
Browse filesThe 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 +49 -21
- backend/tests/test_citations.py +25 -1
- frontend/components/Markdown.tsx +0 -0
|
@@ -17,52 +17,80 @@ class LLMError(Exception):
|
|
| 17 |
pass
|
| 18 |
|
| 19 |
|
| 20 |
-
def
|
| 21 |
-
"""Pull
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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")
|
| 29 |
-
|
|
|
|
|
|
|
| 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,
|
| 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
|
| 48 |
return text
|
| 49 |
hosts = []
|
| 50 |
-
for
|
|
|
|
| 51 |
try:
|
| 52 |
-
host = urlparse(
|
| 53 |
-
except ValueError:
|
| 54 |
continue
|
| 55 |
-
hosts.append((host
|
| 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,
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 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 |
-
|
| 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
|
| 258 |
-
if
|
| 259 |
-
|
| 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),
|
| 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),
|
|
@@ -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():
|
|
Binary files a/frontend/components/Markdown.tsx and b/frontend/components/Markdown.tsx differ
|
|
|