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:
107
GerbilManagerWebAPI/Endpoints/RefsEndpoints.cs
Normal file
107
GerbilManagerWebAPI/Endpoints/RefsEndpoints.cs
Normal 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:<code></c>, <c>kontakt:<code></c>,
|
||||
/// <c>wurf:<code></c>, <c>gehege:<code></c> — <c><code></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());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user