FEAT-14a: Charakterbogen on Gerbil (traits + note) + sale-ad request extension

- Gerbil.CharacterTraits (List<string>, JSON text column, opaque labels) + CharacterNote
  (string?). On GerbilDto + Input; round-trips on GET/POST/PUT. Additive migration.
- SaleAdAnimal gains Traits (German labels) + CharacterNote; prompt builder emits
  "Charakter: …" + "Charakter-Notiz: …" lines for the AI Verkaufstext.
- Tests: traits/note round-trip (POST->GET via SQLite host), empty-default, prompt inclusion.
This commit is contained in:
2026-06-06 09:10:30 +02:00
parent f858a02f50
commit 3afb04f646
10 changed files with 1203 additions and 4 deletions

View File

@@ -49,6 +49,19 @@ public class ApplicationContext : DbContext
e.Property(g => g.Gender).HasConversion<string>();
e.Property(g => g.Status).HasConversion<string>();
// FEAT-14: character traits stored as a JSON text column (works on both
// Npgsql and the SQLite test host; opaque labels, no backend vocabulary).
var traitsConverter = new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter<List<string>, string>(
v => System.Text.Json.JsonSerializer.Serialize(v, (System.Text.Json.JsonSerializerOptions?)null),
v => string.IsNullOrEmpty(v)
? new List<string>()
: System.Text.Json.JsonSerializer.Deserialize<List<string>>(v, (System.Text.Json.JsonSerializerOptions?)null) ?? new List<string>());
var traitsComparer = new Microsoft.EntityFrameworkCore.ChangeTracking.ValueComparer<List<string>>(
(a, b) => (a ?? new List<string>()).SequenceEqual(b ?? new List<string>()),
v => v == null ? 0 : v.Aggregate(0, (h, s) => HashCode.Combine(h, s.GetHashCode())),
v => v.ToList());
e.Property(g => g.CharacterTraits).HasConversion(traitsConverter, traitsComparer);
e.HasOne(g => g.Litter).WithMany()
.HasForeignKey(g => g.LitterId).OnDelete(DeleteBehavior.SetNull);
e.HasOne(g => g.OriginContact).WithMany()

View File

@@ -25,7 +25,9 @@ namespace GerbilManagerWebAPI.Dtos
string? Notes,
string? ImportSource,
string? ExternalRef,
string? OriginBreeder);
string? OriginBreeder,
List<string> CharacterTraits,
string? CharacterNote);
public record LitterDto(
Guid Id,
@@ -71,7 +73,9 @@ namespace GerbilManagerWebAPI.Dtos
string? Notes,
string? ImportSource,
string? ExternalRef,
string? OriginBreeder);
string? OriginBreeder,
List<string>? CharacterTraits,
string? CharacterNote);
public record LitterInput(
string Name,

View File

@@ -95,11 +95,14 @@ namespace GerbilManagerWebAPI.Endpoints
g.ImportSource = i.ImportSource;
g.ExternalRef = i.ExternalRef;
g.OriginBreeder = i.OriginBreeder;
g.CharacterTraits = i.CharacterTraits ?? new List<string>();
g.CharacterNote = i.CharacterNote;
}
internal static GerbilDto ToDto(Gerbil g) => new(
g.Id, g.Name, g.Gender, g.Status, g.LitterId, g.OriginContactId, g.ReceiverContactId,
g.EnclosureId, g.ColorVarietyId, g.DateOfBirth, g.DateOfDeath, g.CauseOfDeath,
g.GoHomeDate, g.Genotype, g.Notes, g.ImportSource, g.ExternalRef, g.OriginBreeder);
g.GoHomeDate, g.Genotype, g.Notes, g.ImportSource, g.ExternalRef, g.OriginBreeder,
g.CharacterTraits, g.CharacterNote);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,39 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class AddCharacterFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "CharacterNote",
table: "Gerbils",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "CharacterTraits",
table: "Gerbils",
type: "text",
nullable: false,
defaultValue: "");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "CharacterNote",
table: "Gerbils");
migrationBuilder.DropColumn(
name: "CharacterTraits",
table: "Gerbils");
}
}
}

View File

@@ -661,6 +661,13 @@ namespace GerbilManagerWebAPI.Migrations
b.Property<string>("CauseOfDeath")
.HasColumnType("text");
b.Property<string>("CharacterNote")
.HasColumnType("text");
b.Property<string>("CharacterTraits")
.IsRequired()
.HasColumnType("text");
b.Property<Guid?>("ColorVarietyId")
.HasColumnType("uuid");

View File

@@ -60,6 +60,13 @@ namespace GerbilManagerWebAPI.Models
/// Kept in sync automatically on save (see ApplicationContext.SaveChanges). Gridify-filterable
/// so "clan kleine chaoten" matches "Clan-Kleine-Chaoten" (client strips separators too).</summary>
public string? NameSearch { get; set; }
/// <summary>FEAT-14: Charakterbogen trait labels (opaque to the backend — the
/// {key,label} vocabulary lives frontend-side). Stored as a JSON text column.</summary>
public List<string> CharacterTraits { get; set; } = new();
/// <summary>FEAT-14: free-text character note; feeds the AI Verkaufstext.</summary>
public string? CharacterNote { get; set; }
}
/// <summary>Shared normalisation for the separator-insensitive name search.</summary>

View File

@@ -9,7 +9,11 @@ namespace GerbilManagerWebAPI.SaleAd
string? Farbschlag,
/// <summary>ISO "YYYY-MM-DD" (frontend sends the DTO string verbatim).</summary>
string? DateOfBirth,
string? Notes);
string? Notes,
/// <summary>FEAT-14: Charakterbogen trait LABELS (German, human-readable) for the prompt.</summary>
List<string>? Traits = null,
/// <summary>FEAT-14: free-text character note for the prompt.</summary>
string? CharacterNote = null);
public sealed record SaleAdRequest(
List<SaleAdAnimal> Animals,

View File

@@ -87,6 +87,10 @@ namespace GerbilManagerWebAPI.SaleAd
sb.Append($" | geboren am {FormatGermanDate(animal.DateOfBirth)}");
if (!string.IsNullOrWhiteSpace(animal.Notes))
sb.Append($" | Notizen: {animal.Notes}");
if (animal.Traits is { Count: > 0 })
sb.Append($" | Charakter: {string.Join(", ", animal.Traits)}");
if (!string.IsNullOrWhiteSpace(animal.CharacterNote))
sb.Append($" | Charakter-Notiz: {animal.CharacterNote}");
sb.AppendLine();
}
if (!string.IsNullOrWhiteSpace(request.Hints))