Skip to content

Commit afa3fb6

Browse files
babatongaclaude
andcommitted
feat(ai): inline wowhead links across every prose section
Today's analysis card has Wowhead-tooltip chips clipped under each finding's prose — and the rotation / cooldown / talent / gear / comparison sections have no links at all even though their prose frequently names spells/items/talents. This adds inline links to every prose surface without letting the AI write actual Wowhead URLs. How - Prompt: every spell / item / talent reference in any prose field (findings.detail, rotation_summary, cooldown_usage_summary, stat_recommendations, talent_recommendations, gear_and_trinket_notes, comparison_to_top_logs, headline, strengths) MUST be written as a markdown link ``[Label](kind:id)``. Three kinds: ``spell``, ``item``, ``talent``. Names come from ``localized_names``, IDs from the existing data fields. Same EN + DE wording. - Backend: new ``_collect_talent_spell_ids`` helper resolves the ``TraitNodeEntry.ID → SpellID`` mapping from ``wow_localizations. extras['spell_id']`` (populated by ``_import_talents``) for every talent id that appears in the player or any reference. Stitched into the analysis's structured output as ``_talent_spell_ids`` so the frontend can turn ``talent:<id>`` markdown into a proper /spell/<id> Wowhead URL — talent ids collide with old MoP spell ids, so a naïve link would 404 onto an unrelated MoP spell. - Frontend: new ``ProseWithLinks`` component parses ``[Label](kind:id)`` with a strict regex and substitutes SpellLink / ItemLink / SpellLink-via-talent-map components. Renders as ``<span class="block">`` so it can sit inside ``<h2>`` (headline) and block contexts equally well. Used in every prose surface of ``AnalysisCard``. - Chip-list fallback: still shown on each finding when the prose has no inline refs at all (legacy analyses). Hidden once any inline markdown link is present in the prose, so users don't see both the link and a redundant chip below. Regex verified against typical AI outputs: - single inline link → matches - multiple per line → all matches - legacy plain prose → no false positives - malformed markdown → graceful (just renders as text) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 12360ba commit afa3fb6

5 files changed

Lines changed: 324 additions & 11 deletions

File tree

backend/app/services/ai/analyzer.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
ReportPlayer,
2121
ReportPlayerCast,
2222
TopLog,
23+
WowLocalization,
2324
)
2425
from app.services.ai.anthropic_provider import AnthropicProvider
2526
from app.services.ai.base import AiProvider, AiResponse
@@ -548,6 +549,55 @@ async def _collect_localized_names(
548549
return names
549550

550551

552+
async def _collect_talent_spell_ids(
553+
session: AsyncSession,
554+
*,
555+
player_summary: dict[str, Any],
556+
references: list[dict[str, Any]],
557+
) -> dict[str, int]:
558+
"""Build a ``{TraitNodeEntry.ID: SpellID}`` map for every talent ID
559+
that appears in the player_summary or any top-log reference detail.
560+
561+
Frontend uses this to turn ``[Label](talent:<id>)`` inline markdown
562+
links into real Wowhead spell links — the TraitNodeEntry IDs WCL
563+
ships collide with old MoP-era spell IDs, so we have to resolve
564+
through ``wow_localizations.extras['spell_id']`` (populated by
565+
``_import_talents``) before linking. Missing entries silently fall
566+
back to bold text on the frontend.
567+
"""
568+
talent_ids: set[int] = set()
569+
for tid in player_summary.get("talent_ids") or []:
570+
if tid:
571+
talent_ids.add(int(tid))
572+
for ref in references:
573+
for tid in (ref.get("detail") or {}).get("talent_ids") or []:
574+
if tid:
575+
talent_ids.add(int(tid))
576+
if not talent_ids:
577+
return {}
578+
# One row per talent (en locale — extras['spell_id'] is locale-
579+
# agnostic). UNIQUE constraint on (kind, game_id, locale) means
580+
# we only ever get one row per ID.
581+
rows = (
582+
await session.execute(
583+
select(WowLocalization.game_id, WowLocalization.extras)
584+
.where(WowLocalization.kind == "talent")
585+
.where(WowLocalization.locale == "en")
586+
.where(WowLocalization.game_id.in_(talent_ids))
587+
)
588+
).all()
589+
out: dict[str, int] = {}
590+
for game_id, extras in rows:
591+
spell_id = (extras or {}).get("spell_id")
592+
if not spell_id:
593+
continue
594+
try:
595+
out[str(int(game_id))] = int(spell_id)
596+
except (TypeError, ValueError):
597+
continue
598+
return out
599+
600+
551601
async def request_analysis(
552602
session: AsyncSession,
553603
*,
@@ -821,6 +871,9 @@ def _annotate_damage_taken(
821871
gear=gear,
822872
references=references,
823873
)
874+
talent_spell_ids = await _collect_talent_spell_ids(
875+
session, player_summary=player_summary, references=references
876+
)
824877

825878
# If the row already exists (worker path), it tells us whether BYOK was
826879
# requested. New rows fall through to the app-wide path.
@@ -915,6 +968,11 @@ def _annotate_damage_taken(
915968
analysis.structured = {
916969
**(response.structured or {}),
917970
"_localized_names": localized_names,
971+
# ``[Label](talent:<id>)`` markdown links in the AI's prose
972+
# carry TraitNodeEntry IDs (different namespace from spell
973+
# IDs). Frontend resolves to the underlying spell ID via
974+
# this map before linking to Wowhead.
975+
"_talent_spell_ids": talent_spell_ids,
918976
"_parse_metrics": {
919977
"parse_percent": parse_metrics.get("rank_percent"),
920978
"ilvl_percent": parse_metrics.get("ilvl_percent"),

backend/app/services/ai/prompts.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,27 @@ def stat_glossary_for(locale: Locale) -> dict[str, str]:
8282
buff/debuff uptime percentages, and damage/healing totals from the JSON.
8383
- For every finding, name the spell(s) or item(s) involved by spell ID / item
8484
ID (so the UI can render Wowhead tooltips).
85+
- **EVERY mention of a spell, item or talent in your prose MUST be written
86+
as an inline markdown link in the form ``[Label](kind:id)``** — the
87+
frontend parses these and renders them as Wowhead tooltip-aware links.
88+
This applies to ``findings[*].detail``, ``rotation_summary``,
89+
``cooldown_usage_summary``, ``stat_recommendations``,
90+
``talent_recommendations``, ``gear_and_trinket_notes``,
91+
``comparison_to_top_logs``, ``headline`` and every entry of
92+
``strengths``. Three valid forms:
93+
94+
[Mana Tea](spell:115294) — buffs, casts, debuffs, boss abilities
95+
[Algari Mana Potion](item:212017) — consumables, trinkets, gear
96+
[Tea Time](talent:124683) — talent-tree picks from talent_ids
97+
98+
``Label`` is the localised name (use ``localized_names`` verbatim — never
99+
invent translations). ``id`` is the numeric ID exactly as it appears in
100+
the relevant key of ``localized_names`` / ``talent_ids`` / the gear's
101+
``item_id`` / the cast's ``ability_id``. Do **not** write a plain spell
102+
name without the ``[…](spell:…)`` wrapper, do **not** emit raw Wowhead
103+
URLs yourself, and do **not** invent IDs that don't appear in the data.
104+
When you genuinely need to refer to an ability whose ID isn't in the
105+
data, write ``the ability "X"`` in plain text — no link.
85106
- Compare the player's casts, buff uptimes, and gear against the supplied
86107
top-log reference players (same spec, same encounter, same region, same
87108
difficulty). Call out concrete deltas:
@@ -257,6 +278,28 @@ def stat_glossary_for(locale: Locale) -> dict[str, str]:
257278
Prozente, Damage-/Healing-Summen aus dem JSON.
258279
- Bei jedem Befund die betroffenen Zauber/Items per Spell-ID / Item-ID
259280
benennen, damit die UI Wowhead-Tooltips rendern kann.
281+
- **JEDE Erwähnung eines Zaubers, Items oder Talents in deinem Fließtext
282+
MUSS als inline Markdown-Link in der Form ``[Label](kind:id)``
283+
geschrieben werden** — das Frontend parst diese und rendert sie als
284+
Wowhead-Tooltips. Gilt für ``findings[*].detail``, ``rotation_summary``,
285+
``cooldown_usage_summary``, ``stat_recommendations``,
286+
``talent_recommendations``, ``gear_and_trinket_notes``,
287+
``comparison_to_top_logs``, ``headline`` und jeden Eintrag von
288+
``strengths``. Drei gültige Formen:
289+
290+
[Mana-Tee](spell:115294) — Buffs, Casts, Debuffs, Boss-Fähigkeiten
291+
[Algari-Manatrank](item:212017) — Verbrauchsgüter, Trinkets, Ausrüstung
292+
[Teezeit](talent:124683) — Talente aus talent_ids
293+
294+
``Label`` ist der lokalisierte Name (nutze ``localized_names`` verbatim
295+
— übersetze nichts selbst). ``id`` ist die numerische ID exakt wie sie
296+
im jeweiligen Schlüssel von ``localized_names`` / ``talent_ids`` / im
297+
``item_id`` des Gears / im ``ability_id`` des Casts steht. Schreib
298+
**keinen** plain Zaubernamen ohne ``[…](spell:…)``-Wrapper, gib
299+
**keine** rohen Wowhead-URLs aus, und **erfinde keine** IDs die nicht
300+
in den Daten stehen. Wenn du wirklich eine Fähigkeit erwähnen musst,
301+
deren ID nicht in den Daten ist, schreib „die Fähigkeit X" als
302+
Plaintext — kein Link.
260303
- Casts, Buff-Uptimes und Ausrüstung des Spielers gegen die mitgelieferten
261304
Top-Log-Referenzspieler (gleiche Spec, gleicher Boss, gleiche Region,
262305
gleiche Schwierigkeit) vergleichen. Konkrete Deltas nennen:

frontend/src/components/AnalysisCard.tsx

Lines changed: 48 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { useEffect, useMemo } from "react";
55

66
import { Card } from "@/components/ui";
77
import { ItemLink } from "@/components/ItemLink";
8+
import { ProseWithLinks, hasInlineRefs } from "@/components/ProseWithLinks";
89
import { SeverityBadge } from "@/components/SeverityBadge";
910
import { SpellLink } from "@/components/SpellLink";
1011
import { formatPercent } from "@/lib/format";
@@ -26,6 +27,12 @@ export function AnalysisCard({ analysis, locale }: Props) {
2627
// for the spell/item chips even when ad blockers stop wowhead's tooltip
2728
// script from auto-renaming the link.
2829
const nameMap = structured._localized_names ?? {};
30+
// ``_talent_spell_ids`` is the per-analysis lookup the backend ships so
31+
// we can render ``[Label](talent:<traitNodeEntryId>)`` markdown links
32+
// as Wowhead spell links. WCL ships TraitNodeEntry IDs which collide
33+
// with old MoP spell IDs, so we MUST resolve to the underlying spell
34+
// ID before linking.
35+
const talentSpellIds = structured._talent_spell_ids ?? {};
2936
const parseMetrics = structured._parse_metrics;
3037

3138
// Wowhead's tooltip script (loaded once at the layout level) only scans
@@ -88,7 +95,15 @@ export function AnalysisCard({ analysis, locale }: Props) {
8895
<div className="space-y-4">
8996
<Card>
9097
<div className="flex flex-wrap items-baseline justify-between gap-3">
91-
<h2 className="text-xl font-semibold">{s.headline ?? "—"}</h2>
98+
<h2 className="text-xl font-semibold">
99+
<ProseWithLinks
100+
text={s.headline ?? "—"}
101+
locale={locale}
102+
nameMap={nameMap}
103+
talentSpellIds={talentSpellIds}
104+
className=""
105+
/>
106+
</h2>
92107
<div className="flex flex-wrap items-baseline gap-2">
93108
{parseMetrics?.parse_percent !== null && parseMetrics?.parse_percent !== undefined ? (
94109
<span
@@ -169,8 +184,19 @@ export function AnalysisCard({ analysis, locale }: Props) {
169184
)}
170185
</div>
171186
<h4 className="mt-2 text-base font-semibold text-zinc-100">{f.title}</h4>
172-
<p className="mt-1 whitespace-pre-line text-sm text-zinc-200">{f.detail}</p>
173-
{f.related_spell_ids?.length || f.related_item_ids?.length ? (
187+
<ProseWithLinks
188+
text={f.detail}
189+
locale={locale}
190+
nameMap={nameMap}
191+
talentSpellIds={talentSpellIds}
192+
className="mt-1 text-sm text-zinc-200"
193+
/>
194+
{/* Chip list only as a fallback for legacy analyses without
195+
inline markdown links in their prose — modern outputs
196+
render the links inline above and the chips become
197+
redundant. */}
198+
{!hasInlineRefs(f.detail) &&
199+
(f.related_spell_ids?.length || f.related_item_ids?.length) ? (
174200
<div className="mt-2 flex flex-wrap gap-2 text-xs">
175201
{f.related_spell_ids?.map((id) => (
176202
<SpellLink
@@ -196,12 +222,12 @@ export function AnalysisCard({ analysis, locale }: Props) {
196222
</Card>
197223

198224
<div className="grid gap-4 md:grid-cols-2">
199-
{textBlock(t("analyze.rotation"), s.rotation_summary)}
200-
{textBlock(t("analyze.cooldowns"), s.cooldown_usage_summary)}
201-
{textBlock(t("analyze.stats"), s.stat_recommendations)}
202-
{textBlock(t("analyze.talents"), s.talent_recommendations)}
203-
{textBlock(t("analyze.gearTrinkets"), s.gear_and_trinket_notes)}
204-
{textBlock(t("analyze.comparison"), s.comparison_to_top_logs)}
225+
{proseBlock(t("analyze.rotation"), s.rotation_summary, locale, nameMap, talentSpellIds)}
226+
{proseBlock(t("analyze.cooldowns"), s.cooldown_usage_summary, locale, nameMap, talentSpellIds)}
227+
{proseBlock(t("analyze.stats"), s.stat_recommendations, locale, nameMap, talentSpellIds)}
228+
{proseBlock(t("analyze.talents"), s.talent_recommendations, locale, nameMap, talentSpellIds)}
229+
{proseBlock(t("analyze.gearTrinkets"), s.gear_and_trinket_notes, locale, nameMap, talentSpellIds)}
230+
{proseBlock(t("analyze.comparison"), s.comparison_to_top_logs, locale, nameMap, talentSpellIds)}
205231
</div>
206232
</div>
207233
);
@@ -211,12 +237,23 @@ function severityRank(s: string): number {
211237
return ({ critical: 0, high: 1, medium: 2, low: 3, info: 4 } as Record<string, number>)[s] ?? 5;
212238
}
213239

214-
function textBlock(title: string, body: string | undefined) {
240+
function proseBlock(
241+
title: string,
242+
body: string | undefined,
243+
locale: Locale,
244+
nameMap: Record<string, string>,
245+
talentSpellIds: Record<string, number>,
246+
) {
215247
if (!body) return null;
216248
return (
217249
<Card>
218250
<h3 className="mb-2 text-sm font-semibold uppercase tracking-wide text-zinc-400">{title}</h3>
219-
<p className="whitespace-pre-line text-sm text-zinc-200">{body}</p>
251+
<ProseWithLinks
252+
text={body}
253+
locale={locale}
254+
nameMap={nameMap}
255+
talentSpellIds={talentSpellIds}
256+
/>
220257
</Card>
221258
);
222259
}

0 commit comments

Comments
 (0)