Skip to main content
  1. AI Projects/

Fremdrift på EG-projektet — Del 1

·702 words·4 mins
EG-projekt - This article is part of a series.
Part : This Article

Hvor vi er nu
#

Projektet startede som en prototype — et proof of concept der skulle besvare spørgsmålet: Giver det overhovedet mening at automatisere dette?

Det spørgsmål er besvaret. Nu er vi i gang med at bygge noget, der faktisk kan bruges.

Dette indlæg dokumenterer, hvad der er implementeret så langt, hvilke beslutninger vi traf undervejs, og hvad der kostede os tid.


Arkitekturen: Hvad vi endte med
#

Domænemodellen
#

Kernen i systemet er en Virksomhed-entitet med en Status-enum der repræsenterer hvor i flowet stadeholderen befinder sig:

public enum Status {
    OPRETTET,
    KONTAKTET,
    KONTRAKT_SENDT,
    KONTRAKT_UNDERSKREVET,
    FAKTURA_SENDT,
    GODKENDT
}

Hvert statusskift logges i en separat StatusHistorik-tabel — så vi altid kan se, hvornår noget skete og i hvilken rækkefølge. Det viste sig hurtigt at være nyttigt til debugging og til at give koordinatoren et overblik over, hvad der skete hvornår.

Vi har tre centrale entiteter udover Virksomhed:

  • Kontrakt — PDF-sti, genereringsdato, underskriftsdato
  • Faktura — beregnet beløb, forfaldsdato, betalingsstatus
  • EmailLog — type (KONTAKT / KONTRAKT / FAKTURA), tidsstempel, preview af indholdet

Hibernate-mapping: Det der gav problemer
#

Den første udgave af domænemodellen brugte @OneToMany med CascadeType.ALL og orphanRemoval = true overalt. Det er standardopskriften, og den fungerer — men vi fik hurtigt N+1-problemer i dashboardet, fordi vi indlæste alle virksomheder med deres tilhørende kontrakter og fakturaer i en enkelt liste.

Løsningen var at opdele: listen på dashboardet henter kun Virksomhed med status — intet nested. Kun når man åbner en enkelt virksomhed, hentes kontrakter og fakturaer.

// Dashboard-query — ingen joins
List<Virksomhed> findAllByOrderByCreatedAtDesc();

// Detail-query — explicit fetch
@Query("SELECT v FROM Virksomhed v LEFT JOIN FETCH v.kontrakter LEFT JOIN FETCH v.fakturaer WHERE v.id = :id")
Optional<Virksomhed> findByIdWithDetails(@Param("id") UUID id);

Det reducerede dashboard-forespørgslen fra ~90 queries (én per virksomhed) til én.


AI-integrationen
#

Vi bruger Ollama med llama3.1:8b til tekstgenerering. Modellen kører lokalt, hvilket har to fordele vi holdt fast i:

  1. Ingen API-udgifter under udvikling
  2. Virksomhedsdata forlader ikke maskinen

Promptstrategien er enkel: vi bygger en struktureret prompt med virksomhedens data og Engestofte Gods’ kontekst (atmosfære, krav, koncept), og beder modellen returnere ren tekst — ingen markdown, ingen formatering, bare brødtekst som kan indsættes direkte i en e-mail.

public String genererKontaktEmail(Virksomhed v) {
    String prompt = """
        Du er en eventkoordinator for Engestofte Gods, et historisk gods i Maribo.
        Skriv en kort, venlig opsøgende e-mail på dansk til %s.
        De sælger: %s. Standtype: %s. Brug en varm og professionel tone.
        Returner kun e-mailteksten — ingen emnelinjer, ingen formatering.
        """.formatted(v.getNavn(), v.getBeskrivelse(), v.getStandtype());

    return ollamaClient.generate(prompt);
}

Det fungerer godt til e-mails. Kontrakter er sværere — modellen har en tendens til at “oversimplificere” juridiske formuleringer, så vi endte med at have en fast kontraktskabelon med variable felter i stedet for fuldt AI-genereret kontrakt.


Frontenden: Hvad vi prioriterede
#

Frontenden er bygget i React med Vite og Bootstrap. Vi prioriterede funktion over form — dette er et internt admin-interface, ikke et kundeoplevelsesprod ukt.

De tre vigtigste views:

1. Dashboard
#

En tabel med alle virksomheder, farvekodet status-badges og hurtig adgang til de primære handlinger (send e-mail, se kontrakt, markér underskrevet). Tabellen kan filtreres på status.

2. Virksomhedsprofil
#

Detaljeret visning med historik, genereret e-mail-preview (redigerbar inline), kontrakt og faktura. Al AI-generering sker fra dette view.

3. Faktura-oversigt
#

Samlet overblik over betalingsstatus — hvilke fakturaer er sendt, hvilke er betalt, hvad er det forventede input pr. dag baseret på underskrevne kontrakter.


Hvad der tog længere end forventet
#

Ollama response-tid. llama3.1:8b er ikke hurtig. En e-mail-generering tager 8–15 sekunder på en gennemsnitlig laptop. Det er acceptabelt for én generering, men vi måtte implementere en loading-state i frontenden, fordi brugeren ellers troede systemet var gået ned.

Hibernate og UUID. Vi bruger UUID som primærnøgle, og PostgreSQL + Hibernate 6 håndterer det fint — men kun hvis man eksplicit sætter @GeneratedValue(strategy = GenerationType.UUID). Den første version brugte AUTO, som gav inkonsistente resultater på tværs af miljøer.

CORS. Altid CORS. Det tager fem minutter at løse og alligevel en time at debugge første gang.


Næste skridt
#

Del 2 dækker:

  • Implementering af rigtig e-mail-afsendelse (ikke mock)
  • Automatiske påmindelser for overskredet deadlines
  • Spec-driven tilgang til at designe API’et, inden vi udvidede med nye endpoints
  • Hvad der sker, når man viser systemet til en rigtig bruger for første gang

Det er den interessante del.

EG-projekt - This article is part of a series.
Part : This Article