Tworzenie Lucas AI: przekształcanie portfolio w produkt

← Powrót do bloga

23 kwietnia 20269 min read

Tworzenie Lucas AI: przekształcanie portfolio w produkt

Projekt systemu dla Lucas AI: warstwowe podpowiedzi, wyszukiwanie leksykalne, pamięć sesji, budżety tokenów i klasyfikator przed lotem na ograniczonej powierzchni czatu portfolio.

produktaiarchitekturafrontend

Ta strona jest portfolio, ale powierzchnia Lucas AI została celowo zbudowana jak mały produkt: dedykowane doświadczenie /{locale}/ai, przewidywalne wydatki i odpowiedzi, które pozostają w pierwszej osobie tylko z ustrukturyzowanego kontekstu — nie ogólny asystent z ambicjami na skalę internetową.

Ten post to głęboki techniczny przegląd tego, jak system jest zbudowany: co jest montowane na żądanie, dlaczego jest warstwowy w ten sposób i jakie ograniczenia są celowe.

Problem

Statyczne portfolio dobrze odpowiadają na jednorazowe pytania, ale zawodzą na poziomie szczegółowości. Odpowiedzi na kolejne pytania („jak to zostało zrealizowane?”, „jakiego stosu technologicznego użyto?”) albo znikają w plikach PDF, albo wymuszają pętle ludzką.

Naive integracje czatu naprawiają model interakcji, ale tworzą nowe problemy: nieograniczony kontekst, wymyślone pracodawcy, ciche rozszerzanie zakresu i każde pytanie jest obciążane przeciwko największemu modelowi, który został skonfigurowany. Portfolio nie potrzebuje otwartego świata czatu; potrzebuje wąskiego interfejsu z audytowalną bazą faktów.

Lucas AI znajduje się w tej luce: ograniczony UX, ograniczona wiedza, wyraźne granice dotyczące tego, co „Lucas” może twierdzić.

Przegląd projektu systemu

Od końca do końca, wysyłanie jest nadal POST /api/chat z JSON (message, locale, opcjonalnie sessionSummary / recentTurns). Trasa posiada politykę i wydatki; przeglądarka posiada interfejs użytkownika i pamięć po stronie klienta (sessionStorage).

Warstwy, w kolejności wykonania:

  1. Weryfikacja danych wejściowych — puste wiadomości są odrzucane; tekst użytkownika jest mocno ograniczony do 2000 znaków, aby ograniczyć nadużycia i powierzchnię wstrzyknięcia podpowiedzi.
  2. Klasyfikator przed lotem (opcjonalnie) — mały Groq chat completion (llama-3.1-8b-instant domyślnie), nieciągły, max_tokens: 5, temperature: 0, werdykt jednoliterowy: OFF_TOPIC lub kontynuacja. Jeśli OFF_TOPIC, API zwraca sztuczny SSE z odmawiającym komunikatem, aby ścieżka klienta pasowała do normalnego strumienia. W przypadku błędu klasyfikatora lub limitu czasowego, handler otwiera się i uruchamia główny model — tanie bramki nie powinny blokować ruchu legit.
  3. Zbiórka podpowiedzibuildChatPrompt komponuje wiadomość systemową; główny model otrzymuje system + bieżącą wiadomość użytkownika (nie wielowątkowy transkrypt czatu).
  4. Główne uzupełnienie — strumieniowe POST …/v1/chat/completions (domyślnie llama-3.3-70b-versatile na Groq, zastąpione przez GROQ_MODEL), odpowiedź przekazywana jako SSE do klienta. max_tokens clamped via CHAT_MAX_TOKENS (256–8192, domyślnie 2048) wiąże UX z ekonomią jednostki.
  5. Ciągłość klienta — interfejs użytkownika utrzymuje wiadomości i točący się podsumowanie sesji w sessionStorage; każde żądanie może dołączyć przycięte podsumowanie i plaster ostatnich tur, dzięki czemu serwer kontynuuje ciągłość bez przechowywania czatu po stronie serwera.

Wyrównanie lokalizacji (nadal polityka po stronie klienta): podczas wysyłania, franc-min klasyfikuje pisanie użytkownika (minimalna długość ~15 znaków). Jeśli wykryty język nie pasuje do oczekiwanego mapowania ISO 639-3 bieżącej lokalizacji witryny, interfejs użytkownika nie wywołuje API — pokazuje kartę oferty do przełączenia lokalizacji (router.push do pasującej {locale}/ai) lub kontynuacji w bieżącym języku. To utrzymuje locale w treści JSON w zgodności z językiem, w którym użytkownik faktycznie pisze, zamiast wymuszać model do zgadywania. Oczekująca wiadomość przeżywa zmianę lokalizacji za pomocą sessionStorage i automatycznego wysyłania po zamontowaniu.

Ważnym ruchem architektonicznym jest rozdzielenie: stabilne prawo podstawowe (kim jest Lucas AI, głos, zasady uziemienia) a dynamiczne fragmenty (blok sesji, pobrane fragmenty, opcjonalny blok FAQ kanonicznego). To rozdzielenie utrzymuje podpowiedź audytowalną, i daje szansę pamięci podręcznej podpowiedzi, ponieważ prefiks pozostaje stabilny, podczas gdy zmienia się tylko odpowiedni kontekst na każdą turę.

Strategia kontekstu

Projekt unika „zawsze wysyłaj wszystko”. Wstawianie pełnego ustrukturyzowanego profilu przy każdym żądaniu skaluje się słabo: koszt tokena rośnie wraz z korpusem, niepowiązane pytania płacą za niepowiązane fakty, i zbliżasz się do limitów dostawcy, gdy profil rośnie.

Zamiast:

  • systemPrompt (corePromptText.ts) — stabilne zasady: pierwsza osoba, transparentność („nie piszę na żywo”), prawo uziemienia, zasady matematyki czasu trwania, zachowanie lokalizacji. Utrzymywane minimalne i stałe, dzięki czemu pamięć podręczna prefiksu (gdzie dostawca deduplikuje tekst systemowy otwarcia) ma szansę pomóc.
  • Dynamiczne fragmenty CONTEXT — dołączane po wspólnym prefiksie, wyraźnie ograniczone (--- CONTEXT (kanoniczny / FAQ) ---, pamięć sesji, pobrane fragmenty) i zamknięte z --- Koniec fragmentów CONTEXT --- plus linia Lokalizacja odwiedzającego: …, dzięki czemu model odpowiada w języku witryny.

Rozdzielenie domen jest egzekwowane w czasie tworzenia fragmentów, nie tylko w treści. Pojedynczy obiekt lucasPersonalContext jest źródłem prawdy; buildKnowledgeChunkIndex wyprowadza fragmenty z etykietami KnowledgeDomain (core_bio, mindset, working_style, strengths, experience, duration, portfolio_*, boundaries itp.). To daje wyszukiwaniu typowaną powierzchnię do oceny, zamiast jednej niezdifferencjowanej ściany tekstu.

To, co model może traktować jako fakt, jest zawsze tym, co fragmenty weszły do tego żądania — testowalny zakres, a nie nastrój.

Routing intencji (tani) vs bramka zakresu (LLM)

Istnieją dwie różne „pomysły” routingu w układzie; pomieszanie ich traci projekt.

Heurystyczny routing intencji (routeIntent w router.ts) nie jest wywołaniem LLM. Jest klasyfikacją napędzana słowem kluczowym i regexem do grubych kubełków (recruiter, engineering, project, blog, portfolio_meta, navigation_contact, conversational, general). Ta intencja tylko stronniczy scoring leksykalny — opóźnienie pozostaje płaskie, koszt jest zerowy.

Przykłady:

  • „Jak mogę się skontaktować / zatrudnić / CV” → navigation_contact — stronniczy zachowanie sąsiadujące z kontaktem pośrednio za pomocą odbiorców fragmentów.
  • „Next.js / i18n / Groq / SSE / Lucas AI API” → portfolio_meta — stronniczy domaine portfolio_* w selectChunks.
  • „Lata doświadczenia / stażu / wielkość zespołu” → recruiter — stronniczy duration, strengths, fragmenty oznaczone rekruterem.
  • Krótkie „hi / thanks” → conversational — unika przeładowywania ciężkich bloków doświadczenia.

Wymiana: heurystyka źle kieruje. Wyszukiwanie rekompensuje awaryjnym: jeśli nic nie punktuje powyżej zera, wstrzykuje core-bio + boundaries, dzięki czemu model nadal ma minimalne uziemienie.

Klasyfikator tematu przed lotem jest bramką LLM: CAREER vs OFF_TOPIC z dedykowanym systemem podpowiedzi (buildClassifierPrompt). To jest polityka („czy to właściwy produkt?”), a nie strojenie wyszukiwania. Jest bardziej elastyczny niż regex, ale kosztuje dodatkową podróż; stąd flagi pomijania i zachowanie awaryjne.

Selektywne wyszukiwanie (nie osadzanie RAG)

„RAG” często implikuje osadzanie i wektorową bazę danych. Ta implementacja nie używa tych. Wyszukiwanie jest leksykalne: tokenizuje wiadomość użytkownika do zestawu słów, ocenia każdy fragment według trafień słów kluczowych + nakładania się podciągów w tekście fragmentu, dodaje wzmocnienia oparte na intencji (np. tagi odbiorców rekrutera, stronniczość portfolio_meta domen portfolio_*), sortuje, a następnie pobiera najlepsze fragmenty pod twardymi limitami.

Fragmenty są małymi jednostkami semantycznymi w stosunku do pełnego profilu: np. jeden fragment na pracodawcę (compactExperience), oddzielne fragmenty dla rury i18n vs funkcji czatu vs faktów monorepo. Małe fragmenty są ważne, ponieważ:

  • scoring jest lokalny — niepowiązani pracodawcy nie rozcieńczają dopasowania do innej nazwy firmy;
  • kompresja obcina na fragmencie (maxChunkChars) bez odrzucania całego profilu.

Zapytania w stylu blogadepriorytetyzowane w wyszukiwaniu (intent === "blog" stosuje negatywne nastawienie), ponieważ treść bloga MDX nie jest indeksowana jako fragmenty wiedzy tutaj. System nie udaje, że istnieje wyszukiwanie w pełnym artykule, gdy korpus nie jest obecny.

Istotność jest przejrzystym kodem: waga słowa kluczowego 3, waga tokena w treści 0,35, wzmocnienia oparte na odbiorcach / intencji na górze. To jest łatwe do audytu i tanie do uruchomienia; kompromis to synonimia i parafraza — jeśli użytkownik nigdy nie pokrywa się leksykalnie z treścią fragmentu, polegasz na fragmentach awaryjnych.

Optymalizacja tokenów

Naive implementacje albo odtwarzają całą historię, albo wstawiają całe korpusy. Oba eksplodują koszt i opóźnienie.

Konkretnie strategie w kodzie:

  • estimateTokens — heurystyka długości znaków / 4; brak zależności tokenizera; wystarczająco dobra do budżetowania, nie do rozliczania.
  • Limit sesjimaxSessionBlockTokens: podsumowanie toczące się + sformatowane ostatnie tury są obcinane iteracyjnie (plastry do 85% poniżej limitu).
  • Ostatnie tury — tylko ostatnie maxRecentTurnPairs par (domyślnie 3), każde wiadomość ograniczona do maxCharsPerTurn (domyślnie 700). Klient wysyła do ośmiu poprzednich wiadomości; serwer egzekwuje surowszy budżet.
  • Podsumowanie sesji — klient buduje linię logowania na turę (User snippet + Assistant snippet), ograniczone po stronie klienta (~2500 znaków), a następnie serwer sanitizeSessionSummary (maxSessionSummaryChars, domyślnie 900). Brak serwera LLM podsumowującego — przewidywalny koszt, ale hałaśliwy, jeśli użytkownicy wklejają ściany tekstu (łagodzone przez ograniczenia).
  • Pobranie top-kmaxRetrievedChunks (domyślnie 5), budżet maxRetrievedTokens (domyślnie 2200 oszacowanych), wczesne zatrzymanie, gdy dodanie kolejnego fragmentu przekroczy budżet (z podłogą co najmniej dwóch fragmentów, jeśli to możliwe).
  • Deduplikacja — fragmenty wybrane przez id do Set, dzięki czemu ten sam blok pracodawcy nie jest duplikowany.
  • KompresjacompressChunkText obcina z wielokropkiem, gdy fragment przekracza maxChunkChars.
  • Ostatnia deska ratunkumaxSystemChars obcina złożoną treść systemową z widocznym znacznikiem [system truncated].

Napięcie produktu zawsze jest bogactwem kontekstu a kosztem. Domyślne ustawienia celowo faworyzują mniejsze podpowiedzi; operatorzy stroją za pomocą zmiennych środowiskowych CHAT_MAX_*. CHAT_DEBUG_METRICS=1 rejestruje jedną linię JSON na żądanie (intencja, liczba fragmentów, przybliżony rozkład tokenów), dzięki czemu możesz zobaczyć budżet w produkcji bez zgadywania.

Pamięć sesji

Pełna replay czatu w systemie nie skaluje się: koszt tokena rośnie liniowo z długością rozmowy, a długie wątki topią sygnał pobrany.

Ten układ używa hybrydy:

  1. Toczący się podsumowanie — tania, autorska przez klienta; zachowuje „to, o czym już rozmawialiśmy” w kilku kilobajtach maksymalnie.
  2. Ostatnie tury — mały ogon rzeczywistej rozmowy dla natychmiastowej coreferencji („ta rola”, „ostatnia odpowiedź”).

Ciągłość jest zatem najlepszym wysiłkiem, a nie dosłownym odtworzeniem — odpowiednim dla powierzchni Q&A portfolio, a nie par programowania. Brak magazynu czatu po stronie serwera: brak synchronizacji między urządzeniami, brak szkolenia na rozmowach; postawa prywatności pozostaje prosta.

Szczegóły projektu podpowiedzi

Krótki obwód FAQ kanonicznego (tryCanonicalAnswer) — mała biblioteka wzorców dla powtarzających się pytań („kto jesteś”, „dlaczego Lucas AI”, „główne mocne strony”, doświadczenie frontendowe). Po trafieniu, gęsty blok ### Fakty kanoniczne jest wstrzykiwany, a wyszukiwanie jest mocniej ograniczone (plastry do co najwyżej trzech fragmentów), aby uniknąć zbędnych tokenów.

Statyczny vs dynamiczny jest wyraźny w kolejności montażu: systemPrompt + data, a następnie opcjonalne sekcje dynamiczne, a następnie znaczniki końcowe + linia lokalizacji. To uporządkowanie jest celowe dla pamięci podręcznej i dla ludzkiej różnicy, gdy CONTEXT dryfuje.

Klasyfikator vs główny — podpowiedź klasyfikatora jest wąsko ograniczona (jedno słowo wyjściowe); główna podpowiedź niesie pełne prawo zachowania. Utrzymywanie ich oddzielenia unika „pomocnego klasyfikatora” dryfu i utrzymuje podpowiedź dużego modelu skoncentrowaną na głosie i uziemieniu.

Kompromisy i niecele

Celowo nie zbudowano:

  • Wyszukiwanie osadzone / wektorowa baza danych dla profilu lub bloga.
  • Wywoływanie narzędzi, załączniki lub pamięć po stronie serwera na sesje.
  • Drugorzędne przesunięcie (enkoder krzyżowy / mały LLM przesunięcia) na pobranych fragmentach.

Uproszczono celowo:

  • Wyszukiwanie leksykalne ponad neuronalne — akceptowalne dla ograniczonego korpusu, gdzie słowa kluczowe fragmentów lustrzane, jak rekruterzy i inżynierowie faktycznie zadają pytania.
  • Podsumowanie autorskie klienta ponad podsumowanie LLM — oszczędza wywołanie i unika halucynacji podsumowania zatruwających prawdę.

Rozszerzenia przyszłe (jeśli potrzebne): osadzanie dla treści bloga, gdy jest indeksowane; magazyn sesji serwera, jeśli ciągłość między zakładkami musi być zaufana bez klienta; przesunięcie, jeśli liczność fragmentów rośnie wystarczająco, że scoring leksykalny staje się kruchy.

Podsumowanie

Lucas AI najlepiej czytać jako powierzchnię produktu z specyfikacją: obiekt lucasPersonalContext i wyprowadzone fragmenty są kontraktem, system jest prawem, budżety tokenów są ograniczeniami, a klasyfikator plus ścieżka poza tematem definiują to, czym produkt odmawia się być.

Jeśli budujesz coś podobnego, dźwignia nie jest największym modelem ogólnym — to decydowanie, co wchodzi do podpowiedzi na każdą turę i pomiar. Portfolio nie potrzebuje nieskończonego kontekstu; potrzebuje uczciwych, ograniczonych odpowiedzi — i architektury wystarczająco nudnej, aby je zachować.