feat(tickets): Kurz-Verweise (Shortlinks) im Ticket-Text auflösen

Die KI kann im Text `tier:<8hex>` / `kontakt:` / `wurf:` / `gehege:` schreiben;
Frontend löst per POST /refs/resolve (Präfix-Match, DB-agnostisch) zu Name +
internem Link auf. Reine KI-Schreibhilfe — kein Nutzer-Button.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 07:23:03 +02:00
parent c7b054e909
commit 00dba91a5c
7 changed files with 407 additions and 7 deletions

View File

@@ -0,0 +1,123 @@
using System.Net.Http.Json;
using System.Text.Json;
using GerbilManagerWebAPI.Models;
using Microsoft.Extensions.DependencyInjection;
namespace GerbilManager.Tests;
/// <summary>
/// REFS: POST /refs/resolve löst getippte Kurz-Verweise (tier|kontakt|wurf|gehege) auf.
/// - exakter (volle GUID) und kurzer (Präfix) Treffer je Typ
/// - mehrdeutiges Präfix (mehrere Treffer) => id=null (nicht geraten)
/// - unbekannter Code / unbekannter Typ => id=null
/// Entitäten werden mit deterministischen GUIDs direkt in die SQLite-Test-DB geseedet,
/// damit Präfixe und Mehrdeutigkeit kontrolliert getestet werden können.
/// </summary>
public class RefsEndpointTests : IClassFixture<ApiFactory>
{
private readonly ApiFactory _factory;
public RefsEndpointTests(ApiFactory factory) => _factory = factory;
// Deterministische GUIDs: zwei Tiere mit GEMEINSAMEM 6-Hex-Präfix "abcdef" (mehrdeutig),
// sowie je eine eindeutige Entität pro Typ.
private static readonly Guid TierA = new("abcdef00-0000-0000-0000-000000000001");
private static readonly Guid TierB = new("abcdef00-0000-0000-0000-000000000002");
private static readonly Guid Kontakt = new("11112222-3333-4444-5555-666677778888");
private static readonly Guid Wurf = new("99990000-aaaa-bbbb-cccc-ddddeeeeffff");
private static readonly Guid Gehege = new("12345678-9abc-def0-1234-567890abcdef");
private async Task SeedAsync()
{
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationContext>();
if (db.Gerbils.Any(g => g.Id == TierA)) return; // einmalig (geteilte Fixture)
db.Gerbils.Add(new Gerbil { Id = TierA, Name = "Krümel", Gender = Gender.female });
db.Gerbils.Add(new Gerbil { Id = TierB, Name = "Möhre", Gender = Gender.male });
db.Contacts.Add(new Contact { Id = Kontakt, Name = "Erika Musterfrau" });
db.Litters.Add(new Litter { Id = Wurf, Name = "Wurf X", Date = new DateOnly(2026, 1, 1) });
db.Enclosures.Add(new Enclosure { Id = Gehege, Name = "Terrarium 1" });
await db.SaveChangesAsync();
}
private async Task<List<JsonElement>> Resolve(params object[] refs)
{
var resp = await _factory.CreateClient()
.PostAsJsonAsync("/refs/resolve", new { refs });
resp.EnsureSuccessStatusCode();
var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync());
return doc.RootElement.EnumerateArray().ToList();
}
[Fact]
public async Task Resolves_full_guid_per_type()
{
await SeedAsync();
var rows = await Resolve(
new { type = "tier", code = TierA.ToString() },
new { type = "kontakt", code = Kontakt.ToString() },
new { type = "wurf", code = Wurf.ToString() },
new { type = "gehege", code = Gehege.ToString() });
Assert.Equal(TierA.ToString(), rows[0].GetProperty("id").GetString());
Assert.Equal("Krümel", rows[0].GetProperty("name").GetString());
Assert.Equal(Kontakt.ToString(), rows[1].GetProperty("id").GetString());
Assert.Equal("Erika Musterfrau", rows[1].GetProperty("name").GetString());
Assert.Equal(Wurf.ToString(), rows[2].GetProperty("id").GetString());
Assert.Equal("Wurf X", rows[2].GetProperty("name").GetString());
Assert.Equal(Gehege.ToString(), rows[3].GetProperty("id").GetString());
Assert.Equal("Terrarium 1", rows[3].GetProperty("name").GetString());
}
[Fact]
public async Task Resolves_short_prefix_when_unique()
{
await SeedAsync();
// 8-Hex-Kurz-ID des Kontakts ("11112222") ist eindeutig.
var rows = await Resolve(new { type = "kontakt", code = "11112222" });
Assert.Equal(Kontakt.ToString(), rows[0].GetProperty("id").GetString());
Assert.Equal("Erika Musterfrau", rows[0].GetProperty("name").GetString());
}
[Fact]
public async Task Tolerates_dashes_and_case_in_code()
{
await SeedAsync();
// Gemischte Schreibweise + Bindestriche im Kurz-Code des Geheges.
var rows = await Resolve(new { type = "gehege", code = "1234-5678" });
Assert.Equal(Gehege.ToString(), rows[0].GetProperty("id").GetString());
Assert.Equal("Terrarium 1", rows[0].GetProperty("name").GetString());
}
[Fact]
public async Task Ambiguous_prefix_resolves_to_null()
{
await SeedAsync();
// "abcdef" trifft TierA UND TierB => mehrdeutig => id/name null.
var rows = await Resolve(new { type = "tier", code = "abcdef" });
Assert.Equal(JsonValueKind.Null, rows[0].GetProperty("id").ValueKind);
Assert.Equal(JsonValueKind.Null, rows[0].GetProperty("name").ValueKind);
Assert.Equal("tier", rows[0].GetProperty("type").GetString());
Assert.Equal("abcdef", rows[0].GetProperty("code").GetString());
}
[Fact]
public async Task Unknown_code_and_unknown_type_resolve_to_null()
{
await SeedAsync();
var rows = await Resolve(
new { type = "tier", code = "fffffffe" }, // unbekannter Code
new { type = "schubladen", code = "11112222" }); // unbekannter Typ
Assert.Equal(JsonValueKind.Null, rows[0].GetProperty("id").ValueKind);
Assert.Equal(JsonValueKind.Null, rows[1].GetProperty("id").ValueKind);
}
[Fact]
public async Task Too_short_code_resolves_to_null()
{
await SeedAsync();
// < 6 Hex-Zeichen wird nicht aufgelöst (auch wenn es eindeutig wäre).
var rows = await Resolve(new { type = "kontakt", code = "1111" });
Assert.Equal(JsonValueKind.Null, rows[0].GetProperty("id").ValueKind);
}
}

View File

@@ -0,0 +1,19 @@
namespace GerbilManagerWebAPI.Dtos
{
/// <summary>
/// REFS: ein einzelner aufzulösender Kurz-Verweis (Shortlink, git-Stil) aus einem
/// Freitext. <c>Type</c> ist "tier" | "kontakt" | "wurf" | "gehege"; <c>Code</c> ist
/// ein GUID-Präfix (mind. 6 Hex-Zeichen) ODER die volle GUID — Bindestrich-tolerant.
/// </summary>
public record RefRequest(string Type, string Code);
/// <summary>REFS: Body von POST /refs/resolve.</summary>
public record ResolveRefsInput(List<RefRequest> Refs);
/// <summary>
/// REFS: Auflösungsergebnis. <c>Id</c>/<c>Name</c> sind null, wenn der Code unbekannt
/// ODER mehrdeutig ist (mehrere Präfix-Treffer) — es wird bewusst nicht geraten.
/// <c>Type</c>/<c>Code</c> werden gespiegelt, damit der Client zuordnen kann.
/// </summary>
public record RefResolution(string Type, string Code, Guid? Id, string? Name);
}

View File

@@ -0,0 +1,107 @@
using GerbilManagerWebAPI.Dtos;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
namespace GerbilManagerWebAPI.Endpoints
{
/// <summary>
/// REFS: Auflöser für getippte Kurz-Verweise (Shortlinks, git-Stil) in Freitexten.
/// Tokens im Text: <c>tier:&lt;code&gt;</c>, <c>kontakt:&lt;code&gt;</c>,
/// <c>wurf:&lt;code&gt;</c>, <c>gehege:&lt;code&gt;</c> — <c>&lt;code&gt;</c> ist ein
/// GUID-Präfix (mind. 6 Hex) oder die volle GUID. Das Frontend rendert daraus
/// Name + interner Link.
///
/// POST /refs/resolve -> [{ type, code, id|null, name|null }]
///
/// DB-AGNOSTISCH (Tests: SQLite in-memory, Prod: Postgres): pro Typ eine schlanke
/// {Id,Name}-Projektion laden und im Speicher per Präfix matchen — KEIN ::text-Cast
/// o.ä. Mehrdeutiges Präfix (mehrere Treffer) => id=null (nicht raten).
/// </summary>
public static class RefsEndpoints
{
/// <summary>Schlanke {Id,Name}-Projektion für den In-Memory-Präfix-Match.</summary>
private readonly record struct NamedRow(Guid Id, string Name);
public static IEndpointRouteBuilder MapRefsEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/refs").WithTags("Refs");
group.MapPost("/resolve", async Task<Ok<List<RefResolution>>> (
ResolveRefsInput input, ApplicationContext db) =>
{
var refs = input.Refs ?? new List<RefRequest>();
// Welche Entitätstypen werden überhaupt angefragt? Nur diese Tabellen laden.
var wantedTypes = refs
.Select(r => NormalizeType(r.Type))
.Where(t => t is not null)
.Distinct()
.ToHashSet();
// Pro Typ einmalig eine leichte {Id,Name}-Projektion laden (DB-agnostisch:
// reine Spaltenauswahl, kein ::text-Cast — läuft auf SQLite wie auf Postgres).
var byType = new Dictionary<string, List<NamedRow>>();
if (wantedTypes.Contains("tier"))
byType["tier"] = await db.Gerbils.AsNoTracking()
.Select(x => new NamedRow(x.Id, x.Name)).ToListAsync();
if (wantedTypes.Contains("kontakt"))
byType["kontakt"] = await db.Contacts.AsNoTracking()
.Select(x => new NamedRow(x.Id, x.Name)).ToListAsync();
if (wantedTypes.Contains("wurf"))
byType["wurf"] = await db.Litters.AsNoTracking()
.Select(x => new NamedRow(x.Id, x.Name)).ToListAsync();
if (wantedTypes.Contains("gehege"))
byType["gehege"] = await db.Enclosures.AsNoTracking()
.Select(x => new NamedRow(x.Id, x.Name)).ToListAsync();
var results = new List<RefResolution>(refs.Count);
foreach (var r in refs)
{
var type = NormalizeType(r.Type);
var prefix = NormalizeCode(r.Code);
if (type is null || prefix.Length < 6 || !byType.TryGetValue(type, out var rows))
{
results.Add(new RefResolution(r.Type, r.Code, null, null));
continue;
}
// Präfix-Match auf der bindestrich-losen, klein geschriebenen GUID ("N"-Format).
var matches = rows
.Where(x => x.Id.ToString("N").StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
.Take(2)
.ToList();
// Genau ein Treffer => auflösen; 0 (unbekannt) oder >1 (mehrdeutig) => null.
if (matches.Count == 1)
results.Add(new RefResolution(r.Type, r.Code, matches[0].Id, matches[0].Name));
else
results.Add(new RefResolution(r.Type, r.Code, null, null));
}
return TypedResults.Ok(results);
});
return app;
}
/// <summary>Akzeptiert die vier Typ-Schlüssel (case-insensitive); sonst null.</summary>
private static string? NormalizeType(string? type) => type?.Trim().ToLowerInvariant() switch
{
"tier" => "tier",
"kontakt" => "kontakt",
"wurf" => "wurf",
"gehege" => "gehege",
_ => null,
};
/// <summary>GUID-Code bindestrich-tolerant + Hex-only normalisieren (Kleinschreibung).</summary>
private static string NormalizeCode(string? code)
{
if (string.IsNullOrWhiteSpace(code)) return string.Empty;
var chars = code.Trim().ToLowerInvariant()
.Where(c => (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'));
return new string(chars.ToArray());
}
}
}

View File

@@ -132,6 +132,7 @@ app.MapSaleReservationEndpoints();
app.MapWaitingListEndpoints();
app.MapReturnRecordEndpoints();
app.MapExhibitionEndpoints();
app.MapRefsEndpoints();
app.Run();

View File

@@ -795,6 +795,34 @@ export async function installMockApi(page: Page): Promise<MockDb> {
return json(route, 405)
}
// REFS: getippte Kurz-Verweise (Shortlinks) auflösen. Spiegelt das Backend:
// Präfix-Match (>=6 Hex, bindestrich-tolerant) auf der bindestrich-losen Id;
// mehrdeutig/unbekannt => id/name null.
if (path === '/refs/resolve' && method === 'POST') {
const body = request.postDataJSON() as { refs?: { type?: string; code?: string }[] }
const tableFor: Record<string, Row[]> = {
tier: db.gerbils as unknown as Row[],
kontakt: db.contacts as unknown as Row[],
wurf: db.litters as unknown as Row[],
gehege: db.enclosures as unknown as Row[],
}
const normCode = (code: string) => (code ?? '').toLowerCase().replace(/[^0-9a-f]/g, '')
const results = (body.refs ?? []).map((r) => {
const type = (r.type ?? '').toLowerCase()
const prefix = normCode(r.code ?? '')
const rows = tableFor[type]
if (!rows || prefix.length < 6) return { type: r.type, code: r.code, id: null, name: null }
const matches = rows.filter((row) =>
String(row.id ?? '').toLowerCase().replace(/-/g, '').startsWith(prefix),
)
if (matches.length === 1) {
return { type: r.type, code: r.code, id: String(matches[0].id), name: String(matches[0].name ?? '') }
}
return { type: r.type, code: r.code, id: null, name: null }
})
return json(route, 200, results)
}
// Generische Kollektionen: /<resource> und /<resource>/<id>
m = path.match(/^\/([a-z-]+)(?:\/([^/]+))?$/)
const col = m ? collections[m[1]] : undefined

View File

@@ -0,0 +1,51 @@
/**
* REFS: API-Client für getippte Kurz-Verweise (Shortlinks, git-Stil) in Freitexten.
*
* Tokens im Text: `tier:<code>`, `kontakt:<code>`, `wurf:<code>`, `gehege:<code>` —
* `<code>` ist ein GUID-Präfix (mind. 6 Hex) oder die volle GUID. Das Backend löst
* sie zu { id, name } auf (mehrdeutig/unbekannt => id=null); das Frontend rendert
* daraus Name + internen Link zur jeweiligen Detailseite.
*/
import { api } from './client'
/** Die vier auflösbaren Entitätstypen (Token-Präfix vor dem ":"). */
export type RefType = 'tier' | 'kontakt' | 'wurf' | 'gehege'
export interface RefRequest {
type: RefType
code: string
}
export interface RefResolution {
type: string
code: string
/** Volle GUID oder null (unbekannt ODER mehrdeutig — nicht geraten). */
id: string | null
name: string | null
}
/** Routen-Präfix je Typ für den internen Link (siehe App.tsx). */
export const REF_ROUTE: Record<RefType, string> = {
tier: '/rennmaeuse',
kontakt: '/kontakte',
wurf: '/wuerfe',
gehege: '/gehege',
}
// Hinweis: Frontend-Routen weichen vom Token-Namen ab (wurf→/wuerfe), daher die Map.
/** Token-Regex: `tier|kontakt|wurf|gehege` ":" gefolgt von >=6 Hex (oder voller GUID mit "-"). */
export const REF_TOKEN_RE = /\b(tier|kontakt|wurf|gehege):([0-9a-fA-F]{6,}(?:-[0-9a-fA-F-]+)?)/g
/** Erste 8 Hex-Stellen (erster GUID-Block) als Kurz-ID — wie eine kurze Git-Commit-ID. */
export function shortCode(id: string): string {
return id.replace(/-/g, '').slice(0, 8)
}
/** Vollständiges Token für die Zwischenablage, z. B. "tier:3c5b747b". */
export function shortToken(type: RefType, id: string): string {
return `${type}:${shortCode(id)}`
}
export function resolveRefs(refs: RefRequest[]): Promise<RefResolution[]> {
return api.post<RefResolution[]>('/refs/resolve', { refs })
}

View File

@@ -22,21 +22,32 @@ import {
type FeedbackTicket,
} from '../api/feedback'
import { getGerbil } from '../api/gerbils'
import { REF_ROUTE, REF_TOKEN_RE, resolveRefs, type RefType } from '../api/refs'
import { useApi } from '../hooks/useApi'
import { useToast } from '../components/toast'
import './tickets.css'
/** Aufgelöster Kurz-Verweis (Shortlink): null = unbekannt/mehrdeutig. */
type ResolvedRef = { id: string; name: string } | null
/**
* Nachschlage-Kontext für Ticket-Text-Links, damit RichText nicht durch jede Ebene
* durchgereicht werden muss:
* - ticketIds: bekannte Ticket-IDs (nackte GUID → Link auf das Ticket).
* - gerbilNames: aufgelöste Tier-Namen je GerbilId (nackte GUID → Name + Link zur Akte).
* Wert `undefined` = noch nicht geladen, `null` = nicht gefunden.
* - refNames: aufgelöste Kurz-Verweise je `typ:code` (die KI kann `tier:3c5b747b` schreiben
* → Name + Link). Wert `undefined` = noch nicht geladen, `null` = unbekannt/mehrdeutig.
*/
const TicketRefsContext = createContext<{
ticketIds: Set<string>
gerbilNames: Record<string, string | null>
}>({ ticketIds: new Set(), gerbilNames: {} })
refNames: Record<string, ResolvedRef>
}>({ ticketIds: new Set(), gerbilNames: {}, refNames: {} })
/** Cache-Schlüssel für einen Kurz-Verweis. */
function refKey(type: string, code: string): string {
return `${type.toLowerCase()}:${code.replace(/-/g, '').toLowerCase()}`
}
const t = de.feedback.tickets
const ctx = de.feedback.contexts
@@ -112,11 +123,13 @@ function TextWithBreaks({ value }: { value: string }) {
)
}
/** Inline-Markdown: **fett**, `code`, [Label](url)-Links, Tier-/Ticket-IDs, Zeilenumbrüche. */
/** Inline-Markdown: **fett**, `code`, [Label](url)-Links, Kurz-Verweise (tier:…), IDs, Umbrüche. */
function InlineMarkdown({ text }: { text: string }) {
const { ticketIds, gerbilNames } = useContext(TicketRefsContext)
const { ticketIds, gerbilNames, refNames } = useContext(TicketRefsContext)
// Gruppen: 1/2 = Markdown-Link, 3 = fett, 4 = code, 5/6 = Kurz-Verweis (typ/code), 7 = nackte GUID.
const re = new RegExp(
`\\[([^\\]]+)\\]\\(([^)]+)\\)|\\*\\*([^*]+)\\*\\*|\`([^\`]+)\`|(${GUID_RE})`,
`\\[([^\\]]+)\\]\\(([^)]+)\\)|\\*\\*([^*]+)\\*\\*|\`([^\`]+)\`|` +
`\\b(tier|kontakt|wurf|gehege):([0-9a-fA-F]{6,}(?:-[0-9a-fA-F-]+)?)|(${GUID_RE})`,
'g',
)
const nodes: ReactNode[] = []
@@ -146,8 +159,21 @@ function InlineMarkdown({ text }: { text: string }) {
{m[4]}
</code>,
)
} else if (m[5] !== undefined && m[6] !== undefined) {
// Kurz-Verweis tier:/kontakt:/wurf:/gehege: → aufgelöster Name + Link, sonst Roh-Text.
const type = m[5] as RefType
const resolved = refNames[refKey(type, m[6])]
if (resolved) {
nodes.push(
<Link key={k++} to={`${REF_ROUTE[type]}/${resolved.id}`} className="ticket-link">
{resolved.name}
</Link>,
)
} else {
nodes.push(<TextWithBreaks key={k++} value={m[0]} />)
}
} else {
const guid = m[5]
const guid = m[7]
const gn = gerbilNames[guid]
if (ticketIds.has(guid)) {
nodes.push(
@@ -269,6 +295,8 @@ export default function TicketsPage() {
const ticketIds = useMemo(() => new Set((rows ?? []).map((r) => r.id)), [rows])
// Aufgelöste Tier-Namen je GerbilId (für nackte Tier-IDs im Text → Name + Link).
const [gerbilNames, setGerbilNames] = useState<Record<string, string | null>>({})
// Aufgelöste Kurz-Verweise (tier:/kontakt:/wurf:/gehege:) je `typ:code`.
const [refNames, setRefNames] = useState<Record<string, ResolvedRef>>({})
// Gruppierung + Zählung je gefilterter Ansicht (für die Badges an den Filter-Chips).
const counts = useMemo(() => {
@@ -343,6 +371,49 @@ export default function TicketsPage() {
}
}, [candidateGerbilIds, gerbilNames])
// Kurz-Verweise (tier:/kontakt:/wurf:/gehege:) in den sichtbaren Tickets sammeln und
// einmalig über POST /refs/resolve auflösen — so kann die KI im Text die Kurz-ID nutzen.
const candidateRefs = useMemo(() => {
const map = new Map<string, { type: RefType; code: string }>()
const add = (txt?: string | null) => {
if (!txt) return
const re = new RegExp(REF_TOKEN_RE.source, REF_TOKEN_RE.flags)
let m: RegExpExecArray | null
while ((m = re.exec(txt)) !== null) {
const type = m[1] as RefType
map.set(refKey(type, m[2]), { type, code: m[2] })
}
}
for (const tk of visible) {
add(tk.message); add(tk.question); add(tk.answer); add(tk.fixNote)
for (const e of tk.thread) add(e.text)
}
return map
}, [visible])
useEffect(() => {
const missing = [...candidateRefs].filter(([key]) => !(key in refNames))
if (missing.length === 0) return
let cancelled = false
resolveRefs(missing.map(([, r]) => r))
.then((results) => {
if (cancelled) return
setRefNames((prev) => {
const next = { ...prev }
for (const r of results) {
next[refKey(r.type, r.code)] = r.id && r.name ? { id: r.id, name: r.name } : null
}
return next
})
})
.catch(() => {
/* Auflösung fehlgeschlagen — Tokens bleiben Roh-Text. */
})
return () => {
cancelled = true
}
}, [candidateRefs, refNames])
const emptyText: Record<TicketView, string> = {
dialog: t.emptyDialog,
open: t.emptyOpen,
@@ -386,7 +457,7 @@ export default function TicketsPage() {
{visible.length === 0 ? (
<p className="tickets-empty">{emptyText[activeView]}</p>
) : (
<TicketRefsContext.Provider value={{ ticketIds, gerbilNames }}>
<TicketRefsContext.Provider value={{ ticketIds, gerbilNames, refNames }}>
<ul className="tickets-list">
{visible.map((ticket) => (
<TicketCard