Skip to content

Commit cc89f41

Browse files
committed
Merge feat/inline-wowhead-links into release/v0.3.0
Inline [Label](kind:id) markdown links in every prose section of the analysis card. Frontend resolves to Wowhead components; talent ids get mapped through their underlying spell id via the new _talent_spell_ids output field.
2 parents 12360ba + afa3fb6 commit cc89f41

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)